Files
dev.shadoll/src/js/settings.js
T

376 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* settings.js
* Connects the settings UI controls to GradientController and EvolutionController.
*
* Persistence:
* - Every setting change is immediately written to localStorage.
* - Call loadSaved() from main.js (after gradient.init()) to restore the
* saved state on the next page load so the system continues as configured.
*
* Reset:
* - resetBtn clears the settings storage and restores DEFAULTS for the
* *settings* that are exposed in the modal (gradient, animation, logo
* hitcount 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;
/** @type {import('./evolution.js').EvolutionController} */
#evolution;
/** @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
*/
constructor(gradient, evolution) {
this.#gradient = gradient;
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 ──────────────────────────────────────────────────
/**
* Wire in the LogoController once it is ready.
* Applies any saved debug setting immediately.
*
* @param {import('./logoController.js').LogoController} logo
*/
setLogoController(logo) {
this.#logo = logo;
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;
}
}
/**
* Apply saved localStorage settings to the controllers and sync the UI.
* Must be called AFTER gradient.init() so saved values override DEFAULTS.
*/
loadSaved() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return;
const s = JSON.parse(raw);
if (typeof s.gradientColor === 'string') this.#gradient.setColor(s.gradientColor);
if (typeof s.gradientSpeed === 'number') this.#gradient.setSpeed(s.gradientSpeed);
if (typeof s.gradientRotation === 'boolean') this.#gradient.toggleRotation(s.gradientRotation);
if (typeof s.moveSpeed === 'number') this.#evolution.setMoveSpeed(s.moveSpeed);
if (typeof s.spawnRate === 'number') this.#evolution.setSpawnRate(s.spawnRate);
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[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 hitcount 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();
}
// ── Private ────────────────────────────────────────────────
#bindEvents() {
const colorPicker = /** @type {HTMLInputElement} */ (document.getElementById('colorPicker'));
const colorValue = /** @type {HTMLElement} */ (document.getElementById('colorValue'));
// helper for mobile theme-color
const setThemeColor = (c) => {
const meta = document.querySelector('meta[name="theme-color"]');
if (meta) meta.setAttribute('content', c);
};
const speedSlider = /** @type {HTMLInputElement} */ (document.getElementById('speedSlider'));
const rotToggle = /** @type {HTMLInputElement} */ (document.getElementById('rotationToggle'));
const moveSpeedSlider = /** @type {HTMLInputElement} */ (document.getElementById('moveSpeedSlider'));
const spawnRateSlider = /** @type {HTMLInputElement} */ (document.getElementById('spawnRateSlider'));
const virusKillSlider = /** @type {HTMLInputElement} */ (document.getElementById('virusKillSlider'));
const bugChanceSlider = /** @type {HTMLInputElement} */ (document.getElementById('bugChanceSlider'));
const bugCountSlider = /** @type {HTMLInputElement} */ (document.getElementById('bugCountSlider'));
const resetBtn = /** @type {HTMLButtonElement} */ (document.getElementById('resetBtn'));
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) => {
this.#gradient.toggleRotation(/** @type {HTMLInputElement} */ (e.target).checked);
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();
});
// ── Background colour picker ─────────────────────────────
colorPicker.addEventListener('input', (e) => {
const col = (/** @type {HTMLInputElement} */ (e.target)).value;
this.#gradient.setColor(col);
colorValue.textContent = col;
setThemeColor(col);
this.#saveState();
});
// ── Icon movement speed ────────────────────────────────
moveSpeedSlider.addEventListener('input', (e) => {
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) => {
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) => {
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) => {
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) => {
const num = Number(/** @type {HTMLInputElement} */ (e.target).value);
this.#evolution.setBugMaxCount(num);
document.getElementById('bugCountValue').textContent = String(num);
this.#saveState();
});
// ── 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);
// 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();
});
}
/** Persist current controller state to localStorage. */
#saveState() {
try {
// 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,
moveSpeed: this.#evolution.moveSpeed,
spawnRate: this.#evolution.spawnRate,
virusKillChance: this.#evolution.virusKillChance,
bugSpawnChance: this.#evolution.bugSpawnChance,
bugMaxCount: this.#evolution.bugMaxCount,
[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 */ }
}
/**
* Push controller state → DOM controls so the UI always matches.
* Called on construction, after loadSaved(), and after reset.
*/
#syncControls() {
const colorPicker = /** @type {HTMLInputElement} */ (document.getElementById('colorPicker'));
const colorValue = /** @type {HTMLElement} */ (document.getElementById('colorValue'));
const speedSlider = /** @type {HTMLInputElement} */ (document.getElementById('speedSlider'));
const rotToggle = /** @type {HTMLInputElement} */ (document.getElementById('rotationToggle'));
const moveSpeedSlider = /** @type {HTMLInputElement} */ (document.getElementById('moveSpeedSlider'));
const spawnRateSlider = /** @type {HTMLInputElement} */ (document.getElementById('spawnRateSlider'));
const virusKillSlider = /** @type {HTMLInputElement} */ (document.getElementById('virusKillSlider'));
const bugChanceSlider = /** @type {HTMLInputElement} */ (document.getElementById('bugChanceSlider'));
const bugCountSlider = /** @type {HTMLInputElement} */ (document.getElementById('bugCountSlider'));
const letterHitCountToggle = /** @type {HTMLInputElement} */ (document.getElementById('letterHitCountToggle'));
colorPicker.value = this.#gradient.color;
setThemeColor(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);
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 hitcount debug off by default on sync/reset
}
}