Enhance calculator modifiers and UI styling
This commit is contained in:
+271
-5
@@ -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();
|
||||
|
||||
+159
-4
@@ -56,6 +56,12 @@ h1 {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.af-body h2,
|
||||
.af-body h3,
|
||||
.af-body h4 {
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.background-roster {
|
||||
background-color: rgba(255, 255, 255, 0.8); /* Semi-transparent background for better readability */
|
||||
padding: 20px;
|
||||
@@ -579,26 +585,50 @@ body.menu-open {
|
||||
.af-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
gap: 10px;
|
||||
padding: 6px;
|
||||
border-radius: 999px;
|
||||
background: rgba(15, 23, 42, 0.65);
|
||||
border: 1px solid rgba(148, 163, 184, 0.3);
|
||||
box-shadow: inset 0 0 0 1px rgba(56, 189, 248, 0.1);
|
||||
}
|
||||
|
||||
.af-nav-link {
|
||||
padding: 8px 16px;
|
||||
position: relative;
|
||||
padding: 10px 18px;
|
||||
border-radius: 999px;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
color: #e2e8f0;
|
||||
background: rgba(148, 163, 184, 0.12);
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.af-nav-link:hover {
|
||||
border-color: rgba(56, 189, 248, 0.4);
|
||||
background: rgba(56, 189, 248, 0.2);
|
||||
background: rgba(56, 189, 248, 0.15);
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.af-nav-link::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
right: 16px;
|
||||
bottom: 6px;
|
||||
height: 2px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(90deg, rgba(56, 189, 248, 0), rgba(56, 189, 248, 0.9), rgba(34, 197, 94, 0.9), rgba(34, 197, 94, 0));
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.af-nav-link:hover::after,
|
||||
.af-nav-link:focus-visible::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.af-nav-toggle {
|
||||
display: none;
|
||||
padding: 8px 16px;
|
||||
@@ -842,6 +872,131 @@ body.menu-open {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.af-stat-grid {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.af-stat-card {
|
||||
padding: 16px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
background: rgba(2, 6, 23, 0.6);
|
||||
}
|
||||
|
||||
.af-stat-label {
|
||||
margin: 0 0 6px;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.af-stat-value {
|
||||
margin: 0;
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.af-list {
|
||||
margin: 16px 0 0;
|
||||
padding-left: 20px;
|
||||
color: #cbd5f5;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.af-pill-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.af-pill {
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(56, 189, 248, 0.15);
|
||||
border: 1px solid rgba(56, 189, 248, 0.35);
|
||||
color: #e2e8f0;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.af-modifier-stack {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.af-modifier-subset {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.af-modifier-title {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.af-checkbox-grid {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.af-checkbox {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
background: rgba(2, 6, 23, 0.55);
|
||||
color: #e2e8f0;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.af-checkbox input[type="checkbox"] {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.af-checkbox.is-disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.af-active-modifiers {
|
||||
margin-top: 20px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.2);
|
||||
}
|
||||
|
||||
.af-tag-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.af-tag {
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(15, 23, 42, 0.8);
|
||||
border: 1px solid rgba(148, 163, 184, 0.25);
|
||||
color: #e2e8f0;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.af-empty-hint {
|
||||
margin: 0;
|
||||
color: #94a3b8;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.af-header {
|
||||
flex-wrap: wrap;
|
||||
|
||||
Reference in New Issue
Block a user