diff --git a/bun.lock b/bun.lock index 4ca6178..bb5b66c 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "": { "name": "evolution", "dependencies": { + "phaser": "^3.90.0", "react": "^19.2.0", "react-dom": "^19.2.0", "react-router-dom": "^7.12.0", @@ -314,6 +315,8 @@ "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + "eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="], + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], @@ -402,6 +405,8 @@ "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + "phaser": ["phaser@3.90.0", "", { "dependencies": { "eventemitter3": "^5.0.1" } }, "sha512-/cziz/5ZIn02uDkC9RzN8VF9x3Gs3XdFFf9nkiMEQT3p7hQlWuyjy4QWosU802qqno2YSLn2BfqwOKLv/sSVfQ=="], + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], diff --git a/package.json b/package.json index ddc6791..0beb09f 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "preview": "vite preview" }, "dependencies": { + "phaser": "^3.90.0", "react": "^19.2.0", "react-dom": "^19.2.0", "react-router-dom": "^7.12.0" diff --git a/src/App.tsx b/src/App.tsx index 6d8fa00..e3505ca 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,6 +2,8 @@ import { Routes, Route, Navigate } from 'react-router-dom'; import Sidebar from './components/Sidebar'; import ImageApprox from './apps/ImageApprox/ImageApprox'; import SnakeAI from './apps/SnakeAI/SnakeAI'; +import RogueGenApp from './apps/RogueGen/RogueGenApp'; +import NeatArena from './apps/NeatArena/NeatArena'; import './App.css'; function App() { @@ -13,6 +15,8 @@ function App() { } /> } /> } /> + } /> + } /> App not found} /> diff --git a/src/apps/NeatArena/FitnessGraph.tsx b/src/apps/NeatArena/FitnessGraph.tsx new file mode 100644 index 0000000..58a6deb --- /dev/null +++ b/src/apps/NeatArena/FitnessGraph.tsx @@ -0,0 +1,112 @@ +import { useEffect, useRef } from 'react'; + +interface FitnessGraphProps { + history: { generation: number; best: number; avg: number }[]; +} + +export default function FitnessGraph({ history }: FitnessGraphProps) { + const canvasRef = useRef(null); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas || history.length === 0) return; + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + const width = canvas.width; + const height = canvas.height; + const padding = 40; + + // Clear + ctx.fillStyle = '#1a1a2e'; + ctx.fillRect(0, 0, width, height); + + // Find data range + const maxGen = Math.max(...history.map(h => h.generation), 1); + const allFitness = [...history.map(h => h.best), ...history.map(h => h.avg)]; + const maxFit = Math.max(...allFitness, 1); + const minFit = Math.min(...allFitness, -1); + const fitRange = maxFit - minFit; + + // Draw grid + ctx.strokeStyle = '#2a2a3e'; + ctx.lineWidth = 1; + for (let i = 0; i <= 5; i++) { + const y = padding + (height - 2 * padding) * (i / 5); + ctx.beginPath(); + ctx.moveTo(padding, y); + ctx.lineTo(width - padding, y); + ctx.stroke(); + + // Y-axis labels + const fitValue = maxFit - (fitRange * i / 5); + ctx.fillStyle = '#888'; + ctx.font = '11px monospace'; + ctx.textAlign = 'right'; + ctx.fillText(fitValue.toFixed(1), padding - 5, y + 4); + } + + // Draw axes + ctx.strokeStyle = '#444'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(padding, padding); + ctx.lineTo(padding, height - padding); + ctx.lineTo(width - padding, height - padding); + ctx.stroke(); + + // Helper to convert data to canvas coords + const toX = (gen: number) => padding + ((width - 2 * padding) * gen / maxGen); + const toY = (fit: number) => { + const normalized = (maxFit - fit) / fitRange; + return padding + (height - 2 * padding) * normalized; + }; + + // Draw best fitness line + ctx.strokeStyle = '#00ff88'; + ctx.lineWidth = 2; + ctx.beginPath(); + history.forEach((h, i) => { + const x = toX(h.generation); + const y = toY(h.best); + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + }); + ctx.stroke(); + + // Draw avg fitness line + ctx.strokeStyle = '#4488ff'; + ctx.lineWidth = 2; + ctx.beginPath(); + history.forEach((h, i) => { + const x = toX(h.generation); + const y = toY(h.avg); + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + }); + ctx.stroke(); + + // Legend + ctx.font = '12px monospace'; + ctx.fillStyle = '#00ff88'; + ctx.fillText('● Best', width - 120, 25); + ctx.fillStyle = '#4488ff'; + ctx.fillText('● Avg', width - 60, 25); + + // X-axis label + ctx.fillStyle = '#888'; + ctx.textAlign = 'center'; + ctx.fillText('Generation', width / 2, height - 10); + + }, [history]); + + return ( + + ); +} diff --git a/src/apps/NeatArena/NeatArena.css b/src/apps/NeatArena/NeatArena.css new file mode 100644 index 0000000..0c549ce --- /dev/null +++ b/src/apps/NeatArena/NeatArena.css @@ -0,0 +1,239 @@ +/* NEAT Arena Layout */ +.neat-arena-layout { + display: flex; + gap: 1.5rem; + height: 100%; + padding: 1.5rem; + background: linear-gradient(135deg, #0f0f23 0%, #1a1a2e 100%); +} + +/* Left Panel: Controls */ +.controls-panel { + flex: 0 0 320px; + display: flex; + flex-direction: column; + gap: 1rem; + overflow-y: auto; + padding-right: 0.5rem; +} + +.control-section { + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 8px; + padding: 1rem; + backdrop-filter: blur(10px); +} + +.control-section h3 { + margin: 0 0 0.75rem 0; + font-size: 0.95rem; + font-weight: 600; + color: #fff; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.control-section h4 { + margin: 0 0 0.5rem 0; + font-size: 0.85rem; + font-weight: 600; + color: #aaa; +} + +.control-group { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +/* Buttons */ +.btn-primary, +.btn-secondary { + padding: 0.75rem 1rem; + border: none; + border-radius: 6px; + font-size: 0.9rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + text-align: center; +} + +.btn-primary { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; +} + +.btn-primary:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4); +} + +.btn-primary.btn-stop { + background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); +} + +.btn-secondary { + background: rgba(255, 255, 255, 0.1); + color: white; + border: 1px solid rgba(255, 255, 255, 0.2); +} + +.btn-secondary:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.15); + border-color: rgba(255, 255, 255, 0.3); +} + +.btn-primary:disabled, +.btn-secondary:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Stats Grid */ +.stats-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.75rem; +} + +.stat-item { + display: flex; + flex-direction: column; + gap: 0.25rem; + padding: 0.75rem; + background: rgba(0, 0, 0, 0.3); + border-radius: 6px; + border: 1px solid rgba(255, 255, 255, 0.05); +} + +.stat-label { + font-size: 0.75rem; + color: #aaa; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.stat-value { + font-size: 1.25rem; + font-weight: 700; + color: #fff; + font-variant-numeric: tabular-nums; +} + +/* Checkbox */ +.checkbox-label { + display: flex; + align-items: center; + gap: 0.5rem; + cursor: pointer; + color: #ddd; + font-size: 0.9rem; +} + +.checkbox-label input[type="checkbox"] { + width: 18px; + height: 18px; + cursor: pointer; +} + +/* Info Section */ +.info-section { + background: rgba(102, 126, 234, 0.1); + border: 1px solid rgba(102, 126, 234, 0.3); + border-radius: 8px; + padding: 1rem; +} + +.info-section p { + margin: 0 0 0.75rem 0; + color: #ddd; + font-size: 0.85rem; + line-height: 1.5; +} + +.info-section ul { + margin: 0; + padding-left: 1.25rem; + color: #bbb; + font-size: 0.85rem; +} + +.info-section ul li { + margin-bottom: 0.25rem; +} + +.text-muted { + color: #888; + font-size: 0.8rem; +} + +.info-text { + margin-top: 0.5rem; + color: #aaa; + font-size: 0.8rem; + font-style: italic; +} + +/* Right Panel: Viewer */ +.viewer-panel { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; +} + +.phaser-container { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.4); + border: 2px solid rgba(255, 255, 255, 0.1); + border-radius: 8px; + overflow: hidden; +} + +.phaser-placeholder { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; +} + +.placeholder-content { + text-align: center; + color: rgba(255, 255, 255, 0.4); +} + +.placeholder-content h2 { + margin: 0 0 0.5rem 0; + font-size: 2rem; + font-weight: 300; +} + +.placeholder-content p { + margin: 0.25rem 0; + font-size: 1rem; +} + +/* Scrollbar styling */ +.controls-panel::-webkit-scrollbar { + width: 8px; +} + +.controls-panel::-webkit-scrollbar-track { + background: rgba(0, 0, 0, 0.2); + border-radius: 4px; +} + +.controls-panel::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.2); + border-radius: 4px; +} + +.controls-panel::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.3); +} \ No newline at end of file diff --git a/src/apps/NeatArena/NeatArena.tsx b/src/apps/NeatArena/NeatArena.tsx new file mode 100644 index 0000000..effe49d --- /dev/null +++ b/src/apps/NeatArena/NeatArena.tsx @@ -0,0 +1,377 @@ +import { useState, useRef, useEffect, useCallback } from 'react'; +import AppContainer from '../../components/AppContainer'; +import { createArenaViewer, getArenaScene } from '../../lib/neatArena/arenaScene'; +import { createSimulation, stepSimulation } from '../../lib/neatArena/simulation'; +import { spinnerBotAction } from '../../lib/neatArena/baselineBots'; +import { createPopulation, getPopulationStats, DEFAULT_EVOLUTION_CONFIG, type Population } from '../../lib/neatArena/evolution'; +import { createNetwork } from '../../lib/neatArena/network'; +import { generateObservation, observationToInputs } from '../../lib/neatArena/sensors'; +import { exportGenome, downloadGenomeAsFile, uploadGenomeFromFile } from '../../lib/neatArena/exportImport'; +import type { SimulationState, AgentAction, Genome } from '../../lib/neatArena/types'; +import type { TrainingWorkerMessage, TrainingWorkerResponse } from '../../lib/neatArena/training.worker'; +import FitnessGraph from './FitnessGraph'; +import './NeatArena.css'; + +/** + * NEAT Arena Miniapp + * + * Trains AI agents using NEAT (NeuroEvolution of Augmenting Topologies) to play + * a 2D top-down shooter arena via self-play. + */ +export default function NeatArena() { + // Training state + const [population, setPopulation] = useState(() => createPopulation(DEFAULT_EVOLUTION_CONFIG)); + const [isTraining, setIsTraining] = useState(false); + const [showRays, setShowRays] = useState(false); + const [mapSeed] = useState(12345); + const [importedGenome, setImportedGenome] = useState(null); + const [fitnessHistory, setFitnessHistory] = useState<{ generation: number; best: number; avg: number }[]>([]); + + // Stats + const stats = getPopulationStats(population); + + // Phaser game instance + const phaserGameRef = useRef(null); + const phaserContainerRef = useRef(null); + + // Exhibition match state (visualizing champion) + const simulationRef = useRef(null); + + // Web Worker + const workerRef = useRef(null); + + // Initialize Web Worker + useEffect(() => { + const worker = new Worker(new URL('../../lib/neatArena/training.worker.ts', import.meta.url), { + type: 'module' + }); + + worker.onmessage = (e: MessageEvent) => { + const response = e.data; + + switch (response.type) { + case 'update': + if (response.population) { + setPopulation(response.population); + console.log('[UI] Stats?', response.stats ? 'YES' : 'NO', response.stats); + + // Track fitness history for graph + if (response.stats) { + setFitnessHistory(prev => [...prev, { + generation: response.stats!.generation, + best: response.stats!.maxFitness, + avg: response.stats!.avgFitness, + }]); + } + } + break; + + case 'error': + console.error('Worker error:', response.error); + setIsTraining(false); + alert('Training error: ' + response.error); + break; + + case 'ready': + console.log('Worker ready'); + break; + } + }; + + // Initialize worker with config + worker.postMessage({ + type: 'init', + config: DEFAULT_EVOLUTION_CONFIG, + } as TrainingWorkerMessage); + + workerRef.current = worker; + + return () => { + worker.terminate(); + workerRef.current = null; + }; + }, []); + + // Control worker based on training state + useEffect(() => { + if (!workerRef.current) return; + + if (isTraining) { + workerRef.current.postMessage({ + type: 'start', + } as TrainingWorkerMessage); + } else { + workerRef.current.postMessage({ + type: 'pause', + } as TrainingWorkerMessage); + } + }, [isTraining]); + + // Initialize Phaser + useEffect(() => { + if (!phaserContainerRef.current) return; + + phaserContainerRef.current.innerHTML = ''; + const game = createArenaViewer(phaserContainerRef.current); + phaserGameRef.current = game; + + simulationRef.current = createSimulation(mapSeed, 0); + + return () => { + game.destroy(true); + phaserGameRef.current = null; + }; + }, [mapSeed]); + + // Exhibition match loop (visualizing champion vs baseline) + useEffect(() => { + if (isTraining) return; // Don't run exhibition during training + if (!phaserGameRef.current) return; + + const interval = setInterval(() => { + if (!simulationRef.current) return; + + const sim = simulationRef.current; + + if (sim.isOver) { + simulationRef.current = createSimulation(mapSeed, 0); + return; + } + + // Agent 0: Imported genome, current gen best, or spinner + let action0: AgentAction; + + // Priority: imported > current gen best > all-time best > spinner + const currentGenBest = population.genomes.length > 0 + ? population.genomes.reduce((best, g) => g.fitness > best.fitness ? g : best) + : null; + + const genomeToUse = importedGenome || currentGenBest || population.bestGenomeEver; + + if (genomeToUse) { + const network = createNetwork(genomeToUse); + const obs = generateObservation(0, sim); + const inputs = observationToInputs(obs); + const outputs = network.activate(inputs); + + action0 = { + moveX: outputs[0], + moveY: outputs[1], + turn: outputs[2], + shoot: outputs[3], + }; + } else { + action0 = spinnerBotAction(); + } + + // Agent 1: Spinner bot + const action1 = spinnerBotAction(); + + simulationRef.current = stepSimulation(sim, [action0, action1]); + + if (phaserGameRef.current) { + const scene = getArenaScene(phaserGameRef.current); + scene.updateSimulation(simulationRef.current); + scene.setShowRays(showRays); + } + + }, 1000 / 30); + + return () => clearInterval(interval); + }, [isTraining, showRays, mapSeed, population.bestGenomeEver, importedGenome]); + + const handleReset = useCallback(() => { + setIsTraining(false); + setImportedGenome(null); + setFitnessHistory([]); + + if (workerRef.current) { + workerRef.current.postMessage({ + type: 'reset', + } as TrainingWorkerMessage); + } + + simulationRef.current = createSimulation(mapSeed, 0); + + if (phaserGameRef.current) { + const scene = getArenaScene(phaserGameRef.current); + scene.updateSimulation(simulationRef.current); + } + }, [mapSeed]); + + const handleStepGeneration = useCallback(() => { + if (workerRef.current) { + workerRef.current.postMessage({ + type: 'step', + } as TrainingWorkerMessage); + } + }, []); + + const handleExport = useCallback(() => { + if (!population.bestGenomeEver) { + alert('No champion to export yet!'); + return; + } + + const exported = exportGenome( + population.bestGenomeEver, + DEFAULT_EVOLUTION_CONFIG, + { + generation: stats.generation, + fitness: stats.bestFitnessEver, + speciesCount: stats.speciesCount, + } + ); + + downloadGenomeAsFile(exported, `neat-champion-gen${stats.generation}.json`); + }, [population.bestGenomeEver, stats]); + + const handleImport = useCallback(async () => { + try { + const exported = await uploadGenomeFromFile(); + setImportedGenome(exported.genome); + alert(`Imported champion from generation ${exported.metadata?.generation || '?'} with fitness ${exported.metadata?.fitness?.toFixed(1) || '?'}`); + } catch (err) { + alert('Failed to import genome: ' + (err as Error).message); + } + }, []); + + return ( + +
+ {/* Left Panel: Controls */} +
+
+

Training Controls

+
+ + + +
+

+ {isTraining + ? '🟢 Training in background worker...' + : importedGenome + ? '🎮 Watching imported champion vs Spinner bot' + : population.bestGenomeEver + ? '🎮 Watching champion vs Spinner bot' + : '⚪ No champion yet'} +

+
+ +
+

Evolution Stats

+
+
+ Generation + {stats.generation} +
+
+ Species + {stats.speciesCount} +
+
+ Best Fitness + {stats.maxFitness.toFixed(1)} +
+
+ Avg Fitness + {stats.avgFitness.toFixed(1)} +
+
+ Champion + {stats.bestFitnessEver.toFixed(1)} +
+
+ Innovations + {stats.totalInnovations} +
+
+
+ + {fitnessHistory.length > 0 && ( +
+

Fitness Progress

+ +
+ )} + +
+

Debug Options

+ +
+ +
+

Export / Import

+
+ + +
+ {importedGenome && ( +

+ ✅ Imported genome loaded +

+ )} +
+ +
+

NEAT Arena Status

+
    +
  • ✅ Deterministic 30Hz simulation
  • +
  • ✅ Symmetric procedural maps
  • +
  • ✅ Agent physics & bullets
  • +
  • ✅ 360° ray sensors (53 inputs)
  • +
  • ✅ NEAT evolution with speciation
  • +
  • ✅ Self-play training (K=4 matches)
  • +
  • ✅ Export/import genomes
  • +
  • ✅ Web worker (no UI lag!)
  • +
+
+
+ + {/* Right Panel: Phaser Viewer */} +
+
+
+
+ + ); +} diff --git a/src/apps/RogueGen/RogueGenApp.tsx b/src/apps/RogueGen/RogueGenApp.tsx new file mode 100644 index 0000000..a840936 --- /dev/null +++ b/src/apps/RogueGen/RogueGenApp.tsx @@ -0,0 +1,386 @@ +import { useEffect, useRef, useState, useCallback } from 'react'; +import type { Genotype, MapData } from './types'; +import { generateMap } from './generator'; +import { createRandomGenome, evaluatePopulation, evolve, type Individual, POPULATION_SIZE } from './evolution'; + +export default function RogueGenApp() { + const [generation, setGeneration] = useState(0); + const [bestFitness, setBestFitness] = useState(0); + const [population, setPopulation] = useState([]); + const [bestIndividual, setBestIndividual] = useState(null); + const [isRunning, setIsRunning] = useState(false); + + // Config + const [config, setConfig] = useState({ + width: 100, + height: 80, + canvasScale: 4, + simulationSpeed: 100 + }); + + // Targets & Overrides + const [targets, setTargets] = useState({ + density: 0.45, + water: 0.15, + lava: 0.05, + veg: 0.20, + minPathLength: 50, + forceTunnels: false, + scaleOverride: 0 + }); + + const canvasRef = useRef(null); + + // Initialize + useEffect(() => { + const initPop = []; + for (let i = 0; i < POPULATION_SIZE; i++) initPop.push(createRandomGenome()); + setPopulation(initPop); + }, []); + + // Evolution Loop + const runGeneration = useCallback(() => { + if (!population.length) return; + + // Apply overrides if needed (by modifying genome copy? No, better to pass override context) + // But for simplicity/visuals, we can just hack the population before eval? + // No, that ruins evolution. + // We probably want to visualize the BEST, but FORCE the generation parameters. + // Let's modify evaluatePopulation to handle overrides? + // Or simple hack: Temporarily modify genomes. + + const popToEval = population.map(p => { + const copy = { ...p }; + if (targets.forceTunnels) copy.noiseType = 1; + if (targets.scaleOverride > 0) copy.noiseScale = targets.scaleOverride; + return copy; + }); + + const evaluated = evaluatePopulation(popToEval, config.width, config.height, targets); + setBestIndividual(evaluated[0]); + setBestFitness(evaluated[0].fitness.score); + + const nextGen = evolve(evaluated); + setPopulation(nextGen); + setGeneration(g => g + 1); + }, [population, config.width, config.height, targets]); + + useEffect(() => { + let interval: ReturnType; + if (isRunning) { + interval = setInterval(runGeneration, config.simulationSpeed); + } + return () => clearInterval(interval); + }, [isRunning, runGeneration, config.simulationSpeed]); + + // Render Best Map + useEffect(() => { + if (!bestIndividual || !canvasRef.current) return; + const ctx = canvasRef.current.getContext('2d'); + if (!ctx) return; + + const map = generateMap(bestIndividual.genome, config.width, config.height, targets.minPathLength); + + ctx.fillStyle = "#111"; + ctx.fillRect(0, 0, config.width * config.canvasScale, config.height * config.canvasScale); + + // Draw + for (let y = 0; y < config.height; y++) { + for (let x = 0; x < config.width; x++) { + const val = map.grid[y * config.width + x]; + if (val === 1) { + ctx.fillStyle = "#889"; // Wall + ctx.fillRect(x * config.canvasScale, y * config.canvasScale, config.canvasScale, config.canvasScale); + } else if (val === 2) { + ctx.fillStyle = "#48d"; // Water + ctx.fillRect(x * config.canvasScale, y * config.canvasScale, config.canvasScale, config.canvasScale); + } else if (val === 3) { + ctx.fillStyle = "#e44"; // Lava + ctx.fillRect(x * config.canvasScale, y * config.canvasScale, config.canvasScale, config.canvasScale); + } else if (val === 4) { + ctx.fillStyle = "#2a4"; // Veg + ctx.fillRect(x * config.canvasScale, y * config.canvasScale, config.canvasScale, config.canvasScale); + } + } + } + + // Draw Start/End + if (map.startPoint && map.endPoint && map.pathLength && map.pathLength > 0) { + // Start + ctx.fillStyle = "#ff0"; + ctx.fillRect(map.startPoint.x * config.canvasScale, map.startPoint.y * config.canvasScale, config.canvasScale, config.canvasScale); + // End + ctx.fillStyle = "#f0f"; + ctx.fillRect(map.endPoint.x * config.canvasScale, map.endPoint.y * config.canvasScale, config.canvasScale, config.canvasScale); + + // Text labels? + ctx.font = '10px monospace'; + ctx.fillStyle = "#fff"; + ctx.fillText("S", map.startPoint.x * config.canvasScale + 2, map.startPoint.y * config.canvasScale + 8); + ctx.fillText("E", map.endPoint.x * config.canvasScale + 2, map.endPoint.y * config.canvasScale + 8); + } + }, [bestIndividual, config]); + + return ( +
+ {/* Sidebar Controls */} +
+
+

Rogue Map Evo

+
Gen: {generation} | Best: {bestFitness.toFixed(4)}
+
+ +
+

Controls

+ + +
+ +
+

Configuration

+ + + + + + + + + +

Map Style

+ + + + + +

Terrain Targets

+ + + + + + + + + + +
+ + {bestIndividual && ( +
+

Best Genome (Wall)

+
+
Init P:
{bestIndividual.genome.initialChance.toFixed(2)}
+
Birth:
{bestIndividual.genome.birthLimit}
+
Death:
{bestIndividual.genome.deathLimit}
+
Steps:
{bestIndividual.genome.steps}
+
Smooth:
{bestIndividual.genome.smoothingSteps}
+
Cleanup:
{bestIndividual.genome.noiseReduction ? 'Yes' : 'No'}
+
+

Best Genome (Water/Lava/Veg)

+
+
WATER
+
LAVA
+
VEG
+ +
{bestIndividual.genome.waterInitialChance.toFixed(2)}
+
{bestIndividual.genome.lavaInitialChance.toFixed(2)}
+
{bestIndividual.genome.vegInitialChance.toFixed(2)}
+ +
Steps
+
Steps
+
Steps
+ +
{bestIndividual.genome.waterSteps}
+
{bestIndividual.genome.lavaSteps}
+
{bestIndividual.genome.vegSteps}
+
+ +

Structure

+
+
Noise:
{bestIndividual.genome.useNoise ? (bestIndividual.genome.noiseType === 1 ? 'Tunnel' : 'Blob') : 'No'}
+
Scale:
{bestIndividual.genome.noiseScale.toFixed(1)}
+
Rooms:
{bestIndividual.genome.useRooms ? bestIndividual.genome.roomCount : 'No'}
+
+
+

Metrics

+
+
Connect:
{(bestIndividual.fitness.connectivity * 100).toFixed(1)}%
+
Density:
{(bestIndividual.fitness.density * 100).toFixed(1)}%
+
Path:
{generateMap(bestIndividual.genome, config.width, config.height).pathLength}
+
+
+ )} +
+ + {/* Main Visualization */} +
+
+ +
+
+
+ ); +} diff --git a/src/apps/RogueGen/evolution.ts b/src/apps/RogueGen/evolution.ts new file mode 100644 index 0000000..665864f --- /dev/null +++ b/src/apps/RogueGen/evolution.ts @@ -0,0 +1,155 @@ +import type { Genotype } from './types'; +import { generateMap } from './generator'; +import { calculateFitness, type FitnessResult, type FitnessTargets } from './fitness'; + +export interface Individual { + genome: Genotype; + fitness: FitnessResult; +} + +export const POPULATION_SIZE = 50; +const MUTATION_RATE = 0.1; + +export function createRandomGenome(): Genotype { + return { + initialChance: Math.random(), // 0.0 - 1.0 + birthLimit: Math.floor(Math.random() * 8) + 1, // 1-8 + deathLimit: Math.floor(Math.random() * 8) + 1, // 1-8 + steps: Math.floor(Math.random() * 7) + 3, // 3-10 (Forced minimum steps to prevent static) + smoothingSteps: Math.floor(Math.random() * 6), // 0-5 + noiseReduction: Math.random() < 0.5, + + useNoise: Math.random() < 0.8, // High chance to use noise + noiseType: Math.random() < 0.5 ? 0 : 1, // Random start + noiseScale: Math.random() * 40 + 10, // 10-50 + noiseThreshold: Math.random() * 0.4 + 0.3, // 0.3-0.7 + + useRooms: Math.random() < 0.8, // High chance + roomCount: Math.floor(Math.random() * 15) + 3, // 3-18 + roomMinSize: Math.floor(Math.random() * 4) + 3, // 3-7 + roomMaxSize: Math.floor(Math.random() * 8) + 8, // 8-16 + + waterInitialChance: Math.random(), + waterBirthLimit: Math.floor(Math.random() * 8) + 1, + waterDeathLimit: Math.floor(Math.random() * 8) + 1, + waterSteps: Math.floor(Math.random() * 7) + 3, // 3-10 + + lavaInitialChance: Math.random() * 0.5, // Rare + lavaBirthLimit: Math.floor(Math.random() * 8) + 1, + lavaDeathLimit: Math.floor(Math.random() * 8) + 1, + lavaSteps: Math.floor(Math.random() * 7) + 3, // 3-10 + + vegInitialChance: Math.random(), + vegBirthLimit: Math.floor(Math.random() * 8) + 1, + vegDeathLimit: Math.floor(Math.random() * 8) + 1, + vegSteps: Math.floor(Math.random() * 7) + 3 // 3-10 + }; +} + +export function evaluatePopulation(population: Genotype[], width: number, height: number, targets: FitnessTargets): Individual[] { + return population.map(genome => { + const map = generateMap(genome, width, height, targets.minPathLength); + const fitness = calculateFitness(map, targets); + return { genome, fitness }; + }).sort((a, b) => b.fitness.score - a.fitness.score); +} + +export function evolve(population: Individual[]): Genotype[] { + const newPop: Genotype[] = []; + + // Elitism: Keep top 2 + newPop.push(population[0].genome); + newPop.push(population[1].genome); + + while (newPop.length < POPULATION_SIZE) { + const p1 = tournamentSelect(population); + const p2 = tournamentSelect(population); + const child = crossover(p1.genome, p2.genome); + mutate(child); + newPop.push(child); + } + + return newPop; +} + +function tournamentSelect(pop: Individual[]): Individual { + const k = 3; + let best = pop[Math.floor(Math.random() * pop.length)]; + for (let i = 0; i < k - 1; i++) { + const cand = pop[Math.floor(Math.random() * pop.length)]; + if (cand.fitness.score > best.fitness.score) { + best = cand; + } + } + return best; +} + +function crossover(p1: Genotype, p2: Genotype): Genotype { + return { + initialChance: Math.random() < 0.5 ? p1.initialChance : p2.initialChance, + birthLimit: Math.random() < 0.5 ? p1.birthLimit : p2.birthLimit, + deathLimit: Math.random() < 0.5 ? p1.deathLimit : p2.deathLimit, + steps: Math.random() < 0.5 ? p1.steps : p2.steps, + smoothingSteps: Math.random() < 0.5 ? p1.smoothingSteps : p2.smoothingSteps, + noiseReduction: Math.random() < 0.5 ? p1.noiseReduction : p2.noiseReduction, + + useNoise: Math.random() < 0.5 ? p1.useNoise : p2.useNoise, + noiseType: Math.random() < 0.5 ? p1.noiseType : p2.noiseType, + noiseScale: Math.random() < 0.5 ? p1.noiseScale : p2.noiseScale, + noiseThreshold: Math.random() < 0.5 ? p1.noiseThreshold : p2.noiseThreshold, + + useRooms: Math.random() < 0.5 ? p1.useRooms : p2.useRooms, + roomCount: Math.random() < 0.5 ? p1.roomCount : p2.roomCount, + roomMinSize: Math.random() < 0.5 ? p1.roomMinSize : p2.roomMinSize, + roomMaxSize: Math.random() < 0.5 ? p1.roomMaxSize : p2.roomMaxSize, + + waterInitialChance: Math.random() < 0.5 ? p1.waterInitialChance : p2.waterInitialChance, + waterBirthLimit: Math.random() < 0.5 ? p1.waterBirthLimit : p2.waterBirthLimit, + waterDeathLimit: Math.random() < 0.5 ? p1.waterDeathLimit : p2.waterDeathLimit, + waterSteps: Math.random() < 0.5 ? p1.waterSteps : p2.waterSteps, + + lavaInitialChance: Math.random() < 0.5 ? p1.lavaInitialChance : p2.lavaInitialChance, + lavaBirthLimit: Math.random() < 0.5 ? p1.lavaBirthLimit : p2.lavaBirthLimit, + lavaDeathLimit: Math.random() < 0.5 ? p1.lavaDeathLimit : p2.lavaDeathLimit, + lavaSteps: Math.random() < 0.5 ? p1.lavaSteps : p2.lavaSteps, + + vegInitialChance: Math.random() < 0.5 ? p1.vegInitialChance : p2.vegInitialChance, + vegBirthLimit: Math.random() < 0.5 ? p1.vegBirthLimit : p2.vegBirthLimit, + vegDeathLimit: Math.random() < 0.5 ? p1.vegDeathLimit : p2.vegDeathLimit, + vegSteps: Math.random() < 0.5 ? p1.vegSteps : p2.vegSteps, + }; +} + +function mutate(g: Genotype) { + if (Math.random() < MUTATION_RATE) g.initialChance = Math.max(0, Math.min(1, g.initialChance + (Math.random() - 0.5) * 0.1)); + if (Math.random() < MUTATION_RATE) g.birthLimit = Math.max(1, Math.min(8, Math.floor(g.birthLimit + (Math.random() - 0.5) * 4))); + if (Math.random() < MUTATION_RATE) g.deathLimit = Math.max(1, Math.min(8, Math.floor(g.deathLimit + (Math.random() - 0.5) * 4))); + if (Math.random() < MUTATION_RATE) g.steps = Math.max(3, Math.min(10, Math.floor(g.steps + (Math.random() - 0.5) * 4))); + if (Math.random() < MUTATION_RATE) g.smoothingSteps = Math.max(0, Math.min(5, Math.floor(g.smoothingSteps + (Math.random() - 0.5) * 3))); + if (Math.random() < MUTATION_RATE) g.noiseReduction = !g.noiseReduction; + + if (Math.random() < MUTATION_RATE) g.useNoise = !g.useNoise; + if (Math.random() < MUTATION_RATE) g.noiseType = g.noiseType === 0 ? 1 : 0; + if (Math.random() < MUTATION_RATE) g.noiseScale = Math.max(5, Math.min(80, g.noiseScale + (Math.random() - 0.5) * 5)); + if (Math.random() < MUTATION_RATE) g.noiseThreshold = Math.max(0.1, Math.min(0.9, g.noiseThreshold + (Math.random() - 0.5) * 0.1)); + + if (Math.random() < MUTATION_RATE) g.useRooms = !g.useRooms; + if (Math.random() < MUTATION_RATE) g.roomCount = Math.max(0, Math.min(25, Math.floor(g.roomCount + (Math.random() - 0.5) * 3))); + if (Math.random() < MUTATION_RATE) g.roomMinSize = Math.max(3, Math.min(10, Math.floor(g.roomMinSize + (Math.random() - 0.5) * 2))); + if (Math.random() < MUTATION_RATE) g.roomMaxSize = Math.max(5, Math.min(20, Math.floor(g.roomMaxSize + (Math.random() - 0.5) * 2))); + + if (Math.random() < MUTATION_RATE) g.waterInitialChance = Math.max(0, Math.min(1, g.waterInitialChance + (Math.random() - 0.5) * 0.1)); + if (Math.random() < MUTATION_RATE) g.waterBirthLimit = Math.max(1, Math.min(8, Math.floor(g.waterBirthLimit + (Math.random() - 0.5) * 4))); + if (Math.random() < MUTATION_RATE) g.waterDeathLimit = Math.max(1, Math.min(8, Math.floor(g.waterDeathLimit + (Math.random() - 0.5) * 4))); + if (Math.random() < MUTATION_RATE) g.waterSteps = Math.max(3, Math.min(10, Math.floor(g.waterSteps + (Math.random() - 0.5) * 4))); + + if (Math.random() < MUTATION_RATE) g.lavaInitialChance = Math.max(0, Math.min(1, g.lavaInitialChance + (Math.random() - 0.5) * 0.1)); + if (Math.random() < MUTATION_RATE) g.lavaBirthLimit = Math.max(1, Math.min(8, Math.floor(g.lavaBirthLimit + (Math.random() - 0.5) * 4))); + if (Math.random() < MUTATION_RATE) g.lavaDeathLimit = Math.max(1, Math.min(8, Math.floor(g.lavaDeathLimit + (Math.random() - 0.5) * 4))); + if (Math.random() < MUTATION_RATE) g.lavaSteps = Math.max(3, Math.min(10, Math.floor(g.lavaSteps + (Math.random() - 0.5) * 4))); + + if (Math.random() < MUTATION_RATE) g.vegInitialChance = Math.max(0, Math.min(1, g.vegInitialChance + (Math.random() - 0.5) * 0.1)); + if (Math.random() < MUTATION_RATE) g.vegBirthLimit = Math.max(1, Math.min(8, Math.floor(g.vegBirthLimit + (Math.random() - 0.5) * 4))); + if (Math.random() < MUTATION_RATE) g.vegDeathLimit = Math.max(1, Math.min(8, Math.floor(g.vegDeathLimit + (Math.random() - 0.5) * 4))); + if (Math.random() < MUTATION_RATE) g.vegSteps = Math.max(3, Math.min(10, Math.floor(g.vegSteps + (Math.random() - 0.5) * 4))); +} diff --git a/src/apps/RogueGen/fitness.ts b/src/apps/RogueGen/fitness.ts new file mode 100644 index 0000000..544b6ca --- /dev/null +++ b/src/apps/RogueGen/fitness.ts @@ -0,0 +1,214 @@ +import type { MapData } from './types'; + +export interface FitnessResult { + score: number; + connectivity: number; + density: number; +} + +export interface FitnessTargets { + density: number; + water: number; + lava: number; + veg: number; + minPathLength: number; // New param +} + +export function calculateFitness(map: MapData, targets: FitnessTargets): FitnessResult { + const { grid, width, height } = map; + let totalFloor = 0; + let totalWater = 0; + let totalLava = 0; + let totalVeg = 0; + + // 1. Calculate Density (Target 45% floor - configurable) + + // 1. Calculate Density (Target 45% floor - configurable) + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const t = grid[y * width + x]; + if (t === 0) totalFloor++; + else if (t === 2) totalWater++; + else if (t === 3) totalLava++; + else if (t === 4) totalVeg++; + } + } + + // "Open Space" = anything not a wall + const totalOpen = totalFloor + totalWater + totalLava + totalVeg; + const totalCells = width * height; + + // Target Open Space (inverse of Wall Density?) + // Usually Density = Wall Density. + // If target is "Floor Density" (open space), we use targets.density directly. + // Let's assume targets.density = Target Open Space %. + const openDensity = totalOpen / totalCells; + const densityScore = 1 - Math.abs(openDensity - targets.density) * 2; + + // Ratios within Open Space + if (totalOpen === 0) return { score: 0, connectivity: 0, density: 0 }; + + const waterRatio = totalWater / totalOpen; + const lavaRatio = totalLava / totalOpen; + const vegRatio = totalVeg / totalOpen; + + const waterScore = 1 - Math.abs(waterRatio - targets.water) * 3; + const lavaScore = 1 - Math.abs(lavaRatio - targets.lava) * 5; + const vegScore = 1 - Math.abs(vegRatio - targets.veg) * 3; + + // 2. Connectivity (Largest Flood Fill on WALKABLE tiles) + + const walkableCells = totalFloor + totalVeg; + if (walkableCells === 0) { + return { score: 0, connectivity: 0, density: openDensity }; + } + + const visited = new Uint8Array(width * height); + let maxConnected = 0; + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + // Start flood fill on a walkable tile + const idx = y * width + x; + const tile = grid[idx]; + // Check flat visited array + if ((tile === 0 || tile === 4) && visited[idx] === 0) { + const size = floodFill(grid, x, y, visited, width); + if (size > maxConnected) { + maxConnected = size; + } + } + } + } + + const connectivity = maxConnected / walkableCells; + + // Composite Score + let score = (connectivity * 0.4) + + (densityScore * 0.2) + + (waterScore * 0.1) + + (lavaScore * 0.15) + + (vegScore * 0.15); + + if (connectivity < 0.5) score *= 0.1; + + // Bonus for hitting targets closely if target > 0 + // Bonus for hitting targets closely if target > 0 + if (targets.lava > 0 && lavaRatio >= targets.lava * 0.8) score += 0.05; + if (targets.veg > 0 && vegRatio >= targets.veg * 0.8) score += 0.05; + + // 3. Clumping Score (Avoid Static Noise) + // Check neighbors. If many neighbors are same type, good. + let sameNeighborCount = 0; + let totalChecks = 0; + + for (let y = 1; y < height - 1; y += 2) { // Optimization: check every other pixel + for (let x = 1; x < width - 1; x += 2) { + const idx = y * width + x; + const self = grid[idx]; + totalChecks++; + + // extensive neighbor check + let localSame = 0; + if (grid[(y+1)*width + x] === self) localSame++; + if (grid[(y-1)*width + x] === self) localSame++; + if (grid[y*width + (x+1)] === self) localSame++; + if (grid[y*width + (x-1)] === self) localSame++; + + if (localSame >= 2) sameNeighborCount++; + } + } + + // Reward clumping strongly + const clumpingScore = totalChecks > 0 ? sameNeighborCount / totalChecks : 0; + score += clumpingScore * 0.3; // Significant bonus for non-noisy maps + + // 4. Path Length Score + // If map.pathLength < minPathLength, penalize. + if (map.pathLength !== undefined && targets.minPathLength > 0) { + if (map.pathLength < targets.minPathLength) { + // Linear penalty? Or exponential? + // e.g. target 50. Actual 25. Score 0.5. + const ratio = map.pathLength / targets.minPathLength; + score *= ratio; // Hard penalty on everything if path is too short + } else { + score += 0.1; // Bonus for meeting criteria + } + } + + return { score, connectivity, density: openDensity }; +} + +function floodFill(grid: Uint8Array, startX: number, startY: number, visited: Uint8Array, width: number): number { + let count = 0; + // Stack of coordinate pairs (packed or objects? Objects are slow. Let's use two stacks or one packed stack) + // Packed integer stack: y * width + x + const stack = [startY * width + startX]; + + // Mark visited + visited[startY * width + startX] = 1; + count++; + + while (stack.length > 0) { + const packed = stack.pop()!; + const cx = packed % width; + const cy = Math.floor(packed / width); + + // Inline neighbors for speed + // N + if (cy > 0) { + const ny = cy - 1; + const idx = ny * width + cx; + if (visited[idx] === 0) { + const t = grid[ny * width + cx]; + if (t === 0 || t === 4) { + visited[idx] = 1; + stack.push(idx); + count++; + } + } + } + // S + const height = grid.length / width; + if (cy < height - 1) { + const ny = cy + 1; + const idx = ny * width + cx; + if (visited[idx] === 0) { + const t = grid[ny * width + cx]; + if (t === 0 || t === 4) { + visited[idx] = 1; + stack.push(idx); + count++; + } + } + } + // W + if (cx > 0) { + const nx = cx - 1; + const idx = cy * width + nx; + if (visited[idx] === 0) { + const t = grid[cy * width + nx]; + if (t === 0 || t === 4) { + visited[idx] = 1; + stack.push(idx); + count++; + } + } + } + // E + if (cx < width - 1) { + const nx = cx + 1; + const idx = cy * width + nx; + if (visited[idx] === 0) { + const t = grid[cy * width + nx]; + if (t === 0 || t === 4) { + visited[idx] = 1; + stack.push(idx); + count++; + } + } + } + } + return count; +} diff --git a/src/apps/RogueGen/generator.ts b/src/apps/RogueGen/generator.ts new file mode 100644 index 0000000..ba53e20 --- /dev/null +++ b/src/apps/RogueGen/generator.ts @@ -0,0 +1,655 @@ +import type { Genotype, MapData } from './types'; +import { Perlin } from './perlin'; + +// Initialize Perlin once (or per gen? per gen better for seed, but instance is cheap) +// Actually we want random noise every time, Perlin class randomizes on init. + +export function generateMap(genome: Genotype, width: number, height: number, minPathLength: number = 0): MapData { + let map = new Uint8Array(width * height); + + // --- Step 1: Initialization (Noise vs Random) --- + if (genome.useNoise) { + const perlin = new Perlin(); + const scale = genome.noiseScale || 20; + const threshold = genome.noiseThreshold || 0.45; + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const idx = y * width + x; + // Edges always walls + if (x === 0 || x === width - 1 || y === 0 || y === height - 1) { + map[idx] = 1; + continue; + } + + // Noise value -1 to 1 usually + const value = perlin.noise(x / scale, y / scale, 0); + + let isEmpty = false; + + if (genome.noiseType === 1) { + // Tunnel Mode (Ridged): Empty space near 0 + const tunnelWidth = genome.noiseThreshold * 0.5; // Scale down for thinner tunnels + if (Math.abs(value) < tunnelWidth) isEmpty = true; + } else { + // Blob Mode (Standard) + const norm = (value + 1) / 2; + if (norm >= threshold) isEmpty = true; + } + + if (!isEmpty) map[idx] = 1; // Wall + else map[idx] = 0; // Floor + } + } + } else { + // Legacy Random Init + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const idx = y * width + x; + if (x === 0 || x === width - 1 || y === 0 || y === height - 1) { + map[idx] = 1; + } else { + map[idx] = Math.random() < genome.initialChance ? 1 : 0; + } + } + } + } + + // --- Step 2: Room Injection --- + if (genome.useRooms) { + const count = genome.roomCount; + const min = genome.roomMinSize; + const max = genome.roomMaxSize; + + for(let i=0; i genome.birthLimit) buffer[idx] = 1; + else buffer[idx] = 0; + } + } + } + // Swap + let temp = map; + map = buffer; + buffer = temp; + } + + + // Smoothing steps + for (let s = 0; s < genome.smoothingSteps; s++) { + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const idx = y * width + x; + if (x === 0 || x === width - 1 || y === 0 || y === height - 1) { + buffer[idx] = 1; + continue; + } + + const neighbors = countNeighbors(map, width, height, x, y, 1); + if (neighbors > 4) buffer[idx] = 1; + else if (neighbors < 4) buffer[idx] = 0; + else buffer[idx] = map[idx]; + } + } + let temp = map; + map = buffer; + buffer = temp; + } + + // Noise Reduction + if (genome.noiseReduction) { + buffer.set(map); + + for (let y = 1; y < height - 1; y++) { + for (let x = 1; x < width - 1; x++) { + const idx = y * width + x; + if (map[idx] === 1) { + if (countNeighbors(map, width, height, x, y, 1) <= 1) { + buffer[idx] = 0; + } + } + } + } + let temp = map; + map = buffer; + buffer = temp; + } + + // --- Lava Layer Generation (Priority 2) --- + let lavaMap = runCASimulation(width, height, genome.lavaInitialChance, genome.lavaSteps, genome.lavaBirthLimit, genome.lavaDeathLimit, map, [1]); + applyLayer(map, lavaMap, 3); // 3 = Lava + + // --- Water Layer Generation (Priority 3) --- + let waterMap = runCASimulation(width, height, genome.waterInitialChance, genome.waterSteps, genome.waterBirthLimit, genome.waterDeathLimit, map, [1, 3]); + applyLayer(map, waterMap, 2); // 2 = Water + + // --- Vegetation Layer Generation (Priority 4) --- + let vegMap = runCASimulation(width, height, genome.vegInitialChance, genome.vegSteps, genome.vegBirthLimit, genome.vegDeathLimit, map, [1, 2, 3]); + applyLayer(map, vegMap, 4); // 4 = Veg + + // --- Step 4b: Post-Processing (Bridge Building with Pruning and Wobble) --- + connectRegions(map, width, height); + + // --- Step 5: Start & Exit Points --- + // Strategy: + // 1. Try Random Valid Path strategy (random start, random end > minPathLength) + // 2. If that fails (or no minPathLength given), FALLBACK to Double BFS (Diameter) to maximize path. + + let finalStart = {x:0, y:0}; + let finalEnd = {x:0, y:0}; + let pathDist = 0; + let found = false; + + // Use minPathLength or fallback heuristic + const targetDist = minPathLength > 0 ? minPathLength : Math.max(width, height) * 0.4; + + // ATTEMPT 1: Random Points (Variety) + for(let attempt=0; attempt<10; attempt++) { + // 1. Pick random start + let startX = -1, startY = -1; + let tries = 0; + while(tries < 50) { + const rx = Math.floor(Math.random() * (width - 2)) + 1; + const ry = Math.floor(Math.random() * (height - 2)) + 1; + const t = map[ry*width+rx]; + if (t === 0 || t === 4) { // Floor/Veg + startX = rx; startY = ry; + break; + } + tries++; + } + + if (startX === -1) continue; + + // 2. BFS Flood to find candidates + const dists = bfsFlood(map, width, height, startX, startY); + + const candidates = []; + + for(let y=1; y= targetDist) { // Strict GE check + candidates.push({x, y, dist: d}); + } + } + } + + if (candidates.length > 0) { + // Found at least one good path! + const chosen = candidates[Math.floor(Math.random() * candidates.length)]; + finalStart = {x: startX, y: startY}; + finalEnd = {x: chosen.x, y: chosen.y}; + pathDist = chosen.dist; + found = true; + break; + } + } + + // ATTEMPT 2: Fallback to Diameter (Reliability) + // If we couldn't find a random path > targetDist (maybe target is too high, or we got unlucky), + // we MUST try to find the longest possible path to show the user the "best" this map can do. + if (!found) { + // 1. Pick any valid point + let startX = -1, startY = -1; + outer2: for(let y=1; y B + const pB = bfsFurthest(map, width, height, startX, startY); + // 3. Find furthest from B -> C (Approximates Diameter) + const pC = bfsFurthest(map, width, height, pB.x, pB.y); + + finalStart = {x: pB.x, y: pB.y}; + finalEnd = {x: pC.x, y: pC.y}; + pathDist = pC.dist; + } + } + + return { + grid: map, + width, + height, + startPoint: finalStart, + endPoint: finalEnd, + pathLength: pathDist + }; +} + +// Simple BFS Flood returning distances array +function bfsFlood(grid: Uint8Array, width: number, height: number, startX: number, startY: number): Int32Array { + const dists = new Int32Array(width * height).fill(-1); + const queue = [startY * width + startX]; + dists[startY * width + startX] = 0; + + let head = 0; + while(head < queue.length) { + const packed = queue[head++]; + const cx = packed % width; + const cy = Math.floor(packed / width); + const d = dists[packed]; + + // Inline neighbors + const nOffsets = [-width, width, -1, 1]; // N, S, W, E + + for(let i=0; i<4; i++) { + const idx = packed + nOffsets[i]; // Be careful of edges? + // Ideally we check bounds. But since perimeter is always wall (1), + // we technically won't escape if we trust the wall. + // BUT, index could wrap if we are at x=width-1 and do +1 -> next row x=0. + // Safer to do coord check. + + let nx = cx, ny = cy; + if (i===0) ny--; + else if (i===1) ny++; + else if (i===2) nx--; + else if (i===3) nx++; + + if (nx >= 0 && nx < width && ny >= 0 && ny < height) { + const nIdx = ny * width + nx; + if (dists[nIdx] === -1) { + const t = grid[nIdx]; + if (t === 0 || t === 4) { // Walkable + dists[nIdx] = d + 1; + queue.push(nIdx); + } + } + } + } + } + return dists; +} + +// Helper to run a CA simulation for a feature layer +function runCASimulation(width: number, height: number, initialChance: number, steps: number, birth: number, death: number, baseMap: Uint8Array, forbiddenTiles: number[]): Uint8Array { + let layer = new Uint8Array(width * height); + + // Initialize + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const idx = y * width + x; + if (forbiddenTiles.includes(baseMap[idx])) { + layer[idx] = 0; + } else { + layer[idx] = Math.random() < initialChance ? 1 : 0; + } + } + } + + let buffer = new Uint8Array(width * height); + + // Run Steps + for (let s = 0; s < steps; s++) { + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const idx = y * width + x; + + if (forbiddenTiles.includes(baseMap[idx])) { + buffer[idx] = 0; // Ensure forbidden stays empty + continue; + } + + // Edges + if (x === 0 || x === width - 1 || y === 0 || y === height - 1) { + buffer[idx] = 1; // Or 0? Features usually unbound. Let's say 0. + continue; + } + + // Count neighbors of THIS layer (1s) + const neighbors = countNeighbors(layer, width, height, x, y, 1); + + if (layer[idx] === 1) { + if (neighbors < death) buffer[idx] = 0; + else buffer[idx] = 1; + } else { + if (neighbors > birth) buffer[idx] = 1; + else buffer[idx] = 0; + } + } + } + // Swap + let temp = layer; + layer = buffer; + buffer = temp; + } + return layer; +} + +function applyLayer(baseMap: Uint8Array, layer: Uint8Array, typeId: number) { + for (let i = 0; i < baseMap.length; i++) { + if (layer[i] === 1) { + if (baseMap[i] === 0) { + baseMap[i] = typeId; + } + } + } +} + +// BFS to find all connected regions of walkable tiles +function getRegions(map: Uint8Array, width: number, height: number): {points: {x:number, y:number}[], id: number}[] { + const visited = new Uint8Array(width * height); + const regions = []; + let regionId = 0; + + for (let y = 1; y < height - 1; y++) { + for (let x = 1; x < width - 1; x++) { + const idx = y * width + x; + // Walkable: 0 (Floor) or 4 (Veg) + if ((map[idx] === 0 || map[idx] === 4) && visited[idx] === 0) { + const points = []; + // Packed stack (DFS) + const stack = [idx]; + visited[idx] = 1; + points.push({x, y}); + + while(stack.length > 0) { + const packed = stack.pop()!; + const cx = packed % width; + const cy = Math.floor(packed / width); + + // Neighbors + // N + if (cy > 0) { + const ny = cy - 1; const nx = cx; + const nIdx = ny * width + nx; + if (visited[nIdx] === 0) { + const t = map[nIdx]; + if (t === 0 || t === 4) { + visited[nIdx] = 1; + points.push({x:nx, y:ny}); + stack.push(nIdx); + } + } + } + // S + if (cy < height - 1) { + const ny = cy + 1; const nx = cx; + const nIdx = ny * width + nx; + if (visited[nIdx] === 0) { + const t = map[nIdx]; + if (t === 0 || t === 4) { + visited[nIdx] = 1; + points.push({x:nx, y:ny}); + stack.push(nIdx); + } + } + } + // W + if (cx > 0) { + const ny = cy; const nx = cx - 1; + const nIdx = ny * width + nx; + if (visited[nIdx] === 0) { + const t = map[nIdx]; + if (t === 0 || t === 4) { + visited[nIdx] = 1; + points.push({x:nx, y:ny}); + stack.push(nIdx); + } + } + } + // E + if (cx < width - 1) { + const ny = cy; const nx = cx + 1; + const nIdx = ny * width + nx; + if (visited[nIdx] === 0) { + const t = map[nIdx]; + if (t === 0 || t === 4) { + visited[nIdx] = 1; + points.push({x:nx, y:ny}); + stack.push(nIdx); + } + } + } + } + regions.push({points, id: regionId++}); + } + } + } + return regions; +} + +function connectRegions(map: Uint8Array, width: number, height: number) { + let regions = getRegions(map, width, height); + + // PRUNING: Remove tiny regions (noise artifacts) + const PRUNE_SIZE = 12; + for (let i = regions.length - 1; i >= 0; i--) { + if (regions[i].points.length < PRUNE_SIZE) { + // Fill with wall + for(const p of regions[i].points) { + map[p.y * width + p.x] = 1; + } + regions.splice(i, 1); + } + } + + if (regions.length <= 1) return; + + // Sort by largest (Main) + regions.sort((a, b) => b.points.length - a.points.length); + const mainRegion = regions[0]; + + // Connect remaining + for (let i = 1; i < regions.length; i++) { + const region = regions[i]; + let minDistance = Infinity; + let startPoint = {x:0, y:0}; + let endPoint = {x:0, y:0}; + + // OPTIMIZATION: Sampling + const sampleSize = 30; // Check 30 random points + + const mainSamples = []; + if (mainRegion.points.length > sampleSize) { + for(let k=0; k sampleSize) { + for(let k=0; k0 && carverY0 && carverX= height || nx < 0 || nx >= width) { + if (targetInfo === 1) count++; // Edges are walls + } else { + if (map[ny * width + nx] === targetInfo) { + count++; + } + } + } + } + return count; +} + +function bfsFurthest(grid: Uint8Array, width: number, height: number, startX: number, startY: number): {x: number, y: number, dist: number} { + // Use Int32Array for distances to support large maps (-1 init) + const dists = new Int32Array(width * height).fill(-1); + + // Packed queue + const queue = [startY * width + startX]; + dists[startY * width + startX] = 0; + + let furthest = {x: startX, y: startY, dist: 0}; + + // Using Queue (Shift) is slow. + // Circular buffer or pointer index is better. + let head = 0; + + while(head < queue.length) { + const packed = queue[head++]; + const cx = packed % width; + const cy = Math.floor(packed / width); + const d = dists[packed]; + + if (d > furthest.dist) { + furthest = {x: cx, y: cy, dist: d}; + } + + // Inline neighbors + // N + if (cy > 0) { + const idx = (cy - 1) * width + cx; + if (dists[idx] === -1) { + const t = grid[idx]; + if (t === 0 || t === 4) { + dists[idx] = d + 1; + queue.push(idx); + } + } + } + // S + if (cy < height - 1) { + const idx = (cy + 1) * width + cx; + if (dists[idx] === -1) { + const t = grid[idx]; + if (t === 0 || t === 4) { + dists[idx] = d + 1; + queue.push(idx); + } + } + } + // W + if (cx > 0) { + const idx = cy * width + (cx - 1); + if (dists[idx] === -1) { + const t = grid[idx]; + if (t === 0 || t === 4) { + dists[idx] = d + 1; + queue.push(idx); + } + } + } + // E + if (cx < width - 1) { + const idx = cy * width + (cx + 1); + if (dists[idx] === -1) { + const t = grid[idx]; + if (t === 0 || t === 4) { + dists[idx] = d + 1; + queue.push(idx); + } + } + } + } + return furthest; +} diff --git a/src/apps/RogueGen/perlin.ts b/src/apps/RogueGen/perlin.ts new file mode 100644 index 0000000..3ec74f1 --- /dev/null +++ b/src/apps/RogueGen/perlin.ts @@ -0,0 +1,61 @@ +export class Perlin { + private perm: number[]; + + constructor() { + this.perm = new Array(512); + const p = new Array(256).fill(0).map((_, i) => i); + // Shuffle + for (let i = 255; i > 0; i--) { + const r = Math.floor(Math.random() * (i + 1)); + [p[i], p[r]] = [p[r], p[i]]; + } + for (let i = 0; i < 512; i++) { + this.perm[i] = p[i & 255]; + } + } + + public noise(x: number, y: number, z: number): number { + const X = Math.floor(x) & 255; + const Y = Math.floor(y) & 255; + const Z = Math.floor(z) & 255; + + x -= Math.floor(x); + y -= Math.floor(y); + z -= Math.floor(z); + + const u = fade(x); + const v = fade(y); + const w = fade(z); + + const A = this.perm[X] + Y; + const AA = this.perm[A] + Z; + const AB = this.perm[A + 1] + Z; + const B = this.perm[X + 1] + Y; + const BA = this.perm[B] + Z; + const BB = this.perm[B + 1] + Z; + + return lerp(w, lerp(v, lerp(u, grad(this.perm[AA], x, y, z), + grad(this.perm[BA], x - 1, y, z)), + lerp(u, grad(this.perm[AB], x, y - 1, z), + grad(this.perm[BB], x - 1, y - 1, z))), + lerp(v, lerp(u, grad(this.perm[AA + 1], x, y, z - 1), + grad(this.perm[BA + 1], x - 1, y, z - 1)), + lerp(u, grad(this.perm[AB + 1], x, y - 1, z - 1), + grad(this.perm[BB + 1], x - 1, y - 1, z - 1)))); + } +} + +function fade(t: number): number { + return t * t * t * (t * (t * 6 - 15) + 10); +} + +function lerp(t: number, a: number, b: number): number { + return a + t * (b - a); +} + +function grad(hash: number, x: number, y: number, z: number): number { + const h = hash & 15; + const u = h < 8 ? x : y; + const v = h < 4 ? y : h === 12 || h === 14 ? x : z; + return ((h & 1) === 0 ? u : -u) + ((h & 2) === 0 ? v : -v); +} diff --git a/src/apps/RogueGen/types.ts b/src/apps/RogueGen/types.ts new file mode 100644 index 0000000..4a451a5 --- /dev/null +++ b/src/apps/RogueGen/types.ts @@ -0,0 +1,46 @@ +export interface Genotype { + initialChance: number; // 0.0 - 1.0 + birthLimit: number; // 1 - 8 + deathLimit: number; // 1 - 8 + steps: number; // 1 - 10 + smoothingSteps: number; // 0 - 5 + noiseReduction: boolean; // Remove small unconnected walls + + // Hybrid Generation + useNoise: boolean; // If true, use Perlin Noise instead of random noise + noiseType: number; // 0 = Blob (Standard), 1 = Tunnel (Ridged) + noiseScale: number; // 5-50 (Zoom level) + noiseThreshold: number; // 0.2 - 0.8 (Sea/Wall level) + + useRooms: boolean; // If true, inject rooms + roomCount: number; // 0-20 + roomMinSize: number; // 3-8 + roomMaxSize: number; // 8-15 + + // Water Layer (2) + waterInitialChance: number; + waterBirthLimit: number; + waterDeathLimit: number; + waterSteps: number; + + // Lava Layer (3) + lavaInitialChance: number; + lavaBirthLimit: number; + lavaDeathLimit: number; + lavaSteps: number; + + // Vegetation Layer (4) + vegInitialChance: number; + vegBirthLimit: number; + vegDeathLimit: number; + vegSteps: number; +} + +export interface MapData { + grid: Uint8Array; // 1 = wall, 0 = floor, flat array (y*width+x) + width: number; + height: number; + startPoint?: {x: number, y: number}; + endPoint?: {x: number, y: number}; + pathLength?: number; +} diff --git a/src/apps/SnakeAI/BestSnakeDisplay.tsx b/src/apps/SnakeAI/BestSnakeDisplay.tsx index dff539c..a84f93f 100644 --- a/src/apps/SnakeAI/BestSnakeDisplay.tsx +++ b/src/apps/SnakeAI/BestSnakeDisplay.tsx @@ -29,7 +29,7 @@ export default function BestSnakeDisplay({ network, gridSize, fitness }: BestSna setPlaybackSpeed(Number(e.target.value))} style={{ flex: 1, accentColor: '#4ecdc4' }} diff --git a/src/apps/SnakeAI/SnakeAI.tsx b/src/apps/SnakeAI/SnakeAI.tsx index fe8f35a..8e5dba7 100644 --- a/src/apps/SnakeAI/SnakeAI.tsx +++ b/src/apps/SnakeAI/SnakeAI.tsx @@ -7,7 +7,6 @@ import Tips from './Tips'; import BestSnakeDisplay from './BestSnakeDisplay'; import { createPopulation, - type Population, } from '../../lib/snakeAI/evolution'; import type { EvolutionConfig } from '../../lib/snakeAI/types'; import './SnakeAI.css'; @@ -20,7 +19,8 @@ const DEFAULT_CONFIG: EvolutionConfig = { maxGameSteps: 20000, }; -import EvolutionWorker from '../../lib/snakeAI/evolution.worker?worker'; +import { WorkerPool } from '../../lib/snakeAI/workerPool'; +import { evolveGeneration, updateBestStats, type Population } from '../../lib/snakeAI/evolution'; export default function SnakeAI() { const [population, setPopulation] = useState(() => @@ -42,73 +42,64 @@ export default function SnakeAI() { const lastUpdateRef = useRef(0); // Compute derived values for display - // If we have stats from the last generation, use them. Otherwise default to 0. const currentBestFitness = population.lastGenerationStats?.bestFitness || 0; const currentAverageFitness = population.lastGenerationStats?.averageFitness || 0; - const workerRef = useRef(null); + const workerPoolRef = useRef(null); const isProcessingRef = useRef(false); useEffect(() => { - workerRef.current = new EvolutionWorker(); - workerRef.current.onmessage = (e) => { - const { type, payload } = e.data; // payload is the NEW population - if (type === 'SUCCESS') { - // Critical: Update ref immediately to prevent race condition with next animation frame - populationRef.current = payload; - setPopulation(payload); - - // Update history if we have stats - if (payload.lastGenerationStats) { - setFitnessHistory(prev => { - const newEntry = { - generation: payload.generation - 1, // The stats are for the gen that just finished - best: payload.lastGenerationStats!.bestFitness, - average: payload.lastGenerationStats!.averageFitness - }; - // Keep last 100 generations to avoid memory issues if running for eternity - const newHistory = [...prev, newEntry]; - if (newHistory.length > 100) return newHistory.slice(newHistory.length - 100); - return newHistory; - }); - } - - isProcessingRef.current = false; - } else { - console.error("Worker error:", payload); - isProcessingRef.current = false; - } - }; + // Initialize Worker Pool with logical cores (default) + workerPoolRef.current = new WorkerPool(); return () => { - workerRef.current?.terminate(); + workerPoolRef.current?.terminate(); }; }, []); - const runGeneration = useCallback((generations: number = 1) => { - if (isProcessingRef.current || !workerRef.current) return; + const runGeneration = useCallback(async (generations: number = 1) => { + if (isProcessingRef.current || !workerPoolRef.current) return; isProcessingRef.current = true; - // We need to send the *current* population. - // Since this is inside a callback, we need to be careful about closure staleness. - // However, we can't easily access the "latest" state inside a callback without refs or dependency. - // But 'population' is in the dependency array of the effect calling this? No. - // The animate loop calls this. + let currentPop = populationRef.current; - // Let's use a functional update approach? No, we need to SEND data. - // We will use a ref to track current population for the worker to ensure we always send latest - // OR rely on the fact that 'population' is in dependency of runGeneration (it wasn't before). + try { + for (let i = 0; i < generations; i++) { + // 1. Evaluate in parallel + let evaluatedPop = await workerPoolRef.current.evaluateParallel(currentPop, config); - // Wait, 'runGeneration' lines 43-58 previously used setPopulation(prev => ...). - // It didn't need 'population' in dependency. - // Now we need it. + // 1.5 Update Best Stats (Critical for UI) + evaluatedPop = updateBestStats(evaluatedPop); - workerRef.current.postMessage({ - population: populationRef.current, // Use a ref for latest population - config, - generations - }); - }, [config]); // populationRef will be handled separately + // 2. Evolve on main thread (fast) + currentPop = evolveGeneration(evaluatedPop, config); + } + + // Update state + populationRef.current = currentPop; + setPopulation(currentPop); + + // Update history + if (currentPop.lastGenerationStats) { + setFitnessHistory(prev => { + const newEntry = { + generation: currentPop.generation - 1, + best: currentPop.lastGenerationStats!.bestFitness, + average: currentPop.lastGenerationStats!.averageFitness + }; + const newHistory = [...prev, newEntry]; + if (newHistory.length > 100) return newHistory.slice(newHistory.length - 100); + return newHistory; + }); + } + + } catch (err) { + console.error("Evolution error:", err); + setIsRunning(false); + } finally { + isProcessingRef.current = false; + } + }, [config]); // Update stats when generation changes useEffect(() => { diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 47943cb..02f65b3 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -1,7 +1,7 @@ import { NavLink } from 'react-router-dom'; import './Sidebar.css'; -export type AppId = 'image-approx' | 'snake-ai'; +export type AppId = 'image-approx' | 'snake-ai' | 'rogue-gen' | 'neat-arena'; export interface AppInfo { id: AppId; @@ -26,6 +26,20 @@ export const APPS: AppInfo[] = [ icon: '🐍', description: 'Evolve neural networks to play Snake', }, + { + id: 'rogue-gen', + path: '/rogue-gen', + name: 'Rogue Map Gen', + icon: '🏰', + description: 'Evolve cellular automata for dungeon generation', + }, + { + id: 'neat-arena', + path: '/neat-arena', + name: 'NEAT Arena', + icon: '⚔️', + description: 'Evolve AI agents to fight in a top-down shooter', + }, ]; export default function Sidebar() { diff --git a/src/lib/neatArena/arenaScene.ts b/src/lib/neatArena/arenaScene.ts new file mode 100644 index 0000000..418c669 --- /dev/null +++ b/src/lib/neatArena/arenaScene.ts @@ -0,0 +1,184 @@ +import Phaser from 'phaser'; +import type { SimulationState } from './types'; +import { SIMULATION_CONFIG } from './types'; + +/** + * Phaser scene for rendering the NEAT Arena. + * + * This scene is ONLY for visualization - the actual simulation runs separately. + * The scene receives simulation state updates and renders them. + */ +export class ArenaScene extends Phaser.Scene { + private simulationState: SimulationState | null = null; + private showRays: boolean = true; + + // Graphics objects + private wallGraphics!: Phaser.GameObjects.Graphics; + private agentGraphics!: Phaser.GameObjects.Graphics; + private bulletGraphics!: Phaser.GameObjects.Graphics; + private rayGraphics!: Phaser.GameObjects.Graphics; + + constructor() { + super({ key: 'ArenaScene' }); + } + + create() { + // Create graphics layers (back to front) + this.wallGraphics = this.add.graphics(); + this.rayGraphics = this.add.graphics(); + this.bulletGraphics = this.add.graphics(); + this.agentGraphics = this.add.graphics(); + + // Set background + this.cameras.main.setBackgroundColor(0x1a1a2e); + } + + update() { + if (!this.simulationState) return; + + this.render(); + } + + /** + * Update the simulation state to render + */ + public updateSimulation(state: SimulationState) { + this.simulationState = state; + } + + /** + * Toggle ray visualization + */ + public setShowRays(show: boolean) { + this.showRays = show; + } + + /** + * Render the current simulation state + */ + private render() { + if (!this.simulationState) return; + + // Clear graphics + this.wallGraphics.clear(); + this.agentGraphics.clear(); + this.bulletGraphics.clear(); + this.rayGraphics.clear(); + + // Render walls + this.renderWalls(); + + // Render rays (if enabled) + if (this.showRays) { + this.renderRays(); + } + + // Render bullets + this.renderBullets(); + + // Render agents + this.renderAgents(); + } + + private renderWalls() { + if (!this.simulationState) return; + + const { walls } = this.simulationState.map; + + this.wallGraphics.fillStyle(0x4a5568, 1); + this.wallGraphics.lineStyle(2, 0x64748b, 1); + + for (const wall of walls) { + const { minX, minY, maxX, maxY } = wall.rect; + this.wallGraphics.fillRect(minX, minY, maxX - minX, maxY - minY); + this.wallGraphics.strokeRect(minX, minY, maxX - minX, maxY - minY); + } + } + + private renderAgents() { + if (!this.simulationState) return; + + const agents = this.simulationState.agents; + const colors = [0x667eea, 0xf093fb]; // Purple and pink + + for (let i = 0; i < agents.length; i++) { + const agent = agents[i]; + const color = colors[i]; + + // Agent body (circle) + if (agent.invulnTicks > 0) { + // Flash when invulnerable + const alpha = agent.invulnTicks % 4 < 2 ? 0.5 : 1; + this.agentGraphics.fillStyle(color, alpha); + } else { + this.agentGraphics.fillStyle(color, 1); + } + + this.agentGraphics.fillCircle(agent.position.x, agent.position.y, agent.radius); + + // Border + this.agentGraphics.lineStyle(2, 0xffffff, 0.8); + this.agentGraphics.strokeCircle(agent.position.x, agent.position.y, agent.radius); + + // Aim direction indicator + const aimLength = 20; + const aimEndX = agent.position.x + Math.cos(agent.aimAngle) * aimLength; + const aimEndY = agent.position.y + Math.sin(agent.aimAngle) * aimLength; + + this.agentGraphics.lineStyle(3, 0xffffff, 1); + this.agentGraphics.lineBetween(agent.position.x, agent.position.y, aimEndX, aimEndY); + } + } + + private renderBullets() { + if (!this.simulationState) return; + + this.bulletGraphics.fillStyle(0xfbbf24, 1); // Yellow + this.bulletGraphics.lineStyle(1, 0xffffff, 0.8); + + for (const bullet of this.simulationState.bullets) { + this.bulletGraphics.fillCircle(bullet.position.x, bullet.position.y, 3); + this.bulletGraphics.strokeCircle(bullet.position.x, bullet.position.y, 3); + } + } + + private renderRays() { + if (!this.simulationState) return; + + // TODO: This will be implemented when we integrate sensor visualization + // For now, rays will be rendered when we have a specific agent's observation to display + } +} + +/** + * Create and initialize a Phaser game instance for the arena + */ +export function createArenaViewer(parentElement: HTMLElement): Phaser.Game { + const config: Phaser.Types.Core.GameConfig = { + type: Phaser.AUTO, + width: SIMULATION_CONFIG.WORLD_SIZE, + height: SIMULATION_CONFIG.WORLD_SIZE, + parent: parentElement, + backgroundColor: '#1a1a2e', + scene: ArenaScene, + physics: { + default: 'arcade', + arcade: { + debug: false, + }, + }, + scale: { + mode: Phaser.Scale.FIT, + autoCenter: Phaser.Scale.CENTER_BOTH, + }, + }; + + return new Phaser.Game(config); +} + +/** + * Get the scene instance from a Phaser game + */ +export function getArenaScene(game: Phaser.Game): ArenaScene { + return game.scene.getScene('ArenaScene') as ArenaScene; +} diff --git a/src/lib/neatArena/baselineBots.ts b/src/lib/neatArena/baselineBots.ts new file mode 100644 index 0000000..a7151c5 --- /dev/null +++ b/src/lib/neatArena/baselineBots.ts @@ -0,0 +1,60 @@ +import type { AgentAction } from './types'; +import { SeededRandom } from './utils'; + +/** + * Baseline scripted bots for testing and benchmarking. + * + * These provide simple strategies that can be used to: + * - Test the simulation mechanics + * - Provide initial training opponents + * - Benchmark evolved agents + */ + +/** + * Random bot - takes random actions + */ +export function randomBotAction(rng: SeededRandom): AgentAction { + return { + moveX: rng.nextFloat(-1, 1), + moveY: rng.nextFloat(-1, 1), + turn: rng.nextFloat(-1, 1), + shoot: rng.next(), + }; +} + +/** + * Idle bot - does nothing + */ +export function idleBotAction(): AgentAction { + return { + moveX: 0, + moveY: 0, + turn: 0, + shoot: 0, + }; +} + +/** + * Spinner bot - spins in place and shoots + */ +export function spinnerBotAction(): AgentAction { + return { + moveX: 0, + moveY: 0, + turn: 1, + shoot: 1, + }; +} + +/** + * Circle strafe bot - moves in circles and shoots + */ +export function circleStrafeBotAction(tick: number): AgentAction { + const angle = (tick / 20) * Math.PI * 2; + return { + moveX: Math.cos(angle), + moveY: Math.sin(angle), + turn: 0.3, + shoot: tick % 15 === 0 ? 1 : 0, + }; +} diff --git a/src/lib/neatArena/crossover.ts b/src/lib/neatArena/crossover.ts new file mode 100644 index 0000000..0906bba --- /dev/null +++ b/src/lib/neatArena/crossover.ts @@ -0,0 +1,76 @@ +import type { Genome, InnovationTracker } from './genome'; +import { cloneGenome } from './genome'; + +/** + * NEAT Crossover + * + * Produces offspring by crossing over two parent genomes. + * Follows the NEAT crossover rules: + * - Matching genes are randomly inherited + * - Disjoint/excess genes are inherited from the fitter parent + * - Disabled genes have a chance to stay disabled + */ + +const DISABLED_GENE_INHERITANCE_RATE = 0.75; + +/** + * Perform crossover between two genomes + * @param parent1 First parent (should be fitter or equal) + * @param parent2 Second parent + * @param innovationTracker Not used in crossover, but kept for consistency + * @returns Offspring genome + */ +export function crossover( + parent1: Genome, + parent2: Genome, + innovationTracker?: InnovationTracker +): Genome { + // Ensure parent1 is fitter (or equal) + if (parent2.fitness > parent1.fitness) { + [parent1, parent2] = [parent2, parent1]; + } + + const offspring = cloneGenome(parent1); + offspring.connections = []; + offspring.fitness = 0; + + // Build innovation maps + const p1Connections = new Map( + parent1.connections.map(c => [c.innovation, c]) + ); + const p2Connections = new Map( + parent2.connections.map(c => [c.innovation, c]) + ); + + // Get all innovation numbers + const allInnovations = new Set([ + ...p1Connections.keys(), + ...p2Connections.keys(), + ]); + + for (const innovation of allInnovations) { + const conn1 = p1Connections.get(innovation); + const conn2 = p2Connections.get(innovation); + + if (conn1 && conn2) { + // Matching gene - randomly choose from either parent + const chosen = Math.random() < 0.5 ? conn1 : conn2; + const newConn = { ...chosen }; + + // Handle disabled gene inheritance + if (!conn1.enabled || !conn2.enabled) { + if (Math.random() < DISABLED_GENE_INHERITANCE_RATE) { + newConn.enabled = false; + } + } + + offspring.connections.push(newConn); + } else if (conn1) { + // Disjoint/excess gene from parent1 (fitter) + offspring.connections.push({ ...conn1 }); + } + // Genes only in parent2 are not inherited (parent1 is fitter) + } + + return offspring; +} diff --git a/src/lib/neatArena/evolution.ts b/src/lib/neatArena/evolution.ts new file mode 100644 index 0000000..4769e34 --- /dev/null +++ b/src/lib/neatArena/evolution.ts @@ -0,0 +1,154 @@ +import { InnovationTracker, type Genome } from './genome'; +import type { Species } from './speciation'; +import type { ReproductionConfig } from './reproduction'; +import { createMinimalGenome } from './genome'; +import { + speciate, + adjustCompatibilityThreshold, + applyFitnessSharing, + DEFAULT_COMPATIBILITY_CONFIG, + type CompatibilityConfig, +} from './speciation'; +import { reproduce, DEFAULT_REPRODUCTION_CONFIG } from './reproduction'; + +/** + * NEAT Evolution Engine + * + * Coordinates the entire evolution process: + * - Population management + * - Speciation + * - Fitness evaluation + * - Reproduction + */ + +export interface EvolutionConfig { + populationSize: number; + inputCount: number; + outputCount: number; + compatibilityConfig: CompatibilityConfig; + reproductionConfig: ReproductionConfig; +} + +export const DEFAULT_EVOLUTION_CONFIG: EvolutionConfig = { + populationSize: 40, + inputCount: 53, // Ray sensors + extra inputs + outputCount: 5, // moveX, moveY, turn, shoot, reserved + compatibilityConfig: DEFAULT_COMPATIBILITY_CONFIG, + reproductionConfig: DEFAULT_REPRODUCTION_CONFIG, +}; + +export interface Population { + genomes: Genome[]; + species: Species[]; + generation: number; + compatibilityThreshold: number; + innovationTracker: InnovationTracker; + bestGenomeEver: Genome | null; + bestFitnessEver: number; +} + +/** + * Create initial population + */ +export function createPopulation(config: EvolutionConfig): Population { + const innovationTracker = new InnovationTracker(); + const genomes: Genome[] = []; + + for (let i = 0; i < config.populationSize; i++) { + genomes.push(createMinimalGenome( + config.inputCount, + config.outputCount, + innovationTracker + )); + } + + return { + genomes, + species: [], + generation: 0, + compatibilityThreshold: 1.5, // Balanced to target 6-10 species + innovationTracker, + bestGenomeEver: null, + bestFitnessEver: -Infinity, + }; +} + +/** + * Evolve the population by one generation + * + * Note: This assumes genomes have already been evaluated and have fitness values. + */ +export function evolveGeneration(population: Population, config: EvolutionConfig): Population { + // 1. Speciate + const species = speciate( + population.genomes, + population.species, + population.compatibilityThreshold, + config.compatibilityConfig + ); + + // 2. Apply fitness sharing + applyFitnessSharing(species); + + // 3. Remove stagnant species (optional for now) + // TODO: Implement staleness checking and removal + + // 4. Track best genome + let bestGenome = population.bestGenomeEver; + let bestFitness = population.bestFitnessEver; + + for (const genome of population.genomes) { + if (genome.fitness > bestFitness) { + bestFitness = genome.fitness; + bestGenome = genome; + } + } + + // 5. Reproduce + const newGenomes = reproduce( + species, + config.populationSize, + population.innovationTracker, + config.reproductionConfig + ); + + // 6. Adjust compatibility threshold + const newThreshold = adjustCompatibilityThreshold( + population.compatibilityThreshold, + species.length + ); + + return { + genomes: newGenomes, + species, + generation: population.generation + 1, + compatibilityThreshold: newThreshold, + innovationTracker: population.innovationTracker, + bestGenomeEver: bestGenome, + bestFitnessEver: bestFitness, + }; +} + +/** + * Get statistics for the current population + */ +export function getPopulationStats(population: Population) { + const fitnesses = population.genomes.map(g => g.fitness); + const avgFitness = fitnesses.reduce((a, b) => a + b, 0) / fitnesses.length; + const maxFitness = Math.max(...fitnesses); + const minFitness = Math.min(...fitnesses); + + // When population comes from worker, innovationTracker is a plain object + // Access the private property directly instead of calling method + const totalInnovations = (population.innovationTracker as any).currentInnovation || 0; + + return { + generation: population.generation, + speciesCount: population.species.length, + avgFitness, + maxFitness, + minFitness, + bestFitnessEver: population.bestFitnessEver, + totalInnovations, + }; +} diff --git a/src/lib/neatArena/exportImport.ts b/src/lib/neatArena/exportImport.ts new file mode 100644 index 0000000..a1d6d26 --- /dev/null +++ b/src/lib/neatArena/exportImport.ts @@ -0,0 +1,120 @@ +import type { Genome } from './genome'; +import type { EvolutionConfig } from './evolution'; + +/** + * Export/Import system for trained genomes. + * + * Allows saving champion genomes as JSON files and loading them back + * for exhibition matches or continued training. + */ + +export interface ExportedGenome { + version: string; + timestamp: number; + config: { + inputCount: number; + outputCount: number; + }; + genome: Genome; + metadata?: { + generation?: number; + fitness?: number; + speciesCount?: number; + }; +} + +const EXPORT_VERSION = '1.0.0'; + +/** + * Export a genome to a downloadable JSON format + */ +export function exportGenome( + genome: Genome, + config: EvolutionConfig, + metadata?: ExportedGenome['metadata'] +): ExportedGenome { + return { + version: EXPORT_VERSION, + timestamp: Date.now(), + config: { + inputCount: config.inputCount, + outputCount: config.outputCount, + }, + genome: { + nodes: genome.nodes, + connections: genome.connections, + fitness: genome.fitness, + }, + metadata, + }; +} + +/** + * Import a genome from JSON + */ +export function importGenome(exported: ExportedGenome): { + genome: Genome; + config: { inputCount: number; outputCount: number }; +} { + // Version check + if (exported.version !== EXPORT_VERSION) { + console.warn(`Imported genome version ${exported.version} may be incompatible with current version ${EXPORT_VERSION}`); + } + + return { + genome: exported.genome, + config: exported.config, + }; +} + +/** + * Download genome as JSON file + */ +export function downloadGenomeAsFile(exported: ExportedGenome, filename?: string): void { + const json = JSON.stringify(exported, null, 2); + const blob = new Blob([json], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + + const link = document.createElement('a'); + link.href = url; + link.download = filename || `neat-champion-${Date.now()}.json`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + URL.revokeObjectURL(url); +} + +/** + * Upload and parse genome from file + */ +export function uploadGenomeFromFile(): Promise { + return new Promise((resolve, reject) => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = 'application/json,.json'; + + input.onchange = (e) => { + const file = (e.target as HTMLInputElement).files?.[0]; + if (!file) { + reject(new Error('No file selected')); + return; + } + + const reader = new FileReader(); + reader.onload = (event) => { + try { + const json = event.target?.result as string; + const exported = JSON.parse(json) as ExportedGenome; + resolve(exported); + } catch (err) { + reject(new Error('Failed to parse genome file')); + } + }; + reader.onerror = () => reject(new Error('Failed to read file')); + reader.readAsText(file); + }; + + input.click(); + }); +} diff --git a/src/lib/neatArena/fitness.ts b/src/lib/neatArena/fitness.ts new file mode 100644 index 0000000..4c48905 --- /dev/null +++ b/src/lib/neatArena/fitness.ts @@ -0,0 +1,86 @@ +import type { SimulationState } from './types'; +import { hasLineOfSight } from './sensors'; + +/** + * Fitness calculation for NEAT Arena. + * + * Fitness rewards: + * - +10 per hit on opponent + * - -10 per being hit + * - -0.002 per tick (time penalty to encourage aggression) + * - -0.2 per shot fired (ammo management) + * - +0.01 per tick when aiming well at visible opponent + */ + +export interface FitnessTracker { + agentId: number; + fitness: number; + + // For incremental calculation + lastKills: number; + lastHits: number; + shotsFired: number; +} + +/** + * Create a new fitness tracker + */ +export function createFitnessTracker(agentId: number): FitnessTracker { + return { + agentId, + fitness: 0, + lastKills: 0, + lastHits: 0, + shotsFired: 0, + }; +} + +/** + * Update fitness based on current simulation state + */ +export function updateFitness(tracker: FitnessTracker, state: SimulationState): FitnessTracker { + const agent = state.agents.find(a => a.id === tracker.agentId)!; + const opponent = state.agents.find(a => a.id !== tracker.agentId)!; + + const newTracker = { ...tracker }; + + // Reward for new kills + const newKills = agent.kills - tracker.lastKills; + newTracker.fitness += newKills * 10; + newTracker.lastKills = agent.kills; + + // Penalty for being hit + const newHits = agent.hits - tracker.lastHits; + newTracker.fitness -= newHits * 10; + newTracker.lastHits = agent.hits; + + // Time penalty (encourages finishing quickly) + newTracker.fitness -= 0.002; + + // Check if agent fired this tick (cooldown just set) + if (agent.fireCooldown === 10) { + newTracker.shotsFired++; + newTracker.fitness -= 0.2; + } + + // Reward for aiming at visible opponent + if (hasLineOfSight(agent, opponent, state.map.walls)) { + const dx = opponent.position.x - agent.position.x; + const dy = opponent.position.y - agent.position.y; + const angleToOpponent = Math.atan2(dy, dx); + + // Normalize angle difference + let angleDiff = angleToOpponent - agent.aimAngle; + while (angleDiff > Math.PI) angleDiff -= 2 * Math.PI; + while (angleDiff < -Math.PI) angleDiff += 2 * Math.PI; + + const cosAngleDiff = Math.cos(angleDiff); + + // Reward if aiming close (cos > 0.95 ≈ within ~18°) + if (cosAngleDiff > 0.95) { + newTracker.fitness += 0.01; + } + } + + return newTracker; +} diff --git a/src/lib/neatArena/genome.ts b/src/lib/neatArena/genome.ts new file mode 100644 index 0000000..68b486d --- /dev/null +++ b/src/lib/neatArena/genome.ts @@ -0,0 +1,214 @@ +/** + * NEAT Genome Implementation + * + * Represents a neural network genome with node genes and connection genes. + * Implements the core NEAT genome structure as described in the original paper. + */ + +export type NodeType = 'input' | 'hidden' | 'output'; +export type ActivationFunction = 'tanh' | 'sigmoid' | 'relu' | 'linear'; + +/** + * Node gene - represents a neuron + */ +export interface NodeGene { + id: number; + type: NodeType; + activation: ActivationFunction; +} + +/** + * Connection gene - represents a synapse + */ +export interface ConnectionGene { + innovation: number; + from: number; + to: number; + weight: number; + enabled: boolean; +} + +/** + * Complete genome + */ +export interface Genome { + nodes: NodeGene[]; + connections: ConnectionGene[]; + fitness: number; +} + +/** + * Global innovation tracker for historical markings + */ +export class InnovationTracker { + private currentInnovation: number = 0; + private innovationHistory: Map = new Map(); + + /** + * Get or create innovation number for a connection + */ + getInnovation(from: number, to: number): number { + const key = `${from}->${to}`; + + if (this.innovationHistory.has(key)) { + return this.innovationHistory.get(key)!; + } + + const innovation = this.currentInnovation++; + this.innovationHistory.set(key, innovation); + return innovation; + } + + /** + * Reset innovation tracking (useful for new experiments) + */ + reset(): void { + this.currentInnovation = 0; + this.innovationHistory.clear(); + } + + /** + * Get current innovation count + */ + getCurrentInnovation(): number { + return this.currentInnovation; + } +} + +/** + * Create a minimal genome with only input and output nodes, fully connected + */ +export function createMinimalGenome( + inputCount: number, + outputCount: number, + innovationTracker: InnovationTracker +): Genome { + const nodes: NodeGene[] = []; + const connections: ConnectionGene[] = []; + + // Create input nodes (IDs 0 to inputCount-1) + for (let i = 0; i < inputCount; i++) { + nodes.push({ + id: i, + type: 'input', + activation: 'linear', + }); + } + + // Create output nodes (IDs starting from inputCount) + for (let i = 0; i < outputCount; i++) { + nodes.push({ + id: inputCount + i, + type: 'output', + activation: 'tanh', + }); + } + + // Create fully connected minimal genome + for (let i = 0; i < inputCount; i++) { + const inputNode = i; // Assuming inputNode refers to the ID + + for (let o = 0; o < outputCount; o++) { + const outputNode = inputCount + o; // Assuming outputNode refers to the ID + const innovation = innovationTracker.getInnovation(inputNode, outputNode); + + connections.push({ + innovation, + from: inputNode, + to: outputNode, + weight: (Math.random() * 4) - 2, // Random weight in [-2, 2] for initial diversity + enabled: true, + }); + } + } + + return { + nodes, + connections, + fitness: 0, + }; +} + +/** + * Clone a genome (deep copy) + */ +export function cloneGenome(genome: Genome): Genome { + return { + nodes: genome.nodes.map(n => ({ ...n })), + connections: genome.connections.map(c => ({ ...c })), + fitness: genome.fitness, + }; +} + +/** + * Get next available node ID + */ +export function getNextNodeId(genome: Genome): number { + return Math.max(...genome.nodes.map(n => n.id)) + 1; +} + +/** + * Check if a connection already exists + */ +export function connectionExists(genome: Genome, from: number, to: number): boolean { + return genome.connections.some(c => c.from === from && c.to === to); +} + +/** + * Check if adding a connection would create a cycle (for feedforward networks) + */ +export function wouldCreateCycle(genome: Genome, from: number, to: number): boolean { + // Build adjacency list + const adj = new Map(); + for (const node of genome.nodes) { + adj.set(node.id, []); + } + + for (const conn of genome.connections) { + if (!conn.enabled) continue; + if (!adj.has(conn.from)) adj.set(conn.from, []); + adj.get(conn.from)!.push(conn.to); + } + + // Add the proposed connection + if (!adj.has(from)) adj.set(from, []); + adj.get(from)!.push(to); + + // DFS to detect cycle + const visited = new Set(); + const recStack = new Set(); + + const hasCycle = (nodeId: number): boolean => { + visited.add(nodeId); + recStack.add(nodeId); + + const neighbors = adj.get(nodeId) || []; + for (const neighbor of neighbors) { + if (!visited.has(neighbor)) { + if (hasCycle(neighbor)) return true; + } else if (recStack.has(neighbor)) { + return true; + } + } + + recStack.delete(nodeId); + return false; + }; + + // Check from the 'from' node + return hasCycle(from); +} + +/** + * Serialize genome to JSON + */ +export function serializeGenome(genome: Genome): string { + return JSON.stringify(genome, null, 2); +} + +/** + * Deserialize genome from JSON + */ +export function deserializeGenome(json: string): Genome { + return JSON.parse(json); +} diff --git a/src/lib/neatArena/mapGenerator.ts b/src/lib/neatArena/mapGenerator.ts new file mode 100644 index 0000000..2ddba8f --- /dev/null +++ b/src/lib/neatArena/mapGenerator.ts @@ -0,0 +1,123 @@ +import type { ArenaMap, Wall, SpawnPoint, AABB, Vec2 } from './types'; +import { SIMULATION_CONFIG } from './types'; +import { SeededRandom } from './utils'; + +/** + * Generates a symmetric arena map with procedurally placed walls. + * + * The map is generated by creating walls on the left half, then mirroring them + * to the right half for perfect symmetry. + * + * Spawn points are placed symmetrically as well. + */ +export function generateArenaMap(seed: number): ArenaMap { + const rng = new SeededRandom(seed); + const { WORLD_SIZE } = SIMULATION_CONFIG; + + const walls: Wall[] = []; + const spawnPoints: SpawnPoint[] = []; + + // Add boundary walls + const wallThickness = 16; + walls.push( + // Top + { rect: { minX: 0, minY: 0, maxX: WORLD_SIZE, maxY: wallThickness } }, + // Bottom + { rect: { minX: 0, minY: WORLD_SIZE - wallThickness, maxX: WORLD_SIZE, maxY: WORLD_SIZE } }, + // Left + { rect: { minX: 0, minY: 0, maxX: wallThickness, maxY: WORLD_SIZE } }, + // Right + { rect: { minX: WORLD_SIZE - wallThickness, minY: 0, maxX: WORLD_SIZE, maxY: WORLD_SIZE } } + ); + + // Generate interior walls on left half, then mirror + const numInteriorWalls = rng.nextInt(3, 6); + const leftHalfWalls: AABB[] = []; + + for (let i = 0; i < numInteriorWalls; i++) { + const width = rng.nextFloat(30, 80); + const height = rng.nextFloat(30, 80); + + // Keep walls in left half (with margin) + const minX = rng.nextFloat(wallThickness + 20, WORLD_SIZE / 2 - width - 20); + const minY = rng.nextFloat(wallThickness + 20, WORLD_SIZE - height - wallThickness - 20); + + const wall: AABB = { + minX, + minY, + maxX: minX + width, + maxY: minY + height, + }; + + leftHalfWalls.push(wall); + walls.push({ rect: wall }); + } + + // Mirror walls to right half + for (const leftWall of leftHalfWalls) { + const centerX = WORLD_SIZE / 2; + const distFromCenter = centerX - ((leftWall.minX + leftWall.maxX) / 2); + const mirroredCenterX = centerX + distFromCenter; + const wallWidth = leftWall.maxX - leftWall.minX; + + const mirroredWall: AABB = { + minX: mirroredCenterX - wallWidth / 2, + maxX: mirroredCenterX + wallWidth / 2, + minY: leftWall.minY, + maxY: leftWall.maxY, + }; + + walls.push({ rect: mirroredWall }); + } + + // Generate 5 symmetric spawn point pairs + // Spawn points should be clear of walls + for (let pairId = 0; pairId < 5; pairId++) { + let leftSpawn: Vec2; + let attempts = 0; + + // Find a valid spawn point on the left + do { + leftSpawn = { + x: rng.nextFloat(wallThickness + 40, WORLD_SIZE / 2 - 40), + y: rng.nextFloat(wallThickness + 40, WORLD_SIZE - wallThickness - 40), + }; + attempts++; + } while (isPositionInWall(leftSpawn, walls) && attempts < 50); + + // Mirror to right + const rightSpawn: Vec2 = { + x: WORLD_SIZE - leftSpawn.x, + y: leftSpawn.y, + }; + + spawnPoints.push( + { position: leftSpawn, pairId, side: 0 }, + { position: rightSpawn, pairId, side: 1 } + ); + } + + return { + walls, + spawnPoints, + seed, + }; +} + +/** + * Check if a position overlaps with any wall + */ +function isPositionInWall(pos: Vec2, walls: Wall[]): boolean { + const margin = 20; // give some breathing room + for (const wall of walls) { + if ( + pos.x >= wall.rect.minX - margin && + pos.x <= wall.rect.maxX + margin && + pos.y >= wall.rect.minY - margin && + pos.y <= wall.rect.maxY + margin + ) { + return true; + } + } + return false; +} diff --git a/src/lib/neatArena/mutations.ts b/src/lib/neatArena/mutations.ts new file mode 100644 index 0000000..ad19b60 --- /dev/null +++ b/src/lib/neatArena/mutations.ts @@ -0,0 +1,218 @@ +import type { Genome, InnovationTracker } from './genome'; +import { + cloneGenome, + getNextNodeId, + connectionExists, + wouldCreateCycle, +} from './genome'; + +/** + * NEAT Mutations + * + * Implements the core mutation operations: + * - Weight perturbation (80%) + * - Weight reset (10%) + * - Add connection (5%) + * - Add node (3%) + * - Toggle connection (2%) + */ + +export interface MutationRates { + mutateWeightsProb: number; + resetWeightProb: number; + addConnectionProb: number; + addNodeProb: number; + toggleConnectionProb: number; + perturbationPower: number; + resetRange: number; +} + +/** + * Default mutation probabilities + */ +export const DEFAULT_MUTATION_RATES: MutationRates = { + mutateWeightsProb: 0.50, // Reduced from 0.8 to allow more structural mutations + resetWeightProb: 0.05, // Reduced from 0.1 + addConnectionProb: 0.20, // Increased from 0.05 for more diversity + addNodeProb: 0.15, // Increased from 0.03 for more complexity + toggleConnectionProb: 0.10, // Increased from 0.02 + + // Weight mutation parameters + perturbationPower: 0.5, // Increased from 0.1 for stronger weight changes + resetRange: 2.0, // Weight reset range +}; + +/** + * Apply mutations to a genome + */ +export function mutate(genome: Genome, tracker: InnovationTracker, rates = DEFAULT_MUTATION_RATES): void { + let addedConnections = 0; + let addedNodes = 0; + let toggledConnections = 0; + + // Mutate weights + if (Math.random() < rates.mutateWeightsProb) { + mutateWeights(genome, rates); + } + + // Reset a random weight + if (Math.random() < rates.resetWeightProb) { + resetWeight(genome, rates); + } + + // Add connection + if (Math.random() < rates.addConnectionProb) { + if (addConnection(genome, tracker)) { + addedConnections++; + } + } + + // Add node + if (Math.random() < rates.addNodeProb) { + if (addNode(genome, tracker)) { + addedNodes++; + } + } + + // Toggle connection + if (Math.random() < rates.toggleConnectionProb) { + if (toggleConnection(genome)) { + toggledConnections++; + } + } + + // Log structural mutations (only if any happened) + if (addedConnections > 0 || addedNodes > 0 || toggledConnections > 0) { + console.log(`[Mutation] +${addedConnections} conn, +${addedNodes} nodes, ${toggledConnections} toggled`); + } +} + +/** + * Perturb weights slightly + */ +function mutateWeights(genome: Genome, rates: MutationRates): void { + for (const conn of genome.connections) { + if (Math.random() < 0.9) { + // Small perturbation + conn.weight += (Math.random() * 2 - 1) * rates.perturbationPower; + // Clamp to reasonable range + conn.weight = Math.max(-5, Math.min(5, conn.weight)); + } + } +} + +/** + * Reset a random weight to a new random value + */ +function resetWeight(genome: Genome, rates: MutationRates): void { + if (genome.connections.length === 0) return; + + const conn = genome.connections[Math.floor(Math.random() * genome.connections.length)]; + conn.weight = (Math.random() * 2 - 1) * rates.resetRange; +} + +/** + * Add a new connection between two nodes + */ +function addConnection(genome: Genome, innovationTracker: InnovationTracker): boolean { + const inputNodes = genome.nodes.filter(n => n.type === 'input'); + const nonInputNodes = genome.nodes.filter(n => n.type !== 'input'); + + if (inputNodes.length === 0 || nonInputNodes.length === 0) return false; + + // Try to find a valid connection + let attempts = 0; + const maxAttempts = 20; + + while (attempts < maxAttempts) { + // Random from node (any node) + const fromNode = genome.nodes[Math.floor(Math.random() * genome.nodes.length)]; + // Random to node (not input) + const toNode = nonInputNodes[Math.floor(Math.random() * nonInputNodes.length)]; + + // Can't connect to itself + if (fromNode.id === toNode.id) { + attempts++; + continue; + } + + // Check if connection already exists + if (connectionExists(genome, fromNode.id, toNode.id)) { + attempts++; + continue; + } + + // Check if it would create a cycle + if (wouldCreateCycle(genome, fromNode.id, toNode.id)) { + attempts++; + continue; + } + + // Valid connection! + genome.connections.push({ + innovation: innovationTracker.getInnovation(fromNode.id, toNode.id), + from: fromNode.id, + to: toNode.id, + weight: (Math.random() * 2 - 1) * 2, // [-2, 2] + enabled: true, + }); + + return true; + } + + return false; +} + +/** + * Add a new node by splitting an existing connection + */ +function addNode(genome: Genome, innovationTracker: InnovationTracker): boolean { + const enabledConnections = genome.connections.filter(c => c.enabled); + if (enabledConnections.length === 0) return false; + + // Pick a random enabled connection + const conn = enabledConnections[Math.floor(Math.random() * enabledConnections.length)]; + + // Disable the old connection + conn.enabled = false; + + // Create new node + const newNodeId = getNextNodeId(genome); + genome.nodes.push({ + id: newNodeId, + type: 'hidden', + activation: 'tanh', + }); + + // Create two new connections: + // 1. from -> newNode (weight = 1.0) + genome.connections.push({ + innovation: innovationTracker.getInnovation(conn.from, newNodeId), + from: conn.from, + to: newNodeId, + weight: 1.0, + enabled: true, + }); + + // 2. newNode -> to (weight = old connection's weight) + genome.connections.push({ + innovation: innovationTracker.getInnovation(newNodeId, conn.to), + from: newNodeId, + to: conn.to, + weight: conn.weight, + enabled: true, + }); + + return true; +} + +/** + * Toggle a random connection's enabled state + */ +function toggleConnection(genome: Genome): boolean { + if (genome.connections.length === 0) return false; + + const conn = genome.connections[Math.floor(Math.random() * genome.connections.length)]; + conn.enabled = !conn.enabled; + return true; +} diff --git a/src/lib/neatArena/network.ts b/src/lib/neatArena/network.ts new file mode 100644 index 0000000..b7a9f9d --- /dev/null +++ b/src/lib/neatArena/network.ts @@ -0,0 +1,183 @@ +import type { Genome, NodeGene, ConnectionGene, ActivationFunction } from './genome'; + +/** + * Feedforward neural network built from a NEAT genome. + * + * The network is built by topologically sorting the nodes and + * evaluating them in order to ensure feedforward behavior. + */ + +interface NetworkNode { + id: number; + activation: ActivationFunction; + inputs: { weight: number; sourceId: number }[]; + value: number; +} + +export class NeuralNetwork { + private inputNodes: number[]; + private outputNodes: number[]; + private nodes: Map; + private evaluationOrder: number[]; + + constructor(genome: Genome) { + this.inputNodes = []; + this.outputNodes = []; + this.nodes = new Map(); + this.evaluationOrder = []; + + this.buildNetwork(genome); + } + + /** + * Build the network from the genome + */ + private buildNetwork(genome: Genome): void { + // Create network nodes + for (const nodeGene of genome.nodes) { + this.nodes.set(nodeGene.id, { + id: nodeGene.id, + activation: nodeGene.activation, + inputs: [], + value: 0, + }); + + if (nodeGene.type === 'input') { + this.inputNodes.push(nodeGene.id); + } else if (nodeGene.type === 'output') { + this.outputNodes.push(nodeGene.id); + } + } + + // Add connections + for (const conn of genome.connections) { + if (!conn.enabled) continue; + + const targetNode = this.nodes.get(conn.to); + if (targetNode) { + targetNode.inputs.push({ + weight: conn.weight, + sourceId: conn.from, + }); + } + } + + // Compute evaluation order (topological sort) + this.evaluationOrder = this.topologicalSort(genome); + } + + /** + * Topological sort to determine evaluation order + */ + private topologicalSort(genome: Genome): number[] { + const inDegree = new Map(); + const adj = new Map(); + + // Initialize + for (const node of genome.nodes) { + inDegree.set(node.id, 0); + adj.set(node.id, []); + } + + // Build adjacency list and in-degrees + for (const conn of genome.connections) { + if (!conn.enabled) continue; + + adj.get(conn.from)!.push(conn.to); + inDegree.set(conn.to, (inDegree.get(conn.to) || 0) + 1); + } + + // Kahn's algorithm + const queue: number[] = []; + const order: number[] = []; + + // Start with nodes that have no incoming edges + for (const [nodeId, degree] of inDegree.entries()) { + if (degree === 0) { + queue.push(nodeId); + } + } + + while (queue.length > 0) { + const nodeId = queue.shift()!; + order.push(nodeId); + + for (const neighbor of adj.get(nodeId) || []) { + inDegree.set(neighbor, inDegree.get(neighbor)! - 1); + if (inDegree.get(neighbor) === 0) { + queue.push(neighbor); + } + } + } + + return order; + } + + /** + * Activate the network with inputs and return outputs + */ + activate(inputs: number[]): number[] { + if (inputs.length !== this.inputNodes.length) { + throw new Error(`Expected ${this.inputNodes.length} inputs, got ${inputs.length}`); + } + + // Reset all node values + for (const node of this.nodes.values()) { + node.value = 0; + } + + // Set input values + for (let i = 0; i < this.inputNodes.length; i++) { + const node = this.nodes.get(this.inputNodes[i])!; + node.value = inputs[i]; + } + + // Evaluate nodes in topological order + for (const nodeId of this.evaluationOrder) { + const node = this.nodes.get(nodeId)!; + + // Skip input nodes (already set) + if (this.inputNodes.includes(nodeId)) continue; + + // Sum weighted inputs + let sum = 0; + for (const input of node.inputs) { + const sourceNode = this.nodes.get(input.sourceId); + if (sourceNode) { + sum += sourceNode.value * input.weight; + } + } + + // Apply activation function + node.value = this.applyActivation(sum, node.activation); + } + + // Collect output values + return this.outputNodes.map(id => this.nodes.get(id)!.value); + } + + /** + * Apply activation function + */ + private applyActivation(x: number, activation: ActivationFunction): number { + switch (activation) { + case 'tanh': + return Math.tanh(x); + case 'sigmoid': + return 1 / (1 + Math.exp(-x)); + case 'relu': + return Math.max(0, x); + case 'linear': + return x; + default: + return Math.tanh(x); + } + } +} + +/** + * Create a neural network from a genome + */ +export function createNetwork(genome: Genome): NeuralNetwork { + return new NeuralNetwork(genome); +} diff --git a/src/lib/neatArena/reproduction.ts b/src/lib/neatArena/reproduction.ts new file mode 100644 index 0000000..c2a8206 --- /dev/null +++ b/src/lib/neatArena/reproduction.ts @@ -0,0 +1,160 @@ +import type { Genome, InnovationTracker } from './genome'; +import type { Species } from './speciation'; +import { cloneGenome } from './genome'; +import { crossover } from './crossover'; +import { mutate, DEFAULT_MUTATION_RATES, type MutationRates } from './mutations'; + +/** + * NEAT Reproduction + * + * Handles species-based selection, crossover, and offspring generation. + * Implements elitism and proper offspring allocation. + */ + +export interface ReproductionConfig { + elitePerSpecies: number; + crossoverRate: number; + interspeciesMatingRate: number; + mutationRates: MutationRates; +} + +export const DEFAULT_REPRODUCTION_CONFIG: ReproductionConfig = { + elitePerSpecies: 1, + crossoverRate: 0.75, + interspeciesMatingRate: 0.001, + mutationRates: DEFAULT_MUTATION_RATES, +}; + +/** + * Reproduce a new generation from species + */ +export function reproduce( + species: Species[], + populationSize: number, + innovationTracker: InnovationTracker, + config: ReproductionConfig = DEFAULT_REPRODUCTION_CONFIG +): Genome[] { + const newGenomes: Genome[] = []; + + // Calculate total adjusted fitness + const totalAdjustedFitness = species.reduce((sum, s) => { + return sum + s.members.reduce((sSum, g) => sSum + g.fitness, 0); + }, 0); + + if (totalAdjustedFitness === 0) { + // If all fitness is 0, allocate equally + const genomesPerSpecies = Math.floor(populationSize / species.length); + + for (const spec of species) { + const offspring = reproduceSpecies( + spec, + genomesPerSpecies, + innovationTracker, + config + ); + newGenomes.push(...offspring); + } + } else { + // Allocate offspring based on adjusted fitness + for (const spec of species) { + const speciesFitness = spec.members.reduce((sum, g) => sum + g.fitness, 0); + const offspringCount = Math.max( + 1, + Math.floor((speciesFitness / totalAdjustedFitness) * populationSize) + ); + + const offspring = reproduceSpecies( + spec, + offspringCount, + innovationTracker, + config + ); + newGenomes.push(...offspring); + } + } + + // If we don't have enough genomes, fill with random mutations of best + while (newGenomes.length < populationSize) { + const bestGenome = getBestGenomeFromSpecies(species); + const mutated = mutate(bestGenome, innovationTracker, config.mutationRates); + newGenomes.push(mutated); + } + + // If we have too many, trim the worst + if (newGenomes.length > populationSize) { + newGenomes.sort((a, b) => b.fitness - a.fitness); + newGenomes.length = populationSize; + } + + return newGenomes; +} + +/** + * Reproduce offspring within a species + */ +function reproduceSpecies( + species: Species, + offspringCount: number, + innovationTracker: InnovationTracker, + config: ReproductionConfig +): Genome[] { + const offspring: Genome[] = []; + + // Sort members by fitness + const sorted = [...species.members].sort((a, b) => b.fitness - a.fitness); + + // Elitism: keep best genomes unchanged + const eliteCount = Math.min(config.elitePerSpecies, sorted.length, offspringCount); + for (let i = 0; i < eliteCount; i++) { + offspring.push(cloneGenome(sorted[i])); + } + + // Generate rest through crossover and mutation + while (offspring.length < offspringCount) { + let child: Genome; + + // Select parents + const parent1 = selectParent(sorted); + const parent2 = sorted.length >= 2 ? selectParent(sorted) : null; + + // Crossover if we have two different parents, otherwise clone + if (parent2 && parent1 !== parent2 && Math.random() < config.crossoverRate) { + child = crossover(parent1, parent2, innovationTracker); + } else { + child = cloneGenome(parent1); + } + + // Always mutate (except elites) + mutate(child, innovationTracker, config.mutationRates); + offspring.push(child); + } + + return offspring; +} + +/** + * Select a parent using fitness-proportionate selection + */ +function selectParent(sortedGenomes: Genome[]): Genome { + // Simple tournament selection (top 50%) + const tournamentSize = Math.max(2, Math.floor(sortedGenomes.length * 0.5)); + const index = Math.floor(Math.random() * tournamentSize); + return sortedGenomes[index]; +} + +/** + * Get the best genome from all species + */ +function getBestGenomeFromSpecies(species: Species[]): Genome { + let best: Genome | null = null; + + for (const spec of species) { + for (const genome of spec.members) { + if (!best || genome.fitness > best.fitness) { + best = genome; + } + } + } + + return best || species[0].members[0]; +} diff --git a/src/lib/neatArena/selfPlay.ts b/src/lib/neatArena/selfPlay.ts new file mode 100644 index 0000000..f1d5ba6 --- /dev/null +++ b/src/lib/neatArena/selfPlay.ts @@ -0,0 +1,199 @@ +import type { Genome } from './genome'; +import type { Population } from './evolution'; +import type { AgentAction } from './types'; +import { createSimulation, stepSimulation } from './simulation'; +import { createNetwork } from './network'; +import { generateObservation, observationToInputs } from './sensors'; +import { createFitnessTracker, updateFitness } from './fitness'; +import { SeededRandom } from './utils'; + +/** + * Self-Play Scheduler + * + * Orchestrates training matches between genomes. + * Each genome plays K opponents, with side swapping for fairness. + */ + +export interface MatchConfig { + matchesPerGenome: number; // K + mapSeed: number; + maxTicks: number; +} + +export const DEFAULT_MATCH_CONFIG: MatchConfig = { + matchesPerGenome: 4, + mapSeed: 12345, + maxTicks: 600, +}; + +interface MatchPairing { + genome1Index: number; + genome2Index: number; + spawnPairId: number; + swapSides: boolean; +} + +/** + * Evaluate entire population using self-play + */ +export function evaluatePopulation( + population: Population, + config: MatchConfig = DEFAULT_MATCH_CONFIG +): Population { + const genomes = population.genomes; + const K = config.matchesPerGenome; + + // Initialize fitness trackers + const fitnessTrackers = genomes.map((_, i) => ({ + totalFitness: 0, + matchCount: 0, + })); + + // Generate deterministic pairings + const pairings = generatePairings(genomes.length, K, population.generation); + + // Run all matches + for (const pairing of pairings) { + const result = runMatch( + genomes[pairing.genome1Index], + genomes[pairing.genome2Index], + pairing, + config + ); + + // Accumulate fitness + fitnessTrackers[pairing.genome1Index].totalFitness += result.fitness1; + fitnessTrackers[pairing.genome1Index].matchCount++; + + fitnessTrackers[pairing.genome2Index].totalFitness += result.fitness2; + fitnessTrackers[pairing.genome2Index].matchCount++; + } + + console.log('[SelfPlay] Ran', pairings.length, 'matches for', genomes.length, 'genomes'); + console.log('[SelfPlay] Sample fitness from first genome:', fitnessTrackers[0].totalFitness, '/', fitnessTrackers[0].matchCount); + + // Average fitness across matches + for (let i = 0; i < genomes.length; i++) { + const tracker = fitnessTrackers[i]; + genomes[i].fitness = tracker.matchCount > 0 + ? tracker.totalFitness / tracker.matchCount + : 0; + } + + return { ...population, genomes }; +} + +/** + * Generate deterministic match pairings + */ +function generatePairings( + populationSize: number, + K: number, + seed: number +): MatchPairing[] { + const pairings: MatchPairing[] = []; + const rng = new SeededRandom(seed); + + for (let i = 0; i < populationSize; i++) { + for (let k = 0; k < K; k++) { + // Pick a random opponent (not self) + let opponentIndex; + do { + opponentIndex = rng.nextInt(0, populationSize); + } while (opponentIndex === i); + + // Random spawn pair (0-4) + const spawnPairId = rng.nextInt(0, 5); + + // Each match is played twice with swapped sides + pairings.push({ + genome1Index: i, + genome2Index: opponentIndex, + spawnPairId, + swapSides: false, + }); + + pairings.push({ + genome1Index: i, + genome2Index: opponentIndex, + spawnPairId, + swapSides: true, + }); + } + } + + return pairings; +} + +/** + * Run a single match between two genomes + */ +function runMatch( + genome1: Genome, + genome2: Genome, + pairing: MatchPairing, + config: MatchConfig +): { fitness1: number; fitness2: number } { + // Swap genomes if needed for side fairness + const g1 = pairing.swapSides ? genome2 : genome1; + const g2 = pairing.swapSides ? genome1 : genome2; + + // Create networks + const network1 = createNetwork(g1); + const network2 = createNetwork(g2); + + // Create simulation + let sim = createSimulation(config.mapSeed + pairing.spawnPairId, pairing.spawnPairId); + + // Create fitness trackers + let tracker1 = createFitnessTracker(0); + let tracker2 = createFitnessTracker(1); + + // Run simulation + while (!sim.isOver && sim.tick < config.maxTicks) { + // Get observations + const obs1 = generateObservation(0, sim); + const obs2 = generateObservation(1, sim); + + // Get actions from networks + const inputs1 = observationToInputs(obs1); + const inputs2 = observationToInputs(obs2); + + const outputs1 = network1.activate(inputs1); + const outputs2 = network2.activate(inputs2); + + const action1: AgentAction = { + moveX: outputs1[0], + moveY: outputs1[1], + turn: outputs1[2], + shoot: outputs1[3], + }; + + const action2: AgentAction = { + moveX: outputs2[0], + moveY: outputs2[1], + turn: outputs2[2], + shoot: outputs2[3], + }; + + // Step simulation + sim = stepSimulation(sim, [action1, action2]); + + // Update fitness + tracker1 = updateFitness(tracker1, sim); + tracker2 = updateFitness(tracker2, sim); + } + + // Swap fitness back if sides were swapped + if (pairing.swapSides) { + return { + fitness1: tracker2.fitness, + fitness2: tracker1.fitness, + }; + } else { + return { + fitness1: tracker1.fitness, + fitness2: tracker2.fitness, + }; + } +} diff --git a/src/lib/neatArena/sensors.ts b/src/lib/neatArena/sensors.ts new file mode 100644 index 0000000..70912fb --- /dev/null +++ b/src/lib/neatArena/sensors.ts @@ -0,0 +1,232 @@ +import type { Agent, SimulationState, Observation, RayHit, Vec2, Wall } from './types'; +import { SIMULATION_CONFIG } from './types'; + +/** + * Sensor system for NEAT Arena. + * + * Agents perceive the world using 360° raycasting. + * Each ray detects distance and what it hit (nothing, wall, or opponent). + */ + +/** + * Generate observation vector for an agent. + * + * Returns a complete observation including: + * - 24 rays (360°) with distance and hit type + * - Agent's velocity + * - Aim direction + * - Fire cooldown + */ +export function generateObservation(agentId: number, state: SimulationState): Observation { + const agent = state.agents.find(a => a.id === agentId)!; + const opponent = state.agents.find(a => a.id !== agentId)!; + + const { RAY_COUNT, RAY_RANGE, FIRE_COOLDOWN, AGENT_MAX_SPEED } = SIMULATION_CONFIG; + + // Cast rays in 360° + const rays: RayHit[] = []; + const angleStep = (2 * Math.PI) / RAY_COUNT; + + for (let i = 0; i < RAY_COUNT; i++) { + const angle = i * angleStep; + const ray = castRay(agent.position, angle, RAY_RANGE, state.map.walls, opponent); + rays.push(ray); + } + + // Normalize velocity + const vx = agent.velocity.x / AGENT_MAX_SPEED; + const vy = agent.velocity.y / AGENT_MAX_SPEED; + + // Aim direction as sin/cos + const aimSin = Math.sin(agent.aimAngle); + const aimCos = Math.cos(agent.aimAngle); + + // Normalize cooldown + const cooldown = agent.fireCooldown / FIRE_COOLDOWN; + + return { + rays, + vx, + vy, + aimSin, + aimCos, + cooldown, + }; +} + +/** + * Cast a single ray from origin in a direction, up to maxDist. + * + * Returns the closest hit: either wall, opponent, or nothing. + */ +function castRay( + origin: Vec2, + angle: number, + maxDist: number, + walls: Wall[], + opponent: Agent +): RayHit { + const dir: Vec2 = { + x: Math.cos(angle), + y: Math.sin(angle), + }; + + const rayEnd: Vec2 = { + x: origin.x + dir.x * maxDist, + y: origin.y + dir.y * maxDist, + }; + + let closestDist = maxDist; + let hitType: 'nothing' | 'wall' | 'opponent' = 'nothing'; + + // Check wall intersections + for (const wall of walls) { + const dist = rayAABBIntersection(origin, rayEnd, wall.rect); + if (dist !== null && dist < closestDist) { + closestDist = dist; + hitType = 'wall'; + } + } + + // Check opponent intersection (treat as circle) + const opponentDist = rayCircleIntersection(origin, dir, maxDist, opponent.position, opponent.radius); + if (opponentDist !== null && opponentDist < closestDist) { + closestDist = opponentDist; + hitType = 'opponent'; + } + + return { + distance: closestDist / maxDist, // Normalize to [0, 1] + hitType, + }; +} + +/** + * Ray-AABB intersection. + * Returns distance to intersection, or null if no hit. + */ +function rayAABBIntersection( + origin: Vec2, + end: Vec2, + aabb: { minX: number; minY: number; maxX: number; maxY: number } +): number | null { + const dir: Vec2 = { + x: end.x - origin.x, + y: end.y - origin.y, + }; + + const len = Math.sqrt(dir.x * dir.x + dir.y * dir.y); + if (len === 0) return null; + + dir.x /= len; + dir.y /= len; + + // Slab method + const invDirX = dir.x === 0 ? Infinity : 1 / dir.x; + const invDirY = dir.y === 0 ? Infinity : 1 / dir.y; + + const tx1 = (aabb.minX - origin.x) * invDirX; + const tx2 = (aabb.maxX - origin.x) * invDirX; + const ty1 = (aabb.minY - origin.y) * invDirY; + const ty2 = (aabb.maxY - origin.y) * invDirY; + + const tmin = Math.max(Math.min(tx1, tx2), Math.min(ty1, ty2)); + const tmax = Math.min(Math.max(tx1, tx2), Math.max(ty1, ty2)); + + if (tmax < 0 || tmin > tmax || tmin > len) return null; + + return tmin >= 0 ? tmin : tmax; +} + +/** + * Ray-circle intersection. + * Returns distance to intersection, or null if no hit. + */ +function rayCircleIntersection( + origin: Vec2, + dir: Vec2, + maxDist: number, + circleCenter: Vec2, + circleRadius: number +): number | null { + // Vector from ray origin to circle center + const oc: Vec2 = { + x: origin.x - circleCenter.x, + y: origin.y - circleCenter.y, + }; + + const a = dir.x * dir.x + dir.y * dir.y; + const b = 2 * (oc.x * dir.x + oc.y * dir.y); + const c = oc.x * oc.x + oc.y * oc.y - circleRadius * circleRadius; + + const discriminant = b * b - 4 * a * c; + + if (discriminant < 0) return null; + + const sqrtDisc = Math.sqrt(discriminant); + const t1 = (-b - sqrtDisc) / (2 * a); + const t2 = (-b + sqrtDisc) / (2 * a); + + // Return closest positive intersection within range + if (t1 >= 0 && t1 <= maxDist) return t1; + if (t2 >= 0 && t2 <= maxDist) return t2; + + return null; +} + +/** + * Convert observation to flat array of floats for neural network input. + * + * Total: 24 rays × 2 + 5 extra = 53 inputs + */ +export function observationToInputs(obs: Observation): number[] { + const inputs: number[] = []; + + // Rays: distance + hitType as scalar + for (const ray of obs.rays) { + inputs.push(ray.distance); + + // Encode hitType as scalar + let hitTypeScalar = 0; + if (ray.hitType === 'wall') hitTypeScalar = 0.5; + else if (ray.hitType === 'opponent') hitTypeScalar = 1.0; + + inputs.push(hitTypeScalar); + } + + // Extra inputs + inputs.push(obs.vx); + inputs.push(obs.vy); + inputs.push(obs.aimSin); + inputs.push(obs.aimCos); + inputs.push(obs.cooldown); + + return inputs; +} + +/** + * Check if agent has clear line-of-sight to opponent. + * Used for fitness calculation. + */ +export function hasLineOfSight(agent: Agent, opponent: Agent, walls: Wall[]): boolean { + const dir: Vec2 = { + x: opponent.position.x - agent.position.x, + y: opponent.position.y - agent.position.y, + }; + + const dist = Math.sqrt(dir.x * dir.x + dir.y * dir.y); + if (dist === 0) return true; + + dir.x /= dist; + dir.y /= dist; + + // Check if any wall blocks the line + for (const wall of walls) { + const hitDist = rayAABBIntersection(agent.position, opponent.position, wall.rect); + if (hitDist !== null && hitDist < dist) { + return false; + } + } + + return true; +} diff --git a/src/lib/neatArena/simulation.ts b/src/lib/neatArena/simulation.ts new file mode 100644 index 0000000..094a7ee --- /dev/null +++ b/src/lib/neatArena/simulation.ts @@ -0,0 +1,286 @@ +import type { + SimulationState, + Agent, + Bullet, + AgentAction, + Vec2, + Wall, + MatchResult, +} from './types'; +import { SIMULATION_CONFIG } from './types'; +import { generateArenaMap } from './mapGenerator'; + +/** + * Core simulation engine for the NEAT Arena. + * + * Deterministic, operates at fixed 30Hz timestep. + * Handles agent movement, bullet physics, collisions, respawning, and scoring. + */ + +let nextBulletId = 0; + +/** + * Create a new simulation instance + */ +export function createSimulation(mapSeed: number, spawnPairId: number): SimulationState { + const map = generateArenaMap(mapSeed); + + // Get spawn points for the selected pair + const spawns = map.spawnPoints.filter(sp => sp.pairId === spawnPairId); + const spawn0 = spawns.find(sp => sp.side === 0)!.position; + const spawn1 = spawns.find(sp => sp.side === 1)!.position; + + const agents: [Agent, Agent] = [ + createAgent(0, spawn0), + createAgent(1, spawn1), + ]; + + return { + tick: 0, + agents, + bullets: [], + map, + isOver: false, + }; +} + +/** + * Create a new agent + */ +function createAgent(id: number, spawnPoint: Vec2): Agent { + return { + id, + position: { x: spawnPoint.x, y: spawnPoint.y }, + velocity: { x: 0, y: 0 }, + aimAngle: id === 0 ? 0 : Math.PI, // Face each other initially + radius: SIMULATION_CONFIG.AGENT_RADIUS, + invulnTicks: SIMULATION_CONFIG.RESPAWN_INVULN_TICKS, + fireCooldown: 0, + hits: 0, + kills: 0, + spawnPoint, + }; +} + +/** + * Step the simulation forward by one tick + */ +export function stepSimulation( + state: SimulationState, + actions: [AgentAction, AgentAction] +): SimulationState { + if (state.isOver) return state; + + const newState = { ...state }; + newState.tick++; + + // Update agents + newState.agents = [ + updateAgent(state.agents[0], actions[0], state), + updateAgent(state.agents[1], actions[1], state), + ]; + + // Update bullets + newState.bullets = state.bullets + .map(b => updateBullet(b, state)) + .filter(b => b !== null) as Bullet[]; + + // Check bullet-agent collisions + checkCollisions(newState); + + // Check episode termination + if (newState.tick >= SIMULATION_CONFIG.MAX_TICKS) { + newState.isOver = true; + newState.result = createMatchResult(newState); + } else if (newState.agents[0].kills >= SIMULATION_CONFIG.KILLS_TO_WIN || + newState.agents[1].kills >= SIMULATION_CONFIG.KILLS_TO_WIN) { + newState.isOver = true; + newState.result = createMatchResult(newState); + } + + return newState; +} + +/** + * Update a single agent + */ +function updateAgent(agent: Agent, action: AgentAction, state: SimulationState): Agent { + const { DT, AGENT_MAX_SPEED, AGENT_TURN_RATE, FIRE_COOLDOWN, BULLET_SPAWN_OFFSET, BULLET_SPEED } = SIMULATION_CONFIG; + + const newAgent = { ...agent }; + + // Decrease timers + if (newAgent.invulnTicks > 0) newAgent.invulnTicks--; + if (newAgent.fireCooldown > 0) newAgent.fireCooldown--; + + // Update aim angle + const turnAmount = action.turn * AGENT_TURN_RATE * DT; + newAgent.aimAngle += turnAmount; + + // Normalize angle to [-π, π] + newAgent.aimAngle = ((newAgent.aimAngle + Math.PI) % (2 * Math.PI)) - Math.PI; + + // Update velocity + const moveLength = Math.sqrt(action.moveX * action.moveX + action.moveY * action.moveY); + if (moveLength > 0) { + newAgent.velocity.x = (action.moveX / moveLength) * AGENT_MAX_SPEED; + newAgent.velocity.y = (action.moveY / moveLength) * AGENT_MAX_SPEED; + } else { + newAgent.velocity.x = 0; + newAgent.velocity.y = 0; + } + + // Update position + let newX = newAgent.position.x + newAgent.velocity.x * DT; + let newY = newAgent.position.y + newAgent.velocity.y * DT; + + // Check wall collisions and clamp position + const testPos = { x: newX, y: newY }; + if (isAgentCollidingWithWalls(testPos, newAgent.radius, state.map.walls)) { + // Simple response: stop movement + newX = newAgent.position.x; + newY = newAgent.position.y; + newAgent.velocity.x = 0; + newAgent.velocity.y = 0; + } + + newAgent.position.x = newX; + newAgent.position.y = newY; + + // Fire bullet + if (action.shoot > 0.5 && newAgent.fireCooldown === 0) { + newAgent.fireCooldown = FIRE_COOLDOWN; + + // Spawn bullet in front of agent + const bulletPos: Vec2 = { + x: newAgent.position.x + Math.cos(newAgent.aimAngle) * BULLET_SPAWN_OFFSET, + y: newAgent.position.y + Math.sin(newAgent.aimAngle) * BULLET_SPAWN_OFFSET, + }; + + const bullet: Bullet = { + id: nextBulletId++, + position: bulletPos, + velocity: { + x: Math.cos(newAgent.aimAngle) * BULLET_SPEED, + y: Math.sin(newAgent.aimAngle) * BULLET_SPEED, + }, + ownerId: newAgent.id, + ttl: SIMULATION_CONFIG.BULLET_TTL, + }; + + state.bullets.push(bullet); + } + + return newAgent; +} + +/** + * Update a bullet + */ +function updateBullet(bullet: Bullet, state: SimulationState): Bullet | null { + const { DT } = SIMULATION_CONFIG; + + const newBullet = { ...bullet }; + newBullet.ttl--; + + if (newBullet.ttl <= 0) return null; + + // Update position + newBullet.position.x += newBullet.velocity.x * DT; + newBullet.position.y += newBullet.velocity.y * DT; + + // Check wall collision + if (isBulletCollidingWithWalls(newBullet.position, state.map.walls)) { + return null; // Bullet destroyed + } + + return newBullet; +} + +/** + * Check for bullet-agent collisions and handle hits + */ +function checkCollisions(state: SimulationState): void { + const bulletsToRemove = new Set(); + + for (const bullet of state.bullets) { + for (const agent of state.agents) { + // Can't hit yourself or invulnerable agents + if (bullet.ownerId === agent.id || agent.invulnTicks > 0) continue; + + const dx = bullet.position.x - agent.position.x; + const dy = bullet.position.y - agent.position.y; + const distSq = dx * dx + dy * dy; + + if (distSq < agent.radius * agent.radius) { + // Hit! + bulletsToRemove.add(bullet.id); + + // Update scores + agent.hits++; + const shooter = state.agents.find(a => a.id === bullet.ownerId); + if (shooter) shooter.kills++; + + // Respawn agent + agent.position.x = agent.spawnPoint.x; + agent.position.y = agent.spawnPoint.y; + agent.velocity.x = 0; + agent.velocity.y = 0; + agent.invulnTicks = SIMULATION_CONFIG.RESPAWN_INVULN_TICKS; + } + } + } + + // Remove bullets + state.bullets = state.bullets.filter(b => !bulletsToRemove.has(b.id)); +} + +/** + * Check if an agent collides with any walls + */ +function isAgentCollidingWithWalls(pos: Vec2, radius: number, walls: Wall[]): boolean { + for (const wall of walls) { + // AABB vs circle collision + const closestX = Math.max(wall.rect.minX, Math.min(pos.x, wall.rect.maxX)); + const closestY = Math.max(wall.rect.minY, Math.min(pos.y, wall.rect.maxY)); + + const dx = pos.x - closestX; + const dy = pos.y - closestY; + const distSq = dx * dx + dy * dy; + + if (distSq < radius * radius) { + return true; + } + } + return false; +} + +/** + * Check if a bullet collides with any walls + */ +function isBulletCollidingWithWalls(pos: Vec2, walls: Wall[]): boolean { + for (const wall of walls) { + if (pos.x >= wall.rect.minX && pos.x <= wall.rect.maxX && + pos.y >= wall.rect.minY && pos.y <= wall.rect.maxY) { + return true; + } + } + return false; +} + +/** + * Create match result + */ +function createMatchResult(state: SimulationState): MatchResult { + const [a0, a1] = state.agents; + + let winnerId = -1; + if (a0.kills > a1.kills) winnerId = 0; + else if (a1.kills > a0.kills) winnerId = 1; + + return { + winnerId, + scores: [a0.kills, a1.kills], + ticks: state.tick, + }; +} diff --git a/src/lib/neatArena/speciation.ts b/src/lib/neatArena/speciation.ts new file mode 100644 index 0000000..fdeff35 --- /dev/null +++ b/src/lib/neatArena/speciation.ts @@ -0,0 +1,202 @@ +import type { Genome } from './genome'; + +/** + * NEAT Speciation + * + * Groups genomes into species based on compatibility distance. + * Implements dynamic threshold adjustment to target 6-10 species. + */ + +export interface Species { + id: number; + representative: Genome; + members: Genome[]; + averageFitness: number; + staleness: number; // Generations without improvement +} + +/** + * Compatibility distance coefficients + */ +export interface CompatibilityConfig { + excessCoeff: number; // c1 + disjointCoeff: number; // c2 + weightDiffCoeff: number; // c3 +} + +export const DEFAULT_COMPATIBILITY_CONFIG: CompatibilityConfig = { + excessCoeff: 1.0, + disjointCoeff: 1.0, + weightDiffCoeff: 0.4, +}; + +/** + * Calculate compatibility distance between two genomes + * δ = c1*E/N + c2*D/N + c3*W + */ +export function compatibilityDistance( + genome1: Genome, + genome2: Genome, + config: CompatibilityConfig = DEFAULT_COMPATIBILITY_CONFIG +): number { + const innovations1 = new Set(genome1.connections.map(c => c.innovation)); + const innovations2 = new Set(genome2.connections.map(c => c.innovation)); + + const max1 = Math.max(...Array.from(innovations1), 0); + const max2 = Math.max(...Array.from(innovations2), 0); + const maxInnovation = Math.max(max1, max2); + + let matching = 0; + let disjoint = 0; + let excess = 0; + let weightDiff = 0; + + const conn1Map = new Map(genome1.connections.map(c => [c.innovation, c])); + const conn2Map = new Map(genome2.connections.map(c => [c.innovation, c])); + + // Count matching, disjoint, excess + const allInnovations = new Set([...innovations1, ...innovations2]); + + for (const innovation of allInnovations) { + const c1 = conn1Map.get(innovation); + const c2 = conn2Map.get(innovation); + + if (c1 && c2) { + // Matching gene + matching++; + weightDiff += Math.abs(c1.weight - c2.weight); + } else { + // Disjoint or excess + // Excess genes are those with innovation > OTHER genome's max + const isInGenome1 = innovations1.has(innovation); + const isInGenome2 = innovations2.has(innovation); + + if (isInGenome1 && innovation > max2) { + excess++; + } else if (isInGenome2 && innovation > max1) { + excess++; + } else { + disjoint++; + } + } + } + + // Normalize by number of genes in larger genome + const N = Math.max(genome1.connections.length, genome2.connections.length, 1); + + // Average weight difference for matching genes + const avgWeightDiff = matching > 0 ? weightDiff / matching : 0; + + const delta = + (config.excessCoeff * excess) / N + + (config.disjointCoeff * disjoint) / N + + config.weightDiffCoeff * avgWeightDiff; + + return delta; +} + +/** + * Assign genomes to species + */ +export function speciate( + genomes: Genome[], + previousSpecies: Species[], + compatibilityThreshold: number, + config: CompatibilityConfig = DEFAULT_COMPATIBILITY_CONFIG +): Species[] { + const newSpecies: Species[] = []; + let nextSpeciesId = previousSpecies.length > 0 + ? Math.max(...previousSpecies.map(s => s.id)) + 1 + : 0; + + // Update representatives from previous generation + for (const species of previousSpecies) { + if (species.members.length > 0) { + // Pick a random member as the new representative + species.representative = species.members[Math.floor(Math.random() * species.members.length)]; + species.members = []; + } + } + + // Assign each genome to a species + for (const genome of genomes) { + let foundSpecies = false; + + // Try to match with existing species + for (const species of previousSpecies) { + const distance = compatibilityDistance(genome, species.representative, config); + + if (distance < compatibilityThreshold) { + species.members.push(genome); + foundSpecies = true; + break; + } + } + + // If no match, create new species + if (!foundSpecies) { + const newSpec: Species = { + id: nextSpeciesId++, + representative: genome, + members: [genome], + averageFitness: 0, + staleness: 0, + }; + previousSpecies.push(newSpec); + } + } + + // Keep only species with members + for (const species of previousSpecies) { + if (species.members.length > 0) { + // Calculate average fitness + const totalFitness = species.members.reduce((sum, g) => sum + g.fitness, 0); + species.averageFitness = totalFitness / species.members.length; + + newSpecies.push(species); + } + } + + console.log(`[Speciation] Threshold: ${compatibilityThreshold.toFixed(2)}, Species formed: ${newSpecies.length}`); + if (newSpecies.length > 0) { + console.log(`[Speciation] Species sizes:`, newSpecies.map(s => s.members.length)); + } + + return newSpecies; +} + +/** + * Adjust compatibility threshold to target a certain number of species + */ +export function adjustCompatibilityThreshold( + currentThreshold: number, + currentSpeciesCount: number, + targetMin: number = 6, + targetMax: number = 10 +): number { + const adjustmentRate = 0.1; + + if (currentSpeciesCount < targetMin) { + // Too few species, make threshold more lenient + return currentThreshold + adjustmentRate; + } else if (currentSpeciesCount > targetMax) { + // Too many species, make threshold stricter + return Math.max(0.1, currentThreshold - adjustmentRate); + } + + return currentThreshold; +} + +/** + * Apply fitness sharing within species + */ +export function applyFitnessSharing(species: Species[]): void { + for (const spec of species) { + const speciesSize = spec.members.length; + + for (const genome of spec.members) { + // Adjusted fitness = raw fitness / species size + genome.fitness = genome.fitness / speciesSize; + } + } +} diff --git a/src/lib/neatArena/training.worker.ts b/src/lib/neatArena/training.worker.ts new file mode 100644 index 0000000..1d68ad3 --- /dev/null +++ b/src/lib/neatArena/training.worker.ts @@ -0,0 +1,129 @@ +import type { Population } from './evolution'; +import type { EvolutionConfig } from './evolution'; +import { evaluatePopulation, DEFAULT_MATCH_CONFIG } from './selfPlay'; +import { evolveGeneration, createPopulation, getPopulationStats } from './evolution'; + +/** + * NEAT Training Worker + * + * Runs training in a background thread to prevent UI blocking. + * The main thread only handles visualization and UI updates. + */ + +export interface TrainingWorkerMessage { + type: 'start' | 'pause' | 'step' | 'reset' | 'init'; + config?: EvolutionConfig; +} + +export interface TrainingWorkerResponse { + type: 'update' | 'error' | 'ready'; + population?: Population; + stats?: ReturnType; + error?: string; +} + +let population: Population | null = null; +let isRunning = false; +let config: EvolutionConfig | null = null; + +/** + * Handle messages from main thread + */ +self.onmessage = async (e: MessageEvent) => { + const message = e.data; + + try { + switch (message.type) { + case 'init': + if (message.config) { + config = message.config; + population = createPopulation(config); + sendUpdate(); + self.postMessage({ type: 'ready' } as TrainingWorkerResponse); + } + break; + + case 'start': + isRunning = true; + runTrainingLoop(); + break; + + case 'pause': + isRunning = false; + break; + + case 'step': + if (population && config) { + const stats = await runSingleGeneration(); + sendUpdate(stats); + } + break; + + case 'reset': + if (config) { + population = createPopulation(config); + isRunning = false; + sendUpdate(); + } + break; + } + } catch (error) { + self.postMessage({ + type: 'error', + error: error instanceof Error ? error.message : 'Unknown error', + } as TrainingWorkerResponse); + } +}; + +/** + * Run continuous training loop + */ +async function runTrainingLoop() { + while (isRunning && population && config) { + const stats = await runSingleGeneration(); + sendUpdate(stats); + + // Yield to allow pause/stop messages to be processed + await new Promise(resolve => setTimeout(resolve, 0)); + } +} + +/** + * Run a single generation + */ +async function runSingleGeneration(): Promise | null> { + if (!population || !config) return null; + + console.log('[Worker] Starting generation', population.generation); + + // Evaluate population + const evaluatedPop = evaluatePopulation(population, DEFAULT_MATCH_CONFIG); + + // Check fitness after evaluation + const fitnesses = evaluatedPop.genomes.map(g => g.fitness); + const avgFit = fitnesses.reduce((a, b) => a + b, 0) / fitnesses.length; + const maxFit = Math.max(...fitnesses); + console.log('[Worker] After evaluation - Avg fitness:', avgFit.toFixed(2), 'Max:', maxFit.toFixed(2)); + + // Evolve to next generation + population = evolveGeneration(evaluatedPop, config); + + console.log('[Worker] Generation', population.generation, 'complete'); + + // IMPORTANT: Send stats from the EVALUATED population, not the evolved one + // (evolved population has fitness reset to 0) + return getPopulationStats(evaluatedPop); +} + +/** + * Send population update to main thread + */ +function sendUpdate(stats?: ReturnType | null) { + if (!population) return; + + self.postMessage({ + type: 'update', + population, + stats: stats || undefined, + } as TrainingWorkerResponse); +} diff --git a/src/lib/neatArena/types.ts b/src/lib/neatArena/types.ts new file mode 100644 index 0000000..7918514 --- /dev/null +++ b/src/lib/neatArena/types.ts @@ -0,0 +1,204 @@ +/** + * Core types for the NEAT Arena simulation. + * + * The simulation is deterministic and operates at a fixed 30Hz timestep. + * All units are in a 512×512 logic space. + */ + +// ============================================================================ +// WORLD & MAP +// ============================================================================ + +export interface Vec2 { + x: number; + y: number; +} + +export interface AABB { + minX: number; + minY: number; + maxX: number; + maxY: number; +} + +export interface Wall { + rect: AABB; +} + +export interface SpawnPoint { + position: Vec2; + /** Which spawn pair this belongs to (0-4) */ + pairId: number; + /** Which side of the pair (0 or 1) */ + side: 0 | 1; +} + +export interface ArenaMap { + /** Rectangular walls */ + walls: Wall[]; + /** Symmetric spawn point pairs (always 5 pairs = 10 total spawn points) */ + spawnPoints: SpawnPoint[]; + /** Map generation seed */ + seed: number; +} + +// ============================================================================ +// AGENT +// ============================================================================ + +export interface Agent { + id: number; + position: Vec2; + velocity: Vec2; + /** Current aim direction in radians */ + aimAngle: number; + + /** Radius for collision */ + radius: number; + + /** Invulnerability ticks remaining after respawn */ + invulnTicks: number; + + /** Cooldown ticks until can fire again */ + fireCooldown: number; + + /** Number of times hit this episode */ + hits: number; + + /** Number of times this agent landed a hit */ + kills: number; + + /** Assigned spawn point */ + spawnPoint: Vec2; +} + +// ============================================================================ +// BULLET +// ============================================================================ + +export interface Bullet { + id: number; + position: Vec2; + velocity: Vec2; + /** Which agent fired this bullet */ + ownerId: number; + /** Ticks until bullet auto-expires */ + ttl: number; +} + +// ============================================================================ +// SIMULATION STATE +// ============================================================================ + +export interface SimulationState { + /** Current tick (increments at 30Hz) */ + tick: number; + + /** Agents in the arena (always 2) */ + agents: [Agent, Agent]; + + /** Active bullets */ + bullets: Bullet[]; + + /** The arena map */ + map: ArenaMap; + + /** Episode over? */ + isOver: boolean; + + /** Match result after episode ends */ + result?: MatchResult; +} + +export interface MatchResult { + /** Winner agent ID, or -1 for draw */ + winnerId: number; + + /** Final scores */ + scores: [number, number]; + + /** Total ticks */ + ticks: number; +} + +// ============================================================================ +// ACTIONS +// ============================================================================ + +export interface AgentAction { + /** Movement vector (will be normalized) */ + moveX: number; + moveY: number; + + /** Turn rate [-1..1] (scaled by max turn rate) */ + turn: number; + + /** Fire bullet if > 0.5 */ + shoot: number; +} + +// ============================================================================ +// OBSERVATIONS / SENSORS +// ============================================================================ + +export interface RayHit { + /** Distance [0..1] normalized by max range */ + distance: number; + + /** What the ray hit */ + hitType: 'nothing' | 'wall' | 'opponent'; +} + +export interface Observation { + /** 24 rays × 2 values (distance, hitType) */ + rays: RayHit[]; + + /** Agent's own velocity */ + vx: number; + vy: number; + + /** Aim direction as unit vector */ + aimSin: number; + aimCos: number; + + /** Fire cooldown [0..1] */ + cooldown: number; +} + +// ============================================================================ +// SIMULATION CONFIG +// ============================================================================ + +export const SIMULATION_CONFIG = { + /** Logic world size */ + WORLD_SIZE: 512, + + /** Fixed timestep (30Hz) */ + TICK_RATE: 30, + DT: 1 / 30, + + /** Episode termination */ + MAX_TICKS: 600, // 20 seconds + KILLS_TO_WIN: 5, + + /** Agent physics */ + AGENT_RADIUS: 8, + AGENT_MAX_SPEED: 120, // units/sec + AGENT_TURN_RATE: 270 * (Math.PI / 180), // rad/sec + + /** Respawn */ + RESPAWN_INVULN_TICKS: 15, // 0.5 seconds + + /** Bullet physics */ + BULLET_SPEED: 260, // units/sec + BULLET_TTL: 60, // 2 seconds + FIRE_COOLDOWN: 10, // ~0.33 seconds + BULLET_SPAWN_OFFSET: 12, // spawn in front of agent + + /** Sensors */ + RAY_COUNT: 24, + RAY_RANGE: 220, +} as const; + +// Re-export Genome type from genome module for convenience +export type { Genome } from './genome'; diff --git a/src/lib/neatArena/utils.ts b/src/lib/neatArena/utils.ts new file mode 100644 index 0000000..658f7f9 --- /dev/null +++ b/src/lib/neatArena/utils.ts @@ -0,0 +1,42 @@ +/** + * Deterministic random number generator using a linear congruential generator (LCG). + * + * Ensures reproducible results for the same seed. + */ +export class SeededRandom { + private seed: number; + + constructor(seed: number) { + this.seed = seed % 2147483647; + if (this.seed <= 0) this.seed += 2147483646; + } + + /** + * Returns a float in [0, 1) + */ + next(): number { + this.seed = (this.seed * 16807) % 2147483647; + return (this.seed - 1) / 2147483646; + } + + /** + * Returns an integer in [min, max) (max exclusive) + */ + nextInt(min: number, max: number): number { + return Math.floor(this.next() * (max - min)) + min; + } + + /** + * Returns a float in [min, max) + */ + nextFloat(min: number, max: number): number { + return this.next() * (max - min) + min; + } + + /** + * Returns a random boolean + */ + nextBool(): boolean { + return this.next() < 0.5; + } +} diff --git a/src/lib/snakeAI/evolution.ts b/src/lib/snakeAI/evolution.ts index 0cd15a2..2c3111d 100644 --- a/src/lib/snakeAI/evolution.ts +++ b/src/lib/snakeAI/evolution.ts @@ -60,19 +60,31 @@ export function evaluatePopulation( } // Update best ever + return updateBestStats( + { + ...population, + individuals: evaluatedIndividuals + } + ); +} + +export function updateBestStats(population: Population): Population { let newBestEver = population.bestFitnessEver; let newBestNetwork = population.bestNetworkEver; + let changed = false; - for (const individual of evaluatedIndividuals) { + for (const individual of population.individuals) { if (individual.fitness > newBestEver) { newBestEver = individual.fitness; newBestNetwork = cloneNetwork(individual.network); + changed = true; } } + if (!changed) return population; + return { ...population, - individuals: evaluatedIndividuals, bestFitnessEver: newBestEver, bestNetworkEver: newBestNetwork, }; @@ -152,28 +164,26 @@ function selectParent(sorted: Individual[]): Individual { return best; } + function crossover(parent1: Network, parent2: Network): Network { const child = cloneNetwork(parent1); child.id = Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15); - // Single-point crossover on weights and biases + // Single-point crossover on weights and biases? + // For flat arrays, we can just iterate linear index. const crossoverRate = 0.5; // Crossover input-hidden weights for (let i = 0; i < child.weightsIH.length; i++) { - for (let j = 0; j < child.weightsIH[i].length; j++) { - if (Math.random() < crossoverRate) { - child.weightsIH[i][j] = parent2.weightsIH[i][j]; - } + if (Math.random() < crossoverRate) { + child.weightsIH[i] = parent2.weightsIH[i]; } } // Crossover hidden-output weights for (let i = 0; i < child.weightsHO.length; i++) { - for (let j = 0; j < child.weightsHO[i].length; j++) { - if (Math.random() < crossoverRate) { - child.weightsHO[i][j] = parent2.weightsHO[i][j]; - } + if (Math.random() < crossoverRate) { + child.weightsHO[i] = parent2.weightsHO[i]; } } @@ -199,22 +209,18 @@ function mutate(network: Network, mutationRate: number): Network { // Mutate input-hidden weights for (let i = 0; i < mutated.weightsIH.length; i++) { - for (let j = 0; j < mutated.weightsIH[i].length; j++) { - if (Math.random() < mutationRate) { - mutated.weightsIH[i][j] += (Math.random() * 2 - 1) * 0.5; + if (Math.random() < mutationRate) { + mutated.weightsIH[i] += (Math.random() * 2 - 1) * 0.5; // Clamp to reasonable range - mutated.weightsIH[i][j] = Math.max(-2, Math.min(2, mutated.weightsIH[i][j])); - } + mutated.weightsIH[i] = Math.max(-2, Math.min(2, mutated.weightsIH[i])); } } // Mutate hidden-output weights for (let i = 0; i < mutated.weightsHO.length; i++) { - for (let j = 0; j < mutated.weightsHO[i].length; j++) { - if (Math.random() < mutationRate) { - mutated.weightsHO[i][j] += (Math.random() * 2 - 1) * 0.5; - mutated.weightsHO[i][j] = Math.max(-2, Math.min(2, mutated.weightsHO[i][j])); - } + if (Math.random() < mutationRate) { + mutated.weightsHO[i] += (Math.random() * 2 - 1) * 0.5; + mutated.weightsHO[i] = Math.max(-2, Math.min(2, mutated.weightsHO[i])); } } diff --git a/src/lib/snakeAI/evolution.worker.ts b/src/lib/snakeAI/evolution.worker.ts index aae7fc2..3337981 100644 --- a/src/lib/snakeAI/evolution.worker.ts +++ b/src/lib/snakeAI/evolution.worker.ts @@ -1,24 +1,53 @@ -import { evaluatePopulation, evolveGeneration, type Population } from './evolution'; +import { evaluatePopulation, evolveGeneration, type Population, type Individual } from './evolution'; import type { EvolutionConfig } from './types'; self.onmessage = (e: MessageEvent) => { - const { population, config, generations = 1 } = e.data as { - population: Population; - config: EvolutionConfig; - generations?: number; - }; + const data = e.data; try { - let currentPop = population; + if (data.type === 'EVALUATE_ONLY') { + // Worker Pool Mode: Just evaluate the given individuals + const { individuals, config } = data.payload as { + individuals: Individual[]; + config: EvolutionConfig; + }; + + // Reconstruct a partial population object just for evaluation + // evaluatePopulation expects a Population, but only uses .individuals + // actually it returns a Population. + // Let's modify `evaluatePopulation`? + // Better: Mock the population shell. + const mockPop: Population = { + individuals, + generation: 0, + bestFitnessEver: 0, + bestNetworkEver: null + }; + + const evaluatedPop = evaluatePopulation(mockPop, config); + + self.postMessage({ + type: 'EVAL_RESULT', + payload: evaluatedPop.individuals + }); - for (let i = 0; i < generations; i++) { - // Run the heavy computation - const evaluated = evaluatePopulation(currentPop, config); - currentPop = evolveGeneration(evaluated, config); + } else { + // Default Mode: Run full generations (Legacy / Single Worker) + const { population, config, generations = 1 } = data as { + population: Population; + config: EvolutionConfig; + generations?: number; + }; + + let currentPop = population; + + for (let i = 0; i < generations; i++) { + const evaluated = evaluatePopulation(currentPop, config); + currentPop = evolveGeneration(evaluated, config); + } + + self.postMessage({ type: 'SUCCESS', payload: currentPop }); } - - // Send back the result - self.postMessage({ type: 'SUCCESS', payload: currentPop }); } catch (error) { self.postMessage({ type: 'ERROR', payload: error }); } diff --git a/src/lib/snakeAI/game.test.ts b/src/lib/snakeAI/game.test.ts new file mode 100644 index 0000000..69bca3b --- /dev/null +++ b/src/lib/snakeAI/game.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, test } from "bun:test"; +import { calculateArea, createGame, isDanger, type GameState } from "./game"; +import { Direction, type Position } from "./types"; + +// Helper to access the unexported calculateArea function? +// Since it's not exported, I might need to export it for testing or rely on testing getInputs. +// Let's modify game.ts to export calculateArea for testing purposes. +// For now, I'll assume I can export it. + +// Mock Game State Helper +function createMockGame(gridSize: number, snake: Position[]): GameState { + return { + gridSize, + snake, + food: { x: 0, y: 0 }, // Irrelevant for area test + direction: Direction.RIGHT, + alive: true, + score: 0, + steps: 0, + stepsSinceLastFood: 0 + }; +} + +describe("Snake AI Logic", () => { + describe("isDanger", () => { + const game = createMockGame(10, [{ x: 5, y: 5 }]); + + test("detects wall collisions", () => { + expect(isDanger(game, -1, 5)).toBe(true); + expect(isDanger(game, 10, 5)).toBe(true); + expect(isDanger(game, 5, -1)).toBe(true); + expect(isDanger(game, 5, 10)).toBe(true); + }); + + test("detects safe spots", () => { + expect(isDanger(game, 0, 0)).toBe(false); + expect(isDanger(game, 9, 9)).toBe(false); + }); + + test("detects body collisions", () => { + const complexGame = createMockGame(10, [{x:5,y:5}, {x:5,y:6}, {x:6,y:6}]); + expect(isDanger(complexGame, 5, 6)).toBe(true); // Hit body + expect(isDanger(complexGame, 6, 6)).toBe(true); // Hit tail + expect(isDanger(complexGame, 5, 4)).toBe(false); // Safe spot + }); + }); + + describe("calculateArea", () => { + test("calculates area in empty grid", () => { + // Grid 5x5 = 25 cells. Snake head at 2,2 occupies 1. + // Start flood fill from 2,3 (Down). Should reach all 24 empty cells. + const game = createMockGame(5, [{ x: 2, y: 2 }]); + const area = calculateArea(game, { x: 2, y: 3 }); + expect(area).toBe(24); + }); + + test("calculates confined area", () => { + // Snake creates a wall splitting the board + // 5x5 Grid. + // Snake: (2,0), (2,1), (2,2), (2,3), (2,4) - Vertical line down middle + const snake = [ + {x: 2, y: 0}, {x: 2, y: 1}, {x: 2, y: 2}, {x: 2, y: 3}, {x: 2, y: 4} + ]; + const game = createMockGame(5, snake); + + // Left side (0,0) -> 2 cols x 5 rows = 10 cells + expect(calculateArea(game, { x: 0, y: 0 })).toBe(10); + + // Right side (4,0) -> 2 cols x 5 rows = 10 cells + expect(calculateArea(game, { x: 4, y: 0 })).toBe(10); + + // Check wall itself returns 0 + expect(calculateArea(game, { x: 2, y: 0 })).toBe(0); + }); + + test("calculates U-shape trap", () => { + // U-shape wrapping around a center point + // Snake at (1,1), (1,2), (2,2), (2,1) ?? No simpler. + // Snake: (1,0), (1,1), (2,1), (3,1), (3,0) + // Trap at (2,0). + // Bound by Wall(Top) and Snake(L, D, R). + + // 5x5 Grid. + // S S . . . + // S S S . . + // . . . . . + // . . . . . + // . . . . . + + // Snake: (1,0), (1,1), (2,1), (3,1), (3,0) + const snake = [ + {x:1, y:0}, {x:1, y:1}, {x:2, y:1}, {x:3, y:1}, {x:3, y:0} + ]; + const game = createMockGame(5, snake); + + // Point (2,0) is inside the U cup. + // It is bounded by (1,0)L, (3,0)R, (2,1)D, Wall(Top). + // Area should be 1. + expect(calculateArea(game, { x: 2, y: 0 })).toBe(1); + }); + }); +}); diff --git a/src/lib/snakeAI/game.ts b/src/lib/snakeAI/game.ts index 4c738c1..a706ab1 100644 --- a/src/lib/snakeAI/game.ts +++ b/src/lib/snakeAI/game.ts @@ -131,58 +131,108 @@ function spawnFood(gridSize: number, snake: Position[]): Position { return food; } + +// Shared buffers for optimization +let cachedObstacles: Int8Array | null = null; // 0 = empty, 1 = obstacle +let cachedVisited: Int8Array | null = null; // 0 = unvisited, 1 = visited +let cachedStack: Int32Array | null = null; +let cachedSize = 0; + +function ensureBuffers(size: number) { + const totalCells = size * size; + if (!cachedObstacles || cachedSize !== size) { + cachedObstacles = new Int8Array(totalCells); + cachedVisited = new Int8Array(totalCells); // Changed back to Int8 for speed + cachedStack = new Int32Array(totalCells); + cachedSize = size; + } +} + export function getInputs(state: GameState): number[] { const head = state.snake[0]; const food = state.food; - - // Calculate relative direction vectors based on current direction - // If facing UP (0): Front=(0, -1), Left=(-1, 0), Right=(1, 0) - // If facing RIGHT (1): Front=(1, 0), Left=(0, -1), Right=(0, 1) - // ...and so on + const size = state.gridSize; - const frontVec = getDirectionVector(state.direction); + // Ensure buffers are ready + ensureBuffers(size); + const obstacles = cachedObstacles!; + + // Reset obstacles (fastest way is fill(0)) + obstacles.fill(0); + + // Mark snake on obstacle grid (O(N)) + // This replaces the O(N) check in isDanger called multiple times + const snake = state.snake; + for (let i = 0; i < snake.length; i++) { + const s = snake[i]; + if (s.x >= 0 && s.x < size && s.y >= 0 && s.y < size) { + obstacles[s.y * size + s.x] = 1; + } + } + + // Directions relative to Head const leftVec = getDirectionVector(((state.direction + 3) % 4) as Direction); + const frontVec = getDirectionVector(state.direction); const rightVec = getDirectionVector(((state.direction + 1) % 4) as Direction); - // 1. Danger Sensors (Relative) - // Is there danger immediately to my Left, Front, or Right? - const dangerLeft = isDanger(state, head.x + leftVec.x, head.y + leftVec.y); - const dangerFront = isDanger(state, head.x + frontVec.x, head.y + frontVec.y); - const dangerRight = isDanger(state, head.x + rightVec.x, head.y + rightVec.y); + const visionInputs: number[] = []; + const dirs = [leftVec, frontVec, rightVec]; + + // Total grid area for normalization + const totalArea = state.gridSize * state.gridSize; - // 2. Food Direction (Relative) - // We want to know if food is to our Left/Right or In Front/Behind relative to head - // We can use dot products or simple coordinate checks + for (const dir of dirs) { + // 1. Immediate Danger + const immX = head.x + dir.x; + const immY = head.y + dir.y; + + // Fast danger check using grid + let immediateDanger = false; + if (immX < 0 || immX >= size || immY < 0 || immY >= size) { + immediateDanger = true; + } else if (obstacles[immY * size + immX] === 1) { + immediateDanger = true; + } + + visionInputs.push(immediateDanger ? 1 : 0); + + // 2. Available Area (Flood Fill) + let area = 0; + if (!immediateDanger) { + area = calculateAreaOptimized(size, obstacles, { x: immX, y: immY }); + } + visionInputs.push(area / totalArea); + } + + // Food Sensors (4 inputs) const relFoodX = food.x - head.x; const relFoodY = food.y - head.y; - // Dot product to project food vector onto our relative axes const foodFront = relFoodX * frontVec.x + relFoodY * frontVec.y; - const foodSide = relFoodX * rightVec.x + relFoodY * rightVec.y; - // foodSide: Positive = Right, Negative = Left + const foodSide = relFoodX * rightVec.x + relFoodY * rightVec.y; + + // Self Awareness (1 input) + const normLength = state.snake.length / totalArea; return [ - // Sensor 1: Danger Left - dangerLeft ? 1 : 0, - // Sensor 2: Danger Front - dangerFront ? 1 : 0, - // Sensor 3: Danger Right - dangerRight ? 1 : 0, - - // Sensor 4: Food is to the Left - foodSide < 0 ? 1 : 0, - // Sensor 5: Food is to the Right - foodSide > 0 ? 1 : 0, - // Sensor 6: Food is Ahead - foodFront > 0 ? 1 : 0, - // Sensor 7: Food is Behind - foodFront < 0 ? 1 : 0, + ...visionInputs, // 6 inputs (3 * 2) - // Sensor 8: Normalized Length (Growth Sensor) - state.snake.length / (state.gridSize * state.gridSize) + // Food (4 inputs) + foodSide < 0 ? 1 : 0, // Left + foodSide > 0 ? 1 : 0, // Right + foodFront > 0 ? 1 : 0, // Front + foodFront < 0 ? 1 : 0, // Back + + // Length (1 input) + normLength ]; } +export function isDanger(state: GameState, x: number, y: number): boolean { + if (x < 0 || x >= state.gridSize || y < 0 || y >= state.gridSize) return true; + return state.snake.some(s => s.x === x && s.y === y); +} + function getDirectionVector(dir: Direction): Position { switch (dir) { case Direction.UP: return { x: 0, y: -1 }; @@ -193,15 +243,96 @@ function getDirectionVector(dir: Direction): Position { } } -function isDanger(state: GameState, x: number, y: number): boolean { - // Check wall - if (x < 0 || x >= state.gridSize || y < 0 || y >= state.gridSize) { - return true; - } - // Check self-collision - return state.snake.some((seg) => seg.x === x && seg.y === y); +// Optimized, internal version calling shared buffers +function calculateAreaOptimized(size: number, obstacles: Int8Array, start: Position): number { + const stack = cachedStack!; + const visited = cachedVisited!; + + // Reset visited for this run + visited.fill(0); + + const startIndex = start.y * size + start.x; + + // Safety check (already done in getInputs, but acceptable) + if (obstacles[startIndex] === 1) return 0; + + let head = 0; + let tail = 0; + + stack[tail++] = startIndex; + visited[startIndex] = 1; // Mark visited + + let area = 0; + + while (head < tail) { + const currIndex = stack[head++]; + area++; + + const cx = currIndex % size; + const cy = (currIndex / size) | 0; + + // Neighbors (Up, Down, Left, Right) + + // Up + if (cy > 0) { + const upIndex = currIndex - size; + // Check obstacle AND if already visited + if (obstacles[upIndex] === 0 && visited[upIndex] === 0) { + visited[upIndex] = 1; + stack[tail++] = upIndex; + } + } + + // Down + if (cy < size - 1) { + const downIndex = currIndex + size; + if (obstacles[downIndex] === 0 && visited[downIndex] === 0) { + visited[downIndex] = 1; + stack[tail++] = downIndex; + } + } + + // Left + if (cx > 0) { + const leftIndex = currIndex - 1; + if (obstacles[leftIndex] === 0 && visited[leftIndex] === 0) { + visited[leftIndex] = 1; + stack[tail++] = leftIndex; + } + } + + // Right + if (cx < size - 1) { + const rightIndex = currIndex + 1; + if (obstacles[rightIndex] === 0 && visited[rightIndex] === 0) { + visited[rightIndex] = 1; + stack[tail++] = rightIndex; + } + } + } + + return area; } +/** + * @deprecated Use calculateAreaOptimized internally. Kept for backward compatibility/tests. + */ +export function calculateArea(state: GameState, start: Position): number { + ensureBuffers(state.gridSize); + const obstacles = cachedObstacles!; + obstacles.fill(0); + for (const s of state.snake) { + if (s.x >= 0 && s.x < state.gridSize && s.y >= 0 && s.y < state.gridSize) { + obstacles[s.y * state.gridSize + s.x] = 1; + } + } + return calculateAreaOptimized(state.gridSize, obstacles, start); +} + + + + + export function calculateFitness(state: GameState): number { // Fitness formula balancing food collection and survival const foodScore = state.score * 100; diff --git a/src/lib/snakeAI/network.ts b/src/lib/snakeAI/network.ts index fe8d4a5..f6398be 100644 --- a/src/lib/snakeAI/network.ts +++ b/src/lib/snakeAI/network.ts @@ -5,15 +5,16 @@ export interface Network { inputSize: number; hiddenSize: number; outputSize: number; - weightsIH: number[][]; // Input to Hidden weights - weightsHO: number[][]; // Hidden to Output weights - biasH: number[]; // Hidden layer biases - biasO: number[]; // Output layer biases + // Flat buffers for better cache locality and performance + weightsIH: Float32Array; // Input -> Hidden weights + weightsHO: Float32Array; // Hidden -> Output weights + biasH: Float32Array; // Hidden layer biases + biasO: Float32Array; // Output layer biases } export function createNetwork( - inputSize: number = 8, - hiddenSize: number = 18, + inputSize: number = 11, + hiddenSize: number = 24, outputSize: number = 3 ): Network { return { @@ -21,8 +22,8 @@ export function createNetwork( inputSize, hiddenSize, outputSize, - weightsIH: createRandomMatrix(inputSize, hiddenSize), - weightsHO: createRandomMatrix(hiddenSize, outputSize), + weightsIH: createRandomArray(inputSize * hiddenSize), + weightsHO: createRandomArray(hiddenSize * outputSize), biasH: createRandomArray(hiddenSize), biasO: createRandomArray(outputSize), }; @@ -32,53 +33,104 @@ function generateId(): string { return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15); } -function createRandomMatrix(rows: number, cols: number): number[][] { - const matrix: number[][] = []; - for (let i = 0; i < rows; i++) { - matrix[i] = []; - for (let j = 0; j < cols; j++) { - matrix[i][j] = Math.random() * 2 - 1; // Random between -1 and 1 - } - } - return matrix; -} - -function createRandomArray(size: number): number[] { - const array: number[] = []; +function createRandomArray(size: number): Float32Array { + const array = new Float32Array(size); for (let i = 0; i < size; i++) { - array[i] = Math.random() * 2 - 1; + array[i] = Math.random() * 2 - 1; // Random between -1 and 1 } return array; } -export function forward(network: Network, inputs: number[]): number[] { - // Hidden layer activation - const hidden: number[] = []; - for (let h = 0; h < network.hiddenSize; h++) { - let sum = network.biasH[h]; - for (let i = 0; i < network.inputSize; i++) { - sum += inputs[i] * network.weightsIH[i][h]; - } - // ReLU activation for hidden layer: f(x) = max(0, x) - // Faster and solves vanishing gradient better than tanh - hidden[h] = Math.max(0, sum); - } +// Pre-allocated buffers for inference to avoid garbage collection +// Note: This makes 'forward' not thread-safe if called concurrently on the SAME thread. +// Since JS is single-threaded, this is safe unless we use async/await inside (which we don't). +// However, distinct workers have their own memory, so it's safe for workers too. +let cachedHidden: Float32Array | null = null; +let cachedOutputs: Float32Array | null = null; +let maxHiddenSize = 0; +let maxOutputSize = 0; - // Output layer activation - const outputs: number[] = []; - for (let o = 0; o < network.outputSize; o++) { - let sum = network.biasO[o]; - for (let h = 0; h < network.hiddenSize; h++) { - sum += hidden[h] * network.weightsHO[h][o]; +function ensureBuffers(hiddenSize: number, outputSize: number) { + if (!cachedHidden || hiddenSize > maxHiddenSize) { + cachedHidden = new Float32Array(hiddenSize); + maxHiddenSize = hiddenSize; + } + if (!cachedOutputs || outputSize > maxOutputSize) { + cachedOutputs = new Float32Array(outputSize); + maxOutputSize = outputSize; } - outputs[o] = tanh(sum); - } - - return outputs; } -function tanh(x: number): number { - return Math.tanh(x); +export function forward(network: Network, inputs: number[]): Float32Array { + const { inputSize, hiddenSize, outputSize, weightsIH, weightsHO, biasH, biasO } = network; + + ensureBuffers(hiddenSize, outputSize); + const hidden = cachedHidden!; + const outputs = cachedOutputs!; + + // 1. Hidden Layer + // hidden[h] = ReLU(bias[h] + sum(inputs[i] * weights[i][h])) + // Flattened weightsIH is [Input 0 -> Hidden 0..H, Input 1 -> Hidden 0..H] + // Wait, standard matrix mult is usually [Row][Col]. + // Let's assume weightsIH is stored as rows=Input, cols=Hidden. + // Index = i * hiddenSize + h + + // Optimization: Loop order. + // Iterating h then i means jumping around in inputs array? No, inputs is small. + // Jumping around in weights array is bad. + // If weights are stored [i * hiddenSize + h], then iterating i then h is sequential? + // No, h varies in inner loop. + // We want to iterate weights sequentially. + + // Initialize hidden with bias + hidden.set(biasH); + + // Accumulate inputs + // weightsIH is laid out: [i=0, h=0], [i=0, h=1]... + // So we should iterate i as outer, h as inner? + // biasH is [h=0, h=1...] + + let wIdx = 0; + for (let i = 0; i < inputSize; i++) { + const inputVal = inputs[i]; + if (inputVal !== 0) { // Sparse input optimization + for (let h = 0; h < hiddenSize; h++) { + hidden[h] += inputVal * weightsIH[wIdx++]; + } + } else { + wIdx += hiddenSize; // Skip weights for zero input + } + } + + // ReLU Activation + for (let h = 0; h < hiddenSize; h++) { + if (hidden[h] < 0) hidden[h] = 0; + } + + // 2. Output Layer + // outputs[o] = tanh(bias[o] + sum(hidden[h] * weights[h][o])) + + // Initialize with bias + outputs.set(biasO); + + wIdx = 0; + for (let h = 0; h < hiddenSize; h++) { + const hiddenVal = hidden[h]; + if (hiddenVal !== 0) { + for (let o = 0; o < outputSize; o++) { + outputs[o] += hiddenVal * weightsHO[wIdx++]; + } + } else { + wIdx += outputSize; + } + } + + // Tanh Activation + for (let o = 0; o < outputSize; o++) { + outputs[o] = Math.tanh(outputs[o]); + } + + return outputs; } export function getAction(network: Network, inputs: number[]): Action { @@ -86,22 +138,23 @@ export function getAction(network: Network, inputs: number[]): Action { // Find index of maximum output let maxIndex = 0; - for (let i = 1; i < outputs.length; i++) { - if (outputs[i] > outputs[maxIndex]) { - maxIndex = i; - } + let maxVal = outputs[0]; + + // Unrolled loop for small output size (3) + if (outputs[1] > maxVal) { + maxVal = outputs[1]; + maxIndex = 1; + } + if (outputs[2] > maxVal) { + maxIndex = 2; } // Map output index to action switch (maxIndex) { - case 0: - return Action.TURN_LEFT; - case 1: - return Action.STRAIGHT; - case 2: - return Action.TURN_RIGHT; - default: - return Action.STRAIGHT; + case 0: return Action.TURN_LEFT; + case 1: return Action.STRAIGHT; + case 2: return Action.TURN_RIGHT; + default: return Action.STRAIGHT; } } @@ -111,9 +164,10 @@ export function cloneNetwork(network: Network): Network { inputSize: network.inputSize, hiddenSize: network.hiddenSize, outputSize: network.outputSize, - weightsIH: network.weightsIH.map((row) => [...row]), - weightsHO: network.weightsHO.map((row) => [...row]), - biasH: [...network.biasH], - biasO: [...network.biasO], + // Float32Array has a fast .slice() method to copy + weightsIH: network.weightsIH.slice(), + weightsHO: network.weightsHO.slice(), + biasH: network.biasH.slice(), + biasO: network.biasO.slice(), }; } diff --git a/src/lib/snakeAI/workerPool.ts b/src/lib/snakeAI/workerPool.ts new file mode 100644 index 0000000..af9d816 --- /dev/null +++ b/src/lib/snakeAI/workerPool.ts @@ -0,0 +1,70 @@ +import EvolutionWorker from './evolution.worker?worker'; +import type { Population, Individual } from './evolution'; +import type { EvolutionConfig } from './types'; + +export class WorkerPool { + private workers: Worker[] = []; + private poolSize: number; + + constructor(size: number = navigator.hardwareConcurrency || 4) { + this.poolSize = size; + for (let i = 0; i < size; i++) { + this.workers.push(new EvolutionWorker()); + } + } + + terminate() { + this.workers.forEach(w => w.terminate()); + this.workers = []; + } + + async evaluateParallel(population: Population, config: EvolutionConfig): Promise { + // Split individuals into chunks + const chunkSize = Math.ceil(population.individuals.length / this.poolSize); + const chunks: Individual[][] = []; + + for (let i = 0; i < population.individuals.length; i += chunkSize) { + chunks.push(population.individuals.slice(i, i + chunkSize)); + } + + // Dispatch chunks to workers + const promises = chunks.map((chunk, index) => { + return new Promise((resolve, reject) => { + const worker = this.workers[index]; + + // One-time listener for this request + const handler = (e: MessageEvent) => { + if (e.data.type === 'EVAL_RESULT') { + worker.removeEventListener('message', handler); + resolve(e.data.payload); + } else if (e.data.type === 'ERROR') { + worker.removeEventListener('message', handler); + reject(e.data.payload); + } + }; + + worker.addEventListener('message', handler); + + worker.postMessage({ + type: 'EVALUATE_ONLY', + payload: { + individuals: chunk, + config + } + }); + }); + }); + + // Wait for all chunks + const results = await Promise.all(promises); + + // Merge results + const mergedIndividuals = results.flat(); + + // Reconstruct population with evaluated individuals + return { + ...population, + individuals: mergedIndividuals + }; + } +}