Last active
January 1, 2026 00:12
-
-
Save Isinlor/33687fd868f37f333500f53203d21628 to your computer and use it in GitHub Desktop.
Evo-Sim2: Creatures evolution
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Evo-Sim: Creature Evolution</title> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <style> | |
| body { | |
| font-family: 'Inter', sans-serif; | |
| overscroll-behavior: none; | |
| } | |
| .param-grid { | |
| display: grid; | |
| grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); | |
| gap: 1rem; | |
| } | |
| input[type=range] { | |
| -webkit-appearance: none; | |
| appearance: none; | |
| width: 100%; | |
| height: 8px; | |
| border-radius: 5px; | |
| background: #d3d3d3; | |
| outline: none; | |
| opacity: 0.7; | |
| -webkit-transition: .2s; | |
| transition: opacity .2s; | |
| } | |
| input[type=range]:hover { | |
| opacity: 1; | |
| } | |
| input[type=range]::-webkit-slider-thumb { | |
| -webkit-appearance: none; | |
| appearance: none; | |
| width: 20px; | |
| height: 20px; | |
| border-radius: 50%; | |
| background: #4A5568; | |
| cursor: pointer; | |
| } | |
| input[type=range]::-moz-range-thumb { | |
| width: 20px; | |
| height: 20px; | |
| border-radius: 50%; | |
| background: #4A5568; | |
| cursor: pointer; | |
| } | |
| /* Small table adjustments */ | |
| #statsTable th, #statsTable td { | |
| text-align: right; | |
| } | |
| #statsTable th:first-child, #statsTable td:first-child { | |
| text-align: left; | |
| } | |
| canvas { | |
| background-color: white; | |
| } | |
| </style> | |
| </head> | |
| <body class="bg-gray-100 text-gray-800 flex flex-col lg:flex-row h-screen overflow-hidden"> | |
| <!-- Simulation Canvas --> | |
| <main class="flex-grow flex flex-col items-center justify-center p-4 bg-gray-900 order-1 lg:order-2"> | |
| <canvas id="simulationCanvas" class="bg-white rounded-lg shadow-xl w-full h-full"></canvas> | |
| </main> | |
| <!-- Controls & Stats Panel --> | |
| <aside class="w-full lg:w-96 bg-white shadow-lg p-4 space-y-4 overflow-y-auto order-2 lg:order-1"> | |
| <h1 class="text-2xl font-bold text-gray-700 text-center">Evo-Sim Controls</h1> | |
| <!-- Main Buttons --> | |
| <div class="grid grid-cols-3 gap-2"> | |
| <button id="startButton" class="bg-blue-500 hover:bg-blue-600 text-white font-bold py-2 px-4 rounded-lg transition duration-300">Start</button> | |
| <button id="pauseButton" class="bg-yellow-500 hover:bg-yellow-600 text-white font-bold py-2 px-4 rounded-lg transition duration-300">Pause</button> | |
| <button id="resetButton" class="bg-red-500 hover:bg-red-600 text-white font-bold py-2 px-4 rounded-lg transition duration-300">Reset</button> | |
| </div> | |
| <div class="grid grid-cols-1 gap-2"> | |
| <button id="stepButton" class="bg-blue-500 hover:bg-blue-600 text-white font-bold py-2 px-4 rounded-lg transition duration-300">Step</button> | |
| </div> | |
| <!-- Network Visualization --> | |
| <div id="net-viz-container" class="bg-gray-50 rounded-lg hidden"> | |
| <h2 class="text-xl font-semibold text-center text-purple-700">Reward Network</h2> | |
| <div class="relative w-full h-96 bg-gray-900 rounded border border-gray-300 overflow-auto"> | |
| <canvas id="networkCanvas" width="400" height="2000" ></canvas> | |
| </div> | |
| <p class="text-xs text-center text-gray-500 mt-1">Computation Graph (Green+, Red-)</p> | |
| </div> | |
| <!-- NEW Statistics System --> | |
| <div class="bg-gray-50 p-3 rounded-lg overflow-x-auto"> | |
| <h2 class="text-xl font-semibold mb-2 text-center">Statistics</h2> | |
| <div class="flex justify-between text-xs text-gray-500 mb-2 px-1"> | |
| <span>Frames: <span id="framesStat">0</span></span> | |
| <span>FPS: <span id="fpsStat">0</span></span> | |
| </div> | |
| <div class="flex justify-between text-xs font-bold mb-2 px-1"> | |
| <span class="text-green-600">Plants: <span id="plantsStat">0</span></span> | |
| <span class="text-blue-600">Creatures: <span id="creaturesStat">0</span></span> | |
| </div> | |
| <table class="w-full text-xs border-collapse" id="statsTable"> | |
| <thead> | |
| <tr id="statsHeaders" class="border-b-2 border-gray-300 text-gray-700"> | |
| </tr> | |
| </thead> | |
| <tbody id="statsBody"> | |
| <!-- Rows will be injected by JS --> | |
| </tbody> | |
| </table> | |
| </div> | |
| <!-- Parameters --> | |
| <div class="bg-gray-50 p-3 rounded-lg"> | |
| <h2 class="text-xl font-semibold mb-2 text-center">World Parameters</h2> | |
| <div id="params-container" class="param-grid"> | |
| <!-- Parameters will be injected here by JS --> | |
| </div> | |
| </div> | |
| <div class="grid grid-cols-1 gap-2"> | |
| <button id="resetColorsButton" class="bg-blue-500 hover:bg-blue-600 text-white font-bold py-2 px-4 rounded-lg transition duration-300">Reset colors</button> | |
| </div> | |
| </aside> | |
| <script type="module"> | |
| // --- PRE-SETUP --- | |
| const canvas = document.getElementById('simulationCanvas'); | |
| const ctx = canvas.getContext('2d'); | |
| const netCanvas = document.getElementById('networkCanvas'); | |
| const netCtx = netCanvas.getContext('2d'); | |
| const netVizContainer = document.getElementById('net-viz-container'); | |
| // --- GLOBAL STATE --- | |
| let creatures = []; | |
| let plants = []; | |
| let walls = []; | |
| let plantGrid; | |
| let creatureGrid; | |
| let wallGrid; | |
| let isRunning = false; | |
| let frameCount = 0; | |
| let animationFrameId; | |
| let gradientAngle = 0; | |
| // --- SELECTION STATE --- | |
| let selectedCreature = null; | |
| const getCreatureType = (c) => c.generation > 2 ? c.color.map(color => Math.round(color)).join('') : "new"; | |
| // --- NEW STATISTICS MANAGER --- | |
| const statisticsManager = { | |
| statDefs: [ | |
| { label: "Count", stat: { key: "count", value: (c) => 1 }, avg: false, fmt: v => v }, | |
| { label: "Energy", stat: { key: "energy", value: (c) => c.energy }, avg: true, fmt: v => v.toFixed(0) }, | |
| { label: "Age", stat: { key: "age", value: (c) => c.age }, avg: true, fmt: v => v.toFixed(0) }, | |
| { label: "Gen", stat: { key: "generation", value: (c) => c.generation }, avg: true, fmt: v => v.toFixed(1) }, | |
| { label: "Strength", stat: { key: "strength", value: (c) => c.strength }, avg: true, fmt: v => v.toFixed(2) }, | |
| { label: "Radius", stat: { key: "maxSenseRadius", value: (c) => c.maxSenseRadius }, avg: true, fmt: v => v.toFixed(1) }, | |
| { label: "Bias (0P-1C)", stat: { key: "controlBias", value: (c) => c.controlBias }, avg: true, fmt: v => v.toFixed(2) }, | |
| { label: "Memory", stat: { key: "memory", value: (c) => c.memory.length }, avg: true, fmt: v => v.toFixed(1) }, | |
| { label: "Hidd Layers", stat: { key: "hiddenStructure", value: (c) => c.net.getHiddenStructure().length }, avg: true, fmt: v => v.toFixed(1) }, | |
| { label: "Hidd Neurons", stat: { key: "hiddenNeurons", value: (c) => c.net.getHiddenStructure().reduce((a, b) => a + b, 0) }, avg: true, fmt: v => v.toFixed(1) }, | |
| { label: "Min Neurons", stat: { key: "minHiddenNeurons", value: (c) => Math.min(...c.net.getHiddenStructure()) }, avg: true, fmt: v => v.toFixed(1) }, | |
| { label: "Max Neurons", stat: { key: "maxHiddenNeurons", value: (c) => Math.max(...c.net.getHiddenStructure()) }, avg: true, fmt: v => v.toFixed(1) }, | |
| { label: "Ensable %", stat: { key: "ensamble", value: (c) => (c.net.numModels ?? 0) > 0 ? 1 : 0 }, avg: true, fmt: v => (v * 100).toFixed(0) + '%' }, | |
| { label: "Mask sum", stat: { key: "maskSum", value: (c) => c.net.rewardNet ? c.net.rewardNet.maskSum() : 0 }, avg: true, fmt: v => v.toFixed(1) }, | |
| { label: "Experts", stat: { key: "numModels", value: (c) => c.net.numModels ?? 0 }, avg: true, fmt: v => v.toFixed(1) }, | |
| { label: "Epsilon", stat: { key: "epsilon", value: (c) => c.net.epsilon ?? 0 }, avg: true, fmt: v => v.toFixed(3) }, | |
| { label: "Top Alloc", stat: { key: "topAllocation", value: (c) => c.net.topAllocation ?? 0 }, avg: true, fmt: v => v.toFixed(3) }, | |
| { label: "Q smooth", stat: { key: "qSmoothing", value: (c) => c.net.qSmoothing ?? 0 }, avg: true, fmt: v => v.toFixed(3) }, | |
| { label: "Mut.Style", stat: { key: "mutationStyle", value: (c) => c.mutationStyle }, avg: true, fmt: v => v.toFixed(2) }, | |
| { label: "Stance", stat: { key: "stance", value: (c) => c.stance }, avg: true, fmt: v => v.toFixed(2) }, | |
| { label: "Sense", stat: { key: "sensing", value: (c) => c.sensing }, avg: true, fmt: v => v.toFixed(2) }, | |
| ], | |
| initialize() { | |
| this.update([], 0, 0); // Clear table on init | |
| }, | |
| triggerEvent(eventName, creature) { | |
| // Not strictly used for the table currently, but kept for future extensibility | |
| }, | |
| update(creatures, frameCount, plantsCount) { | |
| // Initialize buckets | |
| const buckets = { | |
| all: { count: 0, sums: {}, max: {}, min: {} }, | |
| }; | |
| for (const c of creatures) { | |
| const maxColor = Math.max(...c.color); | |
| const color = maxColor === c.color[0] ? 'red' : maxColor === c.color[1] ? 'green' : 'blue'; | |
| buckets[getCreatureType(c)] = { count: 0, sums: {}, max: {}, min: {}, color: c.color, type: c.generation > 2 ? color : 'grey' }; | |
| } | |
| // Initialize sums keys | |
| for(const k in buckets) { | |
| buckets[k].count = 0; | |
| for(const def of this.statDefs) { | |
| if(def.avg) { | |
| buckets[k].sums[def.stat.key] = 0; | |
| buckets[k].max[def.stat.key] = -Infinity; | |
| buckets[k].min[def.stat.key] = Infinity; | |
| } | |
| } | |
| } | |
| // Aggregate Data | |
| for (const c of creatures) { | |
| const type = getCreatureType(c); | |
| const bucketList = [buckets.all, buckets[type]]; | |
| for(const bucket of bucketList) { | |
| bucket.count++; | |
| for(const def of this.statDefs) { | |
| if(def.avg) { | |
| const value = def.stat.value(c); | |
| bucket.sums[def.stat.key] += value; | |
| bucket.max[def.stat.key] = Math.max(bucket.max[def.stat.key], value); | |
| bucket.min[def.stat.key] = Math.min(bucket.min[def.stat.key], value); | |
| } | |
| } | |
| } | |
| } | |
| // Update UI | |
| const newCreatures = creatures.filter(c => c.generation > 2).length; | |
| document.getElementById('framesStat').textContent = frameCount; | |
| document.getElementById('plantsStat').textContent = plantsCount; | |
| document.getElementById('creaturesStat').textContent = `${creatures.length} (${newCreatures} + ${creatures.length - newCreatures})`; | |
| const tbody = document.getElementById('statsBody'); | |
| if(!tbody) return; | |
| let html = ''; | |
| let htmlHeaders = '<th class="p-1 pb-2" style="white-space: nowrap; min-width: 80px;">Param</th>'; | |
| const types = Object.keys(buckets).sort((a, b) => { | |
| if (a === 'all') return -1; | |
| if (b === 'all') return 1; | |
| return buckets[b].count - buckets[a].count; | |
| }); | |
| for(const t of types) { | |
| const color = buckets[t].color; | |
| htmlHeaders += `<th class="p-1 pb-2 truncate" style="color: rgb(${color && t != 'new' ? color.join(',') : ''});">${t}</th>`; | |
| } | |
| document.getElementById('statsHeaders').innerHTML = htmlHeaders; | |
| for(const def of this.statDefs) { | |
| html += `<tr class="border-b border-gray-200 hover:bg-gray-100 transition-colors">`; | |
| html += `<td class="text-left font-medium p-1 text-gray-600">${def.label}</td>`; | |
| for(const t of types) { | |
| let val = 0; | |
| if (def.stat.key === 'count') { | |
| val = buckets[t].count; | |
| } else { | |
| val = buckets[t].count > 0 ? buckets[t].sums[def.stat.key] / buckets[t].count : 0; | |
| } | |
| const display = def.fmt(val); | |
| let colorClass = ""; | |
| if(buckets[t].type === 'red') colorClass = "text-red-700 bg-red-50"; | |
| else if(buckets[t].type === 'green') colorClass = "text-green-700 bg-green-50"; | |
| else if(buckets[t].type === 'blue') colorClass = "text-blue-700 bg-blue-50"; | |
| else colorClass = "font-bold text-gray-800 bg-gray-100"; | |
| // Dim zeros | |
| if(val === 0) colorClass += " opacity-30"; | |
| html += `<td class="p-1 ${colorClass}" title="${def.fmt(buckets[t].min[def.stat.key])} - ${def.fmt(buckets[t].max[def.stat.key])}">${display}</td>`; | |
| } | |
| html += `</tr>`; | |
| } | |
| tbody.innerHTML = html; | |
| } | |
| }; | |
| const params = { | |
| initialCreatures: { value: 0, min: 0, max: 5000, step: 10, label: "Initial Creatures" }, | |
| plantCount: { value: 2500, min: 50, max: 10000, step: 50, label: "Plant Count" }, | |
| plantRespawnRate: { value: 1000, min: 1, max: 1000, step: 10, label: "Plant Respawn Rate" }, | |
| plantGradientRange: { value: 0.5, min: 0, max: 0.5, step: 0.01, label: "Plant Gradient Range" }, | |
| gradientRotationSpeed: { value: 0.1, min: 0, max: 1, step: 0.01, label: "Gradient Rotation Speed" }, | |
| energyFromPlant: { value: 25, min: 1, max: 100, step: 1, label: "Energy from Plant" }, | |
| plantEnergySpread: { value: 3, min: 1, max: 100, step: 1, label: "Plant energy spread" }, | |
| reproductionCost: { value: 350, min: 1, max: 500, step: 1, label: "Reproduction Cost" }, | |
| reproductionThreshold: { value: 1000, min: 100, max: 1000, step: 10, label: "Reproduction Threshold" }, | |
| basalMetabolism: { value: 1, min: 0.1, max: 10, step: 0.1, label: "Constant Energy Drain" }, | |
| moveCostMultiplier: { value: 0.1, min: 0.1, max: 5, step: 0.1, label: "Movement Cost Multiplier" }, | |
| maxSpeed: { value: 7.5, min: 0.5, max: 20, step: 0.5, label: "Max Speed" }, | |
| attackRadius: { value: 10, min: 1, max: 50, step: 0.5, label: "Attack Radius" }, | |
| maxEnergyTransfer: { value: 30, min: 1, max: 1000, step: 1, label: "Max Energy Transfer" }, | |
| stanceCost: { value: 1, min: 0, max: 10, step: 0.1, label: "Stance Cost Multiplier" }, | |
| strengthMetabolism: { value: 0.05, min: 0, max: 0.1, step: 0.001, label: "Strength Metabolism" }, | |
| pushPullCost: { value: 0.5, min: 0, max: 1, step: 0.01, label: "Push/Pull Cost" }, | |
| wallSensorRadius: { value: 100, min: 10, max: 500, step: 10, label: "Wall Detect Dist" }, | |
| mutationRate: { value: 0.5, min: 0, max: 1, step: 0.01, label: "Mutation Rate" }, | |
| mutationAmount: { value: 0.1, min: 0, max: 1, step: 0.01, label: "Mutation Amount" }, | |
| controllerMinCreatures: { value: 200, min: 0, max: 1000, step: 10, label: "Add creatures treshold" }, | |
| controllerLowPop: { value: 350, min: 0, max: 300, step: 10, label: "Low Pop Threshold" }, | |
| controllerAvgPop: { value: 500, min: 0, max: 1000, step: 100, label: "Low Avg Pop Threshold" }, | |
| controllerAvgPopWindow: { value: 10, min: 1, max: 300, step: 10, label: "Avg Pop Window" }, | |
| controllerRateInc: { value: 0.111, min: 0, max: 1, step: 0.001, label: "Pop Correction" }, | |
| controllerRateDecFPS: { value: 0.11, min: 0, max: 1, step: 0.001, label: "Low FPS Correction" }, | |
| controllerFPSThreshold: { value: 2, min: 1, max: 120, step: 1, label: "Low FPS Threshold" }, | |
| controllerNewCreatures: { value: 1, min: 0, max: 10, step: 1, label: "New creatures per frame" }, | |
| }; | |
| const activeController = { | |
| energyHistory: [], popHistory: [], lastTime: 0, frameCount: 0, continuusLowFPSCount: 0, energyReductionCooldown: 0, energyReductionCooldown2: 0, belowTargetPopulationCounter: 0, aboveTargetPopulationCounter: 0, | |
| initialize() { this.energyHistory = []; this.popHistory = []; this.lastTime = performance.now(); this.frameCount = 0; }, | |
| updateParamDisplay(key) { if (params[key] && params[key].uiInput && params[key].uiLabel) { params[key].uiInput.value = params[key].value; params[key].uiLabel.textContent = params[key].value.toFixed(3); } }, | |
| update(creatures) { | |
| const currentPop = creatures.filter(c => c.generation > 2).length; | |
| this.popHistory.push(currentPop); | |
| const popWin = params.controllerAvgPopWindow.value; | |
| if (this.popHistory.length > popWin) this.popHistory.shift(); | |
| const now = performance.now(); | |
| this.frameCount++; | |
| if (now - this.lastTime >= 50) { | |
| const fps = this.frameCount / ((now - this.lastTime) / 1000); | |
| const fpsEl = document.getElementById('fpsStat'); | |
| if (fpsEl) fpsEl.textContent = Math.round(fps); | |
| if (this.frameCount < params.controllerFPSThreshold.value) { params.plantRespawnRate.value = Math.max(params.plantRespawnRate.min, params.plantRespawnRate.value - params.controllerRateDecFPS.value); this.updateParamDisplay('plantRespawnRate'); } | |
| if (this.frameCount * 2 < params.controllerFPSThreshold.value) { params.plantRespawnRate.value = Math.max(params.plantRespawnRate.min, params.plantRespawnRate.value - params.controllerRateDecFPS.value * 2); this.updateParamDisplay('plantRespawnRate'); this.continuusLowFPSCount++; } else { this.continuusLowFPSCount = 0; } | |
| if(this.continuusLowFPSCount > 20) { params.plantRespawnRate.value = Math.max(params.plantRespawnRate.min, params.plantRespawnRate.value - params.controllerRateDecFPS.value * 5); this.updateParamDisplay('plantRespawnRate'); } | |
| this.lastTime = now; this.frameCount = 0; | |
| } | |
| for (let i = 0; i < params.controllerNewCreatures.value; i++) { | |
| const p = plants.length ? plants[Math.floor(Math.random() * plants.length)] : null; | |
| const x = p ? clip(p.pos.x + Math.random() * 100 - 50, 0, canvas.width) : Math.random() * canvas.width; | |
| const y = p ? clip(p.pos.y + Math.random() * 100 - 50, 0, canvas.height) : Math.random() * canvas.height; | |
| creatures.push(new Creature(x, y)); | |
| } | |
| while (creatures.length < params.controllerMinCreatures.value) { creatures.push(new Creature(Math.random() * canvas.width, Math.random() * canvas.height)); } | |
| if (currentPop < params.controllerLowPop.value) { params.plantRespawnRate.value = Math.max(params.plantRespawnRate.max / 2, params.plantRespawnRate.value + params.controllerRateInc.value); this.updateParamDisplay('plantRespawnRate'); } | |
| if (this.popHistory.length >= popWin) { const avgPop = this.popHistory.reduce((a, b) => a + b, 0) / this.popHistory.length; if (avgPop < params.controllerAvgPop.value) { params.plantRespawnRate.value = Math.min(params.plantRespawnRate.max, params.plantRespawnRate.value + params.controllerRateInc.value); this.updateParamDisplay('plantRespawnRate'); } } | |
| if (currentPop > params.controllerAvgPop.value * 4 && this.energyReductionCooldown < 1) { | |
| this.energyReductionCooldown = 1000; | |
| params.energyFromPlant.value = Math.ceil(0.75 * params.energyFromPlant.value); | |
| params.plantRespawnRate.value = params.plantRespawnRate.value * 0.9; | |
| this.updateParamDisplay('energyFromPlant'); | |
| this.updateParamDisplay('plantRespawnRate'); | |
| } | |
| if (currentPop > params.controllerAvgPop.value * 3 && this.energyReductionCooldown < 1) { | |
| this.energyReductionCooldown = 500; | |
| params.energyFromPlant.value = Math.ceil(0.9 * params.energyFromPlant.value); | |
| this.updateParamDisplay('energyFromPlant'); | |
| } | |
| if(this.energyReductionCooldown > 0 && currentPop > params.controllerAvgPop.value * 3) this.energyReductionCooldown--; | |
| if (currentPop > params.controllerAvgPop.value * 2 && this.energyReductionCooldown2 < 1) { | |
| this.energyReductionCooldown2 = 500; | |
| params.energyFromPlant.value = Math.max(1, params.energyFromPlant.value - 1); | |
| this.updateParamDisplay('energyFromPlant'); | |
| } | |
| if(this.energyReductionCooldown2 > 0 && currentPop > params.controllerAvgPop.value) this.energyReductionCooldown2--; | |
| if (currentPop < params.controllerAvgPop.value) this.belowTargetPopulationCounter++; | |
| if (currentPop < params.controllerAvgPop.value) this.aboveTargetPopulationCounter = 0; | |
| if (currentPop > params.controllerAvgPop.value) this.belowTargetPopulationCounter = 0; | |
| if (currentPop > params.controllerAvgPop.value) this.aboveTargetPopulationCounter++; | |
| if (this.belowTargetPopulationCounter > 250 && currentPop < params.controllerLowPop.value) { | |
| params.plantRespawnRate.value = Math.min(params.plantRespawnRate.value + 10, params.plantRespawnRate.max); | |
| this.updateParamDisplay('plantRespawnRate'); | |
| } | |
| if (this.belowTargetPopulationCounter > 500) { | |
| params.plantRespawnRate.value = params.plantRespawnRate.max; | |
| this.updateParamDisplay('plantRespawnRate'); | |
| } | |
| if (this.belowTargetPopulationCounter > 1000 && currentPop < params.controllerLowPop.value) { | |
| params.energyFromPlant.value = params.energyFromPlant.value + 1; | |
| this.updateParamDisplay('energyFromPlant'); | |
| this.updateParamDisplay('plantRespawnRate'); | |
| this.belowTargetPopulationCounter = 0; | |
| } | |
| if (this.aboveTargetPopulationCounter > 250 && params.plantRespawnRate.value < 0.9 * params.plantRespawnRate.max) { | |
| params.plantRespawnRate.value = Math.min(params.plantRespawnRate.value + 10, params.plantRespawnRate.max); | |
| this.updateParamDisplay('plantRespawnRate'); | |
| const reduction = Math.floor(currentPop / params.controllerAvgPop.value) ** 2; | |
| params.energyFromPlant.value = Math.max(1, params.energyFromPlant.value - reduction); | |
| this.updateParamDisplay('energyFromPlant'); | |
| this.aboveTargetPopulationCounter = 0; | |
| } | |
| } | |
| }; | |
| function clip(x, min, max) { return Math.max(Math.min(x, max), min); } | |
| function sliceTailChunks(arr, chunks) { | |
| let i = arr.length; | |
| const out = {}; | |
| for (const [key, n] of Object.entries(chunks)) { out[key] = arr.slice(i - n, i); i -= n; } | |
| return out; | |
| } | |
| function fastRemove(array, predicate) { | |
| let livingCount = array.length; | |
| for (let i = livingCount - 1; i >= 0; i--) { | |
| const item = array[i]; | |
| if (predicate(item, i)) { array[i] = array[livingCount - 1]; livingCount--; } | |
| } | |
| array.length = livingCount; | |
| } | |
| const safeDiv = (n, d) => (Number.isFinite(n) && Number.isFinite(d) && d !== 0) ? n / d : 0; | |
| function signedMagnitudeSoftmax(arr, alpha) { | |
| const a = Math.max(0, Math.min(1, alpha)); | |
| const n = arr.length; | |
| if (!n) return []; | |
| // Hard argmax at a=1 | |
| if (a === 1) { | |
| let k = 0, best = -Infinity; | |
| for (let i = 0; i < n; i++) { | |
| const m = Math.abs(arr[i]); | |
| if (m > best) { best = m; k = i; } | |
| } | |
| const out = Array(n).fill(0); | |
| out[k] = arr[k] >= 0 ? 1 : -1; | |
| return out; | |
| } | |
| const T = Math.max(1e-6, 1 - a); | |
| // Compute max logit for stability without allocating logits array | |
| let maxLogit = -Infinity; | |
| for (let i = 0; i < n; i++) { | |
| const l = Math.abs(arr[i]) / T; | |
| if (l > maxLogit) maxLogit = l; | |
| } | |
| // Exps and sum | |
| const exps = new Array(n); | |
| let sumExps = 0; | |
| for (let i = 0; i < n; i++) { | |
| const e = Math.exp(Math.abs(arr[i]) / T - maxLogit); | |
| exps[i] = e; | |
| sumExps += e; | |
| } | |
| // Normalize + sign restore | |
| const out = new Array(n); | |
| for (let i = 0; i < n; i++) { | |
| const p = exps[i] / sumExps; | |
| out[i] = (arr[i] >= 0 ? 1 : -1) * p; | |
| } | |
| return out; | |
| } | |
| class Vector { | |
| constructor(x = 0, y = 0) { this.x = x; this.y = y; } | |
| add(v) { return new Vector(this.x + v.x, this.y + v.y); } | |
| addInPlace(v) { this.x += v.x; this.y += v.y; return this; } | |
| sub(v) { return new Vector(this.x - v.x, this.y - v.y); } | |
| mult(s) { return new Vector(this.x * s, this.y * s); } | |
| mag() { return Math.sqrt(this.magSquared()); } | |
| magSquared() { return this.x * this.x + this.y * this.y; } | |
| normalize() { const m = this.mag(); return m > 0 ? new Vector(this.x / m, this.y / m) : new Vector(); } | |
| dist(v) { return Math.sqrt(this.distSquared(v)); } | |
| distSquared(v) { const dx = this.x - v.x; const dy = this.y - v.y; return dx * dx + dy * dy; } | |
| } | |
| class Wall { | |
| constructor(x1, y1, x2, y2) { this.start = new Vector(x1, y1); this.end = new Vector(x2, y2); } | |
| draw() { ctx.strokeStyle = '#808080'; ctx.lineWidth = 3; ctx.beginPath(); ctx.moveTo(this.start.x, this.start.y); ctx.lineTo(this.end.x, this.end.y); ctx.stroke(); } | |
| } | |
| function getIntersection(p1, p2, p3, p4) { | |
| const x1 = p1.x, y1 = p1.y, x2 = p2.x, y2 = p2.y, x3 = p3.x, y3 = p3.y, x4 = p4.x, y4 = p4.y; | |
| const denom = (y4 - y3) * (x2 - x1) - (x4 - x3) * (y2 - y1); | |
| if (denom === 0) return null; | |
| const ua = ((x4 - x3) * (y1 - y3) - (y4 - y3) * (x1 - x3)) / denom; | |
| const ub = ((x2 - x1) * (y1 - y3) - (y2 - y1) * (x1 - x3)) / denom; | |
| if (ua >= 0 && ua <= 1 && ub >= 0 && ub <= 1) { return new Vector(x1 + ua * (x2 - x1), y1 + ua * (y2 - y1)); } | |
| return null; | |
| } | |
| class SpatialGrid { | |
| constructor(width, height, cellSize) { | |
| this.width = width; this.height = height; this.cellSize = cellSize; | |
| this.cols = Math.ceil(width / cellSize); this.rows = Math.ceil(height / cellSize); | |
| this.grid = new Map(); | |
| } | |
| getKey(x, y) { return `${x}_${y}`; } | |
| insert(item) { | |
| const col = Math.floor(item.pos.x / this.cellSize); const row = Math.floor(item.pos.y / this.cellSize); | |
| const key = this.getKey(col, row); | |
| if (!this.grid.has(key)) { this.grid.set(key, []); } | |
| this.grid.get(key).push(item); | |
| } | |
| insertSegment(item, p1, p2) { | |
| const minX = Math.min(p1.x, p2.x); const maxX = Math.max(p1.x, p2.x); | |
| const minY = Math.min(p1.y, p2.y); const maxY = Math.max(p1.y, p2.y); | |
| const minCol = Math.floor(minX / this.cellSize); const maxCol = Math.floor(maxX / this.cellSize); | |
| const minRow = Math.floor(minY / this.cellSize); const maxRow = Math.floor(maxY / this.cellSize); | |
| for (let c = minCol; c <= maxCol; c++) { | |
| for (let r = minRow; r <= maxRow; r++) { | |
| if (c >= 0 && c < this.cols && r >= 0 && r < this.rows) { | |
| const key = this.getKey(c, r); | |
| if (!this.grid.has(key)) { this.grid.set(key, []); } | |
| this.grid.get(key).push(item); | |
| } | |
| } | |
| } | |
| } | |
| query(pos, radius) { | |
| const radiusSquared = radius * radius; const results = new Set(); | |
| const minCol = Math.floor((pos.x - radius) / this.cellSize); const maxCol = Math.ceil((pos.x + radius) / this.cellSize); | |
| const minRow = Math.floor((pos.y - radius) / this.cellSize); const maxRow = Math.ceil((pos.y + radius) / this.cellSize); | |
| for (let c = minCol; c <= maxCol; c++) { | |
| for (let r = minRow; r <= maxRow; r++) { | |
| const key = this.getKey(c, r); | |
| if (this.grid.has(key)) { | |
| const bucket = this.grid.get(key); | |
| for (let i = 0; i < bucket.length; i++) { | |
| const item = bucket[i]; | |
| if (item.start && item.end) { results.add(item); } | |
| else { if (pos.distSquared(item.pos) < radiusSquared) { results.add(item); } } | |
| } | |
| } | |
| } | |
| } | |
| return Array.from(results); | |
| } | |
| clear() { this.grid.clear(); } | |
| } | |
| class Matrix { | |
| constructor(rows, cols) { this.rows = rows; this.cols = cols; this.data = Array(rows).fill().map(() => Array(cols).fill(0)); } | |
| static fromArray(arr) { let m = new Matrix(arr.length, 1); m.data = arr.map(el => [el]); return m; } | |
| toArray() { return this.data.flat(); } | |
| randomize() { this.map(() => Math.random() * 2 - 1); return this; } | |
| add(n) { | |
| if (n instanceof Matrix) { | |
| if (this.rows !== n.rows || this.cols !== n.cols) { console.error("Matrix dimensions must match for addition"); return; } | |
| this.map((e, i, j) => e + n.data[i][j]); | |
| } else { this.map(e => e + n); } | |
| return this; | |
| } | |
| static multiply(a, b) { | |
| if (a.cols !== b.rows) { console.error("Columns of A must match rows of B for multiplication"); return; } | |
| let result = new Matrix(a.rows, b.cols); | |
| result.map((_, i, j) => { let sum = 0; for (let k = 0; k < a.cols; k++) { sum += a.data[i][k] * b.data[k][j]; } return sum; }); | |
| return result; | |
| } | |
| map(func) { this.data = this.data.map((row, i) => row.map((val, j) => func(val, i, j))); return this; } | |
| copy() { let m = new Matrix(this.rows, this.cols); m.data = this.data.map(row => row.slice()); if (this.mask) m.mask = this.mask.copy(); return m; } | |
| initSparsity(density) { | |
| this.mask = new Matrix(this.rows, this.cols); | |
| // Create a binary mask: 1 = keep, 0 = remove | |
| this.mask.map(() => Math.random() < density ? 1 : 0); | |
| // Apply it immediately to prune current values | |
| this.applySparsity(); | |
| } | |
| applySparsity() { | |
| if (!this.mask) return; | |
| this.map((val, i, j) => val * this.mask.data[i][j]); | |
| } | |
| } | |
| function tanh(x) { return Math.tanh(x); } | |
| function hardTanh(x) { return clip(x*0.25, -1, 1); } | |
| function sine(x) { return Math.sin(x*0.36); } | |
| function softsign(x) { return x / (1 + Math.abs(x)); } | |
| const activations = [hardTanh]; | |
| const getLogUniform = (min, max) => min * Math.pow(max / min, Math.random()); | |
| const mutate = (v, rate, amount) => { | |
| // 1. Check if mutation happens at all | |
| if (Math.random() < rate) { | |
| // 2. Flip a coin (50/50) to choose between multiplicative or additive | |
| if (Math.random() < 0.5) { | |
| // Multiplicative: | |
| // Uses a base (1.2) raised to a power between -1 and 1. | |
| // This ensures Geometric Mean is 1. (e.g., x2 is balanced by x0.5) | |
| // Range: [v / 1.2, v * 1.2] | |
| return v * (1.2 ** (Math.random() * 2 - 1)); | |
| } else { | |
| // Additive: | |
| // Adds a value between -amount and +amount. | |
| // Expected change is 0. | |
| // Range: [v - amount, v + amount] | |
| return v + (Math.random() * 2 - 1) * amount; | |
| } | |
| } | |
| return v; | |
| }; | |
| function getRandomHiddenLayers(total, maxLayers, maxPerLayer) { | |
| // Determine valid random layer count (min required to fit neurons vs max allowed) | |
| const minLayers = Math.ceil(total / maxPerLayer); | |
| const count = Math.floor(Math.random() * (maxLayers - minLayers + 1)) + minLayers; | |
| // Start with 1 neuron per layer so none are empty | |
| const layers = new Array(count).fill(1); | |
| // Distribute remaining neurons randomly | |
| for (let i = 0; i < total - count; ) { | |
| const idx = Math.floor(Math.random() * count); | |
| if (layers[idx] < maxPerLayer) { | |
| layers[idx]++; | |
| i++; // Only increment loop if we successfully added a neuron | |
| } | |
| } | |
| return layers; | |
| } | |
| const getHiddenConfig = () => getRandomHiddenLayers(Math.floor(getLogUniform(10, 1001)), 10, 100); | |
| class FeedForwardNetwork { | |
| constructor(inputNodes, hiddenNodes, outputNodes, unmaskRatio) { | |
| this.inputNodes = inputNodes; | |
| this.outputNodes = outputNodes; | |
| // 1. Handle Backward Compatibility & New Array Structure | |
| // If hiddenNodes is a number, wrap it in array (unless it's 0) | |
| let hiddenLayers = []; | |
| if (Array.isArray(hiddenNodes)) { | |
| hiddenLayers = hiddenNodes; | |
| } else if (typeof hiddenNodes === 'number' && hiddenNodes > 0) { | |
| hiddenLayers = [hiddenNodes]; | |
| } | |
| // Store structure for the copy() method | |
| this.hiddenStructure = hiddenLayers; | |
| // 2. Define Network Topology | |
| // Example with 1 hidden layer: [Input, Hidden, Output] | |
| // Example with 0 hidden layers: [Input, Output] | |
| const topology = [this.inputNodes, ...hiddenLayers, this.outputNodes]; | |
| this.weights = []; | |
| this.biases = []; | |
| // 3. Initialize Matrices dynamically based on topology | |
| for (let i = 0; i < topology.length - 1; i++) { | |
| const currentLayerSize = topology[i]; | |
| const nextLayerSize = topology[i + 1]; | |
| // Weights: Rows = Target Size, Cols = Source Size | |
| this.weights.push(new Matrix(nextLayerSize, currentLayerSize).randomize()); | |
| // Biases: Rows = Target Size, Cols = 1 | |
| this.biases.push(new Matrix(nextLayerSize, 1).randomize()); | |
| } | |
| this.activation = activations[Math.floor(activations.length * Math.random())]; | |
| unmaskRatio = unmaskRatio !== undefined ? unmaskRatio : Math.random(); | |
| this.inputMask = new Int8Array(this.inputNodes).fill(1).map(_ => Math.random() < unmaskRatio ? 1 : 0); | |
| } | |
| feedForward(inputArray) { | |
| if (inputArray.length !== this.inputNodes) { | |
| throw new Error(`inputArray.length ${inputArray.length} !== this.inputNodes ${this.inputNodes}`); | |
| } | |
| // Apply input mask | |
| const maskedInput = inputArray.map((v, i) => v * (this.inputMask ? (this.inputMask[i] !== undefined ? this.inputMask[i] : 1) : 1)); | |
| // Convert to Matrix | |
| let current = Matrix.fromArray(maskedInput); | |
| // Loop through every layer connection | |
| for (let i = 0; i < this.weights.length; i++) { | |
| current = Matrix.multiply(this.weights[i], current); | |
| current.add(this.biases[i]); | |
| current.map(this.activation); | |
| } | |
| return current.toArray(); | |
| } | |
| internalMutate({ rate, amount }) { | |
| this.mutate(rate, amount); | |
| } | |
| mutate(rate, amount) { | |
| const mutateMatrix = (m) => m.map((v) => mutate(v, rate, amount)); | |
| // Mutate all weight and bias matrices | |
| this.weights.forEach(w => mutateMatrix(w)); | |
| this.biases.forEach(b => mutateMatrix(b)); | |
| const idx = Math.floor(Math.random() * this.inputMask.length); | |
| if (Math.random() < rate) { | |
| this.inputMask[idx] = this.inputMask[idx] === 1 ? 0 : 1; | |
| } | |
| } | |
| copy() { | |
| // Pass the stored hidden structure to the new constructor | |
| const newNet = new FeedForwardNetwork(this.inputNodes, this.hiddenStructure, this.outputNodes); | |
| // Deep copy the arrays of matrices | |
| newNet.weights = this.weights.map(w => w.copy()); | |
| newNet.biases = this.biases.map(b => b.copy()); | |
| newNet.activation = this.activation; | |
| newNet.inputMask = new Int8Array(this.inputMask); | |
| return newNet; | |
| } | |
| maskSum() { | |
| return this.inputMask.reduce((v, c) => v + c, 0); | |
| } | |
| getHiddenStructure() { | |
| return this.hiddenStructure; | |
| } | |
| } | |
| function createStatsObj() { | |
| return { | |
| actions: 0, | |
| gainTotal: 0, gainP: 0, gainPr: 0, | |
| lossTotal: 0, lossPr: 0, lossC: 0, lossM: 0, | |
| move: 0, | |
| children: 0, grandchildren: 0, | |
| childrenAlive: 0, childrenWithGC: 0 | |
| }; | |
| } | |
| class EpsilonGreedyEnsembleNetwork { | |
| constructor(inputNodes, hiddenNodes, outputNodes) { | |
| this.inputNodes = inputNodes; this.hiddenNodes = hiddenNodes; this.outputNodes = outputNodes; | |
| this.numModels = (Math.random() > 0.5) ? Math.floor(getLogUniform(1, 101)) : 1; | |
| this.epsilon = getLogUniform(0.01, 1); | |
| this.topAllocation = getLogUniform(0.01, 1); | |
| this.qSmoothing = getLogUniform(0.01, 1); | |
| const make = (i, h, o, unmaskRatio) => { return new FeedForwardNetwork(i, h, o, unmaskRatio); }; | |
| this.models = Array.from({ length: this.numModels }, () => make(inputNodes, hiddenNodes, outputNodes)); | |
| this.hiddenNodes = 1; | |
| this.rewardNet = make(15 + outputNodes + inputNodes, [3, 2], 1, 0.1); | |
| this.q = Array(this.numModels).fill(0); | |
| this.n = Array(this.numModels).fill(0); | |
| // Initialize Stats per expert | |
| this.stats = Array.from({ length: this.numModels }, () => createStatsObj()); | |
| this.totalStats = createStatsObj(); | |
| this.currentExpert = Math.floor(Math.random() * this.numModels); | |
| this.lastOutputs = Array(outputNodes).fill(0); | |
| this.experts = this.models; | |
| this.madeMove = false; | |
| this.mutatations = 0; | |
| } | |
| getRewardNetCost() { return this.rewardNet.maskSum(); } | |
| getExpert() { return this.currentExpert | 0; } | |
| // Stats Tracking | |
| updateStats(expertIdx, metrics) { | |
| if (!this.stats[expertIdx]) return; | |
| const s = this.stats[expertIdx]; | |
| const t = this.totalStats; | |
| s.gainP += metrics.gainP; t.gainP += metrics.gainP; | |
| s.gainPr += metrics.gainPr; t.gainPr += metrics.gainPr; | |
| const totalGain = metrics.gainP + metrics.gainPr; | |
| s.gainTotal += totalGain; t.gainTotal += totalGain; | |
| s.lossPr += metrics.lossPr; t.lossPr += metrics.lossPr; | |
| s.lossC += metrics.lossC; t.lossC += metrics.lossC; | |
| s.lossM += metrics.lossM; t.lossM += metrics.lossM; | |
| const totalLoss = metrics.lossPr + metrics.lossC + metrics.lossM; | |
| s.lossTotal += totalLoss; t.lossTotal += totalLoss; | |
| s.move += metrics.move; t.move += metrics.move; | |
| s.actions++; t.actions++; | |
| this.n[expertIdx]++; | |
| } | |
| registerChild(expertIdx, child) { | |
| if (!this.stats[expertIdx]) return; | |
| this.stats[expertIdx].children++; | |
| this.totalStats.children++; | |
| this.stats[expertIdx].childrenAlive++; | |
| this.totalStats.childrenAlive++; | |
| const mutationsOnRegister = this.mutatations; | |
| // Lifecycle listeners | |
| child.addLifecycleListener((event, data) => { | |
| // mutation resets experts ids | |
| if (this.mutatations !== mutationsOnRegister) return; | |
| if (event === 'death') { | |
| if (this.stats && this.stats[expertIdx]) { | |
| this.stats[expertIdx].childrenAlive--; | |
| this.totalStats.childrenAlive--; | |
| } | |
| } else if (event === 'reproduce') { | |
| if (this.stats && this.stats[expertIdx]) { | |
| this.stats[expertIdx].grandchildren++; | |
| this.totalStats.grandchildren++; | |
| if (!child.hasReproducedForGrandparent) { | |
| child.hasReproducedForGrandparent = true; | |
| this.stats[expertIdx].childrenWithGC++; | |
| this.totalStats.childrenWithGC++; | |
| } | |
| } | |
| } | |
| }); | |
| } | |
| feedForward(inputArray) { | |
| if(this.madeMove) this.computeReward(inputArray); | |
| if (Math.random() < this.epsilon) { | |
| const untried = this.n.findIndex(v => v < 1); | |
| if (untried !== -1) { | |
| this.currentExpert = untried; | |
| } else { | |
| this.currentExpert = (Math.random() * this.models.length) | 0; | |
| } | |
| } else { let best = 0; for (let i = 1; i < this.q.length; i++) { if (this.q[i] > this.q[best]) best = i; } this.currentExpert = best; } | |
| const outputs = this.models[this.currentExpert].feedForward(inputArray); | |
| this.lastOutputs = outputs; | |
| this.madeMove = true; | |
| return outputs; | |
| } | |
| computeReward(currentInputs) { | |
| const i = this.currentExpert; | |
| const s = this.stats[i]; | |
| const t = this.totalStats; | |
| // HELPER 1: Safe Average | |
| const getAvg = (sum, n) => (n > 0 ? sum / n : 0); | |
| // HELPER 2: Log-Modulus Scale | |
| const logScale = (x) => { | |
| if (x === 0) return 0; | |
| return Math.sign(x) * Math.log(1 + Math.abs(x)); | |
| }; | |
| // HELPER 3: Advantage (Expert Avg - Global Avg) | |
| const getAdvantage = (expertSum, expertN, totalSum, totalN) => { | |
| const eAvg = getAvg(expertSum, expertN); | |
| const tAvg = getAvg(totalSum, totalN); | |
| return logScale(eAvg - tAvg); | |
| }; | |
| // Calculate Global Averages once | |
| const tGainTotal = getAvg(t.gainTotal, t.actions); | |
| const tLossTotal = getAvg(t.lossTotal, t.actions); | |
| const inputs = [ | |
| // --- GROUP A: RAW MAGNITUDE (3) --- | |
| logScale(getAvg(s.gainTotal, s.actions)), | |
| logScale(getAvg(s.lossTotal, s.actions)), | |
| logScale(getAvg(s.move, s.actions)), | |
| // --- GROUP B: THE "ADVANTAGE" (6) --- | |
| // "Am I doing better than my average?" | |
| getAdvantage(s.gainTotal, s.actions, t.gainTotal, t.actions), | |
| getAdvantage(s.gainPr, s.actions, t.gainPr, t.actions), | |
| getAdvantage(s.lossTotal, s.actions, t.lossTotal, t.actions), | |
| getAdvantage(s.lossC, s.actions, t.lossC, t.actions), // Repro Cost | |
| getAdvantage(s.lossM, s.actions, t.lossM, t.actions), // Metabolism Cost | |
| // ADDED BACK to fix size: Predation Loss Advantage | |
| getAdvantage(s.lossPr, s.actions, t.lossPr, t.actions), | |
| // --- GROUP C: RARE EVENTS (2) --- | |
| getAvg(s.children, Math.log(s.actions)), | |
| getAvg(s.grandchildren, Math.log(s.actions)), | |
| // --- GROUP D: CONTEXT (5) --- | |
| safeDiv(s.actions, t.actions), // Confidence | |
| safeDiv(s.childrenAlive, s.children), // Child Survival | |
| // ADDED BACK to fix size: Lineage Success (Children who had children) | |
| safeDiv(s.childrenWithGC, s.children), | |
| this.q[i], // Momentum | |
| ...currentInputs, | |
| ...this.lastOutputs, | |
| ]; | |
| this.lastRewardInputs = inputs; | |
| const [ reward ] = this.rewardNet.feedForward(inputs); | |
| if(!Number.isFinite(reward)) { | |
| console.log(s, inputs); | |
| throw Error(`Unexpected reward ${reward}`, inputs); | |
| } | |
| this.q[i] = this.q[i] * (1 - this.qSmoothing) + reward * this.qSmoothing; | |
| } | |
| mutate(rate, amount) { | |
| this.mutatations++; | |
| this.numModels = Math.round(clip(mutate(this.numModels, rate, 1), 1, 100)); | |
| // We use Q * N for sorting importance | |
| const nSum = this.n.reduce((s, n) => s + n, 1); | |
| // CHANGE 1: We map q and n into the object so we can access them after sorting | |
| const sortedExperts = this.models.map((m, i) => ({ | |
| model: m, | |
| q: this.q[i], | |
| n: this.n[i], | |
| score: this.n[i] > 0 ? (this.q[i] + 1) * (this.n[i] / nSum) : -Infinity | |
| })).sort((a, b) => b.score - a.score); | |
| const newModels = []; | |
| const newQ = []; | |
| const newN = []; | |
| let remainingSlots = this.numModels; | |
| let parentIdx = 0; | |
| while (remainingSlots > 0 && parentIdx < sortedExperts.length) { | |
| const parent = sortedExperts[parentIdx]; | |
| const numToCopy = Math.ceil(remainingSlots * this.topAllocation); | |
| for (let i = 0; i < numToCopy; i++) { | |
| if (newModels.length >= this.numModels) break; | |
| const cloneOnce = (m) => (m && typeof m.copy === "function") ? m.copy() : m; | |
| newModels.push(cloneOnce(parent.model)); | |
| newQ.push(parent.q); | |
| newN.push(parent.n); | |
| } | |
| remainingSlots -= numToCopy; | |
| parentIdx++; | |
| } | |
| const bestParent = sortedExperts[0]; | |
| if (bestParent) { | |
| while (newModels.length < this.numModels) { | |
| const cloneOnce = (m) => (m && typeof m.copy === "function") ? m.copy() : m; | |
| newModels.push(cloneOnce(bestParent.model)); | |
| newQ.push(bestParent.q); | |
| newN.push(bestParent.n); | |
| } | |
| } | |
| // Always replace the last expert with a fresh random one, but assign it Max Q | |
| // This tricks the selection policy into trying it, teaching the reward net to distinguish real vs fake. | |
| if (newModels.length > 0) { | |
| const idx = newModels.length - 1; | |
| newModels[idx] = new FeedForwardNetwork(this.inputNodes, this.hiddenNodes, this.outputNodes); | |
| newQ[idx] = Math.max(...this.q) + 0.0000001; // Assign Max Q | |
| newN[idx] = 0; // Reset experience count | |
| } | |
| this.models = newModels; | |
| this.experts = this.models; | |
| // console.log("topAllocation", this.topAllocation); | |
| // console.log("old q", this.q); | |
| // console.log("old n", this.n); | |
| this.q = newQ; | |
| this.n = newN; | |
| // console.log("q", this.q); | |
| // console.log("n", this.n); | |
| // Reset stats for new generation (keep Q/N, but clear operational stats) | |
| this.stats = Array.from({ length: this.numModels }, () => createStatsObj()); | |
| this.totalStats = createStatsObj(); | |
| // mutate some models a lot more than others to force reward net to select | |
| for (const m of this.models) { m.mutate(rate * 20 * getLogUniform(0.005, 0.5), amount * 20 * getLogUniform(0.005, 0.5)); } | |
| this.rewardNet.mutate(rate, amount); | |
| this.epsilon = clip(mutate(this.epsilon, rate, amount / 10), 0.01, 1); | |
| this.topAllocation = clip(mutate(this.topAllocation, rate, amount / 10), 0.01, 1); | |
| this.qSmoothing = clip(mutate(this.qSmoothing, rate, amount / 10), 0.01, 1); | |
| this.currentExpert = Math.min(this.currentExpert, this.numModels - 1); | |
| this.madeMove = false; | |
| } | |
| internalMutate({ rate, amount }) { | |
| for (const m of this.models) { m.mutate(rate, amount); } | |
| } | |
| copy() { | |
| const clone = new EpsilonGreedyEnsembleNetwork(this.inputNodes, this.hiddenNodes, this.outputNodes, { baseConstructor: this._BaseCtor, innerOptions: this._innerOptions }); | |
| const cloneOnce = (m) => (m && typeof m.copy === "function") ? m.copy() : m; | |
| clone.epsilon = this.epsilon; | |
| clone.topAllocation = this.topAllocation; | |
| clone.qSmoothing = this.qSmoothing; | |
| clone.numModels = this.numModels; | |
| clone.hiddenNodes = this.hiddenNodes; | |
| clone.models = this.models.map(m => cloneOnce(m)); clone.experts = clone.models; | |
| clone.rewardNet = this.rewardNet ? cloneOnce(this.rewardNet) : null; | |
| // Stats reset on copy (new creature life) | |
| clone.stats = Array.from({ length: this.numModels }, () => createStatsObj()); | |
| clone.totalStats = createStatsObj(); | |
| clone.q = [...this.q]; clone.n = [...this.n]; | |
| clone.currentExpert = this.currentExpert; clone.lastOutputs = this.lastOutputs; | |
| return clone; | |
| } | |
| getHiddenStructure() { | |
| return this.models[this.currentExpert].getHiddenStructure(); | |
| } | |
| } | |
| class Plant { | |
| isPlant = true; | |
| isCreature = false; | |
| constructor(x, y, energy) { | |
| this.pos = new Vector(x, y); | |
| this.energy = energy || (params.energyFromPlant.value * (params.plantEnergySpread.value + 1) * Math.random() ** params.plantEnergySpread.value); | |
| this.radius = Math.log2(1 + this.energy); | |
| this.eatenCount = 0; | |
| this.age = 0; | |
| } | |
| draw() { ctx.fillStyle = '#48BB78'; ctx.beginPath(); ctx.arc(this.pos.x, this.pos.y, this.radius, 0, Math.PI * 2); ctx.fill(); } | |
| } | |
| class Creature { | |
| isPlant = false; | |
| isCreature = true; | |
| constructor(x, y, parent) { | |
| this.pos = new Vector(x, y); this.radius = 3; this.moveVector = new Vector(0, 0); this.finalMove = new Vector(0, 0); | |
| this.hitWall = -1; this.plantToEat = null; this.parent = null; this.children = new Set(); | |
| this.toParentMessage = Array(1).fill(0); this.toChildrenMessage = Array(3).fill(0); | |
| this.age = 0; this.controlBias = 0.5; this.memory = Array(Math.floor(getLogUniform(1, 101))).fill(0); this.memoryUpdate = 0; | |
| this.stance = 0; this.totalStance = 0; this.force = 0; this.reproductionIntention = -1; | |
| this.predationGainVec = new Vector(0, 0); this.predationGain = 0; | |
| this.predationLossVec = new Vector(0, 0); this.predationLoss = 0; | |
| this.mutationStyle = 0; | |
| this.lastInputs = []; | |
| this.lastOutputs = []; | |
| this.lifecycleListeners = []; | |
| this.hasReproducedForGrandparent = false; | |
| if (parent) { | |
| this.generation = (parent.generation || 0) + 1; | |
| this.color = parent.color; | |
| this.strength = parent.strength; | |
| this.energy = Math.floor(parent.energy * (1 - parent.reproductionSplit)); | |
| this.maxSenseRadius = parent.maxSenseRadius; | |
| this.memory = [...parent.memory]; | |
| this.parent = parent; this.peaceCode = parent.peaceCode; this.sensing = parent.sensing; | |
| this.net = parent.net.copy(); | |
| this.mutate(parent.mutation); | |
| } else { | |
| this.generation = 1; | |
| this.randomizeColor(); | |
| this.energy = params.reproductionThreshold.value * Math.random(); | |
| const type = Math.random() > 0.5 ? (i, h, o) => new FeedForwardNetwork(i, h, o, 1) : (i, h, o) => new EpsilonGreedyEnsembleNetwork(i, h, o); | |
| this.net = type( | |
| 66 + this.memory.length + this.toParentMessage.length + this.toChildrenMessage.length, | |
| getHiddenConfig(), // 10 + this.memory.length, | |
| 24 + this.memory.length + this.toParentMessage.length + this.toChildrenMessage.length); | |
| this.maxSenseRadius = getLogUniform(1, 150); | |
| this.strength = getLogUniform(1, 1000); | |
| this.peaceCode = Array(3).fill(0).map(() => Math.round(Math.random() * 2 - 1)); | |
| this.sensing = 1; | |
| } | |
| this.senseRadius = this.maxSenseRadius; this.previousEnergy = this.energy; | |
| } | |
| get mutation() { | |
| const ageMutations = ((this.age / 150) ** 2) / 10; | |
| const baseRate = params.mutationRate.value; | |
| const baseAmount = clip(params.mutationAmount.value + ageMutations, 0, 10); | |
| const minRate = baseRate * (1/5); const maxRate = 1; | |
| const mutationRate = clip(minRate + this.mutationStyle * (maxRate - minRate), 1e-4, 1); | |
| const mutationAmount = clip((baseRate * baseAmount) / mutationRate, 1e-4, 10); | |
| return { rate: mutationRate, amount: mutationAmount }; | |
| } | |
| mutate(mutation) { | |
| this.net.mutate(mutation.rate, mutation.amount); | |
| this.maxSenseRadius = clip(mutate(this.maxSenseRadius, mutation.rate, mutation.amount), 1, 150); | |
| this.strength = clip(mutate(this.strength, mutation.rate, mutation.amount), 0, 1000); | |
| } | |
| randomizeColor() { do { this.color = Array(3).fill(256).map((v) => Math.floor(v * Math.random())); } while (this.color[0] + this.color[2] < this.color[1]); } | |
| get type() { return this.controlBias > 0.5 ? 'cartesian' : 'polar'; } | |
| addLifecycleListener(fn) { this.lifecycleListeners.push(fn); } | |
| triggerLifecycleEvent(event, data) { for(const fn of this.lifecycleListeners) fn(event, data); } | |
| // Check if a target is visible (not blocked by walls) | |
| isVisible(target, maxDist) { | |
| if (!wallGrid) return true; | |
| // Optimization: Don't check walls if they are far apart | |
| const dist = this.pos.dist(target.pos); | |
| const walls = wallGrid.query(this.pos, Math.min(maxDist, dist)); | |
| for (const wall of walls) { | |
| if (getIntersection(this.pos, target.pos, wall.start, wall.end)) return false; | |
| } | |
| return true; | |
| } | |
| // Generic Query Engine | |
| findEntities(grid, radius, queries) { | |
| const results = {}; | |
| const bestScores = {}; | |
| // 1. Initialize trackers | |
| for (const key in queries) { | |
| results[key] = null; | |
| bestScores[key] = -Infinity; | |
| } | |
| const neighbors = grid.query(this.pos, radius); | |
| // 2. Single loop through neighbors | |
| for (const n of neighbors) { | |
| if (n === this) continue; | |
| const distSq = this.pos.distSquared(n.pos); | |
| if (distSq > radius * radius) continue; | |
| // 3. Lazy Evaluation: Check if this neighbor is a "potential" winner | |
| // BEFORE doing the expensive isVisible() check. | |
| let isPotential = false; | |
| const currentScores = {}; // Temp cache for this neighbor | |
| for (const key in queries) { | |
| const q = queries[key]; | |
| // A. Check Filter (e.g., is it aggressive?) | |
| if (q.where && !q.where(n)) continue; | |
| // B. Calculate Score (default: nearest = negative distance) | |
| // If q.score is provided use it, otherwise use -distSq | |
| const score = q.score ? q.score(n, distSq) : -distSq; | |
| currentScores[key] = score; | |
| // C. Is this better than our current best? | |
| if (score > bestScores[key]) isPotential = true; | |
| } | |
| // 4. Optimization: Only check visibility if it's a potential winner | |
| if (isPotential && this.isVisible(n, radius)) { | |
| // Commit the updates | |
| for (const key in currentScores) { | |
| if (currentScores[key] > bestScores[key]) { | |
| bestScores[key] = currentScores[key]; | |
| results[key] = n; | |
| } | |
| } | |
| } | |
| } | |
| return results; | |
| } | |
| update() { | |
| this.age++; | |
| // 1. Get up to 3 nearest plants (returns array of 0 to 3 items) | |
| const plants = this.findEntities(plantGrid, this.plantSenseRadius, { | |
| nearest: { /* Default is nearest */ }, | |
| highEnergy: { score: (p) => p.energy }, | |
| bestRatio: { score: (p, dSq) => p.energy / (dSq + 1) } | |
| }); | |
| const creatures = this.findEntities(creatureGrid, this.creatureSenseRadius, { | |
| aggressive: { where: c => c.stance > 0.1 }, | |
| neutral: { where: c => Math.abs(c.stance) <= 0.1 }, | |
| defensive: { where: c => c.stance < -0.1 } | |
| }); | |
| const INPUTS_PER_ENTITY = 5; | |
| const entities = [...Object.values(plants), ...Object.values(creatures)]; | |
| const entitiesVectors = entities.map(entity => { | |
| if(!entity) return null; | |
| const relative = entity.pos.sub(this.pos); | |
| const normalized = relative.normalize(); | |
| return { entity, relative, normalized }; | |
| }) | |
| const entitiesInputs = new Array(entities.length * INPUTS_PER_ENTITY).fill(0); | |
| for (let i = 0; i < entities.length; i++) { | |
| const vectors = entitiesVectors[i]; | |
| if(!vectors) continue; | |
| const { entity, relative, normalized } = vectors; | |
| // Calculate the start index for this specific neighbor (0, 4, or 8) | |
| const offset = i * INPUTS_PER_ENTITY; | |
| entitiesInputs[offset + 0] = normalized.x; | |
| entitiesInputs[offset + 1] = normalized.y; | |
| entitiesInputs[offset + 2] = Math.atan2(normalized.y, normalized.x) / Math.PI; | |
| entitiesInputs[offset + 3] = relative.magSquared() / (this.maxSenseRadius * this.maxSenseRadius); | |
| entitiesInputs[offset + 4] = clip(entity.energy / params.reproductionThreshold.value, 0, 1); | |
| } | |
| // Helper to convert creature to inputs | |
| const getCreatureInputs = (target) => { | |
| if (!target) return [0, 0, 0, 0, 0]; | |
| let relative = target.pos.sub(this.pos); | |
| let dir = target.pos.sub(this.pos).normalize(); | |
| return [ | |
| clip(target.stance, -1, 1), | |
| clip((target.strength - this.strength) / (target.strength + this.strength), -1, 1), | |
| Math.abs(target.generation - this.generation) < 3 ? 1 : -1, | |
| Math.sign(target.age - this.age), | |
| clip(params.attackRadius.value ** 2 / (0.001 + relative.magSquared()), 0, 1) | |
| ]; | |
| }; | |
| const predationInputs = [ | |
| this.predationGainVec.x, this.predationGainVec.y, Math.atan2(this.predationGainVec.y, this.predationGainVec.x) / Math.PI, this.predationGain, | |
| this.predationLossVec.x, this.predationLossVec.y, Math.atan2(this.predationLossVec.y, this.predationLossVec.x) / Math.PI, this.predationLoss | |
| ]; | |
| let finalMoveInputs = [0, 0, 0]; | |
| let dir = this.finalMove.normalize(); | |
| finalMoveInputs[0] = dir.x; finalMoveInputs[1] = dir.y; finalMoveInputs[2] = Math.atan2(dir.y, dir.x) / Math.PI; | |
| let fromParentMessage = Array(this.toChildrenMessage.length).fill(0); | |
| if(this.parent) fromParentMessage = this.parent.toChildrenMessage; | |
| const fromChildrenMessage = Array(this.toParentMessage.length).fill(0); | |
| for(const child of this.children) { for (let i = 0; i < this.toParentMessage.length; i++) { fromChildrenMessage[i] += child.toParentMessage[i] * ( 1 / this.children.size); } } | |
| let wallDist = 0; | |
| let moveDir = this.finalMove.mag() > 0 ? this.finalMove.normalize() : new Vector(0, 0); | |
| if (moveDir.mag() > 0 && wallGrid) { | |
| const sensorRange = params.wallSensorRadius.value; | |
| let rayEnd = this.pos.add(moveDir.mult(sensorRange)); | |
| let closestIntersection = Infinity; | |
| const nearbyWalls = wallGrid.query(this.pos, sensorRange); | |
| for (const wall of nearbyWalls) { | |
| const intersection = getIntersection(this.pos, rayEnd, wall.start, wall.end); | |
| if (intersection) { const d = this.pos.dist(intersection); if (d < closestIntersection) { closestIntersection = d; } } | |
| } | |
| if (closestIntersection !== Infinity) { wallDist = closestIntersection / sensorRange; } | |
| } | |
| const rawInputs = [ | |
| this.sensing, this.energy / params.reproductionThreshold.value, (this.energy - this.previousEnergy) / Math.max(1, this.previousEnergy), | |
| this.age > 4 ** 1 ? 0 : 1, this.age > 4 ** 2 ? 0 : 1, this.age > 4 ** 3 ? 0 : 1, this.age > 4 ** 4 ? 0 : 1, | |
| ...entitiesInputs, | |
| ...getCreatureInputs(creatures.aggressive), | |
| ...getCreatureInputs(creatures.neutral), | |
| ...getCreatureInputs(creatures.defensive), | |
| ...predationInputs, ...finalMoveInputs, | |
| this.memoryUpdate, wallDist, this.hitWall, | |
| ...this.memory, ...fromParentMessage, ...fromChildrenMessage, | |
| ]; | |
| const inputs = rawInputs.map(v => Number.isFinite(v) ? v : 0); | |
| this.lastInputs = inputs; | |
| const outputs = this.net.feedForward(inputs); | |
| this.lastOutputs = outputs; | |
| const dx = outputs[0]; | |
| const dy = outputs[1]; | |
| const angle = outputs[2] * Math.PI * 2; | |
| const length = ((outputs[3] + 1) / 2) * params.maxSpeed.value; | |
| const mixWeight = (outputs[4] + 1) / 2; | |
| this.reproductionSplit = (outputs[5] + 1) / 2; | |
| this.memoryUpdate = (outputs[6] + 1) / 2; | |
| this.stance = outputs[7]; | |
| this.force = (2 * outputs[8]) ** 3; | |
| this.reproductionIntention = outputs[9]; | |
| this.totalStance += this.stance; | |
| this.plantSenseRadius = ((outputs[10] + 1) / 2) * this.maxSenseRadius; | |
| this.creatureSenseRadius = ((outputs[11] + 1) / 2) * this.maxSenseRadius; | |
| this.mutationStyle = (outputs[12] + 1) / 2; | |
| this.towardsUnity = (outputs[13] + 1) / 2; | |
| const pieces = sliceTailChunks(outputs, { towards: 1 + entitiesVectors.length, memory: this.memory.length, toChildren: this.toChildrenMessage.length, toParent: this.toParentMessage.length, peaceCode: this.peaceCode.length }); | |
| this.toParentMessage = pieces.toParent; this.toChildrenMessage = pieces.toChildren; this.peaceCode = pieces.peaceCode.map((c) => Math.round(c)); | |
| const newMemory = pieces.memory.map((v) => v * this.memoryUpdate); | |
| this.memory = this.memory.map((v, i) => Math.round((v * (1 - this.memoryUpdate) + newMemory[i]) * 10) / 10); | |
| this.controlBias = (this.controlBias * this.age + mixWeight) / (this.age + 1); | |
| const cartesianMove = new Vector(dx, dy); | |
| const polarMove = new Vector(Math.cos(angle), Math.sin(angle)); | |
| const freeMoveVector = cartesianMove.mult(mixWeight).addInPlace(polarMove.mult(1 - mixWeight)); | |
| const towards = signedMagnitudeSoftmax(pieces.towards, this.towardsUnity); | |
| // freeMoveVector + towards | |
| let finalVector = freeMoveVector.mult(towards?.[0] ?? 1); | |
| for (let i = 0; i < entities.length; i++) { | |
| const vectors = entitiesVectors[i]; | |
| if (!vectors) continue; | |
| const w = towards?.[i + 1] ?? 0; | |
| if (w === 0) continue; | |
| // Option A: use ONLY unit direction, no distance weighting | |
| finalVector.addInPlace(vectors.normalized.mult(w)); | |
| } | |
| // Set moveVector to the network-chosen speed "length" | |
| if (finalVector.mag() > 0) { | |
| this.moveVector = finalVector.normalize().mult(length); | |
| } else { | |
| this.moveVector = new Vector(0, 0); | |
| } | |
| // --- METRICS CALCULATION --- | |
| let metricPlantGain = 0; | |
| let metricTransferGain = 0; // Will be set by main loop energy transfer logic | |
| let metricTransferLoss = 0; // Will be set by main loop energy transfer logic | |
| const moveCost = params.moveCostMultiplier.value * this.moveVector.magSquared(); | |
| const stanceCostAmount = params.stanceCost.value * (this.stance * this.stance) * (this.stance > 0 ? 2 : 1); | |
| const strengthCost = params.strengthMetabolism.value * this.strength; | |
| const pushPullCostAmount = params.pushPullCost.value * this.strength * Math.abs(this.force); | |
| const miscLoss = params.basalMetabolism.value + moveCost + stanceCostAmount + strengthCost + pushPullCostAmount; | |
| this.previousEnergy = this.energy; | |
| this.energy -= miscLoss; | |
| this._stepStats.lossM = miscLoss; | |
| this._stepStats.move = this.moveVector.mag(); | |
| if((this.mutation.rate / 100) * clip((this.age / 200), 1, 10) > Math.random()) this.net.internalMutate(this.mutation); | |
| } | |
| shouldDie() { | |
| const dead = this.energy <= 0; | |
| if (dead && !this.isDead) { | |
| this.triggerLifecycleEvent('death', this); | |
| } | |
| return dead; | |
| } | |
| shouldReproduce() { return this.energy >= params.reproductionThreshold.value || this.reproductionIntention > 0.5; } | |
| isRelated(creature) { return this.parent === creature || this.children.has(creature); } | |
| reproduce() { | |
| const cost = params.reproductionCost.value; | |
| this.energy -= cost; | |
| if(this.net.updateStats) this.net.updateStats(this.net.getExpert(), { gainP:0, gainPr:0, lossPr:0, lossC:cost, lossM:0, move:0 }); // Immediate record cost | |
| statisticsManager.triggerEvent('reproduce', this); | |
| this.triggerLifecycleEvent('reproduce', this); | |
| const newCreature = new Creature(this.pos.x, this.pos.y, this); | |
| this.energy = Math.floor(this.energy * this.reproductionSplit); | |
| this.children.add(newCreature); | |
| this.reproductionIntention = 0; | |
| if(this.net.registerChild) this.net.registerChild(this.net.getExpert(), newCreature); | |
| return newCreature; | |
| } | |
| draw() { | |
| const avgStance = this.totalStance / this.age; | |
| ctx.fillStyle = `rgb(${this.color[0]}, ${this.color[1]}, ${this.color[2]})`; | |
| ctx.save(); | |
| ctx.translate(this.pos.x, this.pos.y); | |
| const size = Math.max(1, Math.sqrt(1 + this.energy) / 5); | |
| if (this.type === 'polar') { ctx.beginPath(); ctx.arc(0, 0, (2 * size) / Math.sqrt(Math.PI), 0, Math.PI * 2); } | |
| else { ctx.beginPath(); ctx.rect(-size, -size, size * 2, size * 2); } | |
| ctx.fill(); | |
| if (this.stance < 0) { const opacity = -this.stance; ctx.strokeStyle = `rgba(0, 0, 255, ${opacity})`; ctx.lineWidth = 1; } | |
| else { ctx.strokeStyle = '#333'; ctx.lineWidth = 1; } | |
| ctx.stroke(); | |
| if (this.stance > 0) { ctx.strokeStyle = `rgba(255, 0, 0, ${this.stance})`; ctx.lineWidth = 1; ctx.beginPath(); ctx.arc(0, 0, params.attackRadius.value, 0, Math.PI * 2); ctx.stroke(); } | |
| ctx.restore(); | |
| } | |
| } | |
| function resizeCanvas() { const container = canvas.parentElement; canvas.width = container.clientWidth; canvas.height = container.clientHeight; plantGrid = new SpatialGrid(canvas.width, canvas.height, 64); creatureGrid = new SpatialGrid(canvas.width, canvas.height, 25); } | |
| function resetSimulation() { | |
| resizeCanvas(); frameCount = 0; creatures = []; plants = []; walls = []; selectedCreature = null; | |
| plantGrid = new SpatialGrid(canvas.width, canvas.height, 64); creatureGrid = new SpatialGrid(canvas.width, canvas.height, 25); | |
| wallGrid = new SpatialGrid(canvas.width, canvas.height, 100); | |
| const w = canvas.width; const h = canvas.height; | |
| function addWall(x1, y1, x2, y2) { const wall = new Wall(x1, y1, x2, y2); walls.push(wall); wallGrid.insertSegment(wall, wall.start, wall.end); } | |
| const GRID_SIZE = 4; const DOOR_SIZE = 5; | |
| const doors = [ | |
| { x: 1, y: 0, v: true, off: 0.9 }, { x: 2, y: 0, v: true, off: 0.1 }, { x: 3, y: 0, v: true, off: 0.9 }, | |
| { x: 3, y: 1, v: false, off: 0.9 }, | |
| { x: 3, y: 1, v: true, off: 0.9 }, { x: 2, y: 1, v: true, off: 0.1 }, | |
| { x: 1, y: 2, v: false, off: 0.1 }, | |
| { x: 2, y: 2, v: true, off: 0.9 }, { x: 3, y: 2, v: true, off: 0.1 }, | |
| { x: 3, y: 3, v: false, off: 0.9 }, | |
| { x: 3, y: 3, v: true, off: 0.9 }, { x: 2, y: 3, v: true, off: 0.1 }, { x: 1, y: 3, v: true, off: 0.9 }, | |
| { x: 0, y: 3, v: false, off: 0.1 }, { x: 0, y: 2, v: false, off: 0.9 }, { x: 0, y: 1, v: false, off: 0.1 }, | |
| ]; | |
| function getDoor(doors, x, y, isVertical) { return doors.find(d => d.x === x && d.y === y && d.v === isVertical); } | |
| const scaleX = w / GRID_SIZE; const scaleY = h / GRID_SIZE; const halfDoor = DOOR_SIZE / 2; | |
| for (let i = 0; i <= GRID_SIZE; i++) { | |
| const px = i * scaleX; let currentY = 0; | |
| for (let j = 0; j < GRID_SIZE; j++) { | |
| const door = getDoor(doors, i, j, true); | |
| if (door) { | |
| const dCenter = (j + door.off) * scaleY; const gapStart = dCenter - halfDoor; const gapEnd = dCenter + halfDoor; | |
| if (gapStart > currentY) { addWall(px, currentY, px, gapStart); } | |
| currentY = gapEnd; | |
| } | |
| } | |
| if (currentY < h) { addWall(px, currentY, px, h); } | |
| } | |
| for (let j = 0; j <= GRID_SIZE; j++) { | |
| const py = j * scaleY; let currentX = 0; | |
| for (let i = 0; i < GRID_SIZE; i++) { | |
| const door = getDoor(doors, i, j, false); | |
| if (door) { | |
| const dCenter = (i + door.off) * scaleX; const gapStart = dCenter - halfDoor; const gapEnd = dCenter + halfDoor; | |
| if (gapStart > currentX) { addWall(currentX, py, gapStart, py); } | |
| currentX = gapEnd; | |
| } | |
| } | |
| if (currentX < w) { addWall(currentX, py, w, py); } | |
| } | |
| statisticsManager.initialize(); activeController.initialize(); | |
| for (let i = 0; i < params.initialCreatures.value; i++) { creatures.push(new Creature(Math.random() * canvas.width, Math.random() * canvas.height)); } | |
| for (let i = 0; i < params.plantCount.value; i++) { if (plants.length < params.plantCount.value) { plants.push(new Plant(Math.random() * canvas.width, Math.random() * canvas.height)); } } | |
| statisticsManager.update(creatures, frameCount, plants.length); | |
| } | |
| function resetColors() { creatures.forEach((c) => c.randomizeColor()); } | |
| function addPlant() { | |
| if (plants.length >= params.plantCount.value) return; | |
| const x = Math.random() * canvas.width; const y = Math.random() * canvas.height; | |
| const cx = canvas.width / 2; const cy = canvas.height / 2; | |
| const x_rotated = (x - cx) * Math.cos(gradientAngle) + (y - cy) * Math.sin(gradientAngle); | |
| const max_proj = Math.max(canvas.width, canvas.height) / 2; | |
| const norm_proj = clip((x_rotated / max_proj) * 0.5 + 0.5, 0, 1); | |
| const G = params.plantGradientRange.value; const minProb = 0.5 - G; const maxProb = 0.5 + G; | |
| const spawnProb = minProb + (maxProb - minProb) * norm_proj; | |
| if (Math.random() < spawnProb) { plants.push(new Plant(x, y)); } | |
| } | |
| function update() { | |
| activeController.update(creatures); | |
| gradientAngle += params.gradientRotationSpeed.value / 60; | |
| if (gradientAngle > Math.PI * 2) gradientAngle -= Math.PI * 2; | |
| for(const plant of plants) { plant.age++; } | |
| fastRemove(plants, (plant) => plant.age > 100); | |
| const needed = Math.max(0, params.plantCount.value - plants.length); | |
| const attempts = Math.min(needed, params.plantRespawnRate.value); | |
| for (let i = 0; i < attempts; i++) addPlant(); | |
| plantGrid.clear(); for(const plant of plants) { plantGrid.insert(plant); } | |
| if (!creatureGrid) creatureGrid = new SpatialGrid(canvas.width, canvas.height, 25); | |
| creatureGrid.clear(); | |
| const newCreatures = []; | |
| for (const c of creatures) { creatureGrid.insert(c); } | |
| for (const c of creatures) { c._stepStats = { gainP: 0, gainPr: 0, lossPr: 0, lossC: 0 }; } | |
| for (const c of creatures) { c.update(); } | |
| const energyTransfers = new Map(); const forceVectors = new Map(); | |
| for (const c of creatures) { forceVectors.set(c, c.moveVector); } | |
| const victimIntents = new Map(); | |
| for (const c of creatures) { | |
| if (c.stance > 0 || Math.abs(c.force) > 0.01) { | |
| const nearbyCreatures = creatureGrid.query(c.pos, params.attackRadius.value); | |
| for (const other of nearbyCreatures) { | |
| if (c === other) continue; | |
| const codesAgree = c.peaceCode.reduce((agree, code, i) => agree && code === other.peaceCode[i], true); | |
| if (codesAgree) continue; | |
| if (c.stance > 0) { | |
| const transferAmount = (c.stance * c.strength + other.stance * other.strength) * params.maxEnergyTransfer.value; | |
| if (transferAmount <= 0) continue; | |
| const aggressionRatio = (c.stance > 0 && other.stance > 0) ? clip(c.stance / Math.max(1e-6, other.stance), 0.5, 2) : 1; | |
| const requested = Math.max(0, transferAmount * aggressionRatio); | |
| if (requested > 0) { | |
| if (!victimIntents.has(other)) victimIntents.set(other, []); | |
| victimIntents.get(other).push({ attacker: c, requested }); | |
| } | |
| } | |
| if (Math.abs(c.force) > 0.01) { | |
| const relativePosition = other.pos.sub(c.pos); const distance = relativePosition.mag(); | |
| const dir = relativePosition.normalize(); | |
| const forceVector = dir.mult(c.force * c.strength / Math.max(1, distance)); | |
| forceVectors.get(other).addInPlace(forceVector); | |
| } | |
| } | |
| } | |
| if(c.stance < 0) { | |
| const nearbyPlants = plantGrid.query(c.pos, c.radius + 5); | |
| for (let p = 0; p < nearbyPlants.length; p++) { | |
| const plant = nearbyPlants[p]; | |
| if (c.pos.dist(plant.pos) < c.radius + plant.radius) { c.plantToEat = plant; plant.eatenCount++; break; } | |
| } | |
| } | |
| } | |
| const gainVecMap = new Map(); const gainAmtMap = new Map(); const lossVecMap = new Map(); const lossAmtMap = new Map(); | |
| function addGain(creature, vec, amt) { if (!gainVecMap.has(creature)) gainVecMap.set(creature, new Vector(0, 0)); gainVecMap.get(creature).addInPlace(vec); gainAmtMap.set(creature, (gainAmtMap.get(creature) || 0) + amt); } | |
| function addLoss(creature, vec, amt) { if (!lossVecMap.has(creature)) lossVecMap.set(creature, new Vector(0, 0)); lossVecMap.get(creature).addInPlace(vec); lossAmtMap.set(creature, (lossAmtMap.get(creature) || 0) + amt); } | |
| for (const c of creatures) energyTransfers.set(c, 0); | |
| for (const [victim, intents] of victimIntents.entries()) { | |
| const totalRequested = intents.reduce((s, it) => s + it.requested, 0); | |
| const available = Math.max(0, victim.energy); | |
| const scale = totalRequested > 0 ? Math.min(1, available / totalRequested) : 0; | |
| for (const { attacker, requested } of intents) { | |
| const amount = requested * scale; | |
| energyTransfers.set(attacker, energyTransfers.get(attacker) + amount); | |
| energyTransfers.set(victim, energyTransfers.get(victim) - amount); | |
| const dir = victim.pos.sub(attacker.pos).normalize(); | |
| addGain(attacker, dir.mult(amount), amount); | |
| addLoss(victim, dir.mult(-amount), amount); | |
| // Stats Tracking - Predation | |
| attacker._stepStats.gainPr += amount; | |
| victim._stepStats.lossPr += amount; | |
| } | |
| } | |
| for (let i = creatures.length - 1; i >= 0; i--) { | |
| const c = creatures[i]; | |
| if(c.plantToEat) { | |
| const aggressiveStancePenalty = 1 - clip(c.stance, 0, 1); | |
| const gain = (c.plantToEat.energy * aggressiveStancePenalty) / c.plantToEat.eatenCount; | |
| c.energy += gain; | |
| c._stepStats.gainP += gain; | |
| c.plantToEat = null; | |
| } | |
| if (energyTransfers.has(c)) { c.energy += energyTransfers.get(c); } | |
| c.predationGainVec = (gainVecMap.get(c) || new Vector(0,0)).normalize(); | |
| c.predationLossVec = (lossVecMap.get(c) || new Vector(0,0)).normalize(); | |
| c.predationGain = clip((gainAmtMap.get(c) || 0) / c.energy, 0, 1); | |
| c.predationLoss = clip((lossAmtMap.get(c) || 0) / c.energy, 0, 1); | |
| const beforePos = new Vector(c.pos.x, c.pos.y); | |
| const forces = forceVectors.get(c); | |
| const nextPos = c.pos.add(forces); | |
| let collided = false; | |
| const checkRadius = Math.max(forces.mag() + 5, 20); | |
| const localWalls = wallGrid.query(beforePos, checkRadius); | |
| for(const wall of localWalls) { if (getIntersection(beforePos, nextPos, wall.start, wall.end)) { collided = true; break; } } | |
| if (!collided) { c.pos = nextPos; c.hitWall = -1; } else { c.hitWall = 1; } | |
| c.pos.x = clip(c.pos.x, 0, canvas.width); c.pos.y = clip(c.pos.y, 0, canvas.height); | |
| c.finalMove = c.pos.sub(beforePos); | |
| // Finalize stats for this step | |
| // Update the stats for the expert that caused this step | |
| if(c.net.getExpert) c.net.updateStats(c.net.getExpert(), c._stepStats); | |
| if (c.shouldDie()) { | |
| statisticsManager.triggerEvent('death', c); | |
| if (selectedCreature === c) { selectedCreature = null; netVizContainer.classList.add('hidden'); } | |
| c.children.forEach((child) => child.parent = null); c.children = new Set(); | |
| if(c.parent) { c.parent.children.delete(c); c.parent = null; } | |
| c.isDead = true; continue; | |
| } | |
| if (!c.isDead && c.shouldReproduce()) { newCreatures.push(c.reproduce()); } | |
| } | |
| fastRemove(creatures, (c) => c.isDead); | |
| creatures.push(...newCreatures); | |
| fastRemove(plants, (plant) => plant.eatenCount > 0); | |
| } | |
| function drawArrow(ctx, from, to, color) { | |
| const headLength = 10; const angle = Math.atan2(to.y - from.y, to.x - from.x); | |
| ctx.beginPath(); ctx.moveTo(from.x, from.y); ctx.lineTo(to.x, to.y); ctx.strokeStyle = color; ctx.lineWidth = 2; ctx.stroke(); | |
| ctx.beginPath(); ctx.moveTo(to.x, to.y); ctx.lineTo(to.x - headLength * Math.cos(angle - Math.PI / 6), to.y - headLength * Math.sin(angle - Math.PI / 6)); | |
| ctx.lineTo(to.x - headLength * Math.cos(angle + Math.PI / 6), to.y - headLength * Math.sin(angle + Math.PI / 6)); | |
| ctx.lineTo(to.x, to.y); ctx.fillStyle = color; ctx.fill(); | |
| } | |
| function drawFamily(creature) { | |
| if (!creature) return; | |
| let curr = creature; | |
| while(curr.parent) { drawArrow(ctx, curr.parent.pos, curr.pos, '#00FFFF'); curr = curr.parent; } | |
| const queue = [creature]; const visited = new Set([creature]); | |
| while(queue.length > 0) { | |
| const parent = queue.shift(); | |
| for (const child of parent.children) { | |
| if (!visited.has(child)) { drawArrow(ctx, parent.pos, child.pos, '#FFFF00'); queue.push(child); visited.add(child); } | |
| } | |
| } | |
| ctx.beginPath(); ctx.arc(creature.pos.x, creature.pos.y, creature.radius + 8, 0, Math.PI * 2); | |
| ctx.strokeStyle = '#FFFFFF'; ctx.lineWidth = 2; ctx.stroke(); ctx.fillStyle = 'rgba(255, 255, 255, 0.2)'; ctx.fill(); | |
| } | |
| function drawNetwork(ctx, network, inputLabels, inputValues) { | |
| if (!network) return; | |
| // --- 1. FORWARD PASS (Capture Activations) --- | |
| // We calculate the effective values used for math, but keep raw values for display | |
| const maskedInputVals = inputValues.map((v, i) => | |
| v * (network.inputMask ? (network.inputMask[i] !== undefined ? network.inputMask[i] : 1) : 1) | |
| ); | |
| const layerActivations = [maskedInputVals]; | |
| let current = Matrix.fromArray(maskedInputVals); | |
| // Store activation objects to help with visualization source data | |
| // layerActivations[0] is the input layer | |
| for (let i = 0; i < network.weights.length; i++) { | |
| current = Matrix.multiply(network.weights[i], current); | |
| current.add(network.biases[i]); | |
| current.map(network.activation); | |
| layerActivations.push(current.toArray()); | |
| } | |
| // --- 2. BACKWARD PASS (Calculate Influence/Gradients) --- | |
| // Calculates d(Output)/d(Node) to color the nodes (Red=Bad, Green=Good) | |
| const influences = layerActivations.map(l => new Array(l.length).fill(0)); | |
| // Set Output Influence to +1 (We want to maximize Q) | |
| const outputLayerIdx = influences.length - 1; | |
| influences[outputLayerIdx][0] = 1; | |
| // Backpropagate | |
| for (let l = outputLayerIdx - 1; l >= 0; l--) { | |
| const nextLayerInfluences = influences[l + 1]; | |
| const weights = network.weights[l].data; | |
| const currLayerSize = influences[l].length; | |
| const nextLayerSize = nextLayerInfluences.length; | |
| for (let i = 0; i < currLayerSize; i++) { | |
| let sumInfluence = 0; | |
| for (let j = 0; j < nextLayerSize; j++) { | |
| sumInfluence += weights[j][i] * nextLayerInfluences[j]; | |
| } | |
| influences[l][i] = sumInfluence; | |
| } | |
| } | |
| // --- 3. LAYOUT SETUP --- | |
| const nodeRadius = 18; | |
| const layerSpacing = 200; | |
| const nodeSpacing = 50; | |
| const margin = 100; | |
| const layersCount = layerActivations.length; | |
| const maxNodes = Math.max(...layerActivations.map(l => l.length)); | |
| const totalWidth = margin * 2 + (layersCount - 1) * layerSpacing; | |
| const totalHeight = margin * 2 + (maxNodes - 1) * nodeSpacing; | |
| ctx.canvas.width = Math.max(totalWidth + 100, 600); | |
| ctx.canvas.height = Math.max(totalHeight + 100, 400); | |
| const getNodePos = (layerIndex, nodeIndex, totalNodesInLayer) => { | |
| const x = margin + layerIndex * layerSpacing; | |
| const verticalSpacing = Math.max(nodeSpacing, ctx.canvas.height / (totalNodesInLayer + 2)); | |
| const layerHeight = (totalNodesInLayer - 1) * verticalSpacing; | |
| const yOffset = (ctx.canvas.height - layerHeight) / 2; | |
| return { x, y: yOffset + nodeIndex * verticalSpacing }; | |
| }; | |
| ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); | |
| // --- 4. DRAW CONNECTIONS (Active Signal Flow) --- | |
| // We draw lines based on (Activation * Weight) | |
| for (let l = 0; l < network.weights.length; l++) { | |
| const weights = network.weights[l].data; | |
| // The activations of the SOURCE layer | |
| const sourceActivations = layerActivations[l]; | |
| const currLayerNodes = sourceActivations.length; | |
| const nextLayerNodes = layerActivations[l+1].length; | |
| for (let dest = 0; dest < nextLayerNodes; dest++) { | |
| for (let src = 0; src < currLayerNodes; src++) { | |
| // The math: Signal = Activation_Source * Weight | |
| const act = sourceActivations[src]; | |
| const weight = weights[dest][src]; | |
| const signal = act * weight; | |
| // FILTER: If signal is zero (masked input or 0 activation), don't draw | |
| if (Math.abs(signal) < 0.001) continue; | |
| const start = getNodePos(l, src, currLayerNodes); | |
| const end = getNodePos(l + 1, dest, nextLayerNodes); | |
| ctx.beginPath(); | |
| ctx.moveTo(start.x, start.y); | |
| ctx.lineTo(end.x, end.y); | |
| // Color Logic: Signal Positive (Green) or Negative (Red) | |
| // Opacity based on signal strength (capped) | |
| const alpha = Math.min(Math.abs(signal), 1.0); | |
| if (signal > 0) { | |
| ctx.strokeStyle = `rgba(0, 160, 0, ${alpha})`; // Green | |
| } else { | |
| ctx.strokeStyle = `rgba(160, 0, 0, ${alpha})`; // Red | |
| } | |
| // Width based on strength | |
| ctx.lineWidth = Math.min(Math.abs(signal) * 2, 6); | |
| ctx.stroke(); | |
| } | |
| } | |
| } | |
| // --- 5. DRAW NODES (Colored by Influence) --- | |
| ctx.textAlign = "center"; | |
| ctx.textBaseline = "middle"; | |
| for (let l = 0; l < layersCount; l++) { | |
| const nodes = layerActivations[l]; | |
| const nodeInfluences = influences[l]; | |
| const isInput = (l === 0); | |
| const isOutput = (l === layersCount - 1); | |
| // Layer Label | |
| const topNode = getNodePos(l, 0, nodes.length); | |
| ctx.fillStyle = "#333"; | |
| ctx.font = "bold 14px sans-serif"; | |
| let layerName = isInput ? "Input" : (isOutput ? "Output" : `Hidden ${l}`); | |
| ctx.fillText(layerName, topNode.x, topNode.y - nodeRadius - 25); | |
| for (let n = 0; n < nodes.length; n++) { | |
| // Display Value: If input, use raw inputValue, else use activation | |
| let displayValue = (isInput) ? inputValues[n] : nodes[n]; | |
| const influence = nodeInfluences[n]; | |
| const pos = getNodePos(l, n, nodes.length); | |
| // Node Style Defaults | |
| let fillColor = "#fff"; | |
| let strokeColor = "#333"; | |
| let isMasked = false; | |
| if (isInput) { | |
| // Check mask | |
| if (network.inputMask && network.inputMask[n] === 0) { | |
| isMasked = true; | |
| } | |
| } | |
| if (isMasked) { | |
| // Greyed out style | |
| fillColor = "#f0f0f0"; | |
| strokeColor = "#ccc"; | |
| ctx.setLineDash([5, 3]); // Dashed border | |
| } else { | |
| // Active Node: Color by Influence (Gradient) | |
| // Green = Helping Q, Red = Hurting Q | |
| const mag = Math.min(Math.abs(influence), 1); | |
| if (influence > 0) { | |
| fillColor = `rgba(220, 255, 220, ${0.5 + mag * 0.5})`; | |
| strokeColor = `rgba(0, 150, 0, ${0.8})`; | |
| } else if (influence < 0) { | |
| fillColor = `rgba(255, 220, 220, ${0.5 + mag * 0.5})`; | |
| strokeColor = `rgba(150, 0, 0, ${0.8})`; | |
| } | |
| ctx.setLineDash([]); // Solid border | |
| } | |
| // Draw Circle | |
| ctx.beginPath(); | |
| ctx.arc(pos.x, pos.y, nodeRadius, 0, Math.PI * 2); | |
| ctx.fillStyle = fillColor; | |
| ctx.fill(); | |
| ctx.lineWidth = 2; | |
| ctx.strokeStyle = strokeColor; | |
| ctx.stroke(); | |
| ctx.setLineDash([]); // Reset just in case | |
| // Draw Text Value | |
| ctx.font = "10px monospace"; | |
| ctx.fillStyle = isMasked ? "#aaa" : "#000"; | |
| ctx.fillText(displayValue.toFixed(2), pos.x, pos.y); | |
| // External Labels | |
| if (isInput && inputLabels[n]) { | |
| ctx.textAlign = "right"; | |
| ctx.fillStyle = isMasked ? "#ccc" : "#333"; | |
| ctx.font = "12px sans-serif"; | |
| ctx.fillText(inputLabels[n], pos.x - nodeRadius - 8, pos.y); | |
| ctx.textAlign = "center"; | |
| } | |
| if (isOutput) { | |
| ctx.textAlign = "left"; | |
| ctx.fillStyle = "#333"; | |
| ctx.font = "bold 12px sans-serif"; | |
| ctx.fillText("Q-Value", pos.x + nodeRadius + 8, pos.y); | |
| ctx.textAlign = "center"; | |
| } | |
| } | |
| } | |
| } | |
| function draw() { | |
| ctx.clearRect(0, 0, canvas.width, canvas.height); | |
| for (const wall of walls) { wall.draw(); } | |
| for (const plant of plants) { plant.draw(); } | |
| for (const creature of creatures) { creature.draw(); } | |
| if (selectedCreature) { | |
| drawFamily(selectedCreature); | |
| // Ensure the network exists and has cached inputs to show | |
| const net = selectedCreature.net; | |
| if (net && net.rewardNet && net.lastRewardInputs) { | |
| netVizContainer.classList.remove('hidden'); | |
| const rewardInputsLabels = [ | |
| "LogGainTot", "LogLossTot", "LogMove", | |
| "AdvGainTot", "AdvGainPr", "AdvLossTot", | |
| "AdvLossC", "AdvLossM", "AdvLossPr", | |
| "ChildRate", "GChildRate", | |
| "UsageProp", "ChldSurv", "Lineage", | |
| "MomentumQ" | |
| ]; | |
| // USE CACHED INPUTS (Fast & Safe) | |
| drawNetwork(netCtx, net.rewardNet, rewardInputsLabels, net.lastRewardInputs); | |
| } else { | |
| // Hide if selected creature hasn't thought yet (newborn) | |
| netVizContainer.classList.add('hidden'); | |
| } | |
| } else { | |
| netVizContainer.classList.add('hidden'); | |
| } | |
| } | |
| function gameLoop() { | |
| if (!isRunning) return; | |
| update(); draw(); | |
| if (frameCount % 10 === 0) { statisticsManager.update(creatures, frameCount, plants.length); } | |
| frameCount++; | |
| animationFrameId = requestAnimationFrame(gameLoop); | |
| } | |
| function setupControls() { | |
| const container = document.getElementById('params-container'); | |
| container.innerHTML = ''; | |
| for (const key in params) { | |
| const p = params[key]; | |
| const controlDiv = document.createElement('div'); | |
| controlDiv.className = 'flex flex-col space-y-1'; | |
| const label = document.createElement('label'); | |
| label.textContent = p.label; | |
| label.className = 'text-sm font-medium text-gray-600'; | |
| const valueSpan = document.createElement('span'); | |
| valueSpan.textContent = p.value; | |
| valueSpan.className = 'text-sm text-blue-600 font-semibold'; | |
| label.appendChild(document.createTextNode(' (')); label.appendChild(valueSpan); label.appendChild(document.createTextNode(')')); | |
| const input = document.createElement('input'); | |
| input.type = 'range'; input.min = p.min; input.max = p.max; input.step = p.step; input.value = p.value; | |
| p.uiInput = input; p.uiLabel = valueSpan; | |
| input.addEventListener('input', (e) => { p.value = parseFloat(e.target.value); valueSpan.textContent = p.value; }); | |
| controlDiv.appendChild(label); controlDiv.appendChild(input); container.appendChild(controlDiv); | |
| } | |
| } | |
| function setupButtons() { | |
| document.getElementById('startButton').addEventListener('click', () => { if (!isRunning) { isRunning = true; gameLoop(); } }); | |
| document.getElementById('pauseButton').addEventListener('click', () => { isRunning = false; if (animationFrameId) { cancelAnimationFrame(animationFrameId); } }); | |
| document.getElementById('resetButton').addEventListener('click', () => { isRunning = false; if (animationFrameId) { cancelAnimationFrame(animationFrameId); } resetSimulation(); draw(); }); | |
| document.getElementById('stepButton').addEventListener('click', () => { update(); draw(); statisticsManager.update(creatures, frameCount, plants.length); frameCount++; }); | |
| document.getElementById('resetColorsButton').addEventListener('click', () => { resetColors(); }); | |
| canvas.addEventListener('mousedown', (e) => { | |
| const rect = canvas.getBoundingClientRect(); | |
| const scaleX = canvas.width / rect.width; const scaleY = canvas.height / rect.height; | |
| const x = (e.clientX - rect.left) * scaleX; const y = (e.clientY - rect.top) * scaleY; | |
| let best = null; let minDist = Infinity; | |
| for (const c of creatures) { | |
| const dist = (c.pos.x - x) ** 2 + (c.pos.y - y) ** 2; | |
| if (dist < 400 && dist < minDist) { minDist = dist; best = c; } | |
| } | |
| selectedCreature = best; | |
| if (!isRunning) draw(); | |
| }); | |
| } | |
| window.onload = function() { setupControls(); setupButtons(); resetSimulation(); draw(); } | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment