feat: add logo word with letter icons and physics
- Introduced a new logo system consisting of individual letter SVG icons for "shadolldev". - Implemented LogoController to manage the floating logo word, including physics for movement and collisions with entities. - Added LogoLetter class to handle individual letter behavior, including ejection and reattachment based on collisions. - Integrated logo functionality into the main application flow, ensuring it initializes alongside the evolution system. - Enhanced settings to control debug visibility for letter hit counts. - Created styles for the logo letters, including animations for ejection and hit effects. - Updated constants to define logo-related parameters such as letter size, speed, and collision properties.
@@ -11,11 +11,12 @@
|
|||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link href="https://fonts.googleapis.com/css2?family=DM+Mono:ital,wght@0,300;0,400;0,500&display=swap" rel="stylesheet" />
|
<link href="https://fonts.googleapis.com/css2?family=DM+Mono:ital,wght@0,300;0,400;0,500&display=swap" rel="stylesheet" />
|
||||||
|
|
||||||
<!-- Styles (order matters: base → gradient → modal → icons → guide) -->
|
<!-- Styles (order matters: base → gradient → modal → icons → logo → guide) -->
|
||||||
<link rel="stylesheet" href="src/styles/main.css" />
|
<link rel="stylesheet" href="src/styles/main.css" />
|
||||||
<link rel="stylesheet" href="src/styles/gradient.css" />
|
<link rel="stylesheet" href="src/styles/gradient.css" />
|
||||||
<link rel="stylesheet" href="src/styles/modal.css" />
|
<link rel="stylesheet" href="src/styles/modal.css" />
|
||||||
<link rel="stylesheet" href="src/styles/icons.css" />
|
<link rel="stylesheet" href="src/styles/icons.css" />
|
||||||
|
<link rel="stylesheet" href="src/styles/logo.css" />
|
||||||
<link rel="stylesheet" href="src/styles/guide.css" />
|
<link rel="stylesheet" href="src/styles/guide.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -226,6 +227,25 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ── debug ─────────────────────────────── -->
|
||||||
|
<div class="settings-section">
|
||||||
|
<p class="settings-section__label">debug</p>
|
||||||
|
|
||||||
|
<div class="setting-group">
|
||||||
|
<span class="setting-label">letter hit count</span>
|
||||||
|
<div class="toggle-wrapper">
|
||||||
|
<span class="toggle-label">off</span>
|
||||||
|
<label class="toggle" for="debugHitCountToggle" aria-label="Show letter hit counts">
|
||||||
|
<input type="checkbox" id="debugHitCountToggle" />
|
||||||
|
<span class="toggle-track">
|
||||||
|
<span class="toggle-thumb"></span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<span class="toggle-label">on</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button class="reset-btn" id="resetBtn">reset to defaults</button>
|
<button class="reset-btn" id="resetBtn">reset to defaults</button>
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M3 5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2zm8 2a2 2 0 0 0-2 2v8h2v-4h2v4h2V9a2 2 0 0 0-2-2zm0 2h2v2h-2z"/></svg>
|
||||||
|
After Width: | Height: | Size: 250 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M9 7v10h4a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2zm2 2h2v6h-2zM5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2"/></svg>
|
||||||
|
After Width: | Height: | Size: 243 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M3 5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><path d="M10 8v8h2a2 2 0 0 0 2-2v-4a2 2 0 0 0-2-2z"/></g></svg>
|
||||||
|
After Width: | Height: | Size: 328 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M3 5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><path d="M14 8h-4v8h4m-4-4h2.5"/></g></svg>
|
||||||
|
After Width: | Height: | Size: 308 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M9 7v10h2v-4h2v4h2V7h-2v4h-2V7zM5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2"/></svg>
|
||||||
|
After Width: | Height: | Size: 222 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M9 7v10h6v-2h-4V7zM5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2"/></svg>
|
||||||
|
After Width: | Height: | Size: 209 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M11 7a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h2a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2zm0 2h2v6h-2zM5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2"/></svg>
|
||||||
|
After Width: | Height: | Size: 271 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M11 7a2 2 0 0 0-2 2v2a2 2 0 0 0 2 2h2v2H9v2h4a2 2 0 0 0 2-2v-2a2 2 0 0 0-2-2h-2V9h4V7zM5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2"/></svg>
|
||||||
|
After Width: | Height: | Size: 277 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M3 5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><path d="m10 8l2 8l2-8"/></g></svg>
|
||||||
|
After Width: | Height: | Size: 300 B |
@@ -66,4 +66,30 @@ export const DEFAULTS = {
|
|||||||
/** Total ms from kill trigger to entity removal */
|
/** Total ms from kill trigger to entity removal */
|
||||||
KILL_DEATH_DURATION: 3_000,
|
KILL_DEATH_DURATION: 3_000,
|
||||||
|
|
||||||
|
// ── Logo word ──────────────────────────────────────────────
|
||||||
|
/** Rendered size of each letter icon in px */
|
||||||
|
LOGO_LETTER_SIZE: 36,
|
||||||
|
/** Half of LOGO_LETTER_SIZE — used for edge/collision math */
|
||||||
|
LOGO_LETTER_HALF: 18,
|
||||||
|
/** Centre-to-centre spacing between adjacent letter slots (px) */
|
||||||
|
LOGO_LETTER_GAP: 32,
|
||||||
|
/** 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,
|
||||||
|
/** Max bump count before ejection threshold */
|
||||||
|
LOGO_BUMP_THRESHOLD_MAX: 5000,
|
||||||
|
/** Spring constant pulling an ejected letter back toward its slot */
|
||||||
|
LOGO_SPRING_K: 0.04,
|
||||||
|
/** Per-frame velocity damping on ejected letters (0–1) */
|
||||||
|
LOGO_SPRING_DAMPING: 0.88,
|
||||||
|
/** Distance (px) from slot centre at which a letter re-attaches */
|
||||||
|
LOGO_REATTACH_RADIUS: 20,
|
||||||
|
/** Collision diameter for entity-vs-letter: entity half (12) + letter half (18) */
|
||||||
|
LOGO_COLLISION_DIAMETER: 30,
|
||||||
|
/** Max speed cap for an ejected letter in px/frame */
|
||||||
|
LOGO_EJECT_MAX_SPEED: 3.5,
|
||||||
|
/** Initial speed impulse applied to a letter on ejection */
|
||||||
|
LOGO_EJECT_IMPULSE: 2.2,
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ export class EvolutionController {
|
|||||||
/** @type {number|null} */ #animFrame = null;
|
/** @type {number|null} */ #animFrame = null;
|
||||||
/** @type {boolean} */ #running = false;
|
/** @type {boolean} */ #running = false;
|
||||||
/** @type {object|null} */ #iconsData = null;
|
/** @type {object|null} */ #iconsData = null;
|
||||||
|
/** @type {import('./logoController.js').LogoController|null} */ #logo = null;
|
||||||
|
/** @type {object|null} */ #savedLogoState = null;
|
||||||
|
|
||||||
// ── Public API ─────────────────────────────────────────────
|
// ── Public API ─────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -167,6 +169,20 @@ export class EvolutionController {
|
|||||||
this.#entities = [];
|
this.#entities = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wire in a LogoController so the rAF loop drives it.
|
||||||
|
* Must be called after logo.init() completes.
|
||||||
|
*
|
||||||
|
* @param {import('./logoController.js').LogoController} logo
|
||||||
|
*/
|
||||||
|
setLogoController(logo) { this.#logo = logo; }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saved logo state read from localStorage — passed to LogoController.init().
|
||||||
|
* Available after init() resolves; null on first ever load.
|
||||||
|
*/
|
||||||
|
get savedLogoState() { return this.#savedLogoState; }
|
||||||
|
|
||||||
/** Read-only access to current entity count (useful for debugging). */
|
/** Read-only access to current entity count (useful for debugging). */
|
||||||
get entityCount() { return this.#entities.length; }
|
get entityCount() { return this.#entities.length; }
|
||||||
|
|
||||||
@@ -244,9 +260,15 @@ export class EvolutionController {
|
|||||||
entity.update(multiplier);
|
entity.update(multiplier);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Advance logo word + ejected letter physics
|
||||||
|
this.#logo?.update(multiplier);
|
||||||
|
|
||||||
// Check and resolve entity-to-entity collisions after all positions updated
|
// Check and resolve entity-to-entity collisions after all positions updated
|
||||||
this.#checkCollisions();
|
this.#checkCollisions();
|
||||||
|
|
||||||
|
// Check entity-vs-logo-letter collisions
|
||||||
|
this.#logo?.checkCollisionsWithEntities(this.#entities);
|
||||||
|
|
||||||
// Prune entities that died this frame (virus killed by mutation, slow-death complete)
|
// Prune entities that died this frame (virus killed by mutation, slow-death complete)
|
||||||
if (this.#entities.some(e => !e.alive)) {
|
if (this.#entities.some(e => !e.alive)) {
|
||||||
this.#entities = this.#entities.filter(e => e.alive);
|
this.#entities = this.#entities.filter(e => e.alive);
|
||||||
@@ -405,6 +427,7 @@ export class EvolutionController {
|
|||||||
rotation: e.rotation,
|
rotation: e.rotation,
|
||||||
hueShift: e.hueShift,
|
hueShift: e.hueShift,
|
||||||
})),
|
})),
|
||||||
|
logo: this.#logo?.serialise() ?? null,
|
||||||
};
|
};
|
||||||
localStorage.setItem(EVOLUTION_STORAGE_KEY, JSON.stringify(snapshot));
|
localStorage.setItem(EVOLUTION_STORAGE_KEY, JSON.stringify(snapshot));
|
||||||
} catch { /* quota exceeded — ignore */ }
|
} catch { /* quota exceeded — ignore */ }
|
||||||
@@ -426,6 +449,11 @@ export class EvolutionController {
|
|||||||
this.#startTime = saved.startTime;
|
this.#startTime = saved.startTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stash logo state so main.js can pass it to LogoController.init()
|
||||||
|
if (saved.logo && typeof saved.logo === 'object') {
|
||||||
|
this.#savedLogoState = saved.logo;
|
||||||
|
}
|
||||||
|
|
||||||
const entities = saved.entities;
|
const entities = saved.entities;
|
||||||
if (!Array.isArray(entities) || entities.length === 0) return;
|
if (!Array.isArray(entities) || entities.length === 0) return;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,288 @@
|
|||||||
|
/**
|
||||||
|
* logoController.js
|
||||||
|
* Manages the floating "shadolldev" logo word.
|
||||||
|
*
|
||||||
|
* The word is made of 10 letter SVG icons flying together as a unit,
|
||||||
|
* bouncing off viewport edges. Individual letters absorb bumps from
|
||||||
|
* evolution entities; after enough hits (random 500–5000 threshold)
|
||||||
|
* a letter ejects and flies freely with a spring pulling it home.
|
||||||
|
*
|
||||||
|
* EvolutionController drives the physics:
|
||||||
|
* - Calls update(multiplier) every rAF tick to advance word + letter positions.
|
||||||
|
* - Calls checkCollisionsWithEntities(entities[]) after entity positions settle.
|
||||||
|
*
|
||||||
|
* State is serialised via serialise() and restored in init() so the word
|
||||||
|
* continues seamlessly across page reloads.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { LogoLetter } from './logoLetter.js';
|
||||||
|
import { preloadIcons } from './iconLoader.js';
|
||||||
|
import { DEFAULTS } from './constants.js';
|
||||||
|
|
||||||
|
/** Ordered icon filenames for s·h·a·d·o·l·l·d·e·v */
|
||||||
|
const WORD_ICONS = [
|
||||||
|
'letter-s-box', // 0 s
|
||||||
|
'letter-h-box', // 1 h
|
||||||
|
'letter-a-box', // 2 a
|
||||||
|
'letter-d-box', // 3 d
|
||||||
|
'letter-o-box', // 4 o
|
||||||
|
'letter-l-box', // 5 l
|
||||||
|
'letter-l-box', // 6 l
|
||||||
|
'letter-d', // 7 d
|
||||||
|
'letter-e', // 8 e
|
||||||
|
'letter-v', // 9 v
|
||||||
|
];
|
||||||
|
|
||||||
|
export class LogoController {
|
||||||
|
// ── Word-origin physics ───────────────────────────────────
|
||||||
|
/** @type {number} */ #wordX = 0;
|
||||||
|
/** @type {number} */ #wordY = 0;
|
||||||
|
/** @type {number} */ #wordVx = 0;
|
||||||
|
/** @type {number} */ #wordVy = 0;
|
||||||
|
|
||||||
|
/** @type {LogoLetter[]} */ #letters = [];
|
||||||
|
/** @type {HTMLElement|null} */ #container = null;
|
||||||
|
/** @type {boolean} */ #ready = false;
|
||||||
|
|
||||||
|
// ── Public API ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mount all letter DOM nodes and initialise physics.
|
||||||
|
*
|
||||||
|
* @param {HTMLElement} container Same container as EvolutionController.
|
||||||
|
* @param {object|null} savedState Snapshot from serialise(), or null for a fresh start.
|
||||||
|
*/
|
||||||
|
async init(container, savedState = null) {
|
||||||
|
this.#container = container;
|
||||||
|
|
||||||
|
// Warm SVG cache for all unique letter icons before mounting
|
||||||
|
await Promise.all([...new Set(WORD_ICONS)].map(n => preloadIcons([n])));
|
||||||
|
|
||||||
|
if (savedState) {
|
||||||
|
this.#restoreFromSnapshot(savedState);
|
||||||
|
} else {
|
||||||
|
this.#initFresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.#mountLetters(savedState?.letters ?? null);
|
||||||
|
this.#ready = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Advance word + letter physics one frame.
|
||||||
|
* Called from EvolutionController's rAF tick BEFORE collision checks.
|
||||||
|
*
|
||||||
|
* @param {number} multiplier settings speed multiplier (1 = normal)
|
||||||
|
*/
|
||||||
|
update(multiplier) {
|
||||||
|
if (!this.#ready) return;
|
||||||
|
|
||||||
|
// Move word origin
|
||||||
|
this.#wordX += this.#wordVx * multiplier;
|
||||||
|
this.#wordY += this.#wordVy * multiplier;
|
||||||
|
|
||||||
|
// Bounce word off viewport edges.
|
||||||
|
// Word spans: wordX ± (4.5 * GAP) and wordY ± LETTER_HALF
|
||||||
|
const halfW = 4.5 * DEFAULTS.LOGO_LETTER_GAP + DEFAULTS.LOGO_LETTER_HALF;
|
||||||
|
const halfH = DEFAULTS.LOGO_LETTER_HALF;
|
||||||
|
const vw = window.innerWidth;
|
||||||
|
const vh = window.innerHeight;
|
||||||
|
|
||||||
|
if (this.#wordX - halfW <= 0) { this.#wordX = halfW; this.#wordVx = Math.abs(this.#wordVx); }
|
||||||
|
if (this.#wordX + halfW >= vw) { this.#wordX = vw - halfW; this.#wordVx = -Math.abs(this.#wordVx); }
|
||||||
|
if (this.#wordY - halfH <= 0) { this.#wordY = halfH; this.#wordVy = Math.abs(this.#wordVy); }
|
||||||
|
if (this.#wordY + halfH >= vh) { this.#wordY = vh - halfH; this.#wordVy = -Math.abs(this.#wordVy); }
|
||||||
|
|
||||||
|
// Update each letter
|
||||||
|
for (const letter of this.#letters) {
|
||||||
|
if (letter.ejected) {
|
||||||
|
letter.updateEjected(this.#wordX, this.#wordY, multiplier);
|
||||||
|
// Re-attach when drifted back close enough
|
||||||
|
if (letter.isNearSlot(this.#wordX, this.#wordY)) {
|
||||||
|
letter.reattach();
|
||||||
|
letter.snapToSlot(this.#wordX, this.#wordY);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
letter.snapToSlot(this.#wordX, this.#wordY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check every entity against every letter.
|
||||||
|
* Called from EvolutionController's rAF tick AFTER entity positions are updated
|
||||||
|
* and AFTER entity-entity collisions are resolved.
|
||||||
|
*
|
||||||
|
* Collision response:
|
||||||
|
* - Attached letter: entity reflects like a solid wall; letter unmoved.
|
||||||
|
* - Ejected letter: equal-mass elastic exchange along collision normal.
|
||||||
|
* - Letter bump count increments; if threshold reached, letter ejects.
|
||||||
|
*
|
||||||
|
* @param {import('./entity.js').Entity[]} entities
|
||||||
|
*/
|
||||||
|
checkCollisionsWithEntities(entities) {
|
||||||
|
if (!this.#ready) return;
|
||||||
|
|
||||||
|
const colDiam = DEFAULTS.LOGO_COLLISION_DIAMETER;
|
||||||
|
|
||||||
|
for (const entity of entities) {
|
||||||
|
if (!entity.alive || entity.dying) continue;
|
||||||
|
|
||||||
|
for (const letter of this.#letters) {
|
||||||
|
if (!letter.mounted) continue;
|
||||||
|
|
||||||
|
const dx = entity.x - letter.x;
|
||||||
|
const dy = entity.y - letter.y;
|
||||||
|
const dist = Math.hypot(dx, dy);
|
||||||
|
|
||||||
|
if (dist >= colDiam || dist === 0) continue;
|
||||||
|
|
||||||
|
// Collision normal pointing from letter centre → entity centre
|
||||||
|
const nx = dx / dist;
|
||||||
|
const ny = dy / dist;
|
||||||
|
|
||||||
|
// Velocity of entity relative to letter along the collision normal.
|
||||||
|
// nx points FROM letter TO entity, so dot < 0 means entity is approaching.
|
||||||
|
const dot = (entity.vx - letter.vx) * nx + (entity.vy - letter.vy) * ny;
|
||||||
|
|
||||||
|
if (dot < 0) {
|
||||||
|
// Entity moving toward letter — reflect fully, like a screen edge.
|
||||||
|
// Letters are rigid obstacles regardless of ejected state.
|
||||||
|
entity.vx -= 2 * dot * nx;
|
||||||
|
entity.vy -= 2 * dot * ny;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Positional correction — push entity out only (letter is unmoved)
|
||||||
|
const overlap = colDiam - dist;
|
||||||
|
entity.x += overlap * nx;
|
||||||
|
entity.y += overlap * ny;
|
||||||
|
|
||||||
|
// Visual hit on entity
|
||||||
|
entity.onHit();
|
||||||
|
|
||||||
|
// Bump the letter; eject if threshold crossed
|
||||||
|
const shouldEject = letter.onBump();
|
||||||
|
if (shouldEject) {
|
||||||
|
// Eject in the direction the entity pushed from (away from entity)
|
||||||
|
const ejVx = -nx * DEFAULTS.LOGO_EJECT_IMPULSE;
|
||||||
|
const ejVy = -ny * DEFAULTS.LOGO_EJECT_IMPULSE;
|
||||||
|
letter.eject(ejVx, ejVy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialise complete logo state for localStorage.
|
||||||
|
* Called by EvolutionController's #saveState().
|
||||||
|
*
|
||||||
|
* @returns {object}
|
||||||
|
*/
|
||||||
|
serialise() {
|
||||||
|
return {
|
||||||
|
wordX: this.#wordX,
|
||||||
|
wordY: this.#wordY,
|
||||||
|
wordVx: this.#wordVx,
|
||||||
|
wordVy: this.#wordVy,
|
||||||
|
letters: this.#letters.map(l => ({
|
||||||
|
slotIndex: l.slotIndex,
|
||||||
|
iconName: l.iconName,
|
||||||
|
bumpCount: l.bumpCount,
|
||||||
|
bumpThreshold: l.bumpThreshold,
|
||||||
|
ejected: l.ejected,
|
||||||
|
x: l.x,
|
||||||
|
y: l.y,
|
||||||
|
vx: l.vx,
|
||||||
|
vy: l.vy,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove all letter DOM nodes. */
|
||||||
|
destroy() {
|
||||||
|
this.#letters.forEach(l => l.destroy());
|
||||||
|
this.#letters = [];
|
||||||
|
this.#ready = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
get ready() { return this.#ready; }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show or hide the hit-count debug label on every letter.
|
||||||
|
* @param {boolean} visible
|
||||||
|
*/
|
||||||
|
setDebugVisible(visible) {
|
||||||
|
for (const letter of this.#letters) {
|
||||||
|
letter.setDebugVisible(visible);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Private ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Set up a brand-new word at the viewport centre with a random direction. */
|
||||||
|
#initFresh() {
|
||||||
|
const vw = window.innerWidth;
|
||||||
|
const vh = window.innerHeight;
|
||||||
|
const angle = Math.random() * Math.PI * 2;
|
||||||
|
|
||||||
|
this.#wordX = vw / 2;
|
||||||
|
this.#wordY = vh / 2;
|
||||||
|
this.#wordVx = Math.cos(angle) * DEFAULTS.LOGO_WORD_BASE_SPEED;
|
||||||
|
this.#wordVy = Math.sin(angle) * DEFAULTS.LOGO_WORD_BASE_SPEED;
|
||||||
|
|
||||||
|
this.#letters = WORD_ICONS.map((iconName, i) =>
|
||||||
|
new LogoLetter({ iconName, slotIndex: i })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Restore word origin and letter objects from a saved snapshot. */
|
||||||
|
#restoreFromSnapshot(state) {
|
||||||
|
this.#wordX = state.wordX ?? window.innerWidth / 2;
|
||||||
|
this.#wordY = state.wordY ?? window.innerHeight / 2;
|
||||||
|
this.#wordVx = state.wordVx ?? 0;
|
||||||
|
this.#wordVy = state.wordVy ?? DEFAULTS.LOGO_WORD_BASE_SPEED;
|
||||||
|
|
||||||
|
this.#letters = WORD_ICONS.map((iconName, i) => {
|
||||||
|
const saved = state.letters?.[i];
|
||||||
|
return new LogoLetter({
|
||||||
|
iconName,
|
||||||
|
slotIndex: i,
|
||||||
|
bumpThreshold: saved?.bumpThreshold,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mount all letters.
|
||||||
|
* If savedLetterStates is provided, applies runtime state after mounting.
|
||||||
|
*
|
||||||
|
* @param {Array|null} savedLetterStates
|
||||||
|
*/
|
||||||
|
async #mountLetters(savedLetterStates) {
|
||||||
|
if (!this.#container) return;
|
||||||
|
|
||||||
|
await Promise.all(this.#letters.map(async (letter, i) => {
|
||||||
|
await letter.mount(this.#container);
|
||||||
|
if (!letter.mounted) return;
|
||||||
|
|
||||||
|
const saved = savedLetterStates?.[i];
|
||||||
|
if (saved) {
|
||||||
|
letter.bumpCount = saved.bumpCount ?? 0;
|
||||||
|
letter.ejected = saved.ejected ?? false;
|
||||||
|
|
||||||
|
if (saved.ejected) {
|
||||||
|
letter.x = saved.x ?? letter.x;
|
||||||
|
letter.y = saved.y ?? letter.y;
|
||||||
|
letter.vx = saved.vx ?? 0;
|
||||||
|
letter.vy = saved.vy ?? 0;
|
||||||
|
letter.el?.classList.add('logo-letter--ejected');
|
||||||
|
letter._applyTransform();
|
||||||
|
} else {
|
||||||
|
letter.snapToSlot(this.#wordX, this.#wordY);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
letter.snapToSlot(this.#wordX, this.#wordY);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
/**
|
||||||
|
* logoLetter.js
|
||||||
|
* A single letter of the "shadolldev" logo word.
|
||||||
|
*
|
||||||
|
* Each letter:
|
||||||
|
* - Has a "slot" — its home offset from the word origin.
|
||||||
|
* - Is either attached (moves rigidly with the word) or ejected (free-flight).
|
||||||
|
* - Tracks bump count toward a random ejection threshold (500–5000).
|
||||||
|
* - When ejected: spring-attracted back to its slot; re-attaches on arrival.
|
||||||
|
*
|
||||||
|
* DOM structure mirrors Entity:
|
||||||
|
* <div class="logo-letter"> ← position via JS transform
|
||||||
|
* <div class="logo-letter__body"> ← scale animation via CSS transition
|
||||||
|
* <svg>…</svg>
|
||||||
|
* </div>
|
||||||
|
* </div>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { loadIcon } from './iconLoader.js';
|
||||||
|
import { DEFAULTS } from './constants.js';
|
||||||
|
|
||||||
|
export class LogoLetter {
|
||||||
|
// ── Identity ─────────────────────────────────────────────
|
||||||
|
/** @type {string} */ iconName;
|
||||||
|
/** @type {number} */ slotIndex; // 0–9, position in the word
|
||||||
|
|
||||||
|
// ── Physics (absolute screen coords, letter centre) ───────
|
||||||
|
/** @type {number} */ x = 0;
|
||||||
|
/** @type {number} */ y = 0;
|
||||||
|
/** @type {number} */ vx = 0;
|
||||||
|
/** @type {number} */ vy = 0;
|
||||||
|
|
||||||
|
// ── Ejection state ───────────────────────────────────────
|
||||||
|
/** @type {boolean} */ ejected = false;
|
||||||
|
/** @type {number} */ bumpCount = 0;
|
||||||
|
/** @type {number} */ bumpThreshold;
|
||||||
|
|
||||||
|
// ── DOM ───────────────────────────────────────────────────
|
||||||
|
/** @type {HTMLElement|null} */ el = null;
|
||||||
|
/** @type {HTMLElement|null} */ bodyEl = null;
|
||||||
|
/** @type {HTMLElement|null} */ debugEl = null;
|
||||||
|
/** @type {boolean} */ mounted = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{ iconName: string, slotIndex: number, bumpThreshold?: number }} cfg
|
||||||
|
*/
|
||||||
|
constructor({ iconName, slotIndex, bumpThreshold }) {
|
||||||
|
this.iconName = iconName;
|
||||||
|
this.slotIndex = slotIndex;
|
||||||
|
this.bumpThreshold = bumpThreshold
|
||||||
|
?? (DEFAULTS.LOGO_BUMP_THRESHOLD_MIN
|
||||||
|
+ Math.floor(Math.random()
|
||||||
|
* (DEFAULTS.LOGO_BUMP_THRESHOLD_MAX - DEFAULTS.LOGO_BUMP_THRESHOLD_MIN)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Public API ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the DOM element, fetch the SVG, attach to container.
|
||||||
|
* Triggers the scale-in appear animation.
|
||||||
|
*
|
||||||
|
* @param {HTMLElement} container
|
||||||
|
*/
|
||||||
|
async mount(container) {
|
||||||
|
this.el = document.createElement('div');
|
||||||
|
this.el.className = 'logo-letter';
|
||||||
|
|
||||||
|
this.bodyEl = document.createElement('div');
|
||||||
|
this.bodyEl.className = 'logo-letter__body';
|
||||||
|
this.bodyEl.dataset.state = 'spawning';
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.bodyEl.innerHTML = await loadIcon(this.iconName);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[LogoLetter] Could not load "${this.iconName}":`, err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.debugEl = document.createElement('span');
|
||||||
|
this.debugEl.className = 'logo-letter__debug';
|
||||||
|
this.debugEl.textContent = '0';
|
||||||
|
|
||||||
|
this.el.appendChild(this.bodyEl);
|
||||||
|
this.el.appendChild(this.debugEl);
|
||||||
|
container.appendChild(this.el);
|
||||||
|
this.mounted = true;
|
||||||
|
|
||||||
|
// Double-rAF guarantees the 'spawning' state was painted before 'alive',
|
||||||
|
// ensuring the CSS scale transition actually fires.
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (this.bodyEl) this.bodyEl.dataset.state = 'alive';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Snap position to the word slot (used when attached).
|
||||||
|
*
|
||||||
|
* @param {number} wordX word origin x
|
||||||
|
* @param {number} wordY word origin y
|
||||||
|
*/
|
||||||
|
snapToSlot(wordX, wordY) {
|
||||||
|
this.x = wordX + (this.slotIndex - 4.5) * DEFAULTS.LOGO_LETTER_GAP;
|
||||||
|
this.y = wordY;
|
||||||
|
this._applyTransform();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute the absolute slot position without moving the letter.
|
||||||
|
*
|
||||||
|
* @param {number} wordX
|
||||||
|
* @param {number} wordY
|
||||||
|
* @returns {{ sx: number, sy: number }}
|
||||||
|
*/
|
||||||
|
slotPosition(wordX, wordY) {
|
||||||
|
return {
|
||||||
|
sx: wordX + (this.slotIndex - 4.5) * DEFAULTS.LOGO_LETTER_GAP,
|
||||||
|
sy: wordY,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Advance free-flight physics for one frame.
|
||||||
|
* Spring force pulls toward slot; velocity is damped; viewport edges bounce.
|
||||||
|
*
|
||||||
|
* @param {number} wordX
|
||||||
|
* @param {number} wordY
|
||||||
|
* @param {number} multiplier speed multiplier from settings
|
||||||
|
*/
|
||||||
|
updateEjected(wordX, wordY, multiplier) {
|
||||||
|
const { sx, sy } = this.slotPosition(wordX, wordY);
|
||||||
|
|
||||||
|
// Spring force toward slot (applied every frame regardless of multiplier)
|
||||||
|
this.vx += (sx - this.x) * DEFAULTS.LOGO_SPRING_K;
|
||||||
|
this.vy += (sy - this.y) * DEFAULTS.LOGO_SPRING_K;
|
||||||
|
|
||||||
|
// Damping
|
||||||
|
this.vx *= DEFAULTS.LOGO_SPRING_DAMPING;
|
||||||
|
this.vy *= DEFAULTS.LOGO_SPRING_DAMPING;
|
||||||
|
|
||||||
|
// Speed cap
|
||||||
|
const spd = Math.hypot(this.vx, this.vy);
|
||||||
|
if (spd > DEFAULTS.LOGO_EJECT_MAX_SPEED) {
|
||||||
|
this.vx = (this.vx / spd) * DEFAULTS.LOGO_EJECT_MAX_SPEED;
|
||||||
|
this.vy = (this.vy / spd) * DEFAULTS.LOGO_EJECT_MAX_SPEED;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Integrate position
|
||||||
|
this.x += this.vx * multiplier;
|
||||||
|
this.y += this.vy * multiplier;
|
||||||
|
|
||||||
|
// Viewport edge bounce
|
||||||
|
const h = DEFAULTS.LOGO_LETTER_HALF;
|
||||||
|
const vw = window.innerWidth;
|
||||||
|
const vh = window.innerHeight;
|
||||||
|
if (this.x - h <= 0) { this.x = h; this.vx = Math.abs(this.vx); }
|
||||||
|
if (this.x + h >= vw) { this.x = vw - h; this.vx = -Math.abs(this.vx); }
|
||||||
|
if (this.y - h <= 0) { this.y = h; this.vy = Math.abs(this.vy); }
|
||||||
|
if (this.y + h >= vh) { this.y = vh - h; this.vy = -Math.abs(this.vy); }
|
||||||
|
|
||||||
|
this._applyTransform();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the letter is within LOGO_REATTACH_RADIUS of its slot. */
|
||||||
|
isNearSlot(wordX, wordY) {
|
||||||
|
const { sx, sy } = this.slotPosition(wordX, wordY);
|
||||||
|
return Math.hypot(this.x - sx, this.y - sy) <= DEFAULTS.LOGO_REATTACH_RADIUS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called on every entity collision with this letter.
|
||||||
|
* Returns true if this particular hit triggers ejection.
|
||||||
|
*
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
onBump() {
|
||||||
|
this.bumpCount++;
|
||||||
|
if (this.debugEl) this.debugEl.textContent = String(this.bumpCount);
|
||||||
|
this._flashHit();
|
||||||
|
return !this.ejected && this.bumpCount >= this.bumpThreshold;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Launch this letter as a free-flying projectile.
|
||||||
|
*
|
||||||
|
* @param {number} impulseVx
|
||||||
|
* @param {number} impulseVy
|
||||||
|
*/
|
||||||
|
eject(impulseVx, impulseVy) {
|
||||||
|
this.ejected = true;
|
||||||
|
this.vx = impulseVx;
|
||||||
|
this.vy = impulseVy;
|
||||||
|
this.el?.classList.add('logo-letter--ejected');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-attach the letter to its slot after it has drifted back.
|
||||||
|
* Picks a new random threshold for the next ejection cycle.
|
||||||
|
*/
|
||||||
|
reattach() {
|
||||||
|
this.ejected = false;
|
||||||
|
this.vx = 0;
|
||||||
|
this.vy = 0;
|
||||||
|
this.bumpCount = 0;
|
||||||
|
this.bumpThreshold = DEFAULTS.LOGO_BUMP_THRESHOLD_MIN
|
||||||
|
+ Math.floor(Math.random()
|
||||||
|
* (DEFAULTS.LOGO_BUMP_THRESHOLD_MAX - DEFAULTS.LOGO_BUMP_THRESHOLD_MIN));
|
||||||
|
this.el?.classList.remove('logo-letter--ejected');
|
||||||
|
if (this.debugEl) this.debugEl.textContent = '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show or hide the debug hit-count label.
|
||||||
|
* @param {boolean} visible
|
||||||
|
*/
|
||||||
|
setDebugVisible(visible) {
|
||||||
|
this.el?.classList.toggle('logo-letter--show-debug', visible);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove this letter from the DOM. */
|
||||||
|
destroy() {
|
||||||
|
this.el?.remove();
|
||||||
|
this.el = null;
|
||||||
|
this.bodyEl = null;
|
||||||
|
this.debugEl = null;
|
||||||
|
this.mounted = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Private ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_applyTransform() {
|
||||||
|
if (!this.el) return;
|
||||||
|
const h = DEFAULTS.LOGO_LETTER_HALF;
|
||||||
|
this.el.style.transform = `translate(${this.x - h}px, ${this.y - h}px)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
_flashHit() {
|
||||||
|
if (!this.bodyEl) return;
|
||||||
|
this.bodyEl.classList.remove('logo-letter__body--hit');
|
||||||
|
void this.bodyEl.offsetWidth; // force reflow so animation restarts
|
||||||
|
this.bodyEl.classList.add('logo-letter__body--hit');
|
||||||
|
setTimeout(() => this.bodyEl?.classList.remove('logo-letter__body--hit'), 350);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import { ModalController } from './modal.js';
|
|||||||
import { SettingsController } from './settings.js';
|
import { SettingsController } from './settings.js';
|
||||||
import { EvolutionController } from './evolution.js';
|
import { EvolutionController } from './evolution.js';
|
||||||
import { GuideController } from './guide.js';
|
import { GuideController } from './guide.js';
|
||||||
|
import { LogoController } from './logoController.js';
|
||||||
import { DEFAULTS } from './constants.js';
|
import { DEFAULTS } from './constants.js';
|
||||||
|
|
||||||
// ── Initialise controllers ─────────────────────────────────
|
// ── Initialise controllers ─────────────────────────────────
|
||||||
@@ -24,7 +25,6 @@ const evolution = new EvolutionController();
|
|||||||
const guide = new GuideController();
|
const guide = new GuideController();
|
||||||
|
|
||||||
// SettingsController bridges UI → gradient + evolution
|
// SettingsController bridges UI → gradient + evolution
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
const settings = new SettingsController(gradient, evolution);
|
const settings = new SettingsController(gradient, evolution);
|
||||||
|
|
||||||
// ── Wire up settings button ────────────────────────────────
|
// ── Wire up settings button ────────────────────────────────
|
||||||
@@ -41,5 +41,16 @@ gradient.init(DEFAULTS.GRADIENT_COLOR);
|
|||||||
// Apply saved settings after gradient has set its defaults
|
// Apply saved settings after gradient has set its defaults
|
||||||
settings.loadSaved();
|
settings.loadSaved();
|
||||||
|
|
||||||
// ── Boot evolution (async — loads icons.json + warms SVG cache) ──
|
// ── Boot logo + evolution ──────────────────────────────────
|
||||||
evolution.init(document.getElementById('evolutionContainer'));
|
// Sequence: evolution.init() → reads localStorage (populates savedLogoState)
|
||||||
|
// logo.init(savedLogoState) → mounts letters, restores state
|
||||||
|
// setLogoController(logo) → arms per-tick calls
|
||||||
|
const logo = new LogoController();
|
||||||
|
const container = document.getElementById('evolutionContainer');
|
||||||
|
|
||||||
|
evolution.init(container)
|
||||||
|
.then(() => logo.init(container, evolution.savedLogoState))
|
||||||
|
.then(() => {
|
||||||
|
evolution.setLogoController(logo);
|
||||||
|
settings.setLogoController(logo);
|
||||||
|
});
|
||||||
|
|||||||
@@ -8,7 +8,8 @@
|
|||||||
* saved state on the next page load so the system continues as configured.
|
* saved state on the next page load so the system continues as configured.
|
||||||
*
|
*
|
||||||
* Reset:
|
* Reset:
|
||||||
* - resetBtn clears localStorage, restores DEFAULTS, and wipes live entities.
|
* - resetBtn clears localStorage and restores DEFAULTS for settings only.
|
||||||
|
* Evolution state is reset separately via the guide panel.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { DEFAULTS } from './constants.js';
|
import { DEFAULTS } from './constants.js';
|
||||||
@@ -22,6 +23,9 @@ export class SettingsController {
|
|||||||
/** @type {import('./evolution.js').EvolutionController} */
|
/** @type {import('./evolution.js').EvolutionController} */
|
||||||
#evolution;
|
#evolution;
|
||||||
|
|
||||||
|
/** @type {import('./logoController.js').LogoController|null} */
|
||||||
|
#logo = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {import('./gradient.js').GradientController} gradient
|
* @param {import('./gradient.js').GradientController} gradient
|
||||||
* @param {import('./evolution.js').EvolutionController} evolution
|
* @param {import('./evolution.js').EvolutionController} evolution
|
||||||
@@ -35,6 +39,18 @@ export class SettingsController {
|
|||||||
|
|
||||||
// ── Public ──────────────────────────────────────────────────
|
// ── 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('debugHitCountToggle'));
|
||||||
|
if (toggle) logo.setDebugVisible(toggle.checked);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply saved localStorage settings to the controllers and sync the UI.
|
* Apply saved localStorage settings to the controllers and sync the UI.
|
||||||
* Must be called AFTER gradient.init() so saved values override DEFAULTS.
|
* Must be called AFTER gradient.init() so saved values override DEFAULTS.
|
||||||
@@ -52,6 +68,11 @@ export class SettingsController {
|
|||||||
if (typeof s.virusKillChance === 'number') this.#evolution.setVirusKillChance(s.virusKillChance);
|
if (typeof s.virusKillChance === 'number') this.#evolution.setVirusKillChance(s.virusKillChance);
|
||||||
if (typeof s.bugSpawnChance === 'number') this.#evolution.setBugSpawnChance(s.bugSpawnChance);
|
if (typeof s.bugSpawnChance === 'number') this.#evolution.setBugSpawnChance(s.bugSpawnChance);
|
||||||
if (typeof s.bugMaxCount === 'number') this.#evolution.setBugMaxCount(s.bugMaxCount);
|
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);
|
||||||
|
}
|
||||||
} catch { /* corrupt storage — ignore */ }
|
} catch { /* corrupt storage — ignore */ }
|
||||||
this.#syncControls();
|
this.#syncControls();
|
||||||
}
|
}
|
||||||
@@ -68,7 +89,8 @@ export class SettingsController {
|
|||||||
const virusKillSlider = /** @type {HTMLInputElement} */ (document.getElementById('virusKillSlider'));
|
const virusKillSlider = /** @type {HTMLInputElement} */ (document.getElementById('virusKillSlider'));
|
||||||
const bugChanceSlider = /** @type {HTMLInputElement} */ (document.getElementById('bugChanceSlider'));
|
const bugChanceSlider = /** @type {HTMLInputElement} */ (document.getElementById('bugChanceSlider'));
|
||||||
const bugCountSlider = /** @type {HTMLInputElement} */ (document.getElementById('bugCountSlider'));
|
const bugCountSlider = /** @type {HTMLInputElement} */ (document.getElementById('bugCountSlider'));
|
||||||
const resetBtn = /** @type {HTMLButtonElement} */ (document.getElementById('resetBtn'));
|
const resetBtn = /** @type {HTMLButtonElement} */ (document.getElementById('resetBtn'));
|
||||||
|
const debugHitCountToggle = /** @type {HTMLInputElement} */ (document.getElementById('debugHitCountToggle'));
|
||||||
|
|
||||||
// ── Colour picker ──────────────────────────────────────
|
// ── Colour picker ──────────────────────────────────────
|
||||||
colorPicker.addEventListener('input', (e) => {
|
colorPicker.addEventListener('input', (e) => {
|
||||||
@@ -120,6 +142,13 @@ export class SettingsController {
|
|||||||
this.#saveState();
|
this.#saveState();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Debug: letter hit count ────────────────────────────
|
||||||
|
debugHitCountToggle.addEventListener('change', (e) => {
|
||||||
|
const on = /** @type {HTMLInputElement} */ (e.target).checked;
|
||||||
|
this.#logo?.setDebugVisible(on);
|
||||||
|
this.#saveState();
|
||||||
|
});
|
||||||
|
|
||||||
// ── Reset to defaults ──────────────────────────────────
|
// ── Reset to defaults ──────────────────────────────────
|
||||||
resetBtn.addEventListener('click', () => {
|
resetBtn.addEventListener('click', () => {
|
||||||
localStorage.removeItem(STORAGE_KEY);
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
@@ -131,7 +160,8 @@ export class SettingsController {
|
|||||||
this.#evolution.setVirusKillChance(DEFAULTS.VIRUS_KILL_CHANCE);
|
this.#evolution.setVirusKillChance(DEFAULTS.VIRUS_KILL_CHANCE);
|
||||||
this.#evolution.setBugSpawnChance(DEFAULTS.BUG_SPAWN_CHANCE);
|
this.#evolution.setBugSpawnChance(DEFAULTS.BUG_SPAWN_CHANCE);
|
||||||
this.#evolution.setBugMaxCount(DEFAULTS.BUG_MAX_COUNT);
|
this.#evolution.setBugMaxCount(DEFAULTS.BUG_MAX_COUNT);
|
||||||
this.#evolution.clear();
|
debugHitCountToggle.checked = false;
|
||||||
|
this.#logo?.setDebugVisible(false);
|
||||||
this.#syncControls();
|
this.#syncControls();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -148,6 +178,7 @@ export class SettingsController {
|
|||||||
virusKillChance: this.#evolution.virusKillChance,
|
virusKillChance: this.#evolution.virusKillChance,
|
||||||
bugSpawnChance: this.#evolution.bugSpawnChance,
|
bugSpawnChance: this.#evolution.bugSpawnChance,
|
||||||
bugMaxCount: this.#evolution.bugMaxCount,
|
bugMaxCount: this.#evolution.bugMaxCount,
|
||||||
|
debugHitCount: (/** @type {HTMLInputElement} */ (document.getElementById('debugHitCountToggle')))?.checked ?? false,
|
||||||
}));
|
}));
|
||||||
} catch { /* quota exceeded or private browsing — ignore */ }
|
} catch { /* quota exceeded or private browsing — ignore */ }
|
||||||
}
|
}
|
||||||
@@ -165,7 +196,8 @@ export class SettingsController {
|
|||||||
const spawnRateSlider = /** @type {HTMLInputElement} */ (document.getElementById('spawnRateSlider'));
|
const spawnRateSlider = /** @type {HTMLInputElement} */ (document.getElementById('spawnRateSlider'));
|
||||||
const virusKillSlider = /** @type {HTMLInputElement} */ (document.getElementById('virusKillSlider'));
|
const virusKillSlider = /** @type {HTMLInputElement} */ (document.getElementById('virusKillSlider'));
|
||||||
const bugChanceSlider = /** @type {HTMLInputElement} */ (document.getElementById('bugChanceSlider'));
|
const bugChanceSlider = /** @type {HTMLInputElement} */ (document.getElementById('bugChanceSlider'));
|
||||||
const bugCountSlider = /** @type {HTMLInputElement} */ (document.getElementById('bugCountSlider'));
|
const bugCountSlider = /** @type {HTMLInputElement} */ (document.getElementById('bugCountSlider'));
|
||||||
|
const debugHitCountToggle = /** @type {HTMLInputElement} */ (document.getElementById('debugHitCountToggle'));
|
||||||
|
|
||||||
colorPicker.value = this.#gradient.color;
|
colorPicker.value = this.#gradient.color;
|
||||||
colorValue.textContent = this.#gradient.color;
|
colorValue.textContent = this.#gradient.color;
|
||||||
@@ -175,6 +207,7 @@ export class SettingsController {
|
|||||||
spawnRateSlider.value = String(this.#evolution.spawnRate);
|
spawnRateSlider.value = String(this.#evolution.spawnRate);
|
||||||
virusKillSlider.value = String(Math.round(this.#evolution.virusKillChance * 100));
|
virusKillSlider.value = String(Math.round(this.#evolution.virusKillChance * 100));
|
||||||
bugChanceSlider.value = String(Math.round(this.#evolution.bugSpawnChance * 100));
|
bugChanceSlider.value = String(Math.round(this.#evolution.bugSpawnChance * 100));
|
||||||
bugCountSlider.value = String(this.#evolution.bugMaxCount);
|
bugCountSlider.value = String(this.#evolution.bugMaxCount);
|
||||||
|
debugHitCountToggle.checked = false; // debug off by default on sync/reset
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
/* ════════════════════════════════════════════════════════════
|
||||||
|
logo.css — floating "shadolldev" letter word
|
||||||
|
════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
/* ── Letter outer (physics position) ────────────────────── */
|
||||||
|
/*
|
||||||
|
* JS writes transform: translate(Xpx, Ypx) every frame.
|
||||||
|
* Size must match DEFAULTS.LOGO_LETTER_SIZE (36px).
|
||||||
|
*/
|
||||||
|
.logo-letter {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
will-change: transform;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 9;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Letter body (scale transition + appear animation) ───── */
|
||||||
|
.logo-letter__body {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
scale: 0;
|
||||||
|
opacity: 0;
|
||||||
|
color: rgba(255, 255, 255, 0.22);
|
||||||
|
filter: drop-shadow(0 0 3px rgba(255, 255, 255, 0.06));
|
||||||
|
transition:
|
||||||
|
scale 0.7s cubic-bezier(0.34, 1.56, 0.64, 1),
|
||||||
|
opacity 0.3s ease,
|
||||||
|
color 0.3s ease,
|
||||||
|
filter 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-letter__body[data-state="alive"] {
|
||||||
|
scale: 1;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-letter__body svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Ejected state — brighter, glowing ──────────────────── */
|
||||||
|
/*
|
||||||
|
* The class is on the outer .logo-letter so the body
|
||||||
|
* inherits a new filter/color via the transition.
|
||||||
|
*/
|
||||||
|
.logo-letter--ejected .logo-letter__body {
|
||||||
|
color: rgba(255, 255, 255, 0.72);
|
||||||
|
filter:
|
||||||
|
drop-shadow(0 0 8px rgba(255, 255, 255, 0.45))
|
||||||
|
drop-shadow(0 0 2px rgba(255, 255, 255, 0.2));
|
||||||
|
scale: 1.12;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Debug hit-count label ───────────────────────────────── */
|
||||||
|
.logo-letter__debug {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
top: 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.45);
|
||||||
|
white-space: nowrap;
|
||||||
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-letter--show-debug .logo-letter__debug {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Hit flash animation ─────────────────────────────────── */
|
||||||
|
@keyframes logo-letter-hit {
|
||||||
|
0% { scale: 1; }
|
||||||
|
40% { scale: 1.5; }
|
||||||
|
100% { scale: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-letter__body--hit {
|
||||||
|
animation: logo-letter-hit 0.35s cubic-bezier(0.36, 0.07, 0.19, 0.97) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ejected letters get a slightly larger hit pop */
|
||||||
|
.logo-letter--ejected .logo-letter__body--hit {
|
||||||
|
animation: logo-letter-hit-ejected 0.35s cubic-bezier(0.36, 0.07, 0.19, 0.97) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes logo-letter-hit-ejected {
|
||||||
|
0% { scale: 1.12; }
|
||||||
|
40% { scale: 1.65; }
|
||||||
|
100% { scale: 1.12; }
|
||||||
|
}
|
||||||
@@ -12,8 +12,8 @@
|
|||||||
/* ── Design Tokens ───────────────────────────────────────── */
|
/* ── Design Tokens ───────────────────────────────────────── */
|
||||||
:root {
|
:root {
|
||||||
/* Gradient colors — overridden immediately by JS on load */
|
/* Gradient colors — overridden immediately by JS on load */
|
||||||
--color-1: #1a7040;
|
--color-1: #4d22b3;
|
||||||
--color-2: #4db8c4;
|
--color-2: #8d66e7;
|
||||||
|
|
||||||
/* Animation timing — overridden by JS (default = speed 5) */
|
/* Animation timing — overridden by JS (default = speed 5) */
|
||||||
--anim-duration: 19s;
|
--anim-duration: 19s;
|
||||||
|
|||||||