mirror of
https://github.com/shadoll/dev.shadoll.git
synced 2026-08-28 03:26:56 +00:00
feat: implement DNA gene system for cell and virus evolution with mutation and color derivation
This commit is contained in:
+78
-34
@@ -14,13 +14,14 @@
|
||||
<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 → logo → guide) -->
|
||||
<!-- Styles (order matters: base → gradient → modal → icons → logo → guide → population) -->
|
||||
<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" />
|
||||
<link rel="stylesheet" href="src/styles/population.css" />
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -32,6 +33,43 @@
|
||||
#
|
||||
</button>
|
||||
|
||||
<!-- ── Population monitor trigger (top-right, below settings) ── -->
|
||||
<button
|
||||
class="population-btn icon-btn"
|
||||
id="populationBtn"
|
||||
aria-label="Open population monitor"
|
||||
aria-expanded="false"
|
||||
aria-controls="populationPanel"
|
||||
>⊕</button>
|
||||
|
||||
<!-- ── Population monitor panel ─────────────────────────── -->
|
||||
<aside
|
||||
class="pop-panel"
|
||||
id="populationPanel"
|
||||
aria-hidden="true"
|
||||
aria-label="Population monitor"
|
||||
>
|
||||
<div class="pop-panel__header">
|
||||
<p class="pop-panel__title">population</p>
|
||||
<div class="pop-panel__meta">
|
||||
<span class="pop-panel__total">0</span>
|
||||
<span class="pop-panel__uptime">0:00</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Species section (dynamically populated by JS) -->
|
||||
<div class="pop-section">
|
||||
<p class="pop-section__label">species</p>
|
||||
<div id="popSpecies"></div>
|
||||
</div>
|
||||
|
||||
<!-- Spawn section (dynamically populated by JS) -->
|
||||
<div class="pop-section">
|
||||
<p class="pop-section__label">spawn</p>
|
||||
<div id="popSpawn"></div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- ── Main content area (grows with future features) ───── -->
|
||||
<main class="page-main" id="pageMain"></main>
|
||||
|
||||
@@ -44,7 +82,7 @@
|
||||
aria-controls="guidePanel"
|
||||
>ℹ</button>
|
||||
|
||||
<!-- ── Evolution guide panel ─────────────────────────────── -->
|
||||
<!-- ── Evolution guide panel (interactions reference only) ── -->
|
||||
<aside
|
||||
class="guide-panel"
|
||||
id="guidePanel"
|
||||
@@ -52,41 +90,10 @@
|
||||
aria-label="Evolution guide"
|
||||
>
|
||||
<div class="guide-panel__header">
|
||||
<p class="guide-panel__title">evolution info</p>
|
||||
<p class="guide-panel__title">evolution guide</p>
|
||||
<span class="guide-panel__uptime">0:00</span>
|
||||
</div>
|
||||
|
||||
<!-- Entities -->
|
||||
<div class="guide-section">
|
||||
<p class="guide-section__label">entities</p>
|
||||
<ul class="guide-entities">
|
||||
<li class="guide-entity" style="--c:#a8ffb8" data-count-key="cell">
|
||||
<span class="guide-entity__icon" data-guide-icon="cell"></span>
|
||||
<span class="guide-entity__name">cell</span>
|
||||
<span class="guide-entity__role">base lifeform</span>
|
||||
<span class="guide-entity__count">0</span>
|
||||
</li>
|
||||
<li class="guide-entity" style="--c:#ffb0a8" data-count-key="bug">
|
||||
<span class="guide-entity__icon" data-guide-icon="bug"></span>
|
||||
<span class="guide-entity__name">bug</span>
|
||||
<span class="guide-entity__role">rare · infector</span>
|
||||
<span class="guide-entity__count">0</span>
|
||||
</li>
|
||||
<li class="guide-entity" style="--c:#ff4d6d" data-count-key="virus-filled">
|
||||
<span class="guide-entity__icon" data-guide-icon="virus-filled"></span>
|
||||
<span class="guide-entity__name">virus</span>
|
||||
<span class="guide-entity__role">lethal threat</span>
|
||||
<span class="guide-entity__count">0</span>
|
||||
</li>
|
||||
<li class="guide-entity" style="--c:#80ffee" data-count-key="bacteria">
|
||||
<span class="guide-entity__icon" data-guide-icon="bacteria"></span>
|
||||
<span class="guide-entity__name">bacteria</span>
|
||||
<span class="guide-entity__role">mutant · immune</span>
|
||||
<span class="guide-entity__count">0</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Interactions -->
|
||||
<div class="guide-section">
|
||||
<p class="guide-section__label">interactions</p>
|
||||
@@ -133,6 +140,43 @@
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- DNA mutations reference -->
|
||||
<div class="guide-section">
|
||||
<p class="guide-section__label">dna mutations</p>
|
||||
<ul class="guide-rules">
|
||||
<li class="guide-rule">
|
||||
<span class="guide-rule__desc" style="color:rgba(255,255,255,0.55)">cell</span>
|
||||
<span class="guide-rule__op" style="font-size:0.7rem">·</span>
|
||||
<span class="guide-rule__desc"><span style="color:rgba(255,255,255,0.55)">speed</span> — moves faster</span>
|
||||
</li>
|
||||
<li class="guide-rule">
|
||||
<span class="guide-rule__desc" style="color:rgba(255,255,255,0.55)">cell</span>
|
||||
<span class="guide-rule__op" style="font-size:0.7rem">·</span>
|
||||
<span class="guide-rule__desc"><span style="color:rgba(255,255,255,0.55)">resistance</span> — lower kill chance</span>
|
||||
</li>
|
||||
<li class="guide-rule">
|
||||
<span class="guide-rule__desc" style="color:rgba(255,255,255,0.55)">cell</span>
|
||||
<span class="guide-rule__op" style="font-size:0.7rem">·</span>
|
||||
<span class="guide-rule__desc"><span style="color:rgba(255,255,255,0.55)">shield</span> — blocks frontal attacks</span>
|
||||
</li>
|
||||
<li class="guide-rule">
|
||||
<span class="guide-rule__desc" style="color:rgba(255,255,255,0.55)">cell</span>
|
||||
<span class="guide-rule__op" style="font-size:0.7rem">·</span>
|
||||
<span class="guide-rule__desc"><span style="color:rgba(255,255,255,0.55)">division rate</span> — divides faster</span>
|
||||
</li>
|
||||
<li class="guide-rule">
|
||||
<span class="guide-rule__desc" style="color:#ff4d6d">virus</span>
|
||||
<span class="guide-rule__op" style="font-size:0.7rem">·</span>
|
||||
<span class="guide-rule__desc"><span style="color:rgba(255,255,255,0.55)">hunt speed</span> — moves faster</span>
|
||||
</li>
|
||||
<li class="guide-rule">
|
||||
<span class="guide-rule__desc" style="color:#ff4d6d">virus</span>
|
||||
<span class="guide-rule__op" style="font-size:0.7rem">·</span>
|
||||
<span class="guide-rule__desc"><span style="color:rgba(255,255,255,0.55)">lethality</span> — higher kill chance</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="guide-footer">
|
||||
<button class="guide-reset-btn" id="guideResetBtn">restart evolution</button>
|
||||
</div>
|
||||
|
||||
@@ -66,6 +66,18 @@ export const DEFAULTS = {
|
||||
/** Total ms from kill trigger to entity removal */
|
||||
KILL_DEATH_DURATION: 3_000,
|
||||
|
||||
// ── DNA / Cell division ────────────────────────────────────
|
||||
/** Probability that a gene mutates during cell division (0–1). */
|
||||
MUTATION_RATE: 0.10,
|
||||
/** Min frames between cell divisions (≈ 15 s at 60 fps). */
|
||||
DIVISION_INTERVAL_MIN: 900,
|
||||
/** Max frames between cell divisions (≈ 60 s at 60 fps). */
|
||||
DIVISION_INTERVAL_MAX: 3600,
|
||||
/** Cells only spontaneously spawn when fewer than this many exist. */
|
||||
MIN_CELL_COUNT: 2,
|
||||
/** Total entity cap — cell divisions are skipped above this. */
|
||||
MAX_POPULATION: 50,
|
||||
|
||||
// ── Logo word ──────────────────────────────────────────────
|
||||
/** Rendered size of each letter icon in px */
|
||||
LOGO_LETTER_SIZE: 36,
|
||||
|
||||
+215
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* dna.js
|
||||
* Parametric gene system for cell/virus evolution.
|
||||
*
|
||||
* GENE_DEFS is the single registry: each entry is a gene the evolution
|
||||
* system can produce. Adding a new entry is all that's needed to enter
|
||||
* it into the gene pool — no other code changes required.
|
||||
*
|
||||
* DNA instances wrap an immutable Map; mutation always returns a new DNA.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Gene registry.
|
||||
*
|
||||
* Each gene has:
|
||||
* min, max — numeric range (values clamped on deserialise)
|
||||
* default — value used when gene is absent (not yet mutated in)
|
||||
* hueShift — degrees of hue change at full gene value (relative to base)
|
||||
* satShift — saturation % change at full gene value
|
||||
* applies — 'cell' | 'virus' which entity pool this gene belongs to
|
||||
*/
|
||||
export const GENE_DEFS = {
|
||||
|
||||
// ── Cell genes ────────────────────────────────────────────────
|
||||
/** Makes the cell drift faster across the viewport. */
|
||||
speed: { min: 0.7, max: 1.5, default: 1.0, hueShift: 40, satShift: 0, applies: 'cell' },
|
||||
/** Lowers the effective virus-kill chance against this cell. */
|
||||
resistance: { min: 0.0, max: 0.60, default: 0.0, hueShift: -25, satShift: 10, applies: 'cell' },
|
||||
/** Blocks virus attacks approaching from the cell's front arc. */
|
||||
shield: { min: 0.0, max: 1.0, default: 0.0, hueShift: 30, satShift: 12, applies: 'cell' },
|
||||
/** Scales division speed; higher value → shorter interval between divisions. */
|
||||
division_rate: { min: 0.5, max: 2.5, default: 1.0, hueShift: 10, satShift: -8, applies: 'cell' },
|
||||
|
||||
// ── Virus genes ───────────────────────────────────────────────
|
||||
/** Makes the virus drift faster across the viewport. */
|
||||
hunt_speed: { min: 0.8, max: 2.0, default: 1.0, hueShift: 20, satShift: 10, applies: 'virus' },
|
||||
/** Adds a bonus kill chance on top of the base virus lethality setting. */
|
||||
lethality: { min: 0.0, max: 0.35, default: 0.0, hueShift: -15, satShift: 15, applies: 'virus' },
|
||||
|
||||
};
|
||||
|
||||
export class DNA {
|
||||
/** @type {Map<string, number>} */
|
||||
#genes;
|
||||
|
||||
/** @param {Map<string, number>} [genes] */
|
||||
constructor(genes = new Map()) {
|
||||
this.#genes = new Map(genes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the effective value of a gene.
|
||||
* Returns the gene's `default` if it is not yet active in this DNA.
|
||||
*
|
||||
* @param {string} gene
|
||||
* @returns {number}
|
||||
*/
|
||||
get(gene) {
|
||||
return this.#genes.has(gene)
|
||||
? this.#genes.get(gene)
|
||||
: (GENE_DEFS[gene]?.default ?? 0);
|
||||
}
|
||||
|
||||
/** @param {string} gene */
|
||||
has(gene) { return this.#genes.has(gene); }
|
||||
|
||||
/** Number of active (mutated-in) genes. */
|
||||
get size() { return this.#genes.size; }
|
||||
|
||||
/** Iterate active gene entries as [name, value] pairs. */
|
||||
get entries() { return this.#genes.entries(); }
|
||||
|
||||
/**
|
||||
* Produce offspring DNA with three mutation operations applied in order:
|
||||
*
|
||||
* 1. Drift — each active gene independently drifts ±15 % of its range
|
||||
* with probability `mutationRate`.
|
||||
* 2. Emerge — with probability `mutationRate × 0.4`, one random gene
|
||||
* from the appropriate pool that isn't yet active emerges
|
||||
* at a low starting value (min + 10 % of range).
|
||||
* 3. Silence — with probability `mutationRate × 0.15`, one random
|
||||
* active gene is lost entirely.
|
||||
*
|
||||
* @param {number} mutationRate base probability (0–1)
|
||||
* @param {'cell'|'virus'} pool gene pool for emergence
|
||||
* @returns {DNA}
|
||||
*/
|
||||
reproduce(mutationRate = 0.10, pool = 'cell') {
|
||||
const newGenes = new Map(this.#genes);
|
||||
|
||||
// 1. Drift existing genes
|
||||
for (const [gene, value] of newGenes) {
|
||||
if (Math.random() < mutationRate) {
|
||||
const def = GENE_DEFS[gene];
|
||||
if (!def) continue;
|
||||
const drift = (def.max - def.min) * 0.15 * (Math.random() * 2 - 1);
|
||||
newGenes.set(gene, Math.max(def.min, Math.min(def.max, value + drift)));
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Emerge a new gene from the pool
|
||||
if (Math.random() < mutationRate * 0.4) {
|
||||
const candidates = Object.entries(GENE_DEFS)
|
||||
.filter(([key, def]) => def.applies === pool && !newGenes.has(key))
|
||||
.map(([key]) => key);
|
||||
if (candidates.length > 0) {
|
||||
const key = candidates[Math.floor(Math.random() * candidates.length)];
|
||||
const def = GENE_DEFS[key];
|
||||
newGenes.set(key, def.min + (def.max - def.min) * 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Silence a random active gene
|
||||
if (newGenes.size > 0 && Math.random() < mutationRate * 0.15) {
|
||||
const keys = [...newGenes.keys()];
|
||||
newGenes.delete(keys[Math.floor(Math.random() * keys.length)]);
|
||||
}
|
||||
|
||||
return new DNA(newGenes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive an HSL colour from active genes, relative to a base colour.
|
||||
* Each gene contributes hueShift and satShift scaled by its normalised value.
|
||||
*
|
||||
* @param {number} baseH base hue (0–360)
|
||||
* @param {number} baseS base saturation (0–100)
|
||||
* @param {number} baseL base lightness (0–100)
|
||||
* @returns {string} CSS `hsl(…)` string
|
||||
*/
|
||||
computeColor(baseH, baseS, baseL) {
|
||||
let h = baseH;
|
||||
let s = baseS;
|
||||
for (const [gene, value] of this.#genes) {
|
||||
const def = GENE_DEFS[gene];
|
||||
if (!def) continue;
|
||||
const range = def.max - def.min;
|
||||
const norm = range > 0 ? (value - def.min) / range : 0;
|
||||
h += norm * def.hueShift;
|
||||
s += norm * def.satShift;
|
||||
}
|
||||
h = ((h % 360) + 360) % 360;
|
||||
s = Math.max(0, Math.min(100, s));
|
||||
return `hsl(${Math.round(h)}, ${Math.round(s)}%, ${baseL}%)`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialise to a plain object for localStorage.
|
||||
* @returns {Record<string, number>}
|
||||
*/
|
||||
serialise() {
|
||||
return Object.fromEntries(this.#genes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstruct a DNA from a previously serialised object.
|
||||
* Unknown keys and out-of-range values are silently ignored.
|
||||
*
|
||||
* @param {Record<string, number>} obj
|
||||
* @returns {DNA}
|
||||
*/
|
||||
static deserialise(obj) {
|
||||
if (!obj || typeof obj !== 'object') return DNA.empty();
|
||||
const genes = new Map();
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
const def = GENE_DEFS[key];
|
||||
if (!def || typeof value !== 'number') continue;
|
||||
genes.set(key, Math.max(def.min, Math.min(def.max, value)));
|
||||
}
|
||||
return new DNA(genes);
|
||||
}
|
||||
|
||||
/** A pristine DNA with no active genes (default phenotype). */
|
||||
static empty() {
|
||||
return new DNA();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a DNA with a specific set of genes pre-activated at their initial
|
||||
* emergence value (min + 15 % of range). Unknown or pool-mismatched gene
|
||||
* names are silently skipped.
|
||||
*
|
||||
* @param {string[]} geneNames names to activate
|
||||
* @returns {DNA}
|
||||
*/
|
||||
static withGenes(geneNames) {
|
||||
const genes = new Map();
|
||||
for (const name of geneNames) {
|
||||
const def = GENE_DEFS[name];
|
||||
if (!def) continue;
|
||||
genes.set(name, def.min + (def.max - def.min) * 0.15);
|
||||
}
|
||||
return new DNA(genes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create virus DNA, optionally derived from the infecting cell's DNA.
|
||||
* Implements an arms-race mechanic: if the cell had significant resistance,
|
||||
* the resulting virus gains compensating lethality.
|
||||
*
|
||||
* @param {DNA|null} [cellDNA]
|
||||
* @returns {DNA}
|
||||
*/
|
||||
static forVirus(cellDNA = null) {
|
||||
const genes = new Map();
|
||||
if (cellDNA) {
|
||||
const resistance = cellDNA.get('resistance');
|
||||
if (resistance > 0.15) {
|
||||
const def = GENE_DEFS['lethality'];
|
||||
genes.set('lethality', Math.min(def.max, resistance * 0.55));
|
||||
}
|
||||
}
|
||||
return new DNA(genes);
|
||||
}
|
||||
}
|
||||
+137
-30
@@ -9,8 +9,14 @@
|
||||
* ensuring the bounce always sends the icon back into the viewport.
|
||||
* - Rotation: each entity spawns with a fixed random orientation (0–360°).
|
||||
*
|
||||
* DNA model:
|
||||
* - Each cell carries a DNA instance (see dna.js) tracking its active genes.
|
||||
* - Genes shift the visual colour and alter physics (speed, division rate).
|
||||
* - Cells divide over time, copying and mutating DNA to offspring.
|
||||
* - Viruses may carry DNA genes that boost their lethality.
|
||||
*
|
||||
* Collision response (handled by EvolutionController):
|
||||
* - onHit() — colour-shifts the icon via hue-rotate filter + flash anim.
|
||||
* - onHit() — plays a brief scale-punch flash on collision (colour unchanged).
|
||||
* - infectWith() — async; collapses the icon, swaps SVG, re-expands as new form.
|
||||
* - die() — slow death: light-red flash → dark-grayscale fade → destroy.
|
||||
*
|
||||
@@ -27,6 +33,13 @@
|
||||
|
||||
import { loadIcon } from './iconLoader.js';
|
||||
import { DEFAULTS } from './constants.js';
|
||||
import { DNA } from './dna.js';
|
||||
|
||||
// Base HSL values for DNA colour derivation per entity type
|
||||
const DNA_BASE = {
|
||||
'cell': { h: 133, s: 100, l: 83 },
|
||||
'virus-filled':{ h: 350, s: 100, l: 65 },
|
||||
};
|
||||
|
||||
export class Entity {
|
||||
// ── Physics ──────────────────────────────────────────────
|
||||
@@ -45,9 +58,13 @@ export class Entity {
|
||||
/** @type {string} */ color;
|
||||
/** @type {boolean} */ alive = true;
|
||||
|
||||
// ── DNA ───────────────────────────────────────────────────
|
||||
/** @type {DNA} */ dna;
|
||||
/** Frames remaining until this cell wants to divide. -1 = not a cell. */
|
||||
/** @type {number} */ _divisionTimer = -1;
|
||||
|
||||
// ── Visual state ──────────────────────────────────────────
|
||||
/** Accumulated hue-rotate offset (degrees). Increases on each hit. */
|
||||
/** @type {number} */ hueShift = 0;
|
||||
// (hue-rotate no longer accumulated from hits; colour is set by DNA only)
|
||||
|
||||
// ── DOM ───────────────────────────────────────────────────
|
||||
/** @type {HTMLElement|null} */ el = null;
|
||||
@@ -60,9 +77,9 @@ export class Entity {
|
||||
/**
|
||||
* @param {{ name: string, type: string, color: string,
|
||||
* x: number, y: number, vx: number, vy: number,
|
||||
* rotation?: number }} config
|
||||
* rotation?: number, dna?: DNA }} config
|
||||
*/
|
||||
constructor({ name, type, color, x, y, vx, vy, rotation }) {
|
||||
constructor({ name, type, color, x, y, vx, vy, rotation, dna }) {
|
||||
this.name = name;
|
||||
this.entityKey = name;
|
||||
this.type = type;
|
||||
@@ -70,6 +87,7 @@ export class Entity {
|
||||
this.x = x; this.y = y;
|
||||
this.vx = vx; this.vy = vy;
|
||||
this.rotation = rotation !== undefined ? rotation : Math.random() * 360;
|
||||
this.dna = dna ?? DNA.empty();
|
||||
}
|
||||
|
||||
// ── Public API ─────────────────────────────────────────────
|
||||
@@ -102,10 +120,16 @@ export class Entity {
|
||||
this.el.appendChild(this.bodyEl);
|
||||
container.appendChild(this.el);
|
||||
|
||||
// Set initial colour and position
|
||||
this.el.style.color = this.color;
|
||||
// Set colour — DNA-derived if genes are active
|
||||
const dnaColor = this._computeDnaColor();
|
||||
this.el.style.color = dnaColor ?? this.color;
|
||||
this._applyTransform();
|
||||
|
||||
// Initialise division timer for cells
|
||||
if (this.entityKey === 'cell') {
|
||||
this.resetDivision();
|
||||
}
|
||||
|
||||
// Two rAF ticks ensure the browser has painted the spawning state
|
||||
// before adding the 'alive' class, guaranteeing the CSS transition fires.
|
||||
requestAnimationFrame(() => {
|
||||
@@ -124,9 +148,18 @@ export class Entity {
|
||||
update(speedMultiplier) {
|
||||
if (!this.el || !this.alive || this.#dying) return;
|
||||
|
||||
// Tick division timer for cells
|
||||
if (this.entityKey === 'cell' && this._divisionTimer > 0) {
|
||||
this._divisionTimer--;
|
||||
}
|
||||
|
||||
// Speed gene: 'speed' for cells, 'hunt_speed' for viruses
|
||||
const geneKey = this.entityKey === 'virus-filled' ? 'hunt_speed' : 'speed';
|
||||
const geneMult = this.dna.get(geneKey);
|
||||
|
||||
// Move
|
||||
this.x += this.vx * speedMultiplier;
|
||||
this.y += this.vy * speedMultiplier;
|
||||
this.x += this.vx * speedMultiplier * geneMult;
|
||||
this.y += this.vy * speedMultiplier * geneMult;
|
||||
|
||||
// Bounce off viewport edges
|
||||
const { ICON_HALF: h } = DEFAULTS;
|
||||
@@ -155,20 +188,74 @@ export class Entity {
|
||||
this._applyTransform();
|
||||
}
|
||||
|
||||
/**
|
||||
* True when this cell has completed its division interval.
|
||||
* EvolutionController checks this each tick.
|
||||
*/
|
||||
get wantsToDivide() {
|
||||
return this.entityKey === 'cell'
|
||||
&& this._divisionTimer === 0
|
||||
&& this.alive
|
||||
&& !this.#dying;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the division countdown to a new random interval, scaled by the
|
||||
* `division_rate` gene (higher rate → shorter wait).
|
||||
*/
|
||||
resetDivision() {
|
||||
const rate = this.dna.get('division_rate'); // default 1.0
|
||||
const min = Math.round(DEFAULTS.DIVISION_INTERVAL_MIN / rate);
|
||||
const max = Math.round(DEFAULTS.DIVISION_INTERVAL_MAX / rate);
|
||||
this._divisionTimer = min + Math.floor(Math.random() * (max - min + 1));
|
||||
}
|
||||
|
||||
/**
|
||||
* Produce a config object for a child cell.
|
||||
* The offspring is placed just outside this cell, inherits a mutated copy
|
||||
* of its DNA, and is launched in a random direction.
|
||||
*
|
||||
* @returns {{ name: string, type: string, color: string, dna: DNA,
|
||||
* x: number, y: number, vx: number, vy: number }}
|
||||
*/
|
||||
divide() {
|
||||
const h = DEFAULTS.ICON_HALF;
|
||||
const offset = DEFAULTS.COLLISION_DIAMETER * 0.65;
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
const nx = Math.cos(angle);
|
||||
const ny = Math.sin(angle);
|
||||
|
||||
const childDna = this.dna.reproduce(DEFAULTS.MUTATION_RATE, 'cell');
|
||||
const childColor = childDna.size > 0
|
||||
? childDna.computeColor(DNA_BASE.cell.h, DNA_BASE.cell.s, DNA_BASE.cell.l)
|
||||
: this.color;
|
||||
|
||||
// Clamp offspring position within viewport
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
const cx = Math.max(h, Math.min(vw - h, this.x + nx * offset));
|
||||
const cy = Math.max(h, Math.min(vh - h, this.y + ny * offset));
|
||||
|
||||
return {
|
||||
name: 'cell',
|
||||
type: 'good',
|
||||
color: childColor,
|
||||
dna: childDna,
|
||||
x: cx,
|
||||
y: cy,
|
||||
vx: nx * DEFAULTS.BASE_SPEED,
|
||||
vy: ny * DEFAULTS.BASE_SPEED,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Plays a brief scale-punch animation. Colour is not changed — DNA controls colour.
|
||||
*/
|
||||
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.remove('icon-entity__body--hit');
|
||||
void this.bodyEl.offsetWidth; // force reflow so animation restarts
|
||||
this.bodyEl.classList.add('icon-entity__body--hit');
|
||||
setTimeout(() => this.bodyEl?.classList.remove('icon-entity__body--hit'), 350);
|
||||
}
|
||||
@@ -191,10 +278,8 @@ export class Entity {
|
||||
this.vx = 0;
|
||||
this.vy = 0;
|
||||
|
||||
// Phase 1: light-red flash — clear any hit filter so CSS class takes over
|
||||
// Phase 1: light-red flash
|
||||
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
|
||||
@@ -213,18 +298,19 @@ export class Entity {
|
||||
*
|
||||
* @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 {{ force?: boolean, dna?: DNA }} [opts]
|
||||
* force: true bypasses the #infected guard (used for bug cure / forced transforms)
|
||||
* dna: new DNA to assign to the entity after the transform
|
||||
*/
|
||||
/**
|
||||
* @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 } = {}) {
|
||||
async infectWith(iconName, color, { force = false, dna = null } = {}) {
|
||||
if (this.#dying || !this.bodyEl || !this.alive) return;
|
||||
if (!force && this.#infected) return;
|
||||
this.#infected = true;
|
||||
this.entityKey = iconName;
|
||||
|
||||
// Update DNA before anything visual so _computeDnaColor() is correct
|
||||
if (dna) this.dna = dna;
|
||||
|
||||
try {
|
||||
// Pre-fetch the new SVG (likely cached) before touching the DOM
|
||||
const svg = await loadIcon(iconName);
|
||||
@@ -240,9 +326,17 @@ export class Entity {
|
||||
|
||||
this.bodyEl.innerHTML = svg;
|
||||
this.color = color;
|
||||
this.el.style.color = color;
|
||||
this.hueShift = 0;
|
||||
this.bodyEl.style.filter = ''; // clear any hit hue-rotate
|
||||
|
||||
// Apply DNA-derived colour; fall back to the base type colour
|
||||
const dnaColor = this._computeDnaColor();
|
||||
this.el.style.color = dnaColor ?? color;
|
||||
|
||||
// (Re-)initialise division timer when becoming a cell
|
||||
if (iconName === 'cell') {
|
||||
this.resetDivision();
|
||||
} else {
|
||||
this._divisionTimer = -1;
|
||||
}
|
||||
|
||||
// Two rAF ticks guarantee the 'spawning' state was painted
|
||||
// before switching to 'alive', so the expand transition fires.
|
||||
@@ -269,6 +363,19 @@ export class Entity {
|
||||
|
||||
// ── Private ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Compute a DNA-derived CSS colour for this entity, or null if this entity
|
||||
* type doesn't participate in DNA colouring or has no active genes.
|
||||
*
|
||||
* @returns {string|null}
|
||||
*/
|
||||
_computeDnaColor() {
|
||||
if (!this.dna || this.dna.size === 0) return null;
|
||||
const base = DNA_BASE[this.entityKey];
|
||||
if (!base) return null;
|
||||
return this.dna.computeColor(base.h, base.s, base.l);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the current (x, y) position and fixed rotation to the DOM.
|
||||
* Using transform keeps this on the compositor thread (no layout).
|
||||
|
||||
+204
-43
@@ -3,25 +3,33 @@
|
||||
* Orchestrates the icon evolution system:
|
||||
*
|
||||
* 1. Spawning — after a random interval (SPAWN_DELAY_MIN … SPAWN_DELAY_MAX),
|
||||
* an icon appears at a random viewport position. Most spawns produce the
|
||||
* "initial" icon (cell); with rareChance probability the rare
|
||||
* "bug" icon spawns instead.
|
||||
* an icon appears at a random viewport position. Cells only appear
|
||||
* spontaneously when fewer than MIN_CELL_COUNT exist; otherwise new cells
|
||||
* come from division. Bugs still spawn on the timer when the rarity roll
|
||||
* passes and the live bug cap isn't reached.
|
||||
*
|
||||
* 2. Physics loop — a single rAF loop drives all live entities each frame.
|
||||
* After every position update, entity pairs are checked for collisions.
|
||||
*
|
||||
* 3. Collision response — overlapping entities receive an elastic velocity
|
||||
* 3. Cell division — each cell carries a countdown timer. When it reaches
|
||||
* zero the cell divides: an offspring entity is spawned nearby with a
|
||||
* mutated copy of the parent's DNA.
|
||||
*
|
||||
* 4. 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".
|
||||
* entity, the target is infected and transforms into "virus-filled" with
|
||||
* DNA potentially derived from the host cell. Viruses may be blocked by
|
||||
* a cell's shield gene or have their kill chance reduced by resistance.
|
||||
*
|
||||
* 4. Speed control — setMoveSpeed(1–10) scales entity velocity in real time.
|
||||
* 5. 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.
|
||||
*/
|
||||
|
||||
import { Entity } from './entity.js';
|
||||
import { DNA } from './dna.js';
|
||||
import { preloadIcons } from './iconLoader.js';
|
||||
import { DEFAULTS } from './constants.js';
|
||||
|
||||
@@ -204,6 +212,79 @@ export class EvolutionController {
|
||||
/** Cumulative totals for each icon (spawned/mutated) since last clear or load. */
|
||||
get totalCounts() { return { ...this.#totalCounts }; }
|
||||
|
||||
/**
|
||||
* Lightweight snapshot of every live (non-dying) entity's identity and DNA.
|
||||
* Used by PopulationPanelController to compute species + mutation stats.
|
||||
*
|
||||
* @returns {Array<{ key: string, dna: import('./dna.js').DNA }>}
|
||||
*/
|
||||
getEntityDetails() {
|
||||
return this.#entities
|
||||
.filter(e => e.alive && !e.dying)
|
||||
.map(e => ({ key: e.entityKey, dna: e.dna }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a summary of every registered entity type (from icons.json).
|
||||
* Available after init() resolves.
|
||||
*
|
||||
* @returns {Array<{ key: string, label: string, type: string, color: string }>}
|
||||
*/
|
||||
getSpawnableEntities() {
|
||||
if (!this.#iconsData) return [];
|
||||
return Object.entries(this.#iconsData.icons).map(([key, meta]) => ({
|
||||
key,
|
||||
label: meta.label ?? key,
|
||||
type: meta.type,
|
||||
color: this.#iconsData.types[meta.type]?.color ?? '#ffffff',
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn an entity by name, pre-seeding its DNA with the named genes.
|
||||
* For entity types that don't support DNA (bugs, bacteria) the genes
|
||||
* parameter is ignored and the entity spawns with empty DNA.
|
||||
*
|
||||
* @param {string} iconName key in icons.json
|
||||
* @param {string[]} geneNames gene names to pre-activate (from GENE_DEFS)
|
||||
*/
|
||||
async spawnNamedWithDna(iconName, geneNames = []) {
|
||||
if (!this.#container || !this.#iconsData) return;
|
||||
const iconMeta = this.#iconsData.icons[iconName];
|
||||
if (!iconMeta) return;
|
||||
const typeMeta = this.#iconsData.types[iconMeta.type];
|
||||
if (!typeMeta) return;
|
||||
|
||||
const dna = DNA.withGenes(geneNames);
|
||||
let color = typeMeta.color;
|
||||
|
||||
// Apply DNA-derived colour for entity types that support it
|
||||
if (dna.size > 0) {
|
||||
const base = { cell: { h: 133, s: 100, l: 83 }, 'virus-filled': { h: 350, s: 100, l: 65 } }[iconName];
|
||||
if (base) color = dna.computeColor(base.h, base.s, base.l);
|
||||
}
|
||||
|
||||
const margin = DEFAULTS.ICON_SIZE * 2;
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
|
||||
const entity = new Entity({
|
||||
name: iconName,
|
||||
type: iconMeta.type,
|
||||
color,
|
||||
dna,
|
||||
x: margin + Math.random() * (vw - margin * 2),
|
||||
y: margin + Math.random() * (vh - margin * 2),
|
||||
vx: Math.cos(angle) * DEFAULTS.BASE_SPEED,
|
||||
vy: Math.sin(angle) * DEFAULTS.BASE_SPEED,
|
||||
});
|
||||
|
||||
this.#incrementTotal(iconName);
|
||||
await entity.mount(this.#container);
|
||||
if (entity.alive) this.#entities.push(entity);
|
||||
}
|
||||
|
||||
// ── Spawning ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -233,19 +314,34 @@ export class EvolutionController {
|
||||
}, delay);
|
||||
}
|
||||
|
||||
/** Create and mount one icon entity at a random viewport position. */
|
||||
/**
|
||||
* Create and mount one icon entity at a random viewport position.
|
||||
*
|
||||
* Bugs: spawn when the rarity roll passes and the live bug count is below max.
|
||||
* Cells: only spawn spontaneously when fewer than MIN_CELL_COUNT exist;
|
||||
* above that threshold, new cells come exclusively from division.
|
||||
*/
|
||||
async #spawnEntity() {
|
||||
if (!this.#container || !this.#iconsData) return;
|
||||
|
||||
// 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;
|
||||
let name = null;
|
||||
|
||||
// Roll for bug spawn first
|
||||
if (rare && Math.random() < this.#bugSpawnChance) {
|
||||
const liveBugs = this.#entities.filter(e => e.entityKey === 'bug').length;
|
||||
if (liveBugs < this.#bugMaxCount) name = rare;
|
||||
}
|
||||
|
||||
// Spontaneous cell spawn only when population is critically low
|
||||
if (!name) {
|
||||
const liveCells = this.#entities.filter(e => e.entityKey === 'cell').length;
|
||||
if (liveCells < DEFAULTS.MIN_CELL_COUNT) name = initial;
|
||||
}
|
||||
|
||||
// Nothing to spawn this cycle
|
||||
if (!name) return;
|
||||
|
||||
const iconMeta = this.#iconsData.icons[name];
|
||||
const typeMeta = this.#iconsData.types[iconMeta.type];
|
||||
|
||||
@@ -270,11 +366,27 @@ export class EvolutionController {
|
||||
|
||||
if (entity.alive) {
|
||||
this.#entities.push(entity);
|
||||
// record spawn for totals
|
||||
this.#incrementTotal(name);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount and register a child entity produced by cell division.
|
||||
* Fire-and-forget async — called from the synchronous tick loop.
|
||||
*
|
||||
* @param {{ name: string, type: string, color: string, dna: DNA,
|
||||
* x: number, y: number, vx: number, vy: number }} config
|
||||
*/
|
||||
async #spawnOffspring(config) {
|
||||
if (!this.#container) return;
|
||||
const entity = new Entity(config);
|
||||
await entity.mount(this.#container);
|
||||
if (entity.alive) {
|
||||
this.#entities.push(entity);
|
||||
this.#incrementTotal(config.name);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Physics loop ────────────────────────────────────────────
|
||||
|
||||
#startLoop() {
|
||||
@@ -290,6 +402,19 @@ export class EvolutionController {
|
||||
entity.update(multiplier);
|
||||
}
|
||||
|
||||
// ── Cell division ──────────────────────────────────────
|
||||
// Collect cells that have completed their division timer this frame.
|
||||
// Reset the timer immediately (before async spawn) so the cell doesn't
|
||||
// trigger again before its offspring finishes mounting.
|
||||
if (this.#entities.length < DEFAULTS.MAX_POPULATION) {
|
||||
for (const entity of this.#entities) {
|
||||
if (entity.wantsToDivide) {
|
||||
entity.resetDivision();
|
||||
this.#spawnOffspring(entity.divide());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Advance logo word + ejected letter physics
|
||||
this.#logo?.update(multiplier);
|
||||
|
||||
@@ -320,7 +445,9 @@ export class EvolutionController {
|
||||
* 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).
|
||||
* into "virus-filled" with DNA derived from the host cell.
|
||||
* 5. If one entity is "virus-filled", it may kill or mutate the target,
|
||||
* subject to the target's shield and resistance genes.
|
||||
*/
|
||||
#checkCollisions() {
|
||||
const entities = this.#entities;
|
||||
@@ -377,14 +504,15 @@ export class EvolutionController {
|
||||
|
||||
// ── Bug infection ──────────────────────────────────────
|
||||
// The rare "bug" transforms any non-immune entity into "virus-filled".
|
||||
// When infecting a cell, the resulting virus receives DNA derived from
|
||||
// the host (see DNA.forVirus) implementing the arms-race mechanic.
|
||||
// Touching a virus cures it back to a cell.
|
||||
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 cell (cure)
|
||||
const cellMeta = this.#iconsData.icons['cell'];
|
||||
const cellColor = cellMeta
|
||||
? (this.#iconsData.types[cellMeta.type]?.color ?? '#a8ffb8')
|
||||
@@ -395,29 +523,33 @@ export class EvolutionController {
|
||||
if (b.entityKey === 'virus-filled') {
|
||||
b.infectWith('cell', cellColor, { force: true });
|
||||
} else if (!bugImmune.has(b.entityKey)) {
|
||||
b.infectWith('virus-filled', virusColor);
|
||||
b.infectWith('virus-filled', virusColor, { dna: DNA.forVirus(b.dna) });
|
||||
}
|
||||
} else if (b.entityKey === 'bug') {
|
||||
if (a.entityKey === 'virus-filled') {
|
||||
a.infectWith('cell', cellColor, { force: true });
|
||||
} else if (!bugImmune.has(a.entityKey)) {
|
||||
a.infectWith('virus-filled', virusColor);
|
||||
a.infectWith('virus-filled', virusColor, { dna: DNA.forVirus(a.dna) });
|
||||
}
|
||||
}
|
||||
|
||||
// ── Virus kill / mutation ──────────────────────────────
|
||||
// virus-filled hitting a non-immune entity: the target may die or mutate.
|
||||
// On a failed kill roll the contact will convert the target into bacteria
|
||||
// (10% shown in the guide) if that icon exists. Bacteria are immune to
|
||||
// subsequent virus or bug interactions.
|
||||
// The collision normal (nx, ny) points from a → b; we use it to compute
|
||||
// the approach angle so the target's shield gene can be evaluated.
|
||||
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);
|
||||
// Virus (a) approaches target (b): virus is on the -normal side of b.
|
||||
// approachAngle = direction from which virus arrives, seen from b.
|
||||
const approachAngle = Math.atan2(-ny, -nx);
|
||||
this.#resolveVirusContact(a, b, approachAngle);
|
||||
} else if (bIsVirus && !virusImmune.has(a.entityKey)) {
|
||||
this.#resolveVirusContact(b, a);
|
||||
// Virus (b) approaches target (a): virus is on the +normal side of a.
|
||||
const approachAngle = Math.atan2(ny, nx);
|
||||
this.#resolveVirusContact(b, a, approachAngle);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -428,25 +560,57 @@ export class EvolutionController {
|
||||
/**
|
||||
* Handle a collision between a virus-filled entity and a vulnerable target.
|
||||
*
|
||||
* 50%–100% of the time (controlled by virusKillChance) the target is killed.
|
||||
* On the remaining rolls the virus fails to kill and the contact triggers a
|
||||
* rare mutation: the target becomes a "bacteria" entity if that icon is
|
||||
* defined. Bacteria are immune to further virus attacks and bugs.
|
||||
* The target's DNA is consulted before applying damage:
|
||||
*
|
||||
* @param {import('./entity.js').Entity} _virus the virus-filled entity (unused)
|
||||
* @param {import('./entity.js').Entity} target the entity being contacted
|
||||
* Shield gene — blocks the attack if the virus is approaching from within
|
||||
* the cell's front arc (direction of motion ± shieldHalfWidth).
|
||||
* A full-strength shield blocks a ±90° cone.
|
||||
*
|
||||
* Resistance gene — reduces the effective kill chance:
|
||||
* effectiveKill = max(0, baseKill − resistance)
|
||||
*
|
||||
* If the kill roll fails the cell survives unharmed (bacteria path disabled).
|
||||
*
|
||||
* @param {Entity} virus the virus-filled entity
|
||||
* @param {Entity} target the entity being contacted
|
||||
* @param {number} approachAngle angle (radians) the virus arrives from,
|
||||
* as seen from the target's frame
|
||||
*/
|
||||
#resolveVirusContact(_virus, target) {
|
||||
if (Math.random() < this.#virusKillChance) {
|
||||
#resolveVirusContact(virus, target, approachAngle) {
|
||||
// ── Shield check ───────────────────────────────────────
|
||||
const shieldStrength = target.dna.get('shield');
|
||||
if (shieldStrength > 0) {
|
||||
const spd = Math.hypot(target.vx, target.vy);
|
||||
if (spd > 0.01) {
|
||||
// Shield faces forward (direction of motion)
|
||||
const frontAngle = Math.atan2(target.vy, target.vx);
|
||||
const halfWidth = shieldStrength * (Math.PI / 2);
|
||||
|
||||
let diff = approachAngle - frontAngle;
|
||||
// Normalise to -π … π
|
||||
while (diff > Math.PI) diff -= Math.PI * 2;
|
||||
while (diff < -Math.PI) diff += Math.PI * 2;
|
||||
|
||||
if (Math.abs(diff) < halfWidth) {
|
||||
// Attack blocked by shield — entity survives unaffected
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Resistance check ───────────────────────────────────
|
||||
const resistance = target.dna.get('resistance');
|
||||
const killChance = Math.max(0, this.#virusKillChance - resistance);
|
||||
|
||||
// Also apply virus lethality bonus (arms-race gene)
|
||||
const lethalBonus = virus.dna.get('lethality');
|
||||
const effectiveKill = Math.min(1, killChance + lethalBonus);
|
||||
|
||||
if (Math.random() < effectiveKill) {
|
||||
target.die();
|
||||
} else {
|
||||
// mutation path – only if bacteria is registered
|
||||
if (this.#iconsData && this.#iconsData.icons['bacteria']) {
|
||||
const bacMeta = this.#iconsData.icons['bacteria'];
|
||||
const bacColor = this.#iconsData.types[bacMeta.type]?.color || '#80ffee';
|
||||
target.infectWith('bacteria', bacColor, { force: true });
|
||||
}
|
||||
}
|
||||
// Bacteria mutation path disabled — better logic needed.
|
||||
// When the kill roll fails the cell simply survives unharmed.
|
||||
}
|
||||
|
||||
// ── State persistence ────────────────────────────────────────
|
||||
@@ -465,7 +629,7 @@ export class EvolutionController {
|
||||
vx: e.vx,
|
||||
vy: e.vy,
|
||||
rotation: e.rotation,
|
||||
hueShift: e.hueShift,
|
||||
dna: e.dna ? e.dna.serialise() : {},
|
||||
})),
|
||||
logo: this.#logo?.serialise() ?? null,
|
||||
totalCounts: this.#totalCounts,
|
||||
@@ -519,6 +683,8 @@ export class EvolutionController {
|
||||
const typeMeta = this.#iconsData.types[iconMeta.type];
|
||||
if (!typeMeta) return;
|
||||
|
||||
const dna = DNA.deserialise(s.dna ?? {});
|
||||
|
||||
const entity = new Entity({
|
||||
name: s.name,
|
||||
type: iconMeta.type,
|
||||
@@ -528,17 +694,12 @@ export class EvolutionController {
|
||||
vx: s.vx,
|
||||
vy: s.vy,
|
||||
rotation: s.rotation,
|
||||
dna,
|
||||
});
|
||||
|
||||
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);
|
||||
// if the snapshot didn't already include totals, count this entity now
|
||||
if (buildTotalsFromEntities) {
|
||||
|
||||
+22
-55
@@ -2,18 +2,17 @@
|
||||
* 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.
|
||||
* The guide panel is a static reference showing interaction rules and
|
||||
* DNA mutation effects. It also displays the system uptime and provides
|
||||
* the "restart evolution" button.
|
||||
*
|
||||
* While the panel is open, a 200 ms interval keeps entity population
|
||||
* counts and the system uptime display live.
|
||||
* Entity statistics and spawn controls live in the separate population panel
|
||||
* (PopulationPanelController / populationPanel.js).
|
||||
*
|
||||
* 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';
|
||||
@@ -35,14 +34,14 @@ export class GuideController {
|
||||
/** @type {boolean} */ #open = false;
|
||||
/** @type {boolean} */ #iconsLoaded = false;
|
||||
/** @type {import('./evolution.js').EvolutionController|null} */ #evolution = null;
|
||||
/** @type {ReturnType<typeof setInterval>|null} */ #statsTimer = null;
|
||||
/** @type {ReturnType<typeof setInterval>|null} */ #uptimeTimer = 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.
|
||||
* Optional — if provided, the uptime display is kept live and the
|
||||
* restart button clears all entities.
|
||||
*/
|
||||
init(evolution = null) {
|
||||
this.#evolution = evolution;
|
||||
@@ -57,7 +56,7 @@ export class GuideController {
|
||||
|
||||
// Close on outside click
|
||||
document.addEventListener('click', (e) => {
|
||||
if (this.#open && !this.#panel.contains(e.target)) this.close();
|
||||
if (this.#open && !this.#panel.contains(/** @type {Node} */ (e.target))) this.close();
|
||||
});
|
||||
|
||||
// Close on Escape
|
||||
@@ -65,10 +64,7 @@ export class GuideController {
|
||||
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
|
||||
// Restart button — clears all entities and saved state
|
||||
const resetBtn = document.getElementById('guideResetBtn');
|
||||
if (resetBtn && this.#evolution) {
|
||||
resetBtn.addEventListener('click', () => this.#evolution.clear());
|
||||
@@ -81,7 +77,7 @@ export class GuideController {
|
||||
this.#panel.setAttribute('aria-hidden', 'false');
|
||||
this.#btn.setAttribute('aria-expanded', 'true');
|
||||
if (!this.#iconsLoaded) this.#populateIcons();
|
||||
if (this.#evolution) this.#startLiveUpdate();
|
||||
if (this.#evolution) this.#startUptime();
|
||||
}
|
||||
|
||||
close() {
|
||||
@@ -89,56 +85,27 @@ export class GuideController {
|
||||
this.#panel.classList.remove('guide-panel--visible');
|
||||
this.#panel.setAttribute('aria-hidden', 'true');
|
||||
this.#btn.setAttribute('aria-expanded', 'false');
|
||||
this.#stopLiveUpdate();
|
||||
this.#stopUptime();
|
||||
}
|
||||
|
||||
// ── 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));
|
||||
});
|
||||
#startUptime() {
|
||||
this.#refreshUptime();
|
||||
this.#uptimeTimer = setInterval(() => this.#refreshUptime(), 1000);
|
||||
}
|
||||
|
||||
/** 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;
|
||||
#stopUptime() {
|
||||
if (this.#uptimeTimer !== null) {
|
||||
clearInterval(this.#uptimeTimer);
|
||||
this.#uptimeTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Push current entity counts and system lifetime into the panel DOM. */
|
||||
#refreshStats() {
|
||||
#refreshUptime() {
|
||||
if (!this.#evolution || !this.#panel) return;
|
||||
|
||||
const counts = this.#evolution.getCounts();
|
||||
const lifetime = this.#evolution.lifetime;
|
||||
|
||||
// Update per-entity count badges (current + all‑time total in parentheses)
|
||||
const totals = this.#evolution.totalCounts || {};
|
||||
this.#panel.querySelectorAll('[data-count-key]').forEach(item => {
|
||||
const el = item.querySelector('.guide-entity__count');
|
||||
if (el) {
|
||||
const key = item.dataset.countKey;
|
||||
const cur = counts[key] ?? 0;
|
||||
const tot = totals[key] ?? 0;
|
||||
el.textContent = tot > 0 ? `${cur} (${tot})` : String(cur);
|
||||
}
|
||||
});
|
||||
|
||||
// Update uptime display
|
||||
const uptimeEl = this.#panel.querySelector('.guide-panel__uptime');
|
||||
if (uptimeEl) uptimeEl.textContent = formatDuration(lifetime);
|
||||
const el = this.#panel.querySelector('.guide-panel__uptime');
|
||||
if (el) el.textContent = formatDuration(this.#evolution.lifetime);
|
||||
}
|
||||
|
||||
/** Inject SVGs into every [data-guide-icon] slot in the panel. */
|
||||
|
||||
+5
-1
@@ -16,6 +16,7 @@ import { SettingsController } from './settings.js';
|
||||
import { EvolutionController } from './evolution.js';
|
||||
import { GuideController } from './guide.js';
|
||||
import { LogoController } from './logoController.js';
|
||||
import { PopulationPanelController } from './populationPanel.js';
|
||||
import { DEFAULTS } from './constants.js';
|
||||
|
||||
// ── Initialise controllers ─────────────────────────────────
|
||||
@@ -23,6 +24,7 @@ const gradient = new GradientController();
|
||||
const modal = new ModalController();
|
||||
const evolution = new EvolutionController();
|
||||
const guide = new GuideController();
|
||||
const population = new PopulationPanelController();
|
||||
|
||||
// SettingsController bridges UI → gradient + evolution
|
||||
const settings = new SettingsController(gradient, evolution);
|
||||
@@ -41,10 +43,11 @@ gradient.init(DEFAULTS.GRADIENT_COLOR);
|
||||
// Apply saved settings after gradient has set its defaults
|
||||
settings.loadSaved();
|
||||
|
||||
// ── Boot logo + evolution ──────────────────────────────────
|
||||
// ── Boot logo + evolution + population panel ───────────────
|
||||
// Sequence: evolution.init() → reads localStorage (populates savedLogoState)
|
||||
// logo.init(savedLogoState) → mounts letters, restores state
|
||||
// setLogoController(logo) → arms per-tick calls
|
||||
// population.init(evolution) → icons data is available now
|
||||
const logo = new LogoController();
|
||||
const container = document.getElementById('evolutionContainer');
|
||||
|
||||
@@ -53,4 +56,5 @@ evolution.init(container)
|
||||
.then(() => {
|
||||
evolution.setLogoController(logo);
|
||||
settings.setLogoController(logo);
|
||||
population.init(evolution);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,445 @@
|
||||
/**
|
||||
* populationPanel.js
|
||||
* PopulationPanelController — live entity monitor with DNA variant history.
|
||||
*
|
||||
* Opened via the ⊕ button (top-right, below settings).
|
||||
*
|
||||
* Panel sections:
|
||||
* species — for each entity type, shows a header row with the live count,
|
||||
* then one sub-row per observed DNA variant. Variants that have
|
||||
* gone extinct remain visible (dimmed) so you can read the
|
||||
* evolutionary history: which gene combinations appeared, which
|
||||
* survived, and which died out.
|
||||
*
|
||||
* Variant sub-row format:
|
||||
* ● SP1.2 SH0.8 2 / 3
|
||||
* ↑ coloured dot ↑ live / peak count
|
||||
*
|
||||
* Gene shortcodes: SP speed · RS resistance · SH shield
|
||||
* DI division rate · HS hunt speed · LT lethality
|
||||
*
|
||||
* spawn — one row per registered entity type with a [+] button.
|
||||
* For cells and viruses: gene checkboxes pre-seed the spawned DNA.
|
||||
*
|
||||
* #seenVariants tracks every gene-name combination ever observed and persists
|
||||
* until the evolution is cleared (evolutionCleared event).
|
||||
*/
|
||||
|
||||
import { loadIcon } from './iconLoader.js';
|
||||
import { GENE_DEFS, DNA } from './dna.js';
|
||||
|
||||
// ── Gene display helpers ───────────────────────────────────────────────────
|
||||
|
||||
const GENE_SHORT = {
|
||||
speed: 'SP',
|
||||
resistance: 'RS',
|
||||
shield: 'SH',
|
||||
division_rate: 'DI',
|
||||
hunt_speed: 'HS',
|
||||
lethality: 'LT',
|
||||
};
|
||||
|
||||
/** Build a variant signature: "entityKey::gene1|gene2|…" (genes sorted). */
|
||||
function variantSig(entityKey, dna) {
|
||||
const names = [...dna.entries].map(([g]) => g).sort();
|
||||
return `${entityKey}::${names.join('|')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact gene string from an average-values object.
|
||||
* e.g. { speed: 1.24, shield: 0.82 } → "SP1.2 SH0.8"
|
||||
*/
|
||||
function genesStr(avgValues) {
|
||||
const entries = Object.entries(avgValues).sort(([a], [b]) => a.localeCompare(b));
|
||||
if (entries.length === 0) return 'base';
|
||||
return entries.map(([g, v]) => `${GENE_SHORT[g] ?? g}${v.toFixed(1)}`).join(' ');
|
||||
}
|
||||
|
||||
/** DNA-derived colour for an entity type, from average gene values. */
|
||||
function variantColor(entityKey, avgValues) {
|
||||
const BASES = {
|
||||
'cell': { h: 133, s: 100, l: 83 },
|
||||
'virus-filled': { h: 350, s: 100, l: 65 },
|
||||
};
|
||||
const base = BASES[entityKey];
|
||||
if (!base || Object.keys(avgValues).length === 0) {
|
||||
return entityKey === 'virus-filled' ? '#ff4d6d' : '#a8ffb8';
|
||||
}
|
||||
return DNA.deserialise(avgValues).computeColor(base.h, base.s, base.l);
|
||||
}
|
||||
|
||||
/** Format ms → "m:ss" or "h:mm:ss". */
|
||||
function fmt(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')}`;
|
||||
}
|
||||
|
||||
// ── Entity types that carry evolving DNA ───────────────────────────────────
|
||||
const DNA_CAPABLE = new Set(['good', 'viral']);
|
||||
|
||||
// ── Pool for gene checkboxes ───────────────────────────────────────────────
|
||||
function poolForType(type) {
|
||||
return type === 'viral' ? 'virus' : 'cell';
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export class PopulationPanelController {
|
||||
/** @type {HTMLElement|null} */ #panel = null;
|
||||
/** @type {HTMLElement|null} */ #btn = null;
|
||||
/** @type {boolean} */ #open = false;
|
||||
/** @type {boolean} */ #built = false;
|
||||
/** @type {import('./evolution.js').EvolutionController|null} */ #evolution = null;
|
||||
/** @type {ReturnType<typeof setInterval>|null} */ #statsTimer = null;
|
||||
/** Cached list from getSpawnableEntities() — used for label/color lookup. */
|
||||
/** @type {Array<{ key: string, label: string, color: string, type: string }>} */ #spawnable = [];
|
||||
/** Entity keys that already have a header row in #popSpecies. */
|
||||
/** @type {Set<string>} */ #speciesKeys = new Set();
|
||||
|
||||
/**
|
||||
* Observed variant history.
|
||||
* Key: variant signature. Value: VariantRecord.
|
||||
*
|
||||
* @type {Map<string, {
|
||||
* entityKey: string,
|
||||
* geneNames: string[],
|
||||
* liveCount: number,
|
||||
* peakCount: number,
|
||||
* color: string,
|
||||
* label: string,
|
||||
* avgValues: Record<string, number>,
|
||||
* }>}
|
||||
*/
|
||||
#seenVariants = new Map();
|
||||
|
||||
/**
|
||||
* Wire up the panel. Must be called after evolution.init() resolves so that
|
||||
* getSpawnableEntities() has icons data available.
|
||||
*
|
||||
* @param {import('./evolution.js').EvolutionController} evolution
|
||||
*/
|
||||
init(evolution) {
|
||||
this.#evolution = evolution;
|
||||
this.#panel = document.getElementById('populationPanel');
|
||||
this.#btn = document.getElementById('populationBtn');
|
||||
if (!this.#btn || !this.#panel) return;
|
||||
|
||||
this.#btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.#open ? this.close() : this.open();
|
||||
});
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
if (this.#open
|
||||
&& !this.#panel.contains(/** @type {Node} */ (e.target))
|
||||
&& !this.#btn.contains(/** @type {Node} */ (e.target))
|
||||
) this.close();
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && this.#open) this.close();
|
||||
});
|
||||
|
||||
// Reset variant history when evolution is restarted
|
||||
document.addEventListener('evolutionCleared', () => {
|
||||
this.#seenVariants.clear();
|
||||
this.#speciesKeys.clear();
|
||||
const speciesEl = this.#panel?.querySelector('#popSpecies');
|
||||
if (speciesEl) speciesEl.innerHTML = '';
|
||||
});
|
||||
|
||||
this.#buildUI();
|
||||
}
|
||||
|
||||
open() {
|
||||
this.#open = true;
|
||||
this.#panel.classList.add('pop-panel--visible');
|
||||
this.#panel.setAttribute('aria-hidden', 'false');
|
||||
this.#btn.setAttribute('aria-expanded', 'true');
|
||||
this.#startLiveUpdate();
|
||||
}
|
||||
|
||||
close() {
|
||||
this.#open = false;
|
||||
this.#panel.classList.remove('pop-panel--visible');
|
||||
this.#panel.setAttribute('aria-hidden', 'true');
|
||||
this.#btn.setAttribute('aria-expanded', 'false');
|
||||
this.#stopLiveUpdate();
|
||||
}
|
||||
|
||||
// ── Private ─────────────────────────────────────────────────
|
||||
|
||||
#startLiveUpdate() {
|
||||
this.#refreshStats();
|
||||
this.#statsTimer = setInterval(() => this.#refreshStats(), 300);
|
||||
}
|
||||
|
||||
#stopLiveUpdate() {
|
||||
if (this.#statsTimer !== null) {
|
||||
clearInterval(this.#statsTimer);
|
||||
this.#statsTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Build static DOM structure (called once) ──────────────────
|
||||
|
||||
#buildUI() {
|
||||
if (this.#built || !this.#panel || !this.#evolution) return;
|
||||
this.#built = true;
|
||||
|
||||
this.#spawnable = this.#evolution.getSpawnableEntities();
|
||||
const spawnEl = this.#panel.querySelector('#popSpawn');
|
||||
if (!spawnEl) return;
|
||||
|
||||
for (const e of this.#spawnable) {
|
||||
// ── Spawn row ─────────────────────────────────────────────
|
||||
const spawnRow = document.createElement('div');
|
||||
spawnRow.className = 'pop-spawn-row';
|
||||
|
||||
const spawnIcon = document.createElement('span');
|
||||
spawnIcon.className = 'pop-spawn-icon';
|
||||
spawnIcon.style.setProperty('--c', e.color);
|
||||
spawnIcon.dataset.popIcon = e.key;
|
||||
|
||||
const spawnName = document.createElement('span');
|
||||
spawnName.className = 'pop-spawn-name';
|
||||
spawnName.textContent = e.label.toLowerCase();
|
||||
|
||||
const spacer = document.createElement('span');
|
||||
spacer.className = 'pop-spawn-spacer';
|
||||
|
||||
const spawnBtn = document.createElement('button');
|
||||
spawnBtn.className = 'pop-spawn-btn';
|
||||
spawnBtn.textContent = '+';
|
||||
spawnBtn.title = `Spawn ${e.label.toLowerCase()}`;
|
||||
spawnBtn.addEventListener('click', () => {
|
||||
const geneRow = spawnEl.querySelector(`[data-spawn-genes="${e.key}"]`);
|
||||
const checked = geneRow
|
||||
? [...geneRow.querySelectorAll('input[type="checkbox"]:checked')].map(cb => cb.value)
|
||||
: [];
|
||||
this.#evolution.spawnNamedWithDna(e.key, checked);
|
||||
});
|
||||
|
||||
spawnRow.appendChild(spawnIcon);
|
||||
spawnRow.appendChild(spawnName);
|
||||
spawnRow.appendChild(spacer);
|
||||
spawnRow.appendChild(spawnBtn);
|
||||
spawnEl.appendChild(spawnRow);
|
||||
|
||||
// Gene checkboxes for DNA-capable types
|
||||
if (DNA_CAPABLE.has(e.type)) {
|
||||
const pool = poolForType(e.type);
|
||||
const poolGenes = Object.entries(GENE_DEFS).filter(([, d]) => d.applies === pool);
|
||||
|
||||
if (poolGenes.length > 0) {
|
||||
const geneRow = document.createElement('div');
|
||||
geneRow.className = 'pop-spawn-genes';
|
||||
geneRow.dataset.spawnGenes = e.key;
|
||||
|
||||
for (const [geneName, def] of poolGenes) {
|
||||
const label = document.createElement('label');
|
||||
label.className = 'pop-gene-check';
|
||||
label.title = def.label ?? geneName;
|
||||
|
||||
const cb = document.createElement('input');
|
||||
cb.type = 'checkbox';
|
||||
cb.value = geneName;
|
||||
|
||||
const span = document.createElement('span');
|
||||
span.textContent = (GENE_SHORT[geneName] ?? geneName);
|
||||
|
||||
label.appendChild(cb);
|
||||
label.appendChild(span);
|
||||
geneRow.appendChild(label);
|
||||
}
|
||||
|
||||
spawnEl.appendChild(geneRow);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Inject SVGs into all icon slots (fire-and-forget async)
|
||||
this.#populateIcons();
|
||||
}
|
||||
|
||||
// ── Stats refresh ─────────────────────────────────────────────
|
||||
|
||||
#refreshStats() {
|
||||
if (!this.#evolution || !this.#panel) return;
|
||||
|
||||
const details = this.#evolution.getEntityDetails();
|
||||
|
||||
// ── 1. Group live entities by variant signature ────────────
|
||||
/** @type {Map<string, { entityKey: string, geneNames: string[], dnas: DNA[] }>} */
|
||||
const currentGroups = new Map();
|
||||
|
||||
for (const { key, dna } of details) {
|
||||
const sig = variantSig(key, dna);
|
||||
if (!currentGroups.has(sig)) {
|
||||
const geneNames = [...dna.entries].map(([g]) => g).sort();
|
||||
currentGroups.set(sig, { entityKey: key, geneNames, dnas: [] });
|
||||
}
|
||||
currentGroups.get(sig).dnas.push(dna);
|
||||
}
|
||||
|
||||
// ── 2. Reset live counts for all seen variants ─────────────
|
||||
for (const record of this.#seenVariants.values()) {
|
||||
record.liveCount = 0;
|
||||
}
|
||||
|
||||
// ── 3. Update / register variants from current live entities ─
|
||||
for (const [sig, group] of currentGroups) {
|
||||
const { entityKey, geneNames, dnas } = group;
|
||||
const liveCount = dnas.length;
|
||||
|
||||
// Average gene values across all live members of this variant
|
||||
const sums = {};
|
||||
for (const dna of dnas) {
|
||||
for (const [gene, val] of dna.entries) {
|
||||
sums[gene] = (sums[gene] ?? 0) + val;
|
||||
}
|
||||
}
|
||||
const avgValues = {};
|
||||
for (const [gene, sum] of Object.entries(sums)) {
|
||||
avgValues[gene] = sum / liveCount;
|
||||
}
|
||||
|
||||
const color = variantColor(entityKey, avgValues);
|
||||
const label = genesStr(avgValues);
|
||||
|
||||
if (this.#seenVariants.has(sig)) {
|
||||
const r = this.#seenVariants.get(sig);
|
||||
r.liveCount = liveCount;
|
||||
r.peakCount = Math.max(r.peakCount, liveCount);
|
||||
r.color = color;
|
||||
r.label = label;
|
||||
r.avgValues = avgValues;
|
||||
} else {
|
||||
this.#seenVariants.set(sig, {
|
||||
entityKey, geneNames, liveCount,
|
||||
peakCount: liveCount, color, label, avgValues,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── 4. Update totals + uptime ──────────────────────────────
|
||||
const totalEl = this.#panel.querySelector('.pop-panel__total');
|
||||
if (totalEl) totalEl.textContent = String(details.length);
|
||||
|
||||
const uptimeEl = this.#panel.querySelector('.pop-panel__uptime');
|
||||
if (uptimeEl) uptimeEl.textContent = fmt(this.#evolution.lifetime);
|
||||
|
||||
// ── 5. Rebuild species rows (creates headers on first sight) ─
|
||||
this.#rebuildSpecies();
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild the species section from #seenVariants.
|
||||
* Creates a species header row the first time an entity type is observed,
|
||||
* so entity types that have never appeared are never shown.
|
||||
*/
|
||||
#rebuildSpecies() {
|
||||
if (!this.#panel) return;
|
||||
const speciesEl = this.#panel.querySelector('#popSpecies');
|
||||
if (!speciesEl) return;
|
||||
|
||||
// Group seen variants by entity key
|
||||
/** @type {Map<string, Array>} */
|
||||
const byKey = new Map();
|
||||
for (const v of this.#seenVariants.values()) {
|
||||
if (!byKey.has(v.entityKey)) byKey.set(v.entityKey, []);
|
||||
byKey.get(v.entityKey).push(v);
|
||||
}
|
||||
|
||||
// Add a header row the first time an entity type is seen (in spawnable order)
|
||||
for (const e of this.#spawnable) {
|
||||
if (!byKey.has(e.key) || this.#speciesKeys.has(e.key)) continue;
|
||||
this.#speciesKeys.add(e.key);
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'pop-species-header';
|
||||
header.dataset.speciesKey = e.key;
|
||||
|
||||
const iconEl = document.createElement('span');
|
||||
iconEl.className = 'pop-species-header__icon';
|
||||
iconEl.style.setProperty('--c', e.color);
|
||||
loadIcon(e.key).then(svg => { iconEl.innerHTML = svg; }).catch(() => {});
|
||||
|
||||
const nameEl = document.createElement('span');
|
||||
nameEl.className = 'pop-species-header__name';
|
||||
nameEl.textContent = e.label.toLowerCase();
|
||||
|
||||
const countEl = document.createElement('span');
|
||||
countEl.className = 'pop-species-header__count';
|
||||
countEl.dataset.popCount = e.key;
|
||||
|
||||
header.appendChild(iconEl);
|
||||
header.appendChild(nameEl);
|
||||
header.appendChild(countEl);
|
||||
speciesEl.appendChild(header);
|
||||
|
||||
const variantsEl = document.createElement('div');
|
||||
variantsEl.className = 'pop-variants';
|
||||
variantsEl.dataset.speciesVariants = e.key;
|
||||
speciesEl.appendChild(variantsEl);
|
||||
}
|
||||
|
||||
// Update header counts + variant sub-rows for all visible species
|
||||
for (const [entityKey, variants] of byKey) {
|
||||
const liveTotal = variants.reduce((s, v) => s + v.liveCount, 0);
|
||||
|
||||
const countEl = speciesEl.querySelector(`[data-pop-count="${entityKey}"]`);
|
||||
if (countEl) countEl.textContent = String(liveTotal);
|
||||
|
||||
const container = speciesEl.querySelector(`[data-species-variants="${entityKey}"]`);
|
||||
if (!container) continue;
|
||||
|
||||
const sorted = [...variants].sort((a, b) => b.liveCount - a.liveCount || b.peakCount - a.peakCount);
|
||||
container.innerHTML = '';
|
||||
|
||||
for (const v of sorted) {
|
||||
const row = document.createElement('div');
|
||||
row.className = `pop-variant-row${v.liveCount === 0 ? ' pop-variant-row--extinct' : ''}`;
|
||||
|
||||
const dot = document.createElement('span');
|
||||
dot.className = 'pop-variant-dot';
|
||||
dot.style.background = v.color;
|
||||
dot.style.boxShadow = `0 0 5px ${v.color}80`;
|
||||
|
||||
const genes = document.createElement('span');
|
||||
genes.className = 'pop-variant-genes';
|
||||
genes.textContent = v.label;
|
||||
|
||||
const count = document.createElement('span');
|
||||
count.className = 'pop-variant-count';
|
||||
if (v.liveCount > 0) {
|
||||
count.textContent = v.peakCount > v.liveCount
|
||||
? `${v.liveCount} / ${v.peakCount}`
|
||||
: `×${v.liveCount}`;
|
||||
} else {
|
||||
count.textContent = `0 / ${v.peakCount}`;
|
||||
}
|
||||
|
||||
row.appendChild(dot);
|
||||
row.appendChild(genes);
|
||||
row.appendChild(count);
|
||||
container.appendChild(row);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Icon injection ─────────────────────────────────────────────
|
||||
|
||||
async #populateIcons() {
|
||||
if (!this.#panel) return;
|
||||
const slots = this.#panel.querySelectorAll('[data-pop-icon]');
|
||||
await Promise.all(Array.from(slots).map(async (slot) => {
|
||||
try {
|
||||
slot.innerHTML = await loadIcon(slot.dataset.popIcon);
|
||||
} catch { /* silent fail */ }
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
/* ════════════════════════════════════════════════════════════
|
||||
population.css — population monitor button and panel
|
||||
════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* ── Trigger button (top-right, below settings) ─────────── */
|
||||
.population-btn {
|
||||
position: fixed;
|
||||
top: 70px;
|
||||
right: 26px;
|
||||
z-index: 100;
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
|
||||
/* ── Panel ───────────────────────────────────────────────── */
|
||||
.pop-panel {
|
||||
position: fixed;
|
||||
top: 116px;
|
||||
right: 1.5rem;
|
||||
z-index: 200;
|
||||
|
||||
width: 288px;
|
||||
max-height: calc(100dvh - 140px);
|
||||
overflow-y: auto;
|
||||
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);
|
||||
backdrop-filter: blur(18px);
|
||||
-webkit-backdrop-filter: blur(18px);
|
||||
|
||||
opacity: 0;
|
||||
transform: translateY(-6px) scale(0.98);
|
||||
pointer-events: none;
|
||||
transition: opacity 0.18s ease, transform 0.18s ease;
|
||||
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255,255,255,0.1) transparent;
|
||||
}
|
||||
.pop-panel::-webkit-scrollbar { width: 4px; }
|
||||
.pop-panel::-webkit-scrollbar-track { background: transparent; }
|
||||
.pop-panel::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 4px; }
|
||||
|
||||
.pop-panel--visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* ── Header ──────────────────────────────────────────────── */
|
||||
.pop-panel__header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
.pop-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;
|
||||
}
|
||||
|
||||
.pop-panel__meta {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.pop-panel__total {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.62rem;
|
||||
color: rgba(255, 255, 255, 0.28);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.pop-panel__uptime {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.56rem;
|
||||
color: rgba(255, 255, 255, 0.16);
|
||||
letter-spacing: 0.04em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ── Sections ────────────────────────────────────────────── */
|
||||
.pop-section + .pop-section {
|
||||
margin-top: 0.9rem;
|
||||
padding-top: 0.9rem;
|
||||
border-top: 1px solid var(--modal-divider);
|
||||
}
|
||||
|
||||
.pop-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.6rem;
|
||||
}
|
||||
|
||||
/* ── Species section ─────────────────────────────────────── */
|
||||
#popSpecies {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
/* Species header row */
|
||||
.pop-species-header {
|
||||
display: grid;
|
||||
grid-template-columns: 20px 1fr auto;
|
||||
align-items: center;
|
||||
gap: 0 0.5rem;
|
||||
padding: 0.18rem 0;
|
||||
}
|
||||
|
||||
.pop-species-header__icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--c, rgba(255,255,255,0.7));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
filter: drop-shadow(0 0 3px color-mix(in srgb, var(--c, white) 55%, transparent));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.pop-species-header__icon svg { width: 100%; height: 100%; display: block; }
|
||||
|
||||
.pop-species-header__name {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
|
||||
.pop-species-header__count {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.6rem;
|
||||
color: rgba(255, 255, 255, 0.28);
|
||||
font-variant-numeric: tabular-nums;
|
||||
min-width: 16px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* ── Variant rows ────────────────────────────────────────── */
|
||||
.pop-variants {
|
||||
padding-left: 24px; /* indent under species icon */
|
||||
margin-bottom: 0.45rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.22rem;
|
||||
}
|
||||
|
||||
.pop-variant-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.42rem;
|
||||
}
|
||||
|
||||
/* Coloured circle representing the variant's DNA colour */
|
||||
.pop-variant-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pop-variant-genes {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.57rem;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
letter-spacing: 0.02em;
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.pop-variant-count {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.55rem;
|
||||
color: rgba(255, 255, 255, 0.28);
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Extinct variants — dimmed */
|
||||
.pop-variant-row--extinct .pop-variant-dot {
|
||||
opacity: 0.28;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.pop-variant-row--extinct .pop-variant-genes {
|
||||
color: rgba(255, 255, 255, 0.22);
|
||||
text-decoration: line-through;
|
||||
text-decoration-color: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.pop-variant-row--extinct .pop-variant-count {
|
||||
color: rgba(255, 255, 255, 0.14);
|
||||
}
|
||||
|
||||
/* ── Spawn section ───────────────────────────────────────── */
|
||||
.pop-spawn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.pop-spawn-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.pop-spawn-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
color: var(--c, rgba(255,255,255,0.7));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
filter: drop-shadow(0 0 3px color-mix(in srgb, var(--c, white) 50%, transparent));
|
||||
}
|
||||
.pop-spawn-icon svg { width: 100%; height: 100%; display: block; }
|
||||
|
||||
.pop-spawn-name {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.65rem;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
min-width: 48px;
|
||||
}
|
||||
|
||||
.pop-spawn-spacer { flex: 1; }
|
||||
|
||||
.pop-spawn-btn {
|
||||
appearance: none;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
width: 24px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1;
|
||||
color: rgba(255, 255, 255, 0.38);
|
||||
transition: color 0.15s ease, background 0.15s ease, border-color 0.15s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.pop-spawn-btn:hover {
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
.pop-spawn-btn:active {
|
||||
color: #fff;
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
}
|
||||
|
||||
/* Gene checkboxes for spawn */
|
||||
.pop-spawn-genes {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
padding-left: 22px;
|
||||
margin-top: -0.15rem;
|
||||
}
|
||||
|
||||
.pop-gene-check {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
cursor: pointer;
|
||||
padding: 0.08rem 0.35rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.07);
|
||||
border-radius: 100px;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
.pop-gene-check:hover {
|
||||
border-color: rgba(255, 255, 255, 0.18);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.pop-gene-check input[type="checkbox"] {
|
||||
appearance: none;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: background 0.12s, border-color 0.12s;
|
||||
}
|
||||
.pop-gene-check input[type="checkbox"]:checked {
|
||||
background: rgba(255, 255, 255, 0.75);
|
||||
border-color: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
|
||||
.pop-gene-check span {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.52rem;
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
white-space: nowrap;
|
||||
transition: color 0.12s;
|
||||
}
|
||||
.pop-gene-check:has(input:checked) span {
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
.pop-gene-check:has(input:checked) {
|
||||
border-color: rgba(255, 255, 255, 0.22);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
Reference in New Issue
Block a user