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.
This commit is contained in:
sha
2026-02-21 18:44:25 +02:00
parent e3ec163ad7
commit 742a8154cf
18 changed files with 771 additions and 11 deletions
+21 -1
View File
@@ -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>
+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+26
View File
@@ -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 (01) */
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,
}; };
+28
View File
@@ -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;
+288
View File
@@ -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 5005000 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);
}
}));
}
}
+245
View File
@@ -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 (5005000).
* - 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; // 09, 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);
}
}
+14 -3
View File
@@ -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);
});
+38 -5
View File
@@ -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
} }
} }
+100
View File
@@ -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; }
}
+2 -2
View File
@@ -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;