Merge pull request #4 from Fretuks/improve-css-styles-for-visibility

Add modifier stacking UI + operative support and polish nav/about styles
This commit is contained in:
2026-01-14 20:45:27 +01:00
committed by GitHub
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 resetButton = document.getElementById('af-reset');
const copyButton = document.getElementById('af-copy'); const copyButton = document.getElementById('af-copy');
const weaponsById = new Map(); 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 numberOrNull = (value) => {
const parsed = Number.parseFloat(value); const parsed = Number.parseFloat(value);
@@ -26,6 +37,102 @@ const formatValue = (value, suffix = '') => {
return `${formatted}${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 calculate = () => {
const data = new FormData(form); const data = new FormData(form);
const baseDamage = numberOrNull(data.get('baseDamage')); const baseDamage = numberOrNull(data.get('baseDamage'));
@@ -39,18 +146,29 @@ const calculate = () => {
Object.values(outputs).forEach((node) => { Object.values(outputs).forEach((node) => {
node.textContent = '--'; node.textContent = '--';
}); });
updateActiveModifierList([]);
return; return;
} }
const shotDamage = baseDamage * projectiles; const modifiers = getSelectedModifiers();
const shotsPerSecond = rpm / 60; 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 burstDps = shotDamage * shotsPerSecond;
const magDamage = magazine ? shotDamage * magazine : null; const magDamage = magazine ? shotDamage * magazine : null;
const timeToEmpty = magazine ? magazine / shotsPerSecond : null; const timeToEmpty = magazine ? magazine / shotsPerSecond : null;
let sustainedDps = null; let sustainedDps = null;
if (magDamage !== null && reload !== null && timeToEmpty !== null && (timeToEmpty + reload) > 0) { if (magDamage !== null && stats.reload !== null && timeToEmpty !== null && (timeToEmpty + stats.reload) > 0) {
sustainedDps = magDamage / (timeToEmpty + reload); sustainedDps = magDamage / (timeToEmpty + stats.reload);
} }
let ttk = null; let ttk = null;
@@ -64,6 +182,7 @@ const calculate = () => {
outputs.timeToEmpty.textContent = formatValue(timeToEmpty, 's'); outputs.timeToEmpty.textContent = formatValue(timeToEmpty, 's');
outputs.magDamage.textContent = formatValue(magDamage); outputs.magDamage.textContent = formatValue(magDamage);
outputs.ttk.textContent = formatValue(ttk, 's'); outputs.ttk.textContent = formatValue(ttk, 's');
updateActiveModifierList(modifiers);
}; };
const setFormValue = (name, value) => { const setFormValue = (name, value) => {
@@ -84,6 +203,7 @@ const applyWeapon = (weapon) => {
setFormValue('rpm', weapon.rpm ?? ''); setFormValue('rpm', weapon.rpm ?? '');
setFormValue('magazine', weapon.mag_size ?? ''); setFormValue('magazine', weapon.mag_size ?? '');
setFormValue('reload', weapon.reload_time ?? ''); setFormValue('reload', weapon.reload_time ?? '');
currentWeaponTypes = weapon.guntype ?? [];
calculate(); calculate();
}; };
@@ -111,7 +231,8 @@ const loadWeaponLibrary = async () => {
damage_multiplier: weapon.damage_multiplier ?? 1, damage_multiplier: weapon.damage_multiplier ?? 1,
rpm: weapon.rpm, rpm: weapon.rpm,
mag_size: weapon.mag_size, 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)); .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); form.addEventListener('input', calculate);
resetButton.addEventListener('click', () => { resetButton.addEventListener('click', () => {
form.reset(); form.reset();
currentWeaponTypes = [];
updateModifierAvailability();
calculate(); calculate();
}); });
@@ -143,12 +388,31 @@ if (weaponSelect) {
weaponSelect.addEventListener('change', (event) => { weaponSelect.addEventListener('change', (event) => {
const selected = weaponsById.get(event.target.value); const selected = weaponsById.get(event.target.value);
if (!selected) { if (!selected) {
currentWeaponTypes = [];
updateModifierAvailability();
return; return;
} }
applyWeapon(selected); 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 () => { copyButton.addEventListener('click', async () => {
const data = new FormData(form); const data = new FormData(form);
const name = data.get('weaponName') || 'Unnamed loadout'; const name = data.get('weaponName') || 'Unnamed loadout';
@@ -179,3 +443,5 @@ copyButton.addEventListener('click', async () => {
calculate(); calculate();
loadWeaponLibrary(); loadWeaponLibrary();
loadModifierLibrary();
loadOperatives();
+159 -4
View File
@@ -56,6 +56,12 @@ h1 {
margin-bottom: 20px; margin-bottom: 20px;
} }
.af-body h2,
.af-body h3,
.af-body h4 {
color: #f8fafc;
}
.background-roster { .background-roster {
background-color: rgba(255, 255, 255, 0.8); /* Semi-transparent background for better readability */ background-color: rgba(255, 255, 255, 0.8); /* Semi-transparent background for better readability */
padding: 20px; padding: 20px;
@@ -579,26 +585,50 @@ body.menu-open {
.af-nav { .af-nav {
display: flex; display: flex;
align-items: center; 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 { .af-nav-link {
padding: 8px 16px; position: relative;
padding: 10px 18px;
border-radius: 999px; border-radius: 999px;
text-decoration: none; text-decoration: none;
font-weight: 600; font-weight: 600;
color: #e2e8f0; color: #e2e8f0;
background: rgba(148, 163, 184, 0.12); background: transparent;
border: 1px solid transparent; border: 1px solid transparent;
transition: all 0.2s ease; transition: all 0.2s ease;
} }
.af-nav-link:hover { .af-nav-link:hover {
border-color: rgba(56, 189, 248, 0.4); border-color: rgba(56, 189, 248, 0.4);
background: rgba(56, 189, 248, 0.2); background: rgba(56, 189, 248, 0.15);
color: #f8fafc; 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 { .af-nav-toggle {
display: none; display: none;
padding: 8px 16px; padding: 8px 16px;
@@ -842,6 +872,131 @@ body.menu-open {
margin-top: 20px; 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) { @media (max-width: 820px) {
.af-header { .af-header {
flex-wrap: wrap; flex-wrap: wrap;
+51 -6
View File
@@ -34,8 +34,8 @@
<div class="af-card-header"> <div class="af-card-header">
<h1>About the Anomaly Front toolkit</h1> <h1>About the Anomaly Front toolkit</h1>
<p> <p>
This site is the staging ground for the toolkit. We are rebuilding every tool from the ground up to This is the war room for balancing the Anomaly Front sandbox. We are rebuilding every tool from the ground
avoid legacy stats, old branding, or outdated data. up to avoid legacy stats, outdated data, and mismatched tuning guidelines.
</p> </p>
</div> </div>
<div class="af-copy-grid"> <div class="af-copy-grid">
@@ -50,7 +50,7 @@
<h2>What is live today</h2> <h2>What is live today</h2>
<ul> <ul>
<li>Baseline combat calculator</li> <li>Baseline combat calculator</li>
<li>Notes and modifier placeholders</li> <li>Modifiers + operative stacks</li>
<li>Feedback capture workflow</li> <li>Feedback capture workflow</li>
</ul> </ul>
</div> </div>
@@ -63,18 +63,54 @@
</ul> </ul>
</div> </div>
</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>
<section class="af-grid"> <section class="af-grid">
<div class="af-card"> <div class="af-card">
<div class="af-card-header"> <div class="af-card-header">
<h2>Team notes</h2> <h2>How we work</h2>
<p> <p>
This is a community-driven project. Use the feedback channel to share balancing ideas, feature Every number starts as a hypothesis. We iterate fast, log the results, and tag each change with
requests, and UI improvements. context so the community can track the why behind each adjustment.
</p> </p>
</div> </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>
<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">
<div class="af-card-header"> <div class="af-card-header">
<h2>Release posture</h2> <h2>Release posture</h2>
@@ -84,6 +120,15 @@
</p> </p>
</div> </div>
</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> </section>
</main> </main>
+40 -5
View File
@@ -148,12 +148,47 @@
<section class="af-grid"> <section class="af-grid">
<div class="af-card"> <div class="af-card">
<div class="af-card-header"> <div class="af-card-header">
<h2>Modifier library</h2> <h2>Modifiers &amp; operatives</h2>
<p>No live modifiers yet. Use this space to jot down prototype buffs.</p> <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>
<div class="af-empty">
<p>Modifier slots are empty.</p>
<button type="button" class="af-muted-button">Add placeholder</button>
</div> </div>
</div> </div>
<div class="af-card"> <div class="af-card">