+
8%
0%
100%
@@ -219,6 +224,7 @@
+ 2
0
10
@@ -227,16 +233,16 @@
-
+
-
debug
+
logo
+
+
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;