Enhance calculator modifiers and UI styling

This commit is contained in:
2026-01-14 20:44:19 +01:00
parent 9b5ddf5f3d
commit 5da0e5485a
4 changed files with 521 additions and 20 deletions
+271 -5
View File
@@ -12,6 +12,17 @@ const outputs = {
const resetButton = document.getElementById('af-reset');
const copyButton = document.getElementById('af-copy');
const weaponsById = new Map();
const modifiersById = new Map();
const operativesById = new Map();
const operativeSelect = document.getElementById('af-operative-select');
const modifierStack = document.querySelector('.af-modifier-stack');
const itemModifierList = document.getElementById('af-item-modifiers');
const perkModifierList = document.getElementById('af-perk-modifiers');
const abilityModifierList = document.getElementById('af-ability-modifiers');
const operativeModifierList = document.getElementById('af-operative-modifiers');
const activeModifierList = document.getElementById('af-active-modifiers');
let currentWeaponTypes = [];
let currentOperativeCategory = null;
const numberOrNull = (value) => {
const parsed = Number.parseFloat(value);
@@ -26,6 +37,102 @@ const formatValue = (value, suffix = '') => {
return `${formatted}${suffix}`;
};
const matchesOperative = (modifierOperative) => {
if (!modifierOperative) {
return true;
}
if (!currentOperativeCategory) {
return false;
}
return modifierOperative.toLowerCase() === currentOperativeCategory.toLowerCase();
};
const matchesWeaponType = (appliesTo) => {
if (!appliesTo) {
return true;
}
if (!currentWeaponTypes.length) {
return false;
}
return currentWeaponTypes.map((type) => type.toUpperCase()).includes(appliesTo.toUpperCase());
};
const applyBuffValue = (current, value, buffType) => {
if (!Number.isFinite(current)) {
return current;
}
if (!Number.isFinite(value)) {
return current;
}
if (buffType === 'additive') {
return current + value;
}
return current * value;
};
const applyModifierToStats = (modifier, stats) => {
if (!modifier) {
return stats;
}
if (!matchesOperative(modifier.operatives) || !matchesWeaponType(modifier.appliesTo)) {
return stats;
}
if (modifier.value && modifier.type) {
if (modifier.type === 'additive') {
stats.damage += modifier.value;
}
if (modifier.type === 'multiplicative') {
stats.damage *= modifier.value;
}
}
if (Array.isArray(modifier.buffs)) {
modifier.buffs.forEach((buff) => {
const buffType = buff.buffType?.startsWith('additive') ? 'additive' : 'multiplicative';
if (buff.type === 'damage') {
stats.damage = applyBuffValue(stats.damage, buff.value, buffType);
}
if (buff.type === 'rpm') {
stats.rpm = applyBuffValue(stats.rpm, buff.value, buffType);
}
if (buff.type === 'reloadTime') {
stats.reload = applyBuffValue(stats.reload, buff.value, buffType);
}
});
}
return stats;
};
const getSelectedModifiers = () => {
const checkboxes = document.querySelectorAll('[data-modifier-id]:checked');
return Array.from(checkboxes)
.map((checkbox) => modifiersById.get(checkbox.dataset.modifierId))
.filter(Boolean);
};
const updateActiveModifierList = (modifiers) => {
if (!activeModifierList) {
return;
}
activeModifierList.innerHTML = '';
if (!modifiers.length) {
const tag = document.createElement('span');
tag.className = 'af-tag';
tag.textContent = 'No modifiers selected';
activeModifierList.appendChild(tag);
return;
}
modifiers.forEach((modifier) => {
const tag = document.createElement('span');
tag.className = 'af-tag';
tag.textContent = modifier.label ?? 'Unnamed modifier';
activeModifierList.appendChild(tag);
});
};
const calculate = () => {
const data = new FormData(form);
const baseDamage = numberOrNull(data.get('baseDamage'));
@@ -39,18 +146,29 @@ const calculate = () => {
Object.values(outputs).forEach((node) => {
node.textContent = '--';
});
updateActiveModifierList([]);
return;
}
const shotDamage = baseDamage * projectiles;
const shotsPerSecond = rpm / 60;
const modifiers = getSelectedModifiers();
const stats = modifiers.reduce(
(acc, modifier) => applyModifierToStats(modifier, acc),
{
damage: baseDamage,
rpm: rpm,
reload: reload
}
);
const shotDamage = stats.damage * projectiles;
const shotsPerSecond = stats.rpm / 60;
const burstDps = shotDamage * shotsPerSecond;
const magDamage = magazine ? shotDamage * magazine : null;
const timeToEmpty = magazine ? magazine / shotsPerSecond : null;
let sustainedDps = null;
if (magDamage !== null && reload !== null && timeToEmpty !== null && (timeToEmpty + reload) > 0) {
sustainedDps = magDamage / (timeToEmpty + reload);
if (magDamage !== null && stats.reload !== null && timeToEmpty !== null && (timeToEmpty + stats.reload) > 0) {
sustainedDps = magDamage / (timeToEmpty + stats.reload);
}
let ttk = null;
@@ -64,6 +182,7 @@ const calculate = () => {
outputs.timeToEmpty.textContent = formatValue(timeToEmpty, 's');
outputs.magDamage.textContent = formatValue(magDamage);
outputs.ttk.textContent = formatValue(ttk, 's');
updateActiveModifierList(modifiers);
};
const setFormValue = (name, value) => {
@@ -84,6 +203,7 @@ const applyWeapon = (weapon) => {
setFormValue('rpm', weapon.rpm ?? '');
setFormValue('magazine', weapon.mag_size ?? '');
setFormValue('reload', weapon.reload_time ?? '');
currentWeaponTypes = weapon.guntype ?? [];
calculate();
};
@@ -111,7 +231,8 @@ const loadWeaponLibrary = async () => {
damage_multiplier: weapon.damage_multiplier ?? 1,
rpm: weapon.rpm,
mag_size: weapon.mag_size,
reload_time: weapon.reload_time
reload_time: weapon.reload_time,
guntype: weapon.guntype ?? []
}))
.sort((a, b) => a.name.localeCompare(b.name));
@@ -132,10 +253,134 @@ const loadWeaponLibrary = async () => {
}
};
const createModifierOption = (modifier, container) => {
if (!modifier || !container) {
return;
}
const id = modifier.id ?? `${modifier.label}-${Math.random().toString(36).slice(2)}`;
modifiersById.set(id, modifier);
const wrapper = document.createElement('label');
wrapper.className = 'af-checkbox';
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.dataset.modifierId = id;
if (modifier.operatives) {
checkbox.dataset.operatives = modifier.operatives;
}
if (modifier.appliesTo) {
checkbox.dataset.appliesTo = modifier.appliesTo;
}
const label = document.createElement('span');
label.textContent = modifier.label ?? 'Unnamed modifier';
wrapper.appendChild(checkbox);
wrapper.appendChild(label);
container.appendChild(wrapper);
};
const loadModifierLibrary = async () => {
if (!modifierStack || !modifierStack.dataset.source) {
return;
}
try {
const response = await fetch(modifierStack.dataset.source);
if (!response.ok) {
throw new Error('Failed to load modifiers');
}
const payload = await response.json();
itemModifierList.innerHTML = '';
perkModifierList.innerHTML = '';
abilityModifierList.innerHTML = '';
payload.itemBuffs?.forEach((modifier) => createModifierOption(modifier, itemModifierList));
payload.operativePerks?.forEach((modifier) => createModifierOption(modifier, perkModifierList));
payload.abilityPerks?.forEach((modifier) => createModifierOption(modifier, abilityModifierList));
updateModifierAvailability();
} catch (error) {
console.error('Modifier library load failed', error);
const errorNote = document.createElement('p');
errorNote.className = 'af-empty-hint';
errorNote.textContent = 'Modifier library unavailable.';
itemModifierList.appendChild(errorNote.cloneNode(true));
perkModifierList.appendChild(errorNote.cloneNode(true));
abilityModifierList.appendChild(errorNote);
}
};
const renderOperativeModifiers = (operative) => {
if (!operativeModifierList) {
return;
}
operativeModifierList.innerHTML = '';
if (!operative || !Array.isArray(operative.modifiers) || !operative.modifiers.length) {
const placeholder = document.createElement('p');
placeholder.className = 'af-empty-hint';
placeholder.textContent = 'No operative-specific modifiers available.';
operativeModifierList.appendChild(placeholder);
return;
}
operative.modifiers.forEach((modifier) => createModifierOption(modifier, operativeModifierList));
};
const loadOperatives = async () => {
if (!operativeSelect || !operativeSelect.dataset.source) {
return;
}
try {
const response = await fetch(operativeSelect.dataset.source);
if (!response.ok) {
throw new Error('Failed to load operatives');
}
const payload = await response.json();
payload.operatives?.forEach((operative) => {
if (!operative || !operative.name) {
return;
}
operativesById.set(operative.id, operative);
const option = document.createElement('option');
option.value = operative.id;
option.textContent = operative.name;
option.dataset.category = operative.category ?? '';
operativeSelect.appendChild(option);
});
updateModifierAvailability();
} catch (error) {
console.error('Operative load failed', error);
const option = document.createElement('option');
option.textContent = 'Operatives unavailable';
option.disabled = true;
operativeSelect.appendChild(option);
}
};
const updateModifierAvailability = () => {
const checkboxes = document.querySelectorAll('[data-modifier-id]');
checkboxes.forEach((checkbox) => {
const requiredOperative = checkbox.dataset.operatives;
const requiredType = checkbox.dataset.appliesTo;
const wrapper = checkbox.closest('.af-checkbox');
const operativeMatch = requiredOperative ? matchesOperative(requiredOperative) : true;
const typeMatch = requiredType ? matchesWeaponType(requiredType) : true;
const isEnabled = operativeMatch && typeMatch;
checkbox.disabled = !isEnabled;
if (wrapper) {
wrapper.classList.toggle('is-disabled', !isEnabled);
}
if (!isEnabled) {
checkbox.checked = false;
}
});
};
form.addEventListener('input', calculate);
resetButton.addEventListener('click', () => {
form.reset();
currentWeaponTypes = [];
updateModifierAvailability();
calculate();
});
@@ -143,12 +388,31 @@ if (weaponSelect) {
weaponSelect.addEventListener('change', (event) => {
const selected = weaponsById.get(event.target.value);
if (!selected) {
currentWeaponTypes = [];
updateModifierAvailability();
return;
}
applyWeapon(selected);
updateModifierAvailability();
});
}
if (operativeSelect) {
operativeSelect.addEventListener('change', (event) => {
const selected = operativesById.get(event.target.value);
currentOperativeCategory = selected?.category ?? null;
renderOperativeModifiers(selected);
updateModifierAvailability();
calculate();
});
}
document.addEventListener('change', (event) => {
if (event.target && event.target.matches('[data-modifier-id]')) {
calculate();
}
});
copyButton.addEventListener('click', async () => {
const data = new FormData(form);
const name = data.get('weaponName') || 'Unnamed loadout';
@@ -179,3 +443,5 @@ copyButton.addEventListener('click', async () => {
calculate();
loadWeaponLibrary();
loadModifierLibrary();
loadOperatives();