| import { World, EVENT_TYPES } from "./world.js"; | |
| import { EntityManager } from "./entityManager.js"; | |
| import { Entity } from "./entities.js"; | |
| import { ParticleSystem } from "./particles.js"; | |
| import { StatsTracker } from "./stats.js"; | |
| import { UI } from "./ui.js"; | |
| import { createGenome, ENTITY_TYPES } from "./genetics.js"; | |
| import { randomRange } from "./utils.js"; | |
| const SAVE_KEY = "digital-life-simulator-state"; | |
| const TARGET_FPS = 60; | |
| const MIN_POPULATION = 15; | |
| const MAX_POPULATION = 80; | |
| const INITIAL_POPULATION = 35; | |
| const GENERATION_INTERVAL = 45; | |
| export class Ecosystem { | |
| constructor() { | |
| this.world = null; | |
| this.entityManager = null; | |
| this.particles = null; | |
| this.stats = null; | |
| this.ui = null; | |
| this.simCanvas = null; | |
| this.simCtx = null; | |
| this.particleCanvas = null; | |
| this.particleCtx = null; | |
| this.running = false; | |
| this._rafId = null; | |
| this._lastTime = 0; | |
| this._fpsAccum = 0; | |
| this._fpsFrames = 0; | |
| this._currentFps = 60; | |
| this._genTimer = 0; | |
| this._saveTimer = 0; | |
| this._eventCheckTimer = 0; | |
| this._lastEventCount = 0; | |
| } | |
| init() { | |
| this.simCanvas = document.getElementById("simulation-canvas"); | |
| this.particleCanvas = document.getElementById("particle-canvas"); | |
| if (!this.simCanvas || !this.particleCanvas) { | |
| console.error("Canvas elements not found"); | |
| return; | |
| } | |
| this.simCtx = this.simCanvas.getContext("2d"); | |
| this.particleCtx = this.particleCanvas.getContext("2d"); | |
| this._resizeCanvases(); | |
| window.addEventListener("resize", () => this._resizeCanvases()); | |
| const w = this.simCanvas.width; | |
| const h = this.simCanvas.height; | |
| const saved = this._loadState(); | |
| if (saved) { | |
| this.world = World.fromJSON(saved.world); | |
| this.stats = StatsTracker.fromJSON(saved.stats); | |
| this.entityManager = new EntityManager(w, h); | |
| if (saved.entities && saved.entities.length > 0) { | |
| for (const ed of saved.entities) { | |
| try { | |
| const { Genome } = await_import_workaround(); | |
| } catch (_) {} | |
| } | |
| } | |
| this.entityManager.spawnRandom(INITIAL_POPULATION, this.world); | |
| } else { | |
| this.world = new World(w, h); | |
| this.stats = new StatsTracker(); | |
| this.entityManager = new EntityManager(w, h); | |
| this.entityManager.spawnRandom(INITIAL_POPULATION, this.world); | |
| } | |
| this.particles = new ParticleSystem(2000); | |
| const container = document.getElementById("app"); | |
| this.ui = new UI(container); | |
| this.ui.init(); | |
| this.ui.addEvent("Ecosystem initialized", "milestone"); | |
| this.ui.announce("Digital Life Simulator Active"); | |
| const types = Object.keys(ENTITY_TYPES); | |
| for (const type of types) { | |
| const count = this.entityManager.entities.filter( | |
| (e) => e.type === type, | |
| ).length; | |
| if (count > 0) { | |
| this.ui.addEvent( | |
| `${ENTITY_TYPES[type].name}: ${count} spawned`, | |
| "birth", | |
| ); | |
| } | |
| } | |
| } | |
| start() { | |
| if (this.running) return; | |
| this.running = true; | |
| this._lastTime = performance.now(); | |
| this._loop(this._lastTime); | |
| } | |
| stop() { | |
| this.running = false; | |
| if (this._rafId) { | |
| cancelAnimationFrame(this._rafId); | |
| this._rafId = null; | |
| } | |
| } | |
| _loop(timestamp) { | |
| if (!this.running) return; | |
| this._rafId = requestAnimationFrame((t) => this._loop(t)); | |
| const rawDt = (timestamp - this._lastTime) / 1000; | |
| const dt = Math.min(rawDt, 0.05); | |
| this._lastTime = timestamp; | |
| this._fpsAccum += rawDt; | |
| this._fpsFrames++; | |
| if (this._fpsAccum >= 0.5) { | |
| this._currentFps = this._fpsFrames / this._fpsAccum; | |
| this._fpsFrames = 0; | |
| this._fpsAccum = 0; | |
| } | |
| this._update(dt); | |
| this._render(); | |
| } | |
| _update(dt) { | |
| this.world.update(dt); | |
| this._eventCheckTimer += dt; | |
| if (this._eventCheckTimer >= 1) { | |
| this._eventCheckTimer = 0; | |
| if (this.world.events.length > this._lastEventCount) { | |
| const newEvent = this.world.events[this.world.events.length - 1]; | |
| this.ui.announceWorldEvent(newEvent); | |
| this._lastEventCount = this.world.events.length; | |
| } | |
| if (this.world.events.length < this._lastEventCount) { | |
| this._lastEventCount = this.world.events.length; | |
| } | |
| } | |
| this.entityManager.update(dt, this.world, this.particles, this.stats); | |
| this.particles.update(dt); | |
| for (const event of this.world.events) { | |
| if (event.type === "storm" || event.type === "bloom") { | |
| this.particles.emitWeather(event, dt); | |
| } | |
| } | |
| this._managePopulation(dt); | |
| this._genTimer += dt; | |
| if (this._genTimer >= GENERATION_INTERVAL) { | |
| this._genTimer = 0; | |
| this.stats.onGeneration(); | |
| this.ui.addEvent( | |
| `Generation ${this.stats.generationCount} reached`, | |
| "evolution", | |
| ); | |
| this.ui.announce(`Generation ${this.stats.generationCount}`); | |
| const genEl = document.getElementById("gen-count"); | |
| if (genEl) { | |
| genEl.classList.remove("generation-flash"); | |
| void genEl.offsetWidth; | |
| genEl.classList.add("generation-flash"); | |
| } | |
| } | |
| this.stats.record( | |
| this.entityManager.entities, | |
| this.world, | |
| dt, | |
| this._currentFps, | |
| this.particles.count, | |
| ); | |
| this._saveTimer += dt; | |
| if (this._saveTimer >= 30) { | |
| this._saveTimer = 0; | |
| this._saveState(); | |
| } | |
| this.ui.update(this.stats, this.world, this.entityManager, dt); | |
| } | |
| _render() { | |
| const simCtx = this.simCtx; | |
| const partCtx = this.particleCtx; | |
| const w = this.world.width; | |
| const h = this.world.height; | |
| simCtx.fillStyle = "#06060f"; | |
| simCtx.fillRect(0, 0, w, h); | |
| partCtx.clearRect(0, 0, w, h); | |
| this.world.render(simCtx); | |
| this._renderFrame = (this._renderFrame || 0) + 1; | |
| if (this._renderFrame % 15 === 0) { | |
| this.stats.renderHeatmap(simCtx, w, h); | |
| } | |
| this.entityManager.render(simCtx); | |
| this.particles.render(partCtx); | |
| } | |
| _managePopulation(dt) { | |
| const alive = this.entityManager.entities.length; | |
| if (alive < MIN_POPULATION) { | |
| const deficit = MIN_POPULATION - alive; | |
| const toSpawn = Math.min(deficit, 5); | |
| if (alive >= 2) { | |
| this.entityManager.spawnFromPopulation( | |
| this.entityManager.entities, | |
| toSpawn, | |
| this.world, | |
| ); | |
| this.ui.addEvent( | |
| `${toSpawn} entities evolved from survivors`, | |
| "evolution", | |
| ); | |
| } else { | |
| this.entityManager.spawnRandom(toSpawn, this.world); | |
| this.ui.addEvent(`${toSpawn} new entities seeded`, "birth"); | |
| } | |
| } | |
| this._diversityTimer = (this._diversityTimer || 0) + dt; | |
| if (this._diversityTimer >= 8) { | |
| this._diversityTimer = 0; | |
| const types = Object.keys(ENTITY_TYPES); | |
| const typeCounts = {}; | |
| for (const t of types) typeCounts[t] = 0; | |
| for (const e of this.entityManager.entities) { | |
| if (e.alive) typeCounts[e.type]++; | |
| } | |
| for (const type of types) { | |
| if (typeCounts[type] < 2 && alive < MAX_POPULATION - 3) { | |
| const count = Math.min(3, MAX_POPULATION - alive); | |
| for (let i = 0; i < count; i++) { | |
| const genome = createGenome(type); | |
| const x = randomRange(40, this.world.width - 40); | |
| const y = randomRange(40, this.world.height - 40); | |
| const entity = new Entity(x, y, genome, this.stats.generationCount); | |
| this.entityManager.add(entity); | |
| } | |
| this.ui.addEvent( | |
| `${ENTITY_TYPES[type].name} migration wave (${count})`, | |
| "birth", | |
| ); | |
| } | |
| } | |
| } | |
| if (alive > MAX_POPULATION) { | |
| const excess = alive - MAX_POPULATION; | |
| const sorted = [...this.entityManager.entities].sort( | |
| (a, b) => a.fitness - b.fitness, | |
| ); | |
| for (let i = 0; i < excess && i < sorted.length; i++) { | |
| sorted[i].alive = false; | |
| sorted[i].energy = 0; | |
| } | |
| } | |
| } | |
| _resizeCanvases() { | |
| const w = window.innerWidth; | |
| const h = window.innerHeight; | |
| const dpr = Math.min(window.devicePixelRatio || 1, 2); | |
| for (const canvas of [this.simCanvas, this.particleCanvas]) { | |
| if (!canvas) continue; | |
| canvas.width = w * dpr; | |
| canvas.height = h * dpr; | |
| canvas.style.width = w + "px"; | |
| canvas.style.height = h + "px"; | |
| const ctx = canvas.getContext("2d"); | |
| ctx.scale(dpr, dpr); | |
| } | |
| if (this.world) { | |
| this.world.width = w; | |
| this.world.height = h; | |
| } | |
| if (this.entityManager) { | |
| this.entityManager.spatialGrid = new (Object.getPrototypeOf( | |
| this.entityManager.spatialGrid, | |
| ).constructor)(w, h, 60); | |
| } | |
| } | |
| _saveState() { | |
| try { | |
| const state = { | |
| world: this.world.toJSON(), | |
| stats: this.stats.toJSON(), | |
| version: 1, | |
| }; | |
| localStorage.setItem(SAVE_KEY, JSON.stringify(state)); | |
| } catch (e) {} | |
| } | |
| _loadState() { | |
| try { | |
| const raw = localStorage.getItem(SAVE_KEY); | |
| if (!raw) return null; | |
| return JSON.parse(raw); | |
| } catch (e) { | |
| return null; | |
| } | |
| } | |
| } | |
| function await_import_workaround() { | |
| return null; | |
| } | |
| export function bootstrap() { | |
| const eco = new Ecosystem(); | |
| eco.init(); | |
| eco.start(); | |
| window.__ecosystem = eco; | |
| return eco; | |
| } | |