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();
+159 -4
View File
@@ -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;
+51 -6
View File
@@ -34,8 +34,8 @@
<div class="af-card-header">
<h1>About the Anomaly Front toolkit</h1>
<p>
This site is the staging ground for the toolkit. We are rebuilding every tool from the ground up to
avoid legacy stats, old branding, or outdated data.
This is the war room for balancing the Anomaly Front sandbox. We are rebuilding every tool from the ground
up to avoid legacy stats, outdated data, and mismatched tuning guidelines.
</p>
</div>
<div class="af-copy-grid">
@@ -50,7 +50,7 @@
<h2>What is live today</h2>
<ul>
<li>Baseline combat calculator</li>
<li>Notes and modifier placeholders</li>
<li>Modifiers + operative stacks</li>
<li>Feedback capture workflow</li>
</ul>
</div>
@@ -63,18 +63,54 @@
</ul>
</div>
</div>
<div class="af-stat-grid">
<div class="af-stat-card">
<p class="af-stat-label">Weapon types tracked</p>
<p class="af-stat-value">20+</p>
</div>
<div class="af-stat-card">
<p class="af-stat-label">Community draft builds</p>
<p class="af-stat-value">120+</p>
</div>
<div class="af-stat-card">
<p class="af-stat-label">Balance focus</p>
<p class="af-stat-value">Time-to-eliminate</p>
</div>
</div>
</section>
<section class="af-grid">
<div class="af-card">
<div class="af-card-header">
<h2>Team notes</h2>
<h2>How we work</h2>
<p>
This is a community-driven project. Use the feedback channel to share balancing ideas, feature
requests, and UI improvements.
Every number starts as a hypothesis. We iterate fast, log the results, and tag each change with
context so the community can track the why behind each adjustment.
</p>
</div>
<ul class="af-list">
<li>Prototype numbers in the calculator.</li>
<li>Layer in modifiers and operative perks.</li>
<li>Review pacing against target roles.</li>
</ul>
</div>
<div class="af-card">
<div class="af-card-header">
<h2>Community signals</h2>
<p>
We highlight the most requested tweaks, theme them by role, and bring them into each balancing pass.
</p>
</div>
<div class="af-pill-grid">
<span class="af-pill">Time-to-eliminate feels heavy</span>
<span class="af-pill">Operative synergy clarity</span>
<span class="af-pill">Reload pacing in late waves</span>
<span class="af-pill">Attachment power spikes</span>
</div>
</div>
</section>
<section class="af-grid">
<div class="af-card">
<div class="af-card-header">
<h2>Release posture</h2>
@@ -84,6 +120,15 @@
</p>
</div>
</div>
<div class="af-card">
<div class="af-card-header">
<h2>Team notes</h2>
<p>
This is a community-driven project. Use the feedback channel to share balancing ideas, feature
requests, and UI improvements.
</p>
</div>
</div>
</section>
</main>
+40 -5
View File
@@ -148,12 +148,47 @@
<section class="af-grid">
<div class="af-card">
<div class="af-card-header">
<h2>Modifier library</h2>
<p>No live modifiers yet. Use this space to jot down prototype buffs.</p>
<h2>Modifiers &amp; operatives</h2>
<p>Stack items, perks, and abilities with operative-specific bonuses.</p>
</div>
<div class="af-modifier-stack" data-source="{{ url_for('static', filename='json/modifiers.json') }}">
<label>
Operative
<select id="af-operative-select" name="operativeSelect"
data-source="{{ url_for('static', filename='json/operative.json') }}">
<option value="">No operative selected</option>
</select>
<span class="af-field-hint">Pick an operative to load their unique modifiers.</span>
</label>
<div class="af-modifier-subset">
<p class="af-modifier-title">Operative modifiers</p>
<div class="af-checkbox-grid" id="af-operative-modifiers">
<p class="af-empty-hint">Select an operative to see perk modifiers.</p>
</div>
</div>
<div class="af-modifier-subset">
<p class="af-modifier-title">Item buffs</p>
<div class="af-checkbox-grid" id="af-item-modifiers"></div>
</div>
<div class="af-modifier-subset">
<p class="af-modifier-title">Operative perks</p>
<div class="af-checkbox-grid" id="af-perk-modifiers"></div>
</div>
<div class="af-modifier-subset">
<p class="af-modifier-title">Ability perks</p>
<div class="af-checkbox-grid" id="af-ability-modifiers"></div>
</div>
</div>
<div class="af-active-modifiers">
<p class="af-modifier-title">Active stack</p>
<div class="af-tag-list" id="af-active-modifiers">
<span class="af-tag">No modifiers selected</span>
</div>
<div class="af-empty">
<p>Modifier slots are empty.</p>
<button type="button" class="af-muted-button">Add placeholder</button>
</div>
</div>
<div class="af-card">