diff --git a/index.html b/index.html index df0eb61..40a0ade 100644 --- a/index.html +++ b/index.html @@ -156,6 +156,7 @@
+ 2 slow fast @@ -183,6 +184,7 @@
+ 5 slow fast @@ -192,6 +194,7 @@
+ 5 rare frequent @@ -201,6 +204,7 @@
+ 90% 0% 100% @@ -210,6 +214,7 @@
+ 8% 0% 100% @@ -219,6 +224,7 @@
+ 2 0 10 @@ -227,16 +233,16 @@
- +
- +
letter hit count
off -
+
+ detach number +
+ off + + on +
+
+
+ +
+ 50 + 0 + + 5000 +
+
+
+ +
+ 100 + 0 + + 5000 +
+
diff --git a/src/js/constants.js b/src/js/constants.js index cdf653d..4c887e4 100644 --- a/src/js/constants.js +++ b/src/js/constants.js @@ -76,9 +76,9 @@ export const DEFAULTS = { /** Word base velocity in px/frame (slow / heavy feel) */ LOGO_WORD_BASE_SPEED: 0.08, /** Min bump count before ejection threshold can be reached */ - LOGO_BUMP_THRESHOLD_MIN: 500, + LOGO_BUMP_THRESHOLD_MIN: 1000, /** Max bump count before ejection threshold */ - LOGO_BUMP_THRESHOLD_MAX: 5000, + LOGO_BUMP_THRESHOLD_MAX: 1200, /** Spring constant pulling an ejected letter back toward its slot */ LOGO_SPRING_K: 0.04, /** Per-frame velocity damping on ejected letters (0–1) */ diff --git a/src/js/evolution.js b/src/js/evolution.js index d77707d..f0ae755 100644 --- a/src/js/evolution.js +++ b/src/js/evolution.js @@ -155,8 +155,17 @@ export class EvolutionController { this.#entities.forEach(e => e.destroy()); this.#entities = []; this.#startTime = Date.now(); // reset lifetime counter + + // also tell the logo word to forget its bump counts; the visual labels + // should go back to `0` immediately + this.#logo?.resetCounters(); + this.#logo?.resetThresholds(); + // Wipe saved state so a page reload starts fresh try { localStorage.removeItem(EVOLUTION_STORAGE_KEY); } catch { /* ignore */ } + + // notify anyone listening (settings controller) that evolution was cleared + document.dispatchEvent(new Event('evolutionCleared')); } /** Stop all timers and the rAF loop; remove all entities from the DOM. */ diff --git a/src/js/logoController.js b/src/js/logoController.js index af4c446..a51af30 100644 --- a/src/js/logoController.js +++ b/src/js/logoController.js @@ -41,11 +41,28 @@ export class LogoController { /** @type {number} */ #wordVy = 0; /** @type {LogoLetter[]} */ #letters = []; + /** @type {boolean} */ #thresholdVisible = false; /** @type {HTMLElement|null} */ #container = null; /** @type {boolean} */ #ready = false; // ── Public API ───────────────────────────────────────────── + /** + * Randomise the bump threshold for every letter using the current min/max + * settings and update the on‑screen labels. Called when settings change or + * when evolution is restarted. + */ + resetThresholds() { + for (const letter of this.#letters) { + letter.bumpThreshold = DEFAULTS.LOGO_BUMP_THRESHOLD_MIN + + Math.floor(Math.random() + * (DEFAULTS.LOGO_BUMP_THRESHOLD_MAX - DEFAULTS.LOGO_BUMP_THRESHOLD_MIN)); + if (letter.thresholdEl) { + letter.thresholdEl.textContent = String(letter.bumpThreshold); + } + } + } + /** * Mount all letter DOM nodes and initialise physics. * @@ -167,6 +184,14 @@ export class LogoController { const ejVx = -nx * DEFAULTS.LOGO_EJECT_IMPULSE; const ejVy = -ny * DEFAULTS.LOGO_EJECT_IMPULSE; letter.eject(ejVx, ejVy); + // after detaching, reset bump counter and pick a fresh threshold + letter.bumpCount = 0; + letter.bumpThreshold = DEFAULTS.LOGO_BUMP_THRESHOLD_MIN + + Math.floor(Math.random() + * (DEFAULTS.LOGO_BUMP_THRESHOLD_MAX - DEFAULTS.LOGO_BUMP_THRESHOLD_MIN)); + if (letter.thresholdEl) { + letter.thresholdEl.textContent = String(letter.bumpThreshold); + } } } } @@ -179,6 +204,9 @@ export class LogoController { * @returns {object} */ serialise() { + // Note: we deliberately do **not** persist each letter's bumpThreshold. + // When the page reloads we want thresholds to be freshly chosen from the + // current min/max settings rather than sticking to whatever they were. return { wordX: this.#wordX, wordY: this.#wordY, @@ -188,7 +216,7 @@ export class LogoController { slotIndex: l.slotIndex, iconName: l.iconName, bumpCount: l.bumpCount, - bumpThreshold: l.bumpThreshold, + // bumpThreshold intentionally omitted ejected: l.ejected, x: l.x, y: l.y, @@ -217,6 +245,55 @@ export class LogoController { } } + /** + * Toggle the visibility of the bump-threshold numbers on every letter. + * @param {boolean} visible + */ + setThresholdVisible(visible) { + this.#thresholdVisible = visible; + for (const letter of this.#letters) { + letter.setThresholdVisible(visible); + } + } + + /** + * Return an array of the current bump counts for each letter (slot order). + * Used by SettingsController so the zero values are included when persisting. + * @returns {number[]} + */ + getHitCounts() { + return this.#letters.map(l => l.bumpCount); + } + + /** + * Apply previously saved bump counts back onto the letters. If the array is + * shorter/longer than the current word, it is truncated or padded with zeros. + * @param {number[]} counts + */ + setHitCounts(counts) { + if (!Array.isArray(counts)) return; + for (let i = 0; i < this.#letters.length; i++) { + this.#letters[i].bumpCount = counts[i] ?? 0; + if (this.#letters[i].debugEl) { + this.#letters[i].debugEl.textContent = String(this.#letters[i].bumpCount); + } + } + } + + /** + * Reset all bump counters back to zero (used when evolution is cleared). + */ + resetCounters() { + for (const letter of this.#letters) { + letter.bumpCount = 0; + if (letter.debugEl) letter.debugEl.textContent = '0'; + // remove any red proximity colouring so letters appear white again + if (typeof letter._resetProximityColor === 'function') { + letter._resetProximityColor(); + } + } + } + // ── Private ──────────────────────────────────────────────── /** Set up a brand-new word at the viewport centre with a random direction. */ @@ -242,13 +319,20 @@ export class LogoController { this.#wordVx = state.wordVx ?? 0; this.#wordVy = state.wordVy ?? DEFAULTS.LOGO_WORD_BASE_SPEED; + // Ignore saved bumpThreshold; always regenerate using current settings this.#letters = WORD_ICONS.map((iconName, i) => { const saved = state.letters?.[i]; - return new LogoLetter({ + const letter = new LogoLetter({ iconName, slotIndex: i, - bumpThreshold: saved?.bumpThreshold, + // no bumpThreshold argument -> constructor uses DEFAULTS range }); + // restore bumpCount/ejected/position if present + if (saved) { + letter.bumpCount = saved.bumpCount ?? 0; + letter.ejected = saved.ejected ?? false; + } + return letter; }); } @@ -265,11 +349,21 @@ export class LogoController { await letter.mount(this.#container); if (!letter.mounted) return; + // apply current threshold visibility immediately + if (this.#thresholdVisible) { + letter.setThresholdVisible(true); + } + const saved = savedLetterStates?.[i]; if (saved) { letter.bumpCount = saved.bumpCount ?? 0; letter.ejected = saved.ejected ?? false; + // reflect proximity colour based on the restored bump count + if (typeof letter._updateProximityColor === 'function') { + letter._updateProximityColor(); + } + if (saved.ejected) { letter.x = saved.x ?? letter.x; letter.y = saved.y ?? letter.y; diff --git a/src/js/logoLetter.js b/src/js/logoLetter.js index 3a1fd5a..b7828c2 100644 --- a/src/js/logoLetter.js +++ b/src/js/logoLetter.js @@ -39,6 +39,7 @@ export class LogoLetter { /** @type {HTMLElement|null} */ el = null; /** @type {HTMLElement|null} */ bodyEl = null; /** @type {HTMLElement|null} */ debugEl = null; + /** @type {HTMLElement|null} */ thresholdEl = null; /** @type {boolean} */ mounted = false; /** @@ -80,7 +81,13 @@ export class LogoLetter { this.debugEl.className = 'logo-letter__debug'; this.debugEl.textContent = '0'; + // threshold label sits above the letter + this.thresholdEl = document.createElement('span'); + this.thresholdEl.className = 'logo-letter__threshold'; + this.thresholdEl.textContent = String(this.bumpThreshold); + this.el.appendChild(this.bodyEl); + this.el.appendChild(this.thresholdEl); this.el.appendChild(this.debugEl); container.appendChild(this.el); this.mounted = true; @@ -177,8 +184,19 @@ export class LogoLetter { onBump() { this.bumpCount++; if (this.debugEl) this.debugEl.textContent = String(this.bumpCount); + + // colour the letter based on how close it is to the threshold; we only + // start showing red when there are 10 or fewer hits remaining, interpolating + // from white→red as the count rises. + this._updateProximityColor(); + this._flashHit(); - return !this.ejected && this.bumpCount >= this.bumpThreshold; + + // notify listeners that a bump occurred so the settings storage can update + document.dispatchEvent(new CustomEvent('logoLetterBumped')); + + // detach only on *next* hit after threshold reached (bumpCount > threshold) + return !this.ejected && this.bumpCount > this.bumpThreshold; } /** @@ -208,6 +226,8 @@ export class LogoLetter { * (DEFAULTS.LOGO_BUMP_THRESHOLD_MAX - DEFAULTS.LOGO_BUMP_THRESHOLD_MIN)); this.el?.classList.remove('logo-letter--ejected'); if (this.debugEl) this.debugEl.textContent = '0'; + if (this.thresholdEl) this.thresholdEl.textContent = String(this.bumpThreshold); + this._resetProximityColor(); } /** @@ -218,6 +238,37 @@ export class LogoLetter { this.el?.classList.toggle('logo-letter--show-debug', visible); } + /** + * Show or hide the threshold text. + * @param {boolean} visible + */ + setThresholdVisible(visible) { + this.el?.classList.toggle('logo-letter--show-threshold', visible); + } + + /** + * Update the body colour to indicate proximity to the bump threshold. + * Once the count is within 10 of the threshold we fade from white→red; when + * further away the colour is left to CSS default. + * This function may be called any time bumpCount/threshold changes. + */ + _updateProximityColor() { + if (!this.bodyEl || this.bumpThreshold <= 0) return; + // interpolate from white at 0 hits to solid red at threshold + const ratio = Math.min(this.bumpCount / this.bumpThreshold, 1); + const gb = Math.round(255 * (1 - ratio)); + this.bodyEl.style.color = `rgb(255,${gb},${gb})`; + } + + /** + * Clear any proximity colouring so the CSS default can take over again. + */ + _resetProximityColor() { + if (this.bodyEl) { + this.bodyEl.style.color = ''; + } + } + /** Remove this letter from the DOM. */ destroy() { this.el?.remove(); diff --git a/src/js/settings.js b/src/js/settings.js index 88ed16e..c962f8f 100644 --- a/src/js/settings.js +++ b/src/js/settings.js @@ -8,14 +8,27 @@ * saved state on the next page load so the system continues as configured. * * Reset: - * - resetBtn clears localStorage and restores DEFAULTS for settings only. - * Evolution state is reset separately via the guide panel. + * - resetBtn clears the settings storage and restores DEFAULTS for the + * *settings* that are exposed in the modal (gradient, animation, logo + * hit‑count visibility, etc). This button does **not** touch the evolution + * simulation state (entity population, saved logo hit counts, timers, etc). + * The guide panel is responsible for restarting/clearing the evolution. */ import { DEFAULTS } from './constants.js'; const STORAGE_KEY = 'devpage:settings'; +// settings storage keys +const STORAGE_HIT_COUNT_KEY = 'letterHitCountVisible'; +const STORAGE_THRESHOLD_KEY = 'letterThresholdVisible'; +const STORAGE_BUMP_MIN_KEY = 'bumpThresholdMin'; +const STORAGE_BUMP_MAX_KEY = 'bumpThresholdMax'; + +// capture original constants so reset can restore later +const ORIGINAL_BUMP_MIN = DEFAULTS.LOGO_BUMP_THRESHOLD_MIN; +const ORIGINAL_BUMP_MAX = DEFAULTS.LOGO_BUMP_THRESHOLD_MAX; + export class SettingsController { /** @type {import('./gradient.js').GradientController} */ #gradient; @@ -26,6 +39,9 @@ export class SettingsController { /** @type {import('./logoController.js').LogoController|null} */ #logo = null; + /** @type {number[]|null} */ + #savedLetterHitCounts = null; // stored when loadSaved runs before logo is ready + /** * @param {import('./gradient.js').GradientController} gradient * @param {import('./evolution.js').EvolutionController} evolution @@ -35,6 +51,26 @@ export class SettingsController { this.#evolution = evolution; this.#bindEvents(); this.#syncControls(); + + // persist counts whenever a letter is bumped so storage reflects live + // values, not just whatever was saved by the last manual change. + document.addEventListener('logoLetterBumped', () => this.#saveState()); + + // when the evolution system is restarted via the guide panel we want to + // clear the stored letter hit counts (they belong to simulation state, + // not permanent settings). remove the field so loadSaved() won't reapply it + document.addEventListener('evolutionCleared', () => { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (raw) { + const s = JSON.parse(raw); + if (s && typeof s === 'object' && 'letterHitCounts' in s) { + delete s.letterHitCounts; + localStorage.setItem(STORAGE_KEY, JSON.stringify(s)); + } + } + } catch { /* ignore */ } + }); } // ── Public ────────────────────────────────────────────────── @@ -47,8 +83,17 @@ export class SettingsController { */ setLogoController(logo) { this.#logo = logo; - const toggle = /** @type {HTMLInputElement} */ (document.getElementById('debugHitCountToggle')); + const toggle = /** @type {HTMLInputElement} */ (document.getElementById('letterHitCountToggle')); if (toggle) logo.setDebugVisible(toggle.checked); + const thrToggle = /** @type {HTMLInputElement} */ (document.getElementById('letterThresholdToggle')); + if (thrToggle) logo.setThresholdVisible(thrToggle.checked); + + // if we previously loaded hit counts before the logo was available, + // reapply them now so the labels display correctly immediately on load + if (this.#savedLetterHitCounts && typeof logo.setHitCounts === 'function') { + logo.setHitCounts(this.#savedLetterHitCounts); + this.#savedLetterHitCounts = null; + } } /** @@ -68,10 +113,35 @@ export class SettingsController { if (typeof s.virusKillChance === 'number') this.#evolution.setVirusKillChance(s.virusKillChance); if (typeof s.bugSpawnChance === 'number') this.#evolution.setBugSpawnChance(s.bugSpawnChance); if (typeof s.bugMaxCount === 'number') this.#evolution.setBugMaxCount(s.bugMaxCount); - if (typeof s.debugHitCount === 'boolean') { - const toggle = /** @type {HTMLInputElement} */ (document.getElementById('debugHitCountToggle')); - if (toggle) toggle.checked = s.debugHitCount; - this.#logo?.setDebugVisible(s.debugHitCount); + if (typeof s[STORAGE_HIT_COUNT_KEY] === 'boolean') { + const toggle = /** @type {HTMLInputElement} */ (document.getElementById('letterHitCountToggle')); + if (toggle) toggle.checked = s[STORAGE_HIT_COUNT_KEY]; + this.#logo?.setDebugVisible(s[STORAGE_HIT_COUNT_KEY]); + } + if (typeof s[STORAGE_THRESHOLD_KEY] === 'boolean') { + const toggle = /** @type {HTMLInputElement} */ (document.getElementById('letterThresholdToggle')); + if (toggle) toggle.checked = s[STORAGE_THRESHOLD_KEY]; + this.#logo?.setThresholdVisible(s[STORAGE_THRESHOLD_KEY]); + } + if (typeof s[STORAGE_BUMP_MIN_KEY] === 'number') { + DEFAULTS.LOGO_BUMP_THRESHOLD_MIN = s[STORAGE_BUMP_MIN_KEY]; + } + if (typeof s[STORAGE_BUMP_MAX_KEY] === 'number') { + DEFAULTS.LOGO_BUMP_THRESHOLD_MAX = s[STORAGE_BUMP_MAX_KEY]; + } + // restore any saved hit‑count numbers (including zeros). if the + // LogoController isn't wired yet we keep the array so setLogoController() + // can apply it later. + if (Array.isArray(s.letterHitCounts)) { + if (this.#logo && typeof this.#logo.setHitCounts === 'function') { + this.#logo.setHitCounts(s.letterHitCounts); + } else { + this.#savedLetterHitCounts = s.letterHitCounts.slice(); + } + } + // after restoring bump range, ensure letters use it + if (this.#logo && typeof this.#logo.resetThresholds === 'function') { + this.#logo.resetThresholds(); } } catch { /* corrupt storage — ignore */ } this.#syncControls(); @@ -90,21 +160,10 @@ export class SettingsController { const bugChanceSlider = /** @type {HTMLInputElement} */ (document.getElementById('bugChanceSlider')); const bugCountSlider = /** @type {HTMLInputElement} */ (document.getElementById('bugCountSlider')); const resetBtn = /** @type {HTMLButtonElement} */ (document.getElementById('resetBtn')); - const debugHitCountToggle = /** @type {HTMLInputElement} */ (document.getElementById('debugHitCountToggle')); - - // ── Colour picker ────────────────────────────────────── - colorPicker.addEventListener('input', (e) => { - const hex = /** @type {HTMLInputElement} */ (e.target).value; - colorValue.textContent = hex; - this.#gradient.setColor(hex); - this.#saveState(); - }); - - // ── Gradient animation speed ─────────────────────────── - speedSlider.addEventListener('input', (e) => { - this.#gradient.setSpeed(Number(/** @type {HTMLInputElement} */ (e.target).value)); - this.#saveState(); - }); + const letterHitCountToggle = /** @type {HTMLInputElement} */ (document.getElementById('letterHitCountToggle')); + const letterThresholdToggle = /** @type {HTMLInputElement} */ (document.getElementById('letterThresholdToggle')); + const thresholdMinInput = /** @type {HTMLInputElement} */ (document.getElementById('thresholdMinInput')); + const thresholdMaxInput = /** @type {HTMLInputElement} */ (document.getElementById('thresholdMaxInput')); // ── Rotation toggle ──────────────────────────────────── rotToggle.addEventListener('change', (e) => { @@ -112,56 +171,120 @@ export class SettingsController { this.#saveState(); }); + // ── Gradient animation speed ─────────────────────────── + speedSlider.addEventListener('input', (e) => { + const val = Number(/** @type {HTMLInputElement} */ (e.target).value); + this.#gradient.setSpeed(val); + document.getElementById('speedValue').textContent = String(val); + this.#saveState(); + }); + // ── Icon movement speed ──────────────────────────────── moveSpeedSlider.addEventListener('input', (e) => { - this.#evolution.setMoveSpeed(Number(/** @type {HTMLInputElement} */ (e.target).value)); + const val = Number(/** @type {HTMLInputElement} */ (e.target).value); + this.#evolution.setMoveSpeed(val); + document.getElementById('moveSpeedValue').textContent = String(val); this.#saveState(); }); // ── Spawn rate ───────────────────────────────────────── spawnRateSlider.addEventListener('input', (e) => { - this.#evolution.setSpawnRate(Number(/** @type {HTMLInputElement} */ (e.target).value)); + const val = Number(/** @type {HTMLInputElement} */ (e.target).value); + this.#evolution.setSpawnRate(val); + document.getElementById('spawnRateValue').textContent = String(val); this.#saveState(); }); // ── Virus lethality ──────────────────────────────────── virusKillSlider.addEventListener('input', (e) => { - this.#evolution.setVirusKillChance(Number(/** @type {HTMLInputElement} */ (e.target).value) / 100); + const num = Number(/** @type {HTMLInputElement} */ (e.target).value); + this.#evolution.setVirusKillChance(num / 100); + document.getElementById('virusKillValue').textContent = num + '%'; this.#saveState(); }); // ── Bug spawn chance ─────────────────────────────────── bugChanceSlider.addEventListener('input', (e) => { - this.#evolution.setBugSpawnChance(Number(/** @type {HTMLInputElement} */ (e.target).value) / 100); + const num = Number(/** @type {HTMLInputElement} */ (e.target).value); + this.#evolution.setBugSpawnChance(num / 100); + document.getElementById('bugChanceValue').textContent = num + '%'; + this.#saveState(); + }); + + // ── Bump threshold range ─────────────────────────────── + thresholdMinInput.addEventListener('input', (e) => { + const val = Number(/** @type {HTMLInputElement} */ (e.target).value); + DEFAULTS.LOGO_BUMP_THRESHOLD_MIN = val; + document.getElementById('thresholdMinValue').textContent = String(val); + // keep min ≤ max + if (val > DEFAULTS.LOGO_BUMP_THRESHOLD_MAX) { + DEFAULTS.LOGO_BUMP_THRESHOLD_MAX = val; + thresholdMaxInput.value = String(val); + document.getElementById('thresholdMaxValue').textContent = String(val); + } + this.#logo?.resetThresholds(); + this.#saveState(); + }); + thresholdMaxInput.addEventListener('input', (e) => { + const val = Number(/** @type {HTMLInputElement} */ (e.target).value); + DEFAULTS.LOGO_BUMP_THRESHOLD_MAX = val; + document.getElementById('thresholdMaxValue').textContent = String(val); + // keep max ≥ min + if (val < DEFAULTS.LOGO_BUMP_THRESHOLD_MIN) { + DEFAULTS.LOGO_BUMP_THRESHOLD_MIN = val; + thresholdMinInput.value = String(val); + document.getElementById('thresholdMinValue').textContent = String(val); + } + this.#logo?.resetThresholds(); this.#saveState(); }); // ── Bug max count ────────────────────────────────────── bugCountSlider.addEventListener('input', (e) => { - this.#evolution.setBugMaxCount(Number(/** @type {HTMLInputElement} */ (e.target).value)); + const num = Number(/** @type {HTMLInputElement} */ (e.target).value); + this.#evolution.setBugMaxCount(num); + document.getElementById('bugCountValue').textContent = String(num); this.#saveState(); }); - // ── Debug: letter hit count ──────────────────────────── - debugHitCountToggle.addEventListener('change', (e) => { + // ── Logo: letter hit count ──────────────────────────── + // The label on each logo letter shows how many bumps it has taken. + // Persist the visibility state along with the other settings. + letterHitCountToggle.addEventListener('change', (e) => { const on = /** @type {HTMLInputElement} */ (e.target).checked; this.#logo?.setDebugVisible(on); this.#saveState(); }); + // ── Logo: threshold number ──────────────────────────── + // Shows how many bumps are required before ejection. + letterThresholdToggle.addEventListener('change', (e) => { + const on = /** @type {HTMLInputElement} */ (e.target).checked; + this.#logo?.setThresholdVisible(on); + this.#saveState(); + }); + // ── Reset to defaults ────────────────────────────────── resetBtn.addEventListener('click', () => { localStorage.removeItem(STORAGE_KEY); this.#gradient.setColor(DEFAULTS.GRADIENT_COLOR); this.#gradient.setSpeed(DEFAULTS.GRADIENT_SPEED); this.#gradient.toggleRotation(DEFAULTS.GRADIENT_ROTATION); - this.#evolution.setMoveSpeed(DEFAULTS.MOVE_SPEED); - this.#evolution.setSpawnRate(5); - this.#evolution.setVirusKillChance(DEFAULTS.VIRUS_KILL_CHANCE); - this.#evolution.setBugSpawnChance(DEFAULTS.BUG_SPAWN_CHANCE); - this.#evolution.setBugMaxCount(DEFAULTS.BUG_MAX_COUNT); - debugHitCountToggle.checked = false; + // reset bump threshold range to original constants + DEFAULTS.LOGO_BUMP_THRESHOLD_MIN = ORIGINAL_BUMP_MIN; + DEFAULTS.LOGO_BUMP_THRESHOLD_MAX = ORIGINAL_BUMP_MAX; + thresholdMinInput.value = String(DEFAULTS.LOGO_BUMP_THRESHOLD_MIN); + thresholdMaxInput.value = String(DEFAULTS.LOGO_BUMP_THRESHOLD_MAX); + + // NOTE: moveSpeed/spawnRate/etc. are evolution configuration options that + // live in the same UI but are considered part of the simulation state. + // They are **not** reset here; clearing the evolution (including those + // values) is done via the guide panel. + letterHitCountToggle.checked = false; this.#logo?.setDebugVisible(false); + // also clear threshold display toggle + letterThresholdToggle.checked = false; + this.#logo?.setThresholdVisible(false); this.#syncControls(); }); } @@ -169,7 +292,16 @@ export class SettingsController { /** Persist current controller state to localStorage. */ #saveState() { try { - localStorage.setItem(STORAGE_KEY, JSON.stringify({ + // gather optional hit counts from the logo controller; we want to persist + // zeros as well so that a fresh word is restored exactly the same after a + // reload. The counts are also part of the evolution storage but keeping a + // copy here allows the settings key to mirror “all other settings”. + let counts = null; + if (this.#logo && typeof this.#logo.getHitCounts === 'function') { + counts = this.#logo.getHitCounts(); + } + + localStorage.setItem(STORAGE_KEY, JSON.stringify({ gradientColor: this.#gradient.color, gradientSpeed: this.#gradient.speed, gradientRotation: this.#gradient.rotating, @@ -178,7 +310,9 @@ export class SettingsController { virusKillChance: this.#evolution.virusKillChance, bugSpawnChance: this.#evolution.bugSpawnChance, bugMaxCount: this.#evolution.bugMaxCount, - debugHitCount: (/** @type {HTMLInputElement} */ (document.getElementById('debugHitCountToggle')))?.checked ?? false, + [STORAGE_HIT_COUNT_KEY]: (/** @type {HTMLInputElement} */ (document.getElementById('letterHitCountToggle')))?.checked ?? false, + [STORAGE_THRESHOLD_KEY]: (/** @type {HTMLInputElement} */ (document.getElementById('letterThresholdToggle')))?.checked ?? false, + letterHitCounts: counts, })); } catch { /* quota exceeded or private browsing — ignore */ } } @@ -197,17 +331,30 @@ export class SettingsController { const virusKillSlider = /** @type {HTMLInputElement} */ (document.getElementById('virusKillSlider')); const bugChanceSlider = /** @type {HTMLInputElement} */ (document.getElementById('bugChanceSlider')); const bugCountSlider = /** @type {HTMLInputElement} */ (document.getElementById('bugCountSlider')); - const debugHitCountToggle = /** @type {HTMLInputElement} */ (document.getElementById('debugHitCountToggle')); + const letterHitCountToggle = /** @type {HTMLInputElement} */ (document.getElementById('letterHitCountToggle')); colorPicker.value = this.#gradient.color; colorValue.textContent = this.#gradient.color; speedSlider.value = String(this.#gradient.speed); + document.getElementById('speedValue').textContent = String(this.#gradient.speed); rotToggle.checked = this.#gradient.rotating; moveSpeedSlider.value = String(this.#evolution.moveSpeed); + document.getElementById('moveSpeedValue').textContent = String(this.#evolution.moveSpeed); spawnRateSlider.value = String(this.#evolution.spawnRate); + document.getElementById('spawnRateValue').textContent = String(this.#evolution.spawnRate); virusKillSlider.value = String(Math.round(this.#evolution.virusKillChance * 100)); + document.getElementById('virusKillValue').textContent = virusKillSlider.value + '%'; bugChanceSlider.value = String(Math.round(this.#evolution.bugSpawnChance * 100)); + document.getElementById('bugChanceValue').textContent = bugChanceSlider.value + '%'; bugCountSlider.value = String(this.#evolution.bugMaxCount); - debugHitCountToggle.checked = false; // debug off by default on sync/reset + document.getElementById('bugCountValue').textContent = bugCountSlider.value; + thresholdMinInput.value = String(DEFAULTS.LOGO_BUMP_THRESHOLD_MIN); + document.getElementById('thresholdMinValue').textContent = thresholdMinInput.value; + thresholdMaxInput.value = String(DEFAULTS.LOGO_BUMP_THRESHOLD_MAX); + document.getElementById('thresholdMaxValue').textContent = thresholdMaxInput.value; + // leave the toggle alone; its state is already correct from the + // controllers / saved settings. forcibly turning it off here prevented the + // stored value from ever sticking. + // debugHitCountToggle.checked = false; // logo hit‑count debug off by default on sync/reset } } diff --git a/src/styles/logo.css b/src/styles/logo.css index d33d238..99aab68 100644 --- a/src/styles/logo.css +++ b/src/styles/logo.css @@ -77,6 +77,26 @@ display: block; } +/* ── Threshold label ───────────────────────────────────── */ +.logo-letter__threshold { + display: none; + position: absolute; + bottom: calc(100% + 3px); + left: 50%; + transform: translateX(-50%); + font-family: 'DM Mono', monospace; + font-size: 8px; + line-height: 1; + color: rgba(255, 255, 255, 0.6); + white-space: nowrap; + pointer-events: none; + user-select: none; +} + +.logo-letter--show-threshold .logo-letter__threshold { + display: block; +} + /* ── Hit flash animation ─────────────────────────────────── */ @keyframes logo-letter-hit { 0% { scale: 1; } diff --git a/src/styles/modal.css b/src/styles/modal.css index ea08e88..78b8b56 100644 --- a/src/styles/modal.css +++ b/src/styles/modal.css @@ -225,6 +225,29 @@ text-align: right; } +.slider-current { + font-family: var(--font-mono); + font-size: 0.72rem; /* larger for better legibility */ + letter-spacing: 0.09em; + color: #fff !important; /* always white for readability */ + text-shadow: 0 0 2px rgba(0,0,0,0.8); + user-select: none; + /* remove background so only text remains */ + background: transparent; + padding: 0 2px; + border-radius: 2px; + width: 3ch; /* fixed column width */ + text-align: right; +} + +.slider-wrapper { + display: grid; + /* columns: current-value fixed width, first label auto, slider flex, last label auto */ + grid-template-columns: 3ch auto 1fr auto; + align-items: center; + gap: 10px; + min-height: 28px; /* ensure consistent row height */ +} /* Reset */ .slider { flex: 1;