448 lines
15 KiB
JavaScript
448 lines
15 KiB
JavaScript
const form = document.getElementById('af-calculator');
|
|
const weaponSelect = document.getElementById('af-weapon-select');
|
|
const outputs = {
|
|
shotDamage: document.querySelector('[data-output="shotDamage"]'),
|
|
burstDps: document.querySelector('[data-output="burstDps"]'),
|
|
sustainedDps: document.querySelector('[data-output="sustainedDps"]'),
|
|
timeToEmpty: document.querySelector('[data-output="timeToEmpty"]'),
|
|
magDamage: document.querySelector('[data-output="magDamage"]'),
|
|
ttk: document.querySelector('[data-output="ttk"]')
|
|
};
|
|
|
|
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);
|
|
return Number.isFinite(parsed) ? parsed : null;
|
|
};
|
|
|
|
const formatValue = (value, suffix = '') => {
|
|
if (value === null) {
|
|
return '--';
|
|
}
|
|
const formatted = Number.isFinite(value) ? value.toFixed(2) : '--';
|
|
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'));
|
|
const projectiles = numberOrNull(data.get('projectiles')) ?? 1;
|
|
const rpm = numberOrNull(data.get('rpm'));
|
|
const magazine = numberOrNull(data.get('magazine'));
|
|
const reload = numberOrNull(data.get('reload'));
|
|
const targetHealth = numberOrNull(data.get('targetHealth'));
|
|
|
|
if (!baseDamage || !rpm) {
|
|
Object.values(outputs).forEach((node) => {
|
|
node.textContent = '--';
|
|
});
|
|
updateActiveModifierList([]);
|
|
return;
|
|
}
|
|
|
|
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 && stats.reload !== null && timeToEmpty !== null && (timeToEmpty + stats.reload) > 0) {
|
|
sustainedDps = magDamage / (timeToEmpty + stats.reload);
|
|
}
|
|
|
|
let ttk = null;
|
|
if (targetHealth && burstDps > 0) {
|
|
ttk = targetHealth / burstDps;
|
|
}
|
|
|
|
outputs.shotDamage.textContent = formatValue(shotDamage);
|
|
outputs.burstDps.textContent = formatValue(burstDps);
|
|
outputs.sustainedDps.textContent = formatValue(sustainedDps);
|
|
outputs.timeToEmpty.textContent = formatValue(timeToEmpty, 's');
|
|
outputs.magDamage.textContent = formatValue(magDamage);
|
|
outputs.ttk.textContent = formatValue(ttk, 's');
|
|
updateActiveModifierList(modifiers);
|
|
};
|
|
|
|
const setFormValue = (name, value) => {
|
|
const field = form.elements[name];
|
|
if (!field) {
|
|
return;
|
|
}
|
|
field.value = value ?? '';
|
|
};
|
|
|
|
const applyWeapon = (weapon) => {
|
|
if (!weapon) {
|
|
return;
|
|
}
|
|
setFormValue('weaponName', weapon.name ?? '');
|
|
setFormValue('baseDamage', weapon.damage ?? '');
|
|
setFormValue('projectiles', weapon.damage_multiplier ?? 1);
|
|
setFormValue('rpm', weapon.rpm ?? '');
|
|
setFormValue('magazine', weapon.mag_size ?? '');
|
|
setFormValue('reload', weapon.reload_time ?? '');
|
|
currentWeaponTypes = weapon.guntype ?? [];
|
|
calculate();
|
|
};
|
|
|
|
const loadWeaponLibrary = async () => {
|
|
if (!weaponSelect) {
|
|
return;
|
|
}
|
|
const source = weaponSelect.dataset.source;
|
|
if (!source) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(source);
|
|
if (!response.ok) {
|
|
throw new Error('Failed to load weapons');
|
|
}
|
|
const weapons = await response.json();
|
|
const normalized = weapons
|
|
.filter((weapon) => weapon && weapon.name)
|
|
.map((weapon) => ({
|
|
id: weapon.id ?? weapon.name,
|
|
name: weapon.name,
|
|
damage: weapon.damage,
|
|
damage_multiplier: weapon.damage_multiplier ?? 1,
|
|
rpm: weapon.rpm,
|
|
mag_size: weapon.mag_size,
|
|
reload_time: weapon.reload_time,
|
|
guntype: weapon.guntype ?? []
|
|
}))
|
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
|
|
normalized.forEach((weapon) => {
|
|
const key = String(weapon.id);
|
|
weaponsById.set(key, weapon);
|
|
const option = document.createElement('option');
|
|
option.value = key;
|
|
option.textContent = weapon.name;
|
|
weaponSelect.appendChild(option);
|
|
});
|
|
} catch (error) {
|
|
console.error('Weapon library load failed', error);
|
|
const option = document.createElement('option');
|
|
option.textContent = 'Weapon library unavailable';
|
|
option.disabled = true;
|
|
weaponSelect.appendChild(option);
|
|
}
|
|
};
|
|
|
|
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();
|
|
});
|
|
|
|
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';
|
|
const payload = [
|
|
`Loadout: ${name}`,
|
|
`Damage per shot: ${outputs.shotDamage.textContent}`,
|
|
`Burst DPS: ${outputs.burstDps.textContent}`,
|
|
`Sustained DPS: ${outputs.sustainedDps.textContent}`,
|
|
`Time to empty: ${outputs.timeToEmpty.textContent}`,
|
|
`Magazine damage: ${outputs.magDamage.textContent}`,
|
|
`Time to eliminate: ${outputs.ttk.textContent}`
|
|
].join('\n');
|
|
|
|
try {
|
|
await navigator.clipboard.writeText(payload);
|
|
copyButton.textContent = 'Copied!';
|
|
setTimeout(() => {
|
|
copyButton.textContent = 'Copy summary';
|
|
}, 2000);
|
|
} catch (error) {
|
|
console.error('Clipboard copy failed', error);
|
|
copyButton.textContent = 'Copy failed';
|
|
setTimeout(() => {
|
|
copyButton.textContent = 'Copy summary';
|
|
}, 2000);
|
|
}
|
|
});
|
|
|
|
calculate();
|
|
loadWeaponLibrary();
|
|
loadModifierLibrary();
|
|
loadOperatives();
|