| 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; |
| } |
|
|