mirror of
https://github.com/shadoll/dev.shadoll.git
synced 2026-08-28 03:26:56 +00:00
376 lines
19 KiB
JavaScript
376 lines
19 KiB
JavaScript
/**
|
||
* 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
|
||
* 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;
|
||
|
||
/** @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 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();
|
||
}
|
||
|
||
// ── 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 hit‑count debug off by default on sync/reset
|
||
}
|
||
}
|