-
-
diff --git a/src/data/icons.json b/src/data/icons.json
index 799e2f2..acf6472 100644
--- a/src/data/icons.json
+++ b/src/data/icons.json
@@ -1,11 +1,12 @@
{
- "_comment": "Icon registry for the evolution system. Each icon has a type (good/bad/neutral) and belongs to a group. Types define the visual colour tint. Spawn config controls initial entity creation.",
+ "_comment": "Icon registry for the evolution system. Each icon has a type (good/bad/neutral/viral) and belongs to a group. Types define the visual colour tint. Spawn config controls initial entity creation and rare spawns.",
"groups": [
- { "id": "biology", "label": "Biology", "icons": ["dna-bold-duotone"] },
+ { "id": "biology", "label": "Biology", "icons": ["dna-bold-duotone", "bacteria"] },
{ "id": "tech", "label": "Technology", "icons": ["database-bold-duotone", "display-line-duotone", "bug-bold-duotone"] },
{ "id": "social", "label": "Social", "icons": ["chat-round-dots-bold-duotone", "chat-round-unread-bold-duotone"] },
- { "id": "economy", "label": "Economy", "icons": ["chat-round-money-bold-duotone", "delivery-bold-duotone", "buildings-3-bold-duotone"] }
+ { "id": "economy", "label": "Economy", "icons": ["chat-round-money-bold-duotone", "delivery-bold-duotone", "buildings-3-bold-duotone"] },
+ { "id": "threat", "label": "Threat", "icons": ["bug", "virus-filled"] }
],
"icons": {
@@ -17,16 +18,23 @@
"chat-round-unread-bold-duotone": { "label": "Unread Chat", "type": "neutral", "group": "social" },
"database-bold-duotone": { "label": "Database", "type": "good", "group": "tech" },
"delivery-bold-duotone": { "label": "Delivery", "type": "good", "group": "economy" },
- "display-line-duotone": { "label": "Display", "type": "neutral", "group": "tech" }
+ "display-line-duotone": { "label": "Display", "type": "neutral", "group": "tech" },
+ "bug": { "label": "Bug (rare)", "type": "bad", "group": "threat", "isRare": true, "behavior": "infect", "infectWith": "virus-filled" },
+ "virus-filled": { "label": "Virus", "type": "viral", "group": "threat" },
+ "bacteria": { "label": "Bacteria", "type": "mutant", "group": "biology" }
},
"types": {
"good": { "label": "Beneficial", "color": "#a8ffb8" },
"bad": { "label": "Harmful", "color": "#ffb0a8" },
- "neutral": { "label": "Neutral", "color": "#c4d8ff" }
+ "neutral": { "label": "Neutral", "color": "#c4d8ff" },
+ "viral": { "label": "Viral", "color": "#ff4d6d" },
+ "mutant": { "label": "Mutant", "color": "#80ffee" }
},
"spawn": {
- "initial": "dna-bold-duotone"
+ "initial": "dna-bold-duotone",
+ "rare": "bug",
+ "rareChance": 0.08
}
}
diff --git a/src/icons/bacteria-new.svg b/src/icons/bacteria-new.svg
new file mode 100644
index 0000000..64c1f2e
--- /dev/null
+++ b/src/icons/bacteria-new.svg
@@ -0,0 +1,15 @@
+
diff --git a/src/icons/bug.svg b/src/icons/bug.svg
index 3ad459a..15a12f3 100644
--- a/src/icons/bug.svg
+++ b/src/icons/bug.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
diff --git a/src/icons/bug_new.svg b/src/icons/bug_new.svg
new file mode 100644
index 0000000..91ba501
--- /dev/null
+++ b/src/icons/bug_new.svg
@@ -0,0 +1,23 @@
+
diff --git a/src/icons/virus-filled.svg b/src/icons/virus-filled.svg
index e1a3f5a..488f53c 100644
--- a/src/icons/virus-filled.svg
+++ b/src/icons/virus-filled.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
diff --git a/src/icons/virus-new.svg b/src/icons/virus-new.svg
new file mode 100644
index 0000000..b9e79eb
--- /dev/null
+++ b/src/icons/virus-new.svg
@@ -0,0 +1,18 @@
+
diff --git a/src/js/constants.js b/src/js/constants.js
index 9c7304f..eb6c33c 100644
--- a/src/js/constants.js
+++ b/src/js/constants.js
@@ -40,4 +40,30 @@ export const DEFAULTS = {
/** Max |Δv| applied on each drift kick */
DRIFT_MAGNITUDE: 0.25,
+ // ── Collisions ─────────────────────────────────────────────
+ /** Distance between centres (px) at which two entities collide (= ICON_SIZE) */
+ COLLISION_DIAMETER: 24,
+
+ // ── Hit colour shift ───────────────────────────────────────
+ /** Minimum hue-rotate degrees added to an entity's filter on each collision */
+ HIT_HUE_MIN: 60,
+ /** Additional random range on top of HIT_HUE_MIN */
+ HIT_HUE_RANGE: 60,
+
+ // ── Bug / infection ────────────────────────────────────────
+ /** Fallback probability for a rare-bug spawn when icons.json rareChance is absent */
+ BUG_SPAWN_CHANCE: 0.08,
+ /** Max number of bug entities alive simultaneously */
+ BUG_MAX_COUNT: 2,
+
+ // ── Virus kill ─────────────────────────────────────────────
+ /** Probability that a virus contact kills the target (else it survives unaffected) */
+ VIRUS_KILL_CHANCE: 0.9,
+ /** CSS colour applied to a dying entity immediately on contact */
+ KILL_FADE_COLOR: '#ffaaaa',
+ /** Delay (ms) before the grayscale-dead visual phase begins */
+ KILL_FADE_MS: 400,
+ /** Total ms from kill trigger to entity removal */
+ KILL_DEATH_DURATION: 3_000,
+
};
diff --git a/src/js/entity.js b/src/js/entity.js
index 413c425..61452bf 100644
--- a/src/js/entity.js
+++ b/src/js/entity.js
@@ -7,9 +7,15 @@
* organic, chaotic-feeling motion (not perfectly straight lines).
* - Edge detection: velocity component is reflected (±abs) on contact,
* ensuring the bounce always sends the icon back into the viewport.
+ * - Rotation: each entity spawns with a fixed random orientation (0–360°).
+ *
+ * Collision response (handled by EvolutionController):
+ * - onHit() — colour-shifts the icon via hue-rotate filter + flash anim.
+ * - infectWith() — async; collapses the icon, swaps SVG, re-expands as new form.
+ * - die() — slow death: light-red flash → dark-grayscale fade → destroy.
*
* DOM structure:
- *
← position via JS transform
+ *
← position + rotation via JS transform
*
← scale animation via CSS transition
* ← injected SVG, sized by CSS
*
@@ -28,27 +34,42 @@ export class Entity {
/** @type {number} */ y;
/** @type {number} */ vx;
/** @type {number} */ vy;
+ /** Fixed spawn orientation in degrees (0–360) */
+ /** @type {number} */ rotation;
// ── Identity ─────────────────────────────────────────────
+ /** The current icon name — changes on infection. */
+ /** @type {string} */ entityKey;
/** @type {string} */ name;
/** @type {string} */ type;
/** @type {string} */ color;
/** @type {boolean} */ alive = true;
+ // ── Visual state ──────────────────────────────────────────
+ /** Accumulated hue-rotate offset (degrees). Increases on each hit. */
+ /** @type {number} */ hueShift = 0;
+
// ── DOM ───────────────────────────────────────────────────
/** @type {HTMLElement|null} */ el = null;
/** @type {HTMLElement|null} */ bodyEl = null;
+ // ── Private ───────────────────────────────────────────────
+ /** @type {boolean} */ #infected = false;
+ /** @type {boolean} */ #dying = false;
+
/**
* @param {{ name: string, type: string, color: string,
- * x: number, y: number, vx: number, vy: number }} config
+ * x: number, y: number, vx: number, vy: number,
+ * rotation?: number }} config
*/
- constructor({ name, type, color, x, y, vx, vy }) {
- this.name = name;
- this.type = type;
- this.color = color;
- this.x = x; this.y = y;
- this.vx = vx; this.vy = vy;
+ constructor({ name, type, color, x, y, vx, vy, rotation }) {
+ this.name = name;
+ this.entityKey = name;
+ this.type = type;
+ this.color = color;
+ this.x = x; this.y = y;
+ this.vx = vx; this.vy = vy;
+ this.rotation = rotation !== undefined ? rotation : Math.random() * 360;
}
// ── Public API ─────────────────────────────────────────────
@@ -101,7 +122,7 @@ export class Entity {
* @param {number} speedMultiplier scales BASE_SPEED (e.g. slider / 5)
*/
update(speedMultiplier) {
- if (!this.el || !this.alive) return;
+ if (!this.el || !this.alive || this.#dying) return;
// Move
this.x += this.vx * speedMultiplier;
@@ -134,6 +155,110 @@ export class Entity {
this._applyTransform();
}
+ /**
+ * React to a physical collision with another entity.
+ * Shifts the hue-rotate filter by a random step and plays a brief
+ * scale-punch animation to signal the impact.
+ */
+ onHit() {
+ if (!this.bodyEl || !this.alive || this.#dying) return;
+ const shift = DEFAULTS.HIT_HUE_MIN + Math.floor(Math.random() * DEFAULTS.HIT_HUE_RANGE);
+ this.hueShift = (this.hueShift + shift) % 360;
+ this.bodyEl.style.filter =
+ `hue-rotate(${this.hueShift}deg) drop-shadow(0 1px 6px rgba(0,0,0,0.35))`;
+ // Flash animation — class removed after its duration
+ this.bodyEl.classList.remove('icon-entity__body--hit'); // reset if mid-anim
+ void this.bodyEl.offsetWidth; // force reflow
+ this.bodyEl.classList.add('icon-entity__body--hit');
+ setTimeout(() => this.bodyEl?.classList.remove('icon-entity__body--hit'), 350);
+ }
+
+ /** True while the entity is in its slow-death sequence. */
+ get dying() { return this.#dying; }
+
+ /**
+ * Begin a slow death sequence triggered by a virus contact.
+ *
+ * Phase 1 (immediate): entity stops moving, colour changes to light red.
+ * Phase 2 (+KILL_FADE_MS): grayscale-dark CSS class fades the icon out.
+ * Phase 3 (+KILL_DEATH_DURATION): entity is removed from the DOM.
+ */
+ die() {
+ if (this.#dying || !this.bodyEl || !this.alive) return;
+ this.#dying = true;
+
+ // Freeze movement
+ this.vx = 0;
+ this.vy = 0;
+
+ // Phase 1: light-red flash — clear any hit filter so CSS class takes over
+ this.el.style.color = DEFAULTS.KILL_FADE_COLOR;
+ this.hueShift = 0;
+ this.bodyEl.style.filter = '';
+ this.bodyEl.classList.remove('icon-entity__body--hit');
+
+ // Phase 2: grayscale dark fade
+ setTimeout(() => {
+ if (this.bodyEl) this.bodyEl.classList.add('icon-entity__body--dying');
+ }, DEFAULTS.KILL_FADE_MS);
+
+ // Phase 3: remove from DOM — EvolutionController cleans up entity array each tick
+ setTimeout(() => this.destroy(), DEFAULTS.KILL_DEATH_DURATION);
+ }
+
+ /**
+ * Transform this entity into another icon (infection mechanic).
+ * Pre-fetches the new SVG, collapses the current icon, swaps content,
+ * then expands the new icon using the standard appear animation.
+ *
+ * @param {string} iconName Key of the SVG to transform into (e.g. 'virus-filled')
+ * @param {string} color CSS colour string for the new icon tint
+ */
+ /**
+ * @param {string} iconName
+ * @param {string} color
+ * @param {{ force?: boolean }} [opts] force:true bypasses the #infected guard (used for bug cure)
+ */
+ async infectWith(iconName, color, { force = false } = {}) {
+ if (this.#dying || !this.bodyEl || !this.alive) return;
+ if (!force && this.#infected) return;
+ this.#infected = true;
+ this.entityKey = iconName;
+
+ try {
+ // Pre-fetch the new SVG (likely cached) before touching the DOM
+ const svg = await loadIcon(iconName);
+ if (!this.bodyEl || !this.alive) return;
+
+ // Collapse current icon — CSS transition: scale 0.6s spring
+ this.bodyEl.dataset.state = 'spawning';
+
+ // Wait ~300 ms — at this point the entity is visibly shrinking.
+ // Swap the SVG content while it's small and hard to see.
+ await new Promise(r => setTimeout(r, 300));
+ if (!this.bodyEl || !this.alive) return;
+
+ this.bodyEl.innerHTML = svg;
+ this.color = color;
+ this.el.style.color = color;
+ this.hueShift = 0;
+ this.bodyEl.style.filter = ''; // clear any hit hue-rotate
+
+ // Two rAF ticks guarantee the 'spawning' state was painted
+ // before switching to 'alive', so the expand transition fires.
+ requestAnimationFrame(() => {
+ requestAnimationFrame(() => {
+ if (this.bodyEl) this.bodyEl.dataset.state = 'alive';
+ });
+ });
+ } catch (err) {
+ console.warn('[Entity] infectWith failed:', err);
+ this.#infected = false;
+ this.entityKey = this.name; // revert
+ if (this.bodyEl) this.bodyEl.dataset.state = 'alive';
+ }
+ }
+
/** Remove this entity from the DOM and mark it as dead. */
destroy() {
this.alive = false;
@@ -145,13 +270,16 @@ export class Entity {
// ── Private ────────────────────────────────────────────────
/**
- * Write the current (x, y) position to the DOM via transform.
+ * Write the current (x, y) position and fixed rotation to the DOM.
* Using transform keeps this on the compositor thread (no layout).
- * We offset by ICON_HALF so (x, y) represents the icon's centre point.
+ * Offset by ICON_HALF so (x, y) is the icon's centre point.
+ * rotate() is applied after translate so it spins the icon around
+ * its own centre, independent of its screen position.
*/
_applyTransform() {
if (!this.el) return;
const { ICON_HALF: h } = DEFAULTS;
- this.el.style.transform = `translate(${this.x - h}px, ${this.y - h}px)`;
+ this.el.style.transform =
+ `translate(${this.x - h}px, ${this.y - h}px) rotate(${this.rotation}deg)`;
}
}
diff --git a/src/js/evolution.js b/src/js/evolution.js
index cb154f0..ffaea9d 100644
--- a/src/js/evolution.js
+++ b/src/js/evolution.js
@@ -3,12 +3,19 @@
* Orchestrates the icon evolution system:
*
* 1. Spawning — after a random interval (SPAWN_DELAY_MIN … SPAWN_DELAY_MAX),
- * a dna-bold-duotone icon appears at a random viewport position.
- * After each spawn the timer resets for the next one.
+ * an icon appears at a random viewport position. Most spawns produce the
+ * "initial" icon (dna-bold-duotone); with rareChance probability the rare
+ * "bug" icon spawns instead.
*
* 2. Physics loop — a single rAF loop drives all live entities each frame.
+ * After every position update, entity pairs are checked for collisions.
*
- * 3. Speed control — setMoveSpeed(1–10) scales entity velocity in real time.
+ * 3. Collision response — overlapping entities receive an elastic velocity
+ * impulse (equal-mass reflection), are pushed apart, and both call onHit()
+ * to shift their hue colour. If a "bug" entity collides with a non-immune
+ * entity, the target is infected and transforms into "virus-filled".
+ *
+ * 4. Speed control — setMoveSpeed(1–10) scales entity velocity in real time.
*
* The icon to spawn first is declared in icons.json under spawn.initial.
* All icon metadata (type, colour) is also read from icons.json.
@@ -18,13 +25,20 @@ import { Entity } from './entity.js';
import { preloadIcons } from './iconLoader.js';
import { DEFAULTS } from './constants.js';
-const ICONS_DATA_URL = 'src/data/icons.json';
+const ICONS_DATA_URL = 'src/data/icons.json';
+const EVOLUTION_STORAGE_KEY = 'devpage:evolution';
export class EvolutionController {
- /** @type {Entity[]} */ #entities = [];
- /** @type {HTMLElement|null} */ #container = null;
- /** @type {number} */ #moveSpeed = DEFAULTS.MOVE_SPEED;
+ /** @type {Entity[]} */ #entities = [];
+ /** @type {HTMLElement|null} */ #container = null;
+ /** @type {number} */ #moveSpeed = DEFAULTS.MOVE_SPEED;
+ /** @type {number} */ #spawnRate = 5;
+ /** @type {number} */ #virusKillChance = DEFAULTS.VIRUS_KILL_CHANCE;
+ /** @type {number} */ #bugSpawnChance = DEFAULTS.BUG_SPAWN_CHANCE;
+ /** @type {number} */ #bugMaxCount = DEFAULTS.BUG_MAX_COUNT;
+ /** @type {number} */ #startTime = 0;
/** @type {ReturnType
|null} */ #spawnTimer = null;
+ /** @type {ReturnType|null} */ #saveTimer = null;
/** @type {number|null} */ #animFrame = null;
/** @type {boolean} */ #running = false;
/** @type {object|null} */ #iconsData = null;
@@ -41,12 +55,25 @@ export class EvolutionController {
this.#iconsData = await this.#loadIconsData();
- // Warm the SVG cache for the initial icon so first spawn is instant
- const initialName = this.#iconsData.spawn.initial;
- preloadIcons([initialName]);
+ // Warm the SVG cache for both the normal and rare icons so first spawns are instant
+ const { initial, rare } = this.#iconsData.spawn;
+ const toPreload = [initial];
+ if (rare) toPreload.push(rare);
+ // Also preload virus-filled and bacteria so transforms are instant
+ if (this.#iconsData.icons['virus-filled']) toPreload.push('virus-filled');
+ if (this.#iconsData.icons['bacteria']) toPreload.push('bacteria');
+ preloadIcons(toPreload);
+
+ this.#startTime = Date.now();
+
+ // Restore any previously saved entity state before the spawner kicks off
+ await this.#restoreState();
this.#scheduleNextSpawn();
this.#startLoop();
+
+ // Persist entity state to localStorage every 2 s
+ this.#saveTimer = setInterval(() => this.#saveState(), 2000);
}
/**
@@ -59,11 +86,83 @@ export class EvolutionController {
this.#moveSpeed = speed;
}
+ /** Current move speed setting (1–50). Used by SettingsController to sync UI on boot. */
+ get moveSpeed() { return this.#moveSpeed; }
+
+ /** Spawn frequency (1 = rare / slow, 10 = frequent / fast). */
+ setSpawnRate(rate) { this.#spawnRate = rate; }
+ get spawnRate() { return this.#spawnRate; }
+
+ /** Probability (0–1) that a virus contact kills its target. */
+ setVirusKillChance(v) { this.#virusKillChance = v; }
+ get virusKillChance() { return this.#virusKillChance; }
+
+ /** Probability (0–1) that a rare-bug icon spawns instead of the normal icon. */
+ setBugSpawnChance(v) { this.#bugSpawnChance = v; }
+ get bugSpawnChance() { return this.#bugSpawnChance; }
+
+ /** Max number of bug entities allowed alive at the same time (0 = no bugs). */
+ setBugMaxCount(n) { this.#bugMaxCount = n; }
+ get bugMaxCount() { return this.#bugMaxCount; }
+
+ /** Milliseconds elapsed since init() was called. */
+ get lifetime() { return this.#startTime ? Date.now() - this.#startTime : 0; }
+
+ /** Count of live entities grouped by entityKey. */
+ getCounts() {
+ /** @type {Record} */
+ const counts = {};
+ for (const e of this.#entities) {
+ counts[e.entityKey] = (counts[e.entityKey] ?? 0) + 1;
+ }
+ return counts;
+ }
+
+ /**
+ * Manually spawn a specific icon by name at a random viewport position.
+ * Used by the guide panel when the user clicks an entity row.
+ *
+ * @param {string} iconName Key from icons.json (e.g. 'dna-bold-duotone', 'bug')
+ */
+ async spawnNamed(iconName) {
+ if (!this.#container || !this.#iconsData) return;
+ const iconMeta = this.#iconsData.icons[iconName];
+ if (!iconMeta) return;
+ const typeMeta = this.#iconsData.types[iconMeta.type];
+
+ const margin = DEFAULTS.ICON_SIZE * 2;
+ const vw = window.innerWidth;
+ const vh = window.innerHeight;
+ const x = margin + Math.random() * (vw - margin * 2);
+ const y = margin + Math.random() * (vh - margin * 2);
+ const angle = Math.random() * Math.PI * 2;
+
+ const entity = new Entity({
+ name: iconName, type: iconMeta.type, color: typeMeta.color,
+ x, y,
+ vx: Math.cos(angle) * DEFAULTS.BASE_SPEED,
+ vy: Math.sin(angle) * DEFAULTS.BASE_SPEED,
+ });
+
+ await entity.mount(this.#container);
+ if (entity.alive) this.#entities.push(entity);
+ }
+
+ /** Remove all entities from the screen without stopping the physics loop. */
+ clear() {
+ this.#entities.forEach(e => e.destroy());
+ this.#entities = [];
+ this.#startTime = Date.now(); // reset lifetime counter
+ // Wipe saved state so a page reload starts fresh
+ try { localStorage.removeItem(EVOLUTION_STORAGE_KEY); } catch { /* ignore */ }
+ }
+
/** Stop all timers and the rAF loop; remove all entities from the DOM. */
stop() {
this.#running = false;
if (this.#spawnTimer !== null) clearTimeout(this.#spawnTimer);
if (this.#animFrame !== null) cancelAnimationFrame(this.#animFrame);
+ if (this.#saveTimer !== null) clearInterval(this.#saveTimer);
this.#entities.forEach(e => e.destroy());
this.#entities = [];
}
@@ -78,9 +177,11 @@ export class EvolutionController {
* Self-resets after each spawn so the process continues indefinitely.
*/
#scheduleNextSpawn() {
- const min = DEFAULTS.SPAWN_DELAY_MIN;
- const max = DEFAULTS.SPAWN_DELAY_MAX;
- const delay = min + Math.random() * (max - min);
+ // Exponential scale: rate 5 → default delays; higher rate → shorter delays.
+ const factor = Math.pow(this.#spawnRate / 5, 1.5);
+ const min = Math.max(300, Math.round(DEFAULTS.SPAWN_DELAY_MIN / factor));
+ const max = Math.max(1500, Math.round(DEFAULTS.SPAWN_DELAY_MAX / factor));
+ const delay = min + Math.random() * (max - min);
this.#spawnTimer = setTimeout(async () => {
await this.#spawnEntity();
@@ -92,7 +193,15 @@ export class EvolutionController {
async #spawnEntity() {
if (!this.#container || !this.#iconsData) return;
- const name = this.#iconsData.spawn.initial;
+ // Decide whether this spawn is a bug or the normal icon.
+ // A bug only spawns if the rarity roll passes AND the live bug cap isn't reached.
+ const { initial, rare } = this.#iconsData.spawn;
+ let name = initial;
+ if (rare && Math.random() < this.#bugSpawnChance) {
+ const liveBugs = this.#entities.filter(e => e.entityKey === 'bug').length;
+ if (liveBugs < this.#bugMaxCount) name = rare;
+ }
+
const iconMeta = this.#iconsData.icons[name];
const typeMeta = this.#iconsData.types[iconMeta.type];
@@ -135,12 +244,224 @@ export class EvolutionController {
entity.update(multiplier);
}
+ // Check and resolve entity-to-entity collisions after all positions updated
+ this.#checkCollisions();
+
+ // 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);
+ }
+
this.#animFrame = requestAnimationFrame(tick);
};
this.#animFrame = requestAnimationFrame(tick);
}
+ // ── Collision detection ─────────────────────────────────────
+
+ /**
+ * O(n²) broad + narrow-phase collision check for all entity pairs.
+ *
+ * On overlap:
+ * 1. Elastic velocity impulse along the collision normal (equal-mass reflection).
+ * 2. Positional correction — push both entities apart so they no longer overlap.
+ * 3. Both entities call onHit() to shift their hue colour.
+ * 4. If one entity is the rare "bug", the other is infected and transforms
+ * into "virus-filled" (unless it's already a bug or virus).
+ */
+ #checkCollisions() {
+ const entities = this.#entities;
+ const diameter = DEFAULTS.COLLISION_DIAMETER;
+
+ for (let i = 0; i < entities.length; i++) {
+ for (let j = i + 1; j < entities.length; j++) {
+ const a = entities[i];
+ const b = entities[j];
+ if (!a.alive || !b.alive || a.dying || b.dying) continue;
+
+ const dx = b.x - a.x;
+ const dy = b.y - a.y;
+ const dist = Math.hypot(dx, dy);
+
+ if (dist >= diameter || dist === 0) continue; // no collision
+
+ // ── Collision normal (unit vector from a → b) ──────────
+ const nx = dx / dist;
+ const ny = dy / dist;
+
+ // ── Elastic impulse along normal ───────────────────────
+ // Bug collisions: only the bug's velocity reflects — the entity it hits
+ // keeps its direction (bug is the "ghost" infector, not a physics partner).
+ // Normal collisions: equal-mass exchange.
+ const aIsBug = a.entityKey === 'bug';
+ const bIsBug = b.entityKey === 'bug';
+ const dot = (a.vx - b.vx) * nx + (a.vy - b.vy) * ny;
+ if (dot > 0) {
+ if (aIsBug || bIsBug) {
+ if (aIsBug) { a.vx -= dot * nx; a.vy -= dot * ny; }
+ else { b.vx += dot * nx; b.vy += dot * ny; }
+ } else {
+ a.vx -= dot * nx; a.vy -= dot * ny;
+ b.vx += dot * nx; b.vy += dot * ny;
+ }
+ }
+
+ // ── Positional correction — push apart equally ─────────
+ const half = (diameter - dist) / 2;
+ a.x -= half * nx; a.y -= half * ny;
+ b.x += half * nx; b.y += half * ny;
+
+ // ── Visual feedback — hue shift for non-bug collisions only ───
+ // Bug passes through entities silently (no hue flash on target).
+ if (!aIsBug && !bIsBug) {
+ a.onHit();
+ b.onHit();
+ } else if (aIsBug) {
+ a.onHit();
+ } else {
+ b.onHit();
+ }
+
+ // ── Bug infection ──────────────────────────────────────
+ // The rare "bug" transforms any non-immune entity into "virus-filled".
+ if (!this.#iconsData) continue;
+ const virusMeta = this.#iconsData.icons['virus-filled'];
+ const virusColor = virusMeta
+ ? (this.#iconsData.types[virusMeta.type]?.color ?? '#ff4d6d')
+ : '#ff4d6d';
+
+ // bug + normal entity → infects with virus
+ // bug + virus-filled → backwards-transforms virus back to DNA (cure)
+ const dnaMeta = this.#iconsData.icons['dna-bold-duotone'];
+ const dnaColor = dnaMeta
+ ? (this.#iconsData.types[dnaMeta.type]?.color ?? '#a8ffb8')
+ : '#a8ffb8';
+ const bugImmune = new Set(['bug', 'bacteria']);
+
+ if (a.entityKey === 'bug') {
+ if (b.entityKey === 'virus-filled') {
+ b.infectWith('dna-bold-duotone', dnaColor, { force: true });
+ } else if (!bugImmune.has(b.entityKey)) {
+ b.infectWith('virus-filled', virusColor);
+ }
+ } else if (b.entityKey === 'bug') {
+ if (a.entityKey === 'virus-filled') {
+ a.infectWith('dna-bold-duotone', dnaColor, { force: true });
+ } else if (!bugImmune.has(a.entityKey)) {
+ a.infectWith('virus-filled', virusColor);
+ }
+ }
+
+ // ── Virus kill / mutation ──────────────────────────────
+ // virus-filled hitting a non-immune entity: 90% kills it, 10% triggers mutation.
+ const virusImmune = new Set(['bug', 'virus-filled', 'bacteria']);
+ const aIsVirus = a.entityKey === 'virus-filled';
+ const bIsVirus = b.entityKey === 'virus-filled';
+
+ if (aIsVirus && !virusImmune.has(b.entityKey)) {
+ this.#resolveVirusContact(a, b);
+ } else if (bIsVirus && !virusImmune.has(a.entityKey)) {
+ this.#resolveVirusContact(b, a);
+ }
+ }
+ }
+ }
+
+ // ── Virus contact resolution ─────────────────────────────────
+
+ /**
+ * Handle a collision between a virus-filled entity and a vulnerable target.
+ * Bug is the sole mutation/transformation trigger — virus only kills.
+ *
+ * - virusKillChance: target begins slow-death sequence.
+ * - else: target survives unaffected (virus bounces away normally).
+ *
+ * @param {import('./entity.js').Entity} _virus the virus-filled entity (unused)
+ * @param {import('./entity.js').Entity} target the entity being contacted
+ */
+ #resolveVirusContact(_virus, target) {
+ if (Math.random() < this.#virusKillChance) {
+ target.die();
+ }
+ // else: target survives — virus bounces, no transformation
+ }
+
+ // ── State persistence ────────────────────────────────────────
+
+ /** Serialise all live (non-dying) entities + startTime to localStorage. */
+ #saveState() {
+ try {
+ const snapshot = {
+ startTime: this.#startTime,
+ entities: this.#entities
+ .filter(e => e.alive && !e.dying)
+ .map(e => ({
+ name: e.entityKey,
+ x: e.x,
+ y: e.y,
+ vx: e.vx,
+ vy: e.vy,
+ rotation: e.rotation,
+ hueShift: e.hueShift,
+ })),
+ };
+ localStorage.setItem(EVOLUTION_STORAGE_KEY, JSON.stringify(snapshot));
+ } catch { /* quota exceeded — ignore */ }
+ }
+
+ /**
+ * Recreate entities from a previously saved localStorage snapshot.
+ * Called once during init(), before the spawner starts.
+ */
+ async #restoreState() {
+ try {
+ const raw = localStorage.getItem(EVOLUTION_STORAGE_KEY);
+ if (!raw) return;
+ const saved = JSON.parse(raw);
+ if (!saved || typeof saved !== 'object') return;
+
+ // Restore elapsed time so lifetime continues across reloads
+ if (typeof saved.startTime === 'number') {
+ this.#startTime = saved.startTime;
+ }
+
+ const entities = saved.entities;
+ if (!Array.isArray(entities) || entities.length === 0) return;
+
+ await Promise.all(entities.map(async (s) => {
+ if (!this.#iconsData || !this.#container) return;
+ const iconMeta = this.#iconsData.icons[s.name];
+ if (!iconMeta) return;
+ const typeMeta = this.#iconsData.types[iconMeta.type];
+ if (!typeMeta) return;
+
+ const entity = new Entity({
+ name: s.name,
+ type: iconMeta.type,
+ color: typeMeta.color,
+ x: s.x,
+ y: s.y,
+ vx: s.vx,
+ vy: s.vy,
+ rotation: s.rotation,
+ });
+
+ await entity.mount(this.#container);
+
+ if (entity.alive) {
+ // Restore accumulated hue-rotate from collisions
+ if (s.hueShift && entity.bodyEl) {
+ entity.hueShift = s.hueShift;
+ entity.bodyEl.style.filter =
+ `hue-rotate(${s.hueShift}deg) drop-shadow(0 1px 6px rgba(0,0,0,0.35))`;
+ }
+ this.#entities.push(entity);
+ }
+ }));
+ } catch { /* corrupt save — start fresh */ }
+ }
+
// ── Data loading ────────────────────────────────────────────
/** Fetch and return the icons.json configuration. */
diff --git a/src/js/gradient.js b/src/js/gradient.js
index 32a8205..0c3fd78 100644
--- a/src/js/gradient.js
+++ b/src/js/gradient.js
@@ -5,6 +5,7 @@
*/
import { deriveGradientPair } from '../utils/colorUtils.js';
+import { DEFAULTS } from './constants.js';
/** Root element — CSS custom properties live here */
const ROOT = document.documentElement;
@@ -23,9 +24,9 @@ function speedToDuration(speed) {
}
export class GradientController {
- /** @type {string} */ #color = '#4d22b3';
- /** @type {number} */ #speed = 2;
- /** @type {boolean} */ #rotating = false;
+ /** @type {string} */ #color = DEFAULTS.GRADIENT_COLOR;
+ /** @type {number} */ #speed = DEFAULTS.GRADIENT_SPEED;
+ /** @type {boolean} */ #rotating = DEFAULTS.GRADIENT_ROTATION;
/**
* Initialise gradient with a base colour.
@@ -36,6 +37,7 @@ export class GradientController {
init(hex) {
this.setColor(hex);
this.setSpeed(this.#speed);
+ this.toggleRotation(this.#rotating);
}
/**
diff --git a/src/js/guide.js b/src/js/guide.js
new file mode 100644
index 0000000..36e6a00
--- /dev/null
+++ b/src/js/guide.js
@@ -0,0 +1,150 @@
+/**
+ * guide.js
+ * GuideController — manages the evolution guide panel.
+ *
+ * The panel lives in the DOM (HTML), always hidden.
+ * On first open, guide.js fetches and injects SVG icons into
+ * [data-guide-icon] slots so the panel shows actual game icons.
+ *
+ * While the panel is open, a 200 ms interval keeps entity population
+ * counts and the system uptime display live.
+ *
+ * Behaviour:
+ * - Guide button (bottom-right): click to open/close.
+ * - Click outside the panel: closes it.
+ * - Escape key: closes it.
+ * - Clicking an entity row spawns that entity at a random position.
+ */
+
+import { loadIcon } from './iconLoader.js';
+
+/** Format ms → "m:ss" or "h:mm:ss". */
+function formatDuration(ms) {
+ const s = Math.floor(ms / 1000);
+ const m = Math.floor(s / 60);
+ const h = Math.floor(m / 60);
+ if (h > 0) {
+ return `${h}:${String(m % 60).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`;
+ }
+ return `${m}:${String(s % 60).padStart(2, '0')}`;
+}
+
+export class GuideController {
+ /** @type {HTMLElement|null} */ #panel = null;
+ /** @type {HTMLElement|null} */ #btn = null;
+ /** @type {boolean} */ #open = false;
+ /** @type {boolean} */ #iconsLoaded = false;
+ /** @type {import('./evolution.js').EvolutionController|null} */ #evolution = null;
+ /** @type {ReturnType|null} */ #statsTimer = null;
+
+ /**
+ * Attach event listeners. Must be called after DOM is ready.
+ *
+ * @param {import('./evolution.js').EvolutionController} [evolution]
+ * Optional — if provided, clicking an entity row will spawn that entity
+ * and the panel will show live population counts + system uptime.
+ */
+ init(evolution = null) {
+ this.#evolution = evolution;
+ this.#btn = document.getElementById('guideBtn');
+ this.#panel = document.getElementById('guidePanel');
+ if (!this.#btn || !this.#panel) return;
+
+ this.#btn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ this.#open ? this.close() : this.open();
+ });
+
+ // Close on outside click
+ document.addEventListener('click', (e) => {
+ if (this.#open && !this.#panel.contains(e.target)) this.close();
+ });
+
+ // Close on Escape
+ document.addEventListener('keydown', (e) => {
+ if (e.key === 'Escape' && this.#open) this.close();
+ });
+
+ // Entity row clicks → spawn that entity
+ if (this.#evolution) this.#bindEntityClicks();
+
+ // Restart button → clear all entities + saved state
+ const resetBtn = document.getElementById('guideResetBtn');
+ if (resetBtn && this.#evolution) {
+ resetBtn.addEventListener('click', () => this.#evolution.clear());
+ }
+ }
+
+ open() {
+ this.#open = true;
+ this.#panel.classList.add('guide-panel--visible');
+ this.#panel.setAttribute('aria-hidden', 'false');
+ this.#btn.setAttribute('aria-expanded', 'true');
+ if (!this.#iconsLoaded) this.#populateIcons();
+ if (this.#evolution) this.#startLiveUpdate();
+ }
+
+ close() {
+ this.#open = false;
+ this.#panel.classList.remove('guide-panel--visible');
+ this.#panel.setAttribute('aria-hidden', 'true');
+ this.#btn.setAttribute('aria-expanded', 'false');
+ this.#stopLiveUpdate();
+ }
+
+ // ── Private ─────────────────────────────────────────────────
+
+ /** Wire each .guide-entity row to spawn that entity on click. */
+ #bindEntityClicks() {
+ this.#panel.querySelectorAll('.guide-entity').forEach(item => {
+ const slot = item.querySelector('[data-guide-icon]');
+ if (!slot) return;
+ const iconName = slot.dataset.guideIcon;
+ item.addEventListener('click', () => this.#evolution.spawnNamed(iconName));
+ });
+ }
+
+ /** Start a 200 ms interval that refreshes counts + uptime while the panel is open. */
+ #startLiveUpdate() {
+ this.#refreshStats();
+ this.#statsTimer = setInterval(() => this.#refreshStats(), 200);
+ }
+
+ #stopLiveUpdate() {
+ if (this.#statsTimer !== null) {
+ clearInterval(this.#statsTimer);
+ this.#statsTimer = null;
+ }
+ }
+
+ /** Push current entity counts and system lifetime into the panel DOM. */
+ #refreshStats() {
+ if (!this.#evolution || !this.#panel) return;
+
+ const counts = this.#evolution.getCounts();
+ const lifetime = this.#evolution.lifetime;
+
+ // Update per-entity count badges
+ this.#panel.querySelectorAll('[data-count-key]').forEach(item => {
+ const el = item.querySelector('.guide-entity__count');
+ if (el) el.textContent = String(counts[item.dataset.countKey] ?? 0);
+ });
+
+ // Update uptime display
+ const uptimeEl = this.#panel.querySelector('.guide-panel__uptime');
+ if (uptimeEl) uptimeEl.textContent = formatDuration(lifetime);
+ }
+
+ /** Inject SVGs into every [data-guide-icon] slot in the panel. */
+ async #populateIcons() {
+ this.#iconsLoaded = true;
+ const slots = this.#panel.querySelectorAll('[data-guide-icon]');
+ await Promise.all(Array.from(slots).map(async (slot) => {
+ try {
+ slot.innerHTML = await loadIcon(slot.dataset.guideIcon);
+ } catch {
+ // Silent fail — slot stays empty
+ }
+ }));
+ }
+}
diff --git a/src/js/main.js b/src/js/main.js
index 69dbf57..caf1e7b 100644
--- a/src/js/main.js
+++ b/src/js/main.js
@@ -14,12 +14,14 @@ import { GradientController } from './gradient.js';
import { ModalController } from './modal.js';
import { SettingsController } from './settings.js';
import { EvolutionController } from './evolution.js';
+import { GuideController } from './guide.js';
import { DEFAULTS } from './constants.js';
// ── Initialise controllers ─────────────────────────────────
const gradient = new GradientController();
const modal = new ModalController();
const evolution = new EvolutionController();
+const guide = new GuideController();
// SettingsController bridges UI → gradient + evolution
// eslint-disable-next-line no-unused-vars
@@ -30,8 +32,14 @@ document.getElementById('settingsBtn').addEventListener('click', (e) => {
modal.open(/** @type {HTMLElement} */ (e.currentTarget));
});
+// ── Wire up evolution guide ────────────────────────────────
+guide.init(evolution);
+
// ── Boot gradient ──────────────────────────────────────────
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'));
diff --git a/src/js/settings.js b/src/js/settings.js
index 8c880e3..5858fb0 100644
--- a/src/js/settings.js
+++ b/src/js/settings.js
@@ -1,10 +1,20 @@
/**
* settings.js
* Connects the settings UI controls to GradientController and EvolutionController.
- * Owns no state of its own — it only reads from the DOM and
- * forwards values to the relevant controller.
+ *
+ * Persistence:
+ * - Every setting change is immediately written to localStorage.
+ * - Call loadSaved() from main.js (after gradient.init()) to restore the
+ * saved state on the next page load so the system continues as configured.
+ *
+ * Reset:
+ * - resetBtn clears localStorage, restores DEFAULTS, and wipes live entities.
*/
+import { DEFAULTS } from './constants.js';
+
+const STORAGE_KEY = 'devpage:settings';
+
export class SettingsController {
/** @type {import('./gradient.js').GradientController} */
#gradient;
@@ -20,41 +30,151 @@ export class SettingsController {
this.#gradient = gradient;
this.#evolution = evolution;
this.#bindEvents();
+ this.#syncControls();
+ }
+
+ // ── Public ──────────────────────────────────────────────────
+
+ /**
+ * Apply saved localStorage settings to the controllers and sync the UI.
+ * Must be called AFTER gradient.init() so saved values override DEFAULTS.
+ */
+ loadSaved() {
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY);
+ if (!raw) return;
+ const s = JSON.parse(raw);
+ if (typeof s.gradientColor === 'string') this.#gradient.setColor(s.gradientColor);
+ if (typeof s.gradientSpeed === 'number') this.#gradient.setSpeed(s.gradientSpeed);
+ if (typeof s.gradientRotation === 'boolean') this.#gradient.toggleRotation(s.gradientRotation);
+ if (typeof s.moveSpeed === 'number') this.#evolution.setMoveSpeed(s.moveSpeed);
+ if (typeof s.spawnRate === 'number') this.#evolution.setSpawnRate(s.spawnRate);
+ if (typeof s.virusKillChance === 'number') this.#evolution.setVirusKillChance(s.virusKillChance);
+ if (typeof s.bugSpawnChance === 'number') this.#evolution.setBugSpawnChance(s.bugSpawnChance);
+ if (typeof s.bugMaxCount === 'number') this.#evolution.setBugMaxCount(s.bugMaxCount);
+ } catch { /* corrupt storage — ignore */ }
+ this.#syncControls();
}
// ── Private ────────────────────────────────────────────────
#bindEvents() {
- const colorPicker = /** @type {HTMLInputElement} */ (document.getElementById('colorPicker'));
- const colorValue = /** @type {HTMLElement} */ (document.getElementById('colorValue'));
- const speedSlider = /** @type {HTMLInputElement} */ (document.getElementById('speedSlider'));
- const moveSpeedSlider = /** @type {HTMLInputElement} */ (document.getElementById('moveSpeedSlider'));
- const rotToggle = /** @type {HTMLInputElement} */ (document.getElementById('rotationToggle'));
+ const colorPicker = /** @type {HTMLInputElement} */ (document.getElementById('colorPicker'));
+ const colorValue = /** @type {HTMLElement} */ (document.getElementById('colorValue'));
+ const speedSlider = /** @type {HTMLInputElement} */ (document.getElementById('speedSlider'));
+ const rotToggle = /** @type {HTMLInputElement} */ (document.getElementById('rotationToggle'));
+ const moveSpeedSlider = /** @type {HTMLInputElement} */ (document.getElementById('moveSpeedSlider'));
+ const spawnRateSlider = /** @type {HTMLInputElement} */ (document.getElementById('spawnRateSlider'));
+ const virusKillSlider = /** @type {HTMLInputElement} */ (document.getElementById('virusKillSlider'));
+ const bugChanceSlider = /** @type {HTMLInputElement} */ (document.getElementById('bugChanceSlider'));
+ const bugCountSlider = /** @type {HTMLInputElement} */ (document.getElementById('bugCountSlider'));
+ const resetBtn = /** @type {HTMLButtonElement} */ (document.getElementById('resetBtn'));
// ── Colour picker ──────────────────────────────────────
- // 'input' fires on every pointer move — real-time background preview
colorPicker.addEventListener('input', (e) => {
const hex = /** @type {HTMLInputElement} */ (e.target).value;
colorValue.textContent = hex;
this.#gradient.setColor(hex);
+ this.#saveState();
});
// ── Gradient animation speed ───────────────────────────
speedSlider.addEventListener('input', (e) => {
- const speed = Number(/** @type {HTMLInputElement} */ (e.target).value);
- this.#gradient.setSpeed(speed);
- });
-
- // ── Icon movement speed ────────────────────────────────
- moveSpeedSlider.addEventListener('input', (e) => {
- const speed = Number(/** @type {HTMLInputElement} */ (e.target).value);
- this.#evolution.setMoveSpeed(speed);
+ this.#gradient.setSpeed(Number(/** @type {HTMLInputElement} */ (e.target).value));
+ this.#saveState();
});
// ── Rotation toggle ────────────────────────────────────
rotToggle.addEventListener('change', (e) => {
- const enabled = /** @type {HTMLInputElement} */ (e.target).checked;
- this.#gradient.toggleRotation(enabled);
+ this.#gradient.toggleRotation(/** @type {HTMLInputElement} */ (e.target).checked);
+ this.#saveState();
+ });
+
+ // ── Icon movement speed ────────────────────────────────
+ moveSpeedSlider.addEventListener('input', (e) => {
+ this.#evolution.setMoveSpeed(Number(/** @type {HTMLInputElement} */ (e.target).value));
+ this.#saveState();
+ });
+
+ // ── Spawn rate ─────────────────────────────────────────
+ spawnRateSlider.addEventListener('input', (e) => {
+ this.#evolution.setSpawnRate(Number(/** @type {HTMLInputElement} */ (e.target).value));
+ this.#saveState();
+ });
+
+ // ── Virus lethality ────────────────────────────────────
+ virusKillSlider.addEventListener('input', (e) => {
+ this.#evolution.setVirusKillChance(Number(/** @type {HTMLInputElement} */ (e.target).value) / 100);
+ this.#saveState();
+ });
+
+ // ── Bug spawn chance ───────────────────────────────────
+ bugChanceSlider.addEventListener('input', (e) => {
+ this.#evolution.setBugSpawnChance(Number(/** @type {HTMLInputElement} */ (e.target).value) / 100);
+ this.#saveState();
+ });
+
+ // ── Bug max count ──────────────────────────────────────
+ bugCountSlider.addEventListener('input', (e) => {
+ this.#evolution.setBugMaxCount(Number(/** @type {HTMLInputElement} */ (e.target).value));
+ this.#saveState();
+ });
+
+ // ── Reset to defaults ──────────────────────────────────
+ resetBtn.addEventListener('click', () => {
+ localStorage.removeItem(STORAGE_KEY);
+ this.#gradient.setColor(DEFAULTS.GRADIENT_COLOR);
+ this.#gradient.setSpeed(DEFAULTS.GRADIENT_SPEED);
+ this.#gradient.toggleRotation(DEFAULTS.GRADIENT_ROTATION);
+ this.#evolution.setMoveSpeed(DEFAULTS.MOVE_SPEED);
+ this.#evolution.setSpawnRate(5);
+ this.#evolution.setVirusKillChance(DEFAULTS.VIRUS_KILL_CHANCE);
+ this.#evolution.setBugSpawnChance(DEFAULTS.BUG_SPAWN_CHANCE);
+ this.#evolution.setBugMaxCount(DEFAULTS.BUG_MAX_COUNT);
+ this.#evolution.clear();
+ this.#syncControls();
});
}
+
+ /** Persist current controller state to localStorage. */
+ #saveState() {
+ try {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify({
+ gradientColor: this.#gradient.color,
+ gradientSpeed: this.#gradient.speed,
+ gradientRotation: this.#gradient.rotating,
+ moveSpeed: this.#evolution.moveSpeed,
+ spawnRate: this.#evolution.spawnRate,
+ virusKillChance: this.#evolution.virusKillChance,
+ bugSpawnChance: this.#evolution.bugSpawnChance,
+ bugMaxCount: this.#evolution.bugMaxCount,
+ }));
+ } catch { /* quota exceeded or private browsing — ignore */ }
+ }
+
+ /**
+ * Push controller state → DOM controls so the UI always matches.
+ * Called on construction, after loadSaved(), and after reset.
+ */
+ #syncControls() {
+ const colorPicker = /** @type {HTMLInputElement} */ (document.getElementById('colorPicker'));
+ const colorValue = /** @type {HTMLElement} */ (document.getElementById('colorValue'));
+ const speedSlider = /** @type {HTMLInputElement} */ (document.getElementById('speedSlider'));
+ const rotToggle = /** @type {HTMLInputElement} */ (document.getElementById('rotationToggle'));
+ const moveSpeedSlider = /** @type {HTMLInputElement} */ (document.getElementById('moveSpeedSlider'));
+ const spawnRateSlider = /** @type {HTMLInputElement} */ (document.getElementById('spawnRateSlider'));
+ const virusKillSlider = /** @type {HTMLInputElement} */ (document.getElementById('virusKillSlider'));
+ const bugChanceSlider = /** @type {HTMLInputElement} */ (document.getElementById('bugChanceSlider'));
+ const bugCountSlider = /** @type {HTMLInputElement} */ (document.getElementById('bugCountSlider'));
+
+ colorPicker.value = this.#gradient.color;
+ colorValue.textContent = this.#gradient.color;
+ speedSlider.value = String(this.#gradient.speed);
+ rotToggle.checked = this.#gradient.rotating;
+ moveSpeedSlider.value = String(this.#evolution.moveSpeed);
+ 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);
+ }
}
diff --git a/src/styles/guide.css b/src/styles/guide.css
new file mode 100644
index 0000000..7372d32
--- /dev/null
+++ b/src/styles/guide.css
@@ -0,0 +1,237 @@
+/* ════════════════════════════════════════════════════════════
+ guide.css — evolution guide button and panel
+ ════════════════════════════════════════════════════════════ */
+
+/* ── Guide trigger button (position only — appearance from .icon-btn) ── */
+.guide-btn {
+ position: fixed;
+ bottom: 1.5rem;
+ right: 1.5rem;
+ z-index: 100;
+}
+
+/* ── Guide panel ─────────────────────────────────────────── */
+.guide-panel {
+ position: fixed;
+ bottom: 4.2rem;
+ right: 1.5rem;
+ z-index: 200;
+
+ width: 272px;
+ padding: 1.1rem 1.2rem 1.2rem;
+
+ background: var(--modal-surface);
+ border: 1px solid var(--modal-border);
+ border-radius: 14px;
+ box-shadow: 0 16px 48px rgba(0, 0, 0, 0.45);
+
+ opacity: 0;
+ transform: translateY(6px) scale(0.98);
+ pointer-events: none;
+ transition: opacity 0.18s ease, transform 0.18s ease;
+}
+
+.guide-panel--visible {
+ opacity: 1;
+ transform: translateY(0) scale(1);
+ pointer-events: auto;
+}
+
+/* ── Panel header (title + uptime) ───────────────────────── */
+.guide-panel__header {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ margin: 0 0 1rem;
+}
+
+.guide-panel__title {
+ font-family: var(--font-mono);
+ font-size: 0.68rem;
+ font-weight: 400;
+ color: var(--text-lo);
+ text-transform: lowercase;
+ letter-spacing: 0.1em;
+ margin: 0;
+}
+
+.guide-panel__uptime {
+ font-family: var(--font-mono);
+ font-size: 0.58rem;
+ color: rgba(255, 255, 255, 0.2);
+ letter-spacing: 0.05em;
+ font-variant-numeric: tabular-nums;
+}
+
+/* ── Section ─────────────────────────────────────────────── */
+.guide-section + .guide-section {
+ margin-top: 0.9rem;
+ padding-top: 0.9rem;
+ border-top: 1px solid var(--modal-divider);
+}
+
+.guide-section__label {
+ font-family: var(--font-mono);
+ font-size: 0.58rem;
+ color: rgba(255, 255, 255, 0.28);
+ text-transform: uppercase;
+ letter-spacing: 0.14em;
+ margin: 0 0 0.55rem;
+}
+
+/* ── Entities list ───────────────────────────────────────── */
+.guide-entities {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 0.48rem;
+}
+
+.guide-entity {
+ display: flex;
+ align-items: center;
+ gap: 0.55rem;
+ cursor: pointer;
+ border-radius: 6px;
+ padding: 0.2rem 0.35rem;
+ margin: 0 -0.35rem;
+ transition: background 0.15s ease;
+}
+
+.guide-entity:hover {
+ background: var(--surface-tint);
+}
+
+/* Icon container — SVG injected here by guide.js */
+.guide-entity__icon {
+ width: 16px;
+ height: 16px;
+ flex-shrink: 0;
+ color: var(--c, rgba(255, 255, 255, 0.6));
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ filter: drop-shadow(0 0 4px color-mix(in srgb, var(--c, white) 60%, transparent));
+}
+.guide-entity__icon svg { width: 100%; height: 100%; display: block; }
+
+.guide-entity__name {
+ font-family: var(--font-mono);
+ font-size: 0.7rem;
+ font-weight: 500;
+ color: var(--c, rgba(255, 255, 255, 0.8));
+ width: 54px;
+ flex-shrink: 0;
+}
+
+.guide-entity__role {
+ font-family: var(--font-mono);
+ font-size: 0.6rem;
+ color: rgba(255, 255, 255, 0.28);
+ flex: 1;
+}
+
+.guide-entity__count {
+ font-family: var(--font-mono);
+ font-size: 0.62rem;
+ color: rgba(255, 255, 255, 0.22);
+ min-width: 20px;
+ text-align: right;
+ flex-shrink: 0;
+ font-variant-numeric: tabular-nums;
+}
+
+/* ── Interactions list ───────────────────────────────────── */
+.guide-rules {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 0.42rem;
+}
+
+.guide-rule {
+ display: grid;
+ grid-template-columns: 9px 9px 9px 1fr;
+ align-items: center;
+ gap: 0.28rem;
+}
+
+/* Coloured circle representing an entity type */
+.guide-rule__dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: var(--c, rgba(255, 255, 255, 0.25));
+ flex-shrink: 0;
+ box-shadow: 0 0 5px color-mix(in srgb, var(--c, white) 55%, transparent);
+}
+
+.guide-rule__op {
+ font-family: var(--font-mono);
+ font-size: 0.55rem;
+ color: rgba(255, 255, 255, 0.22);
+ text-align: center;
+}
+
+.guide-rule__desc {
+ font-family: var(--font-mono);
+ font-size: 0.62rem;
+ color: rgba(255, 255, 255, 0.45);
+ line-height: 1.3;
+}
+
+.guide-rule__pct {
+ color: rgba(255, 255, 255, 0.22);
+ font-size: 0.58rem;
+}
+
+/* ── Guide footer + restart button ───────────────────────── */
+.guide-footer {
+ margin-top: 0.9rem;
+ padding-top: 0.9rem;
+ border-top: 1px solid var(--modal-divider);
+}
+
+.guide-reset-btn {
+ appearance: none;
+ width: 100%;
+ padding: 5px 0;
+ background: transparent;
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ border-radius: 6px;
+ cursor: pointer;
+ outline: none;
+ -webkit-tap-highlight-color: transparent;
+
+ font-family: var(--font-mono);
+ font-size: 0.58rem;
+ font-weight: 400;
+ letter-spacing: 0.12em;
+ text-transform: lowercase;
+ color: rgba(255, 255, 255, 0.28);
+
+ transition:
+ color 0.18s ease,
+ background 0.18s ease,
+ border-color 0.18s ease;
+}
+
+.guide-reset-btn:hover {
+ color: rgba(255, 255, 255, 0.65);
+ background: rgba(255, 255, 255, 0.06);
+ border-color: rgba(255, 255, 255, 0.2);
+}
+
+.guide-reset-btn:active {
+ color: var(--text-hi);
+ background: rgba(255, 255, 255, 0.1);
+}
+
+.guide-reset-btn:focus-visible {
+ outline: 2px solid rgba(255, 255, 255, 0.35);
+ outline-offset: 2px;
+}
diff --git a/src/styles/icons.css b/src/styles/icons.css
index 983da28..56f724a 100644
--- a/src/styles/icons.css
+++ b/src/styles/icons.css
@@ -70,3 +70,38 @@
display: block;
/* SVGs use currentColor — colour is inherited from .icon-entity */
}
+
+/* ── Collision hit flash ─────────────────────────────────── */
+/*
+ * Brief scale-punch animation applied by Entity.onHit().
+ * Runs independently of the scale transition on .icon-entity__body —
+ * CSS animations take priority over transitions, and both settle on scale:1.
+ *
+ * The class is added and removed by JS; the `both` fill-mode holds the
+ * final scale:1 state until the class is removed, avoiding any jump.
+ */
+@keyframes entity-hit {
+ 0% { scale: 1; }
+ 35% { scale: 1.5; }
+ 100% { scale: 1; }
+}
+
+.icon-entity__body--hit {
+ animation: entity-hit 0.35s cubic-bezier(0.36, 0.07, 0.19, 0.97) both;
+}
+
+/* ── Entity slow-death state ─────────────────────────────── */
+/*
+ * Added by Entity.die() after KILL_FADE_MS delay.
+ * The entity is already light-red (set via inline color) when this kicks in.
+ * Transitions to dark + grayscale over ~2.6s before JS destroys the DOM node.
+ * scale:1 prevents the spawning transition from interfering.
+ */
+.icon-entity__body--dying {
+ scale: 1 !important;
+ opacity: 0.12;
+ filter: brightness(0.2) grayscale(1) drop-shadow(0 0 0 transparent) !important;
+ transition:
+ opacity 2.6s ease-in,
+ filter 2.6s ease-in !important;
+}
diff --git a/src/styles/main.css b/src/styles/main.css
index 8d52582..546bef0 100644
--- a/src/styles/main.css
+++ b/src/styles/main.css
@@ -20,7 +20,7 @@
--rotation-duration: 22s;
/* Modal surface */
- --modal-surface: rgba(10, 10, 14, 0.82);
+ --modal-surface: rgba(10, 10, 14, 0.6);
--modal-border: rgba(255, 255, 255, 0.08);
--modal-divider: rgba(255, 255, 255, 0.06);
@@ -52,14 +52,9 @@ body {
justify-content: center;
}
-/* ── Settings Button ─────────────────────────────────────── */
-.settings-btn {
- position: fixed;
- top: 22px;
- right: 26px;
- z-index: 100;
-
- /* Strip all button decoration */
+/* ── Icon Button (shared base — settings + guide) ────────── */
+.icon-btn {
+ /* Strip browser defaults */
appearance: none;
background: transparent;
border: none;
@@ -67,21 +62,22 @@ body {
padding: 0;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
+ user-select: none;
- /* Shape & layout */
+ /* Shape */
width: 38px;
height: 38px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
+ flex-shrink: 0;
/* Typography */
font-family: var(--font-mono);
font-size: 1.05rem;
font-weight: 500;
line-height: 1;
- letter-spacing: -0.02em;
color: rgba(255, 255, 255, 0.65);
transition:
@@ -89,18 +85,27 @@ body {
background 0.18s ease;
}
-.settings-btn:hover {
- color: rgba(255, 255, 255, 0.95);
- background: rgba(255, 255, 255, 0.14);
+.icon-btn:hover,
+.icon-btn[aria-expanded="true"] {
+ color: var(--text-hi);
+ background: var(--surface-tint);
}
-.settings-btn:active {
+.icon-btn:active {
color: #fff;
background: rgba(255, 255, 255, 0.22);
}
-/* Focus-visible ring for keyboard nav */
-.settings-btn:focus-visible {
+.icon-btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.5);
outline-offset: 2px;
}
+
+/* ── Settings Button (position only) ────────────────────── */
+.settings-btn {
+ position: fixed;
+ top: 22px;
+ right: 26px;
+ z-index: 100;
+ letter-spacing: -0.02em;
+}
diff --git a/src/styles/modal.css b/src/styles/modal.css
index 92a151f..ea08e88 100644
--- a/src/styles/modal.css
+++ b/src/styles/modal.css
@@ -112,7 +112,35 @@
padding: 20px;
display: flex;
flex-direction: column;
- gap: 22px;
+ gap: 0;
+ max-height: calc(90vh - 60px);
+ overflow-y: auto;
+ scrollbar-width: none;
+}
+.modal-body::-webkit-scrollbar { display: none; }
+
+/* ── Settings section (visual group) ─────────────────────── */
+.settings-section {
+ padding: 16px 0;
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+}
+
+.settings-section + .settings-section {
+ border-top: 1px solid var(--modal-divider);
+}
+
+.settings-section__label {
+ font-family: var(--font-mono);
+ font-size: 0.56rem;
+ font-weight: 400;
+ letter-spacing: 0.18em;
+ text-transform: uppercase;
+ color: var(--text-lo);
+ opacity: 0.5;
+ user-select: none;
+ margin: 0;
}
/* ── Setting group (label + control) ─────────────────────── */
@@ -333,3 +361,49 @@
outline: 2px solid rgba(255, 255, 255, 0.45);
outline-offset: 2px;
}
+
+/* ── Modal footer + reset button ─────────────────────────── */
+.modal-footer {
+ padding: 14px 20px 18px;
+ border-top: 1px solid var(--modal-divider);
+}
+
+.reset-btn {
+ appearance: none;
+ width: 100%;
+ padding: 7px 0;
+ background: transparent;
+ border: 1px solid rgba(255, 80, 80, 0.22);
+ border-radius: 8px;
+ cursor: pointer;
+ outline: none;
+ -webkit-tap-highlight-color: transparent;
+
+ font-family: var(--font-mono);
+ font-size: 0.60rem;
+ font-weight: 400;
+ letter-spacing: 0.14em;
+ text-transform: lowercase;
+ color: rgba(255, 120, 100, 0.55);
+
+ transition:
+ color 0.18s ease,
+ background 0.18s ease,
+ border-color 0.18s ease;
+}
+
+.reset-btn:hover {
+ color: rgba(255, 140, 110, 0.85);
+ background: rgba(255, 80, 60, 0.09);
+ border-color: rgba(255, 80, 80, 0.40);
+}
+
+.reset-btn:active {
+ color: rgba(255, 160, 130, 1);
+ background: rgba(255, 80, 60, 0.16);
+}
+
+.reset-btn:focus-visible {
+ outline: 2px solid rgba(255, 100, 80, 0.45);
+ outline-offset: 2px;
+}