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 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/gradient.css" />
|
||||
<link rel="stylesheet" href="src/styles/modal.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" />
|
||||
</head>
|
||||
<body>
|
||||
@@ -226,6 +227,25 @@
|
||||
|
||||
</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 class="modal-footer">
|
||||
<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 */
|
||||
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 {boolean} */ #running = false;
|
||||
/** @type {object|null} */ #iconsData = null;
|
||||
/** @type {import('./logoController.js').LogoController|null} */ #logo = null;
|
||||
/** @type {object|null} */ #savedLogoState = null;
|
||||
|
||||
// ── Public API ─────────────────────────────────────────────
|
||||
|
||||
@@ -167,6 +169,20 @@ export class EvolutionController {
|
||||
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). */
|
||||
get entityCount() { return this.#entities.length; }
|
||||
|
||||
@@ -244,9 +260,15 @@ export class EvolutionController {
|
||||
entity.update(multiplier);
|
||||
}
|
||||
|
||||
// Advance logo word + ejected letter physics
|
||||
this.#logo?.update(multiplier);
|
||||
|
||||
// Check and resolve entity-to-entity collisions after all positions updated
|
||||
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)
|
||||
if (this.#entities.some(e => !e.alive)) {
|
||||
this.#entities = this.#entities.filter(e => e.alive);
|
||||
@@ -405,6 +427,7 @@ export class EvolutionController {
|
||||
rotation: e.rotation,
|
||||
hueShift: e.hueShift,
|
||||
})),
|
||||
logo: this.#logo?.serialise() ?? null,
|
||||
};
|
||||
localStorage.setItem(EVOLUTION_STORAGE_KEY, JSON.stringify(snapshot));
|
||||
} catch { /* quota exceeded — ignore */ }
|
||||
@@ -426,6 +449,11 @@ export class EvolutionController {
|
||||
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;
|
||||
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 { EvolutionController } from './evolution.js';
|
||||
import { GuideController } from './guide.js';
|
||||
import { LogoController } from './logoController.js';
|
||||
import { DEFAULTS } from './constants.js';
|
||||
|
||||
// ── Initialise controllers ─────────────────────────────────
|
||||
@@ -24,7 +25,6 @@ const evolution = new EvolutionController();
|
||||
const guide = new GuideController();
|
||||
|
||||
// SettingsController bridges UI → gradient + evolution
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const settings = new SettingsController(gradient, evolution);
|
||||
|
||||
// ── Wire up settings button ────────────────────────────────
|
||||
@@ -41,5 +41,16 @@ gradient.init(DEFAULTS.GRADIENT_COLOR);
|
||||
// Apply saved settings after gradient has set its defaults
|
||||
settings.loadSaved();
|
||||
|
||||
// ── Boot evolution (async — loads icons.json + warms SVG cache) ──
|
||||
evolution.init(document.getElementById('evolutionContainer'));
|
||||
// ── Boot logo + evolution ──────────────────────────────────
|
||||
// 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.
|
||||
*
|
||||
* 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';
|
||||
@@ -22,6 +23,9 @@ export class SettingsController {
|
||||
/** @type {import('./evolution.js').EvolutionController} */
|
||||
#evolution;
|
||||
|
||||
/** @type {import('./logoController.js').LogoController|null} */
|
||||
#logo = null;
|
||||
|
||||
/**
|
||||
* @param {import('./gradient.js').GradientController} gradient
|
||||
* @param {import('./evolution.js').EvolutionController} evolution
|
||||
@@ -35,6 +39,18 @@ export class SettingsController {
|
||||
|
||||
// ── 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.
|
||||
* 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.bugSpawnChance === 'number') this.#evolution.setBugSpawnChance(s.bugSpawnChance);
|
||||
if (typeof s.bugMaxCount === 'number') this.#evolution.setBugMaxCount(s.bugMaxCount);
|
||||
if (typeof s.debugHitCount === 'boolean') {
|
||||
const toggle = /** @type {HTMLInputElement} */ (document.getElementById('debugHitCountToggle'));
|
||||
if (toggle) toggle.checked = s.debugHitCount;
|
||||
this.#logo?.setDebugVisible(s.debugHitCount);
|
||||
}
|
||||
} catch { /* corrupt storage — ignore */ }
|
||||
this.#syncControls();
|
||||
}
|
||||
@@ -68,7 +89,8 @@ export class SettingsController {
|
||||
const virusKillSlider = /** @type {HTMLInputElement} */ (document.getElementById('virusKillSlider'));
|
||||
const bugChanceSlider = /** @type {HTMLInputElement} */ (document.getElementById('bugChanceSlider'));
|
||||
const bugCountSlider = /** @type {HTMLInputElement} */ (document.getElementById('bugCountSlider'));
|
||||
const resetBtn = /** @type {HTMLButtonElement} */ (document.getElementById('resetBtn'));
|
||||
const resetBtn = /** @type {HTMLButtonElement} */ (document.getElementById('resetBtn'));
|
||||
const debugHitCountToggle = /** @type {HTMLInputElement} */ (document.getElementById('debugHitCountToggle'));
|
||||
|
||||
// ── Colour picker ──────────────────────────────────────
|
||||
colorPicker.addEventListener('input', (e) => {
|
||||
@@ -120,6 +142,13 @@ export class SettingsController {
|
||||
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 ──────────────────────────────────
|
||||
resetBtn.addEventListener('click', () => {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
@@ -131,7 +160,8 @@ export class SettingsController {
|
||||
this.#evolution.setVirusKillChance(DEFAULTS.VIRUS_KILL_CHANCE);
|
||||
this.#evolution.setBugSpawnChance(DEFAULTS.BUG_SPAWN_CHANCE);
|
||||
this.#evolution.setBugMaxCount(DEFAULTS.BUG_MAX_COUNT);
|
||||
this.#evolution.clear();
|
||||
debugHitCountToggle.checked = false;
|
||||
this.#logo?.setDebugVisible(false);
|
||||
this.#syncControls();
|
||||
});
|
||||
}
|
||||
@@ -148,6 +178,7 @@ export class SettingsController {
|
||||
virusKillChance: this.#evolution.virusKillChance,
|
||||
bugSpawnChance: this.#evolution.bugSpawnChance,
|
||||
bugMaxCount: this.#evolution.bugMaxCount,
|
||||
debugHitCount: (/** @type {HTMLInputElement} */ (document.getElementById('debugHitCountToggle')))?.checked ?? false,
|
||||
}));
|
||||
} catch { /* quota exceeded or private browsing — ignore */ }
|
||||
}
|
||||
@@ -165,7 +196,8 @@ export class SettingsController {
|
||||
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 bugCountSlider = /** @type {HTMLInputElement} */ (document.getElementById('bugCountSlider'));
|
||||
const debugHitCountToggle = /** @type {HTMLInputElement} */ (document.getElementById('debugHitCountToggle'));
|
||||
|
||||
colorPicker.value = this.#gradient.color;
|
||||
colorValue.textContent = this.#gradient.color;
|
||||
@@ -175,6 +207,7 @@ export class SettingsController {
|
||||
spawnRateSlider.value = String(this.#evolution.spawnRate);
|
||||
virusKillSlider.value = String(Math.round(this.#evolution.virusKillChance * 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 ───────────────────────────────────────── */
|
||||
:root {
|
||||
/* Gradient colors — overridden immediately by JS on load */
|
||||
--color-1: #1a7040;
|
||||
--color-2: #4db8c4;
|
||||
--color-1: #4d22b3;
|
||||
--color-2: #8d66e7;
|
||||
|
||||
/* Animation timing — overridden by JS (default = speed 5) */
|
||||
--anim-duration: 19s;
|
||||
|
||||