Add neat arena
This commit is contained in:
5
bun.lock
5
bun.lock
@@ -5,6 +5,7 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "evolution",
|
"name": "evolution",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"phaser": "^3.90.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-router-dom": "^7.12.0",
|
"react-router-dom": "^7.12.0",
|
||||||
@@ -314,6 +315,8 @@
|
|||||||
|
|
||||||
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
|
"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-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=="],
|
"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=="],
|
"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=="],
|
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||||
|
|
||||||
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"phaser": "^3.90.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-router-dom": "^7.12.0"
|
"react-router-dom": "^7.12.0"
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { Routes, Route, Navigate } from 'react-router-dom';
|
|||||||
import Sidebar from './components/Sidebar';
|
import Sidebar from './components/Sidebar';
|
||||||
import ImageApprox from './apps/ImageApprox/ImageApprox';
|
import ImageApprox from './apps/ImageApprox/ImageApprox';
|
||||||
import SnakeAI from './apps/SnakeAI/SnakeAI';
|
import SnakeAI from './apps/SnakeAI/SnakeAI';
|
||||||
|
import RogueGenApp from './apps/RogueGen/RogueGenApp';
|
||||||
|
import NeatArena from './apps/NeatArena/NeatArena';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
@@ -13,6 +15,8 @@ function App() {
|
|||||||
<Route path="/" element={<Navigate to="/image-approx" replace />} />
|
<Route path="/" element={<Navigate to="/image-approx" replace />} />
|
||||||
<Route path="/image-approx" element={<ImageApprox />} />
|
<Route path="/image-approx" element={<ImageApprox />} />
|
||||||
<Route path="/snake-ai" element={<SnakeAI />} />
|
<Route path="/snake-ai" element={<SnakeAI />} />
|
||||||
|
<Route path="/rogue-gen" element={<RogueGenApp />} />
|
||||||
|
<Route path="/neat-arena" element={<NeatArena />} />
|
||||||
<Route path="*" element={<div>App not found</div>} />
|
<Route path="*" element={<div>App not found</div>} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
112
src/apps/NeatArena/FitnessGraph.tsx
Normal file
112
src/apps/NeatArena/FitnessGraph.tsx
Normal file
@@ -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<HTMLCanvasElement>(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 (
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
width={600}
|
||||||
|
height={200}
|
||||||
|
style={{ width: '100%', height: 'auto', borderRadius: '8px' }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
239
src/apps/NeatArena/NeatArena.css
Normal file
239
src/apps/NeatArena/NeatArena.css
Normal file
@@ -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);
|
||||||
|
}
|
||||||
377
src/apps/NeatArena/NeatArena.tsx
Normal file
377
src/apps/NeatArena/NeatArena.tsx
Normal file
@@ -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<Population>(() => createPopulation(DEFAULT_EVOLUTION_CONFIG));
|
||||||
|
const [isTraining, setIsTraining] = useState(false);
|
||||||
|
const [showRays, setShowRays] = useState(false);
|
||||||
|
const [mapSeed] = useState(12345);
|
||||||
|
const [importedGenome, setImportedGenome] = useState<Genome | null>(null);
|
||||||
|
const [fitnessHistory, setFitnessHistory] = useState<{ generation: number; best: number; avg: number }[]>([]);
|
||||||
|
|
||||||
|
// Stats
|
||||||
|
const stats = getPopulationStats(population);
|
||||||
|
|
||||||
|
// Phaser game instance
|
||||||
|
const phaserGameRef = useRef<Phaser.Game | null>(null);
|
||||||
|
const phaserContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Exhibition match state (visualizing champion)
|
||||||
|
const simulationRef = useRef<SimulationState | null>(null);
|
||||||
|
|
||||||
|
// Web Worker
|
||||||
|
const workerRef = useRef<Worker | null>(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<TrainingWorkerResponse>) => {
|
||||||
|
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 (
|
||||||
|
<AppContainer title="NEAT Arena">
|
||||||
|
<div className="neat-arena-layout">
|
||||||
|
{/* Left Panel: Controls */}
|
||||||
|
<div className="controls-panel">
|
||||||
|
<section className="control-section">
|
||||||
|
<h3>Training Controls</h3>
|
||||||
|
<div className="control-group">
|
||||||
|
<button
|
||||||
|
className={`btn-primary ${isTraining ? 'btn-stop' : 'btn-start'}`}
|
||||||
|
onClick={() => setIsTraining(!isTraining)}
|
||||||
|
>
|
||||||
|
{isTraining ? '⏸ Pause Training' : '▶ Start Training'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn-secondary"
|
||||||
|
onClick={handleStepGeneration}
|
||||||
|
disabled={isTraining}
|
||||||
|
>
|
||||||
|
⏭ Step Generation
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn-secondary"
|
||||||
|
onClick={handleReset}
|
||||||
|
disabled={isTraining}
|
||||||
|
>
|
||||||
|
🔄 Reset
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="info-text">
|
||||||
|
{isTraining
|
||||||
|
? '🟢 Training in background worker...'
|
||||||
|
: importedGenome
|
||||||
|
? '🎮 Watching imported champion vs Spinner bot'
|
||||||
|
: population.bestGenomeEver
|
||||||
|
? '🎮 Watching champion vs Spinner bot'
|
||||||
|
: '⚪ No champion yet'}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="control-section">
|
||||||
|
<h3>Evolution Stats</h3>
|
||||||
|
<div className="stats-grid">
|
||||||
|
<div className="stat-item">
|
||||||
|
<span className="stat-label">Generation</span>
|
||||||
|
<span className="stat-value">{stats.generation}</span>
|
||||||
|
</div>
|
||||||
|
<div className="stat-item">
|
||||||
|
<span className="stat-label">Species</span>
|
||||||
|
<span className="stat-value">{stats.speciesCount}</span>
|
||||||
|
</div>
|
||||||
|
<div className="stat-item">
|
||||||
|
<span className="stat-label">Best Fitness</span>
|
||||||
|
<span className="stat-value">{stats.maxFitness.toFixed(1)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="stat-item">
|
||||||
|
<span className="stat-label">Avg Fitness</span>
|
||||||
|
<span className="stat-value">{stats.avgFitness.toFixed(1)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="stat-item">
|
||||||
|
<span className="stat-label">Champion</span>
|
||||||
|
<span className="stat-value">{stats.bestFitnessEver.toFixed(1)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="stat-item">
|
||||||
|
<span className="stat-label">Innovations</span>
|
||||||
|
<span className="stat-value">{stats.totalInnovations}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{fitnessHistory.length > 0 && (
|
||||||
|
<section className="control-section">
|
||||||
|
<h3>Fitness Progress</h3>
|
||||||
|
<FitnessGraph history={fitnessHistory} />
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section className="control-section">
|
||||||
|
<h3>Debug Options</h3>
|
||||||
|
<label className="checkbox-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={showRays}
|
||||||
|
onChange={(e) => setShowRays(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>Show Ray Sensors</span>
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="control-section">
|
||||||
|
<h3>Export / Import</h3>
|
||||||
|
<div className="control-group">
|
||||||
|
<button
|
||||||
|
className="btn-secondary"
|
||||||
|
onClick={handleExport}
|
||||||
|
disabled={!population.bestGenomeEver}
|
||||||
|
>
|
||||||
|
💾 Export Champion
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn-secondary"
|
||||||
|
onClick={handleImport}
|
||||||
|
>
|
||||||
|
📂 Import Genome
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{importedGenome && (
|
||||||
|
<p className="info-text">
|
||||||
|
✅ Imported genome loaded
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="info-section">
|
||||||
|
<h4>NEAT Arena Status</h4>
|
||||||
|
<ul>
|
||||||
|
<li>✅ Deterministic 30Hz simulation</li>
|
||||||
|
<li>✅ Symmetric procedural maps</li>
|
||||||
|
<li>✅ Agent physics & bullets</li>
|
||||||
|
<li>✅ 360° ray sensors (53 inputs)</li>
|
||||||
|
<li>✅ NEAT evolution with speciation</li>
|
||||||
|
<li>✅ Self-play training (K=4 matches)</li>
|
||||||
|
<li>✅ Export/import genomes</li>
|
||||||
|
<li>✅ Web worker (no UI lag!)</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Panel: Phaser Viewer */}
|
||||||
|
<div className="viewer-panel">
|
||||||
|
<div
|
||||||
|
ref={phaserContainerRef}
|
||||||
|
className="phaser-container"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AppContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
386
src/apps/RogueGen/RogueGenApp.tsx
Normal file
386
src/apps/RogueGen/RogueGenApp.tsx
Normal file
@@ -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<Genotype[]>([]);
|
||||||
|
const [bestIndividual, setBestIndividual] = useState<Individual | null>(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<HTMLCanvasElement>(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<typeof setInterval>;
|
||||||
|
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 (
|
||||||
|
<div className="rogue-gen-app" style={{
|
||||||
|
display: 'flex',
|
||||||
|
height: '100%',
|
||||||
|
background: '#1a1a1a',
|
||||||
|
color: '#eee',
|
||||||
|
fontFamily: 'monospace'
|
||||||
|
}}>
|
||||||
|
{/* Sidebar Controls */}
|
||||||
|
<div className="sidebar-panel" style={{
|
||||||
|
width: '320px',
|
||||||
|
padding: '20px',
|
||||||
|
background: '#222',
|
||||||
|
borderRight: '1px solid #333',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '20px',
|
||||||
|
overflowY: 'auto'
|
||||||
|
}}>
|
||||||
|
<div style={{ borderBottom: '1px solid #444', paddingBottom: '10px' }}>
|
||||||
|
<h2 style={{ margin: '0 0 5px 0', fontSize: '1.2em', color: '#88f' }}>Rogue Map Evo</h2>
|
||||||
|
<div style={{ fontSize: '0.8em', color: '#888' }}>Gen: {generation} | Best: {bestFitness.toFixed(4)}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="control-group">
|
||||||
|
<h3 style={{ fontSize: '1em', marginBottom: '10px', color: '#ccc' }}>Controls</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsRunning(!isRunning)}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '12px',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
background: isRunning ? '#c44' : '#4a4',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
marginBottom: '10px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isRunning ? 'STOP EVOLUTION' : 'START EVOLUTION'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setGeneration(0);
|
||||||
|
const initPop = [];
|
||||||
|
for (let i = 0; i < POPULATION_SIZE; i++) initPop.push(createRandomGenome());
|
||||||
|
setPopulation(initPop);
|
||||||
|
setBestIndividual(null);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '8px',
|
||||||
|
background: '#444',
|
||||||
|
color: '#ccc',
|
||||||
|
border: '1px solid #555',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Reset Population
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="control-group">
|
||||||
|
<h3 style={{ fontSize: '1em', marginBottom: '10px', color: '#ccc' }}>Configuration</h3>
|
||||||
|
|
||||||
|
<label style={{ display: 'block', marginBottom: '8px', fontSize: '0.9em' }}>
|
||||||
|
Map Width: {config.width}
|
||||||
|
<input
|
||||||
|
type="range" min="20" max="300" step="10"
|
||||||
|
value={config.width}
|
||||||
|
onChange={e => setConfig({ ...config, width: Number(e.target.value) })}
|
||||||
|
style={{ width: '100%', accentColor: '#88f' }}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label style={{ display: 'block', marginBottom: '8px', fontSize: '0.9em' }}>
|
||||||
|
Map Height: {config.height}
|
||||||
|
<input
|
||||||
|
type="range" min="20" max="300" step="10"
|
||||||
|
value={config.height}
|
||||||
|
onChange={e => setConfig({ ...config, height: Number(e.target.value) })}
|
||||||
|
style={{ width: '100%', accentColor: '#88f' }}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label style={{ display: 'block', marginBottom: '8px', fontSize: '0.9em' }}>
|
||||||
|
Zoom: {config.canvasScale}x
|
||||||
|
<input
|
||||||
|
type="range" min="1" max="20" step="1"
|
||||||
|
value={config.canvasScale}
|
||||||
|
onChange={e => setConfig({ ...config, canvasScale: Number(e.target.value) })}
|
||||||
|
style={{ width: '100%', accentColor: '#88f' }}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label style={{ display: 'block', marginBottom: '8px', fontSize: '0.9em' }}>
|
||||||
|
Speed: {config.simulationSpeed}ms
|
||||||
|
<input
|
||||||
|
type="range" min="10" max="1000" step="10"
|
||||||
|
value={config.simulationSpeed}
|
||||||
|
onChange={e => setConfig({ ...config, simulationSpeed: Number(e.target.value) })}
|
||||||
|
style={{ width: '100%', accentColor: '#88f' }}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<h3 style={{ fontSize: '1em', marginBottom: '10px', marginTop: '20px', color: '#ccc' }}>Map Style</h3>
|
||||||
|
|
||||||
|
<label style={{ display: 'block', marginBottom: '8px', fontSize: '0.9em', cursor: 'pointer' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={targets.forceTunnels}
|
||||||
|
onChange={e => setTargets({ ...targets, forceTunnels: e.target.checked })}
|
||||||
|
style={{ marginRight: '5px' }}
|
||||||
|
/>
|
||||||
|
Force Tunnels (Ridged)
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label style={{ display: 'block', marginBottom: '8px', fontSize: '0.9em' }}>
|
||||||
|
Feature Scale: {targets.scaleOverride > 0 ? targets.scaleOverride : 'Auto'}
|
||||||
|
<input
|
||||||
|
type="range" min="0" max="50" step="1"
|
||||||
|
value={targets.scaleOverride}
|
||||||
|
onChange={e => setTargets({ ...targets, scaleOverride: Number(e.target.value) })}
|
||||||
|
style={{ width: '100%', accentColor: '#aaa' }}
|
||||||
|
/>
|
||||||
|
<div style={{ fontSize: '0.8em', color: '#666' }}>(0 = Evolve Scale)</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<h3 style={{ fontSize: '1em', marginBottom: '10px', marginTop: '20px', color: '#ccc' }}>Terrain Targets</h3>
|
||||||
|
|
||||||
|
<label style={{ display: 'block', marginBottom: '8px', fontSize: '0.9em' }}>
|
||||||
|
Open Space: {(targets.density * 100).toFixed(0)}%
|
||||||
|
<input
|
||||||
|
type="range" min="0.1" max="0.9" step="0.05"
|
||||||
|
value={targets.density}
|
||||||
|
onChange={e => setTargets({ ...targets, density: Number(e.target.value) })}
|
||||||
|
style={{ width: '100%', accentColor: '#aaa' }}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label style={{ display: 'block', marginBottom: '8px', fontSize: '0.9em', color: '#48d' }}>
|
||||||
|
Water: {(targets.water * 100).toFixed(0)}%
|
||||||
|
<input
|
||||||
|
type="range" min="0" max="0.5" step="0.05"
|
||||||
|
value={targets.water}
|
||||||
|
onChange={e => setTargets({ ...targets, water: Number(e.target.value) })}
|
||||||
|
style={{ width: '100%', accentColor: '#48d' }}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label style={{ display: 'block', marginBottom: '8px', fontSize: '0.9em', color: '#e44' }}>
|
||||||
|
Lava: {(targets.lava * 100).toFixed(0)}%
|
||||||
|
<input
|
||||||
|
type="range" min="0" max="0.5" step="0.05"
|
||||||
|
value={targets.lava}
|
||||||
|
onChange={e => setTargets({ ...targets, lava: Number(e.target.value) })}
|
||||||
|
style={{ width: '100%', accentColor: '#e44' }}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label style={{ display: 'block', marginBottom: '8px', fontSize: '0.9em', color: '#2a4' }}>
|
||||||
|
Veg: {(targets.veg * 100).toFixed(0)}%
|
||||||
|
<input
|
||||||
|
type="range" min="0" max="0.8" step="0.05"
|
||||||
|
value={targets.veg}
|
||||||
|
onChange={e => setTargets({ ...targets, veg: Number(e.target.value) })}
|
||||||
|
style={{ width: '100%', accentColor: '#2a4' }}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label style={{ display: 'block', marginBottom: '8px', fontSize: '0.9em', color: '#fa4' }}>
|
||||||
|
Min Path: {targets.minPathLength} tiles
|
||||||
|
<input
|
||||||
|
type="range" min="0" max="1000" step="5"
|
||||||
|
value={targets.minPathLength}
|
||||||
|
onChange={e => setTargets({ ...targets, minPathLength: Number(e.target.value) })}
|
||||||
|
style={{ width: '100%', accentColor: '#fa4' }}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{bestIndividual && (
|
||||||
|
<div className="stats-panel" style={{
|
||||||
|
background: '#111',
|
||||||
|
padding: '10px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '0.85em',
|
||||||
|
border: '1px solid #333'
|
||||||
|
}}>
|
||||||
|
<h4 style={{ margin: '0 0 10px 0', color: '#aaa' }}>Best Genome (Wall)</h4>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '5px', marginBottom: '10px' }}>
|
||||||
|
<div>Init P:</div><div style={{ textAlign: 'right', color: '#8f8' }}>{bestIndividual.genome.initialChance.toFixed(2)}</div>
|
||||||
|
<div>Birth:</div><div style={{ textAlign: 'right', color: '#8f8' }}>{bestIndividual.genome.birthLimit}</div>
|
||||||
|
<div>Death:</div><div style={{ textAlign: 'right', color: '#8f8' }}>{bestIndividual.genome.deathLimit}</div>
|
||||||
|
<div>Steps:</div><div style={{ textAlign: 'right', color: '#8f8' }}>{bestIndividual.genome.steps}</div>
|
||||||
|
<div>Smooth:</div><div style={{ textAlign: 'right', color: '#8f8' }}>{bestIndividual.genome.smoothingSteps}</div>
|
||||||
|
<div>Cleanup:</div><div style={{ textAlign: 'right', color: '#8f8' }}>{bestIndividual.genome.noiseReduction ? 'Yes' : 'No'}</div>
|
||||||
|
</div>
|
||||||
|
<h4 style={{ margin: '0 0 10px 0', color: '#aaa' }}>Best Genome (Water/Lava/Veg)</h4>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '5px', fontSize: '0.8em' }}>
|
||||||
|
<div style={{ color: '#48d' }}>WATER</div>
|
||||||
|
<div style={{ color: '#e44' }}>LAVA</div>
|
||||||
|
<div style={{ color: '#2a4' }}>VEG</div>
|
||||||
|
|
||||||
|
<div style={{ color: '#ccc' }}>{bestIndividual.genome.waterInitialChance.toFixed(2)}</div>
|
||||||
|
<div style={{ color: '#ccc' }}>{bestIndividual.genome.lavaInitialChance.toFixed(2)}</div>
|
||||||
|
<div style={{ color: '#ccc' }}>{bestIndividual.genome.vegInitialChance.toFixed(2)}</div>
|
||||||
|
|
||||||
|
<div style={{ color: '#666' }}>Steps</div>
|
||||||
|
<div style={{ color: '#666' }}>Steps</div>
|
||||||
|
<div style={{ color: '#666' }}>Steps</div>
|
||||||
|
|
||||||
|
<div style={{ color: '#ccc' }}>{bestIndividual.genome.waterSteps}</div>
|
||||||
|
<div style={{ color: '#ccc' }}>{bestIndividual.genome.lavaSteps}</div>
|
||||||
|
<div style={{ color: '#ccc' }}>{bestIndividual.genome.vegSteps}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4 style={{ margin: '10px 0 5px 0', color: '#aaa' }}>Structure</h4>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '5px' }}>
|
||||||
|
<div>Noise:</div><div style={{ textAlign: 'right', color: '#ccc' }}>{bestIndividual.genome.useNoise ? (bestIndividual.genome.noiseType === 1 ? 'Tunnel' : 'Blob') : 'No'}</div>
|
||||||
|
<div>Scale:</div><div style={{ textAlign: 'right', color: '#ccc' }}>{bestIndividual.genome.noiseScale.toFixed(1)}</div>
|
||||||
|
<div>Rooms:</div><div style={{ textAlign: 'right', color: '#ccc' }}>{bestIndividual.genome.useRooms ? bestIndividual.genome.roomCount : 'No'}</div>
|
||||||
|
</div>
|
||||||
|
<hr style={{ borderColor: '#333', margin: '10px 0' }} />
|
||||||
|
<h4 style={{ margin: '0 0 10px 0', color: '#aaa' }}>Metrics</h4>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '5px' }}>
|
||||||
|
<div>Connect:</div><div style={{ textAlign: 'right', color: '#fa4' }}>{(bestIndividual.fitness.connectivity * 100).toFixed(1)}%</div>
|
||||||
|
<div>Density:</div><div style={{ textAlign: 'right', color: '#fa4' }}>{(bestIndividual.fitness.density * 100).toFixed(1)}%</div>
|
||||||
|
<div>Path:</div><div style={{ textAlign: 'right', color: '#ff0' }}>{generateMap(bestIndividual.genome, config.width, config.height).pathLength}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Visualization */}
|
||||||
|
<div className="visualization-area" style={{
|
||||||
|
flex: 1,
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
background: '#0d0d0d',
|
||||||
|
overflow: 'auto',
|
||||||
|
padding: '20px'
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
border: '5px solid #333',
|
||||||
|
borderRadius: '4px',
|
||||||
|
boxShadow: '0 0 20px rgba(0,0,0,0.5)',
|
||||||
|
lineHeight: 0
|
||||||
|
}}>
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
width={config.width * config.canvasScale}
|
||||||
|
height={config.height * config.canvasScale}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
155
src/apps/RogueGen/evolution.ts
Normal file
155
src/apps/RogueGen/evolution.ts
Normal file
@@ -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)));
|
||||||
|
}
|
||||||
214
src/apps/RogueGen/fitness.ts
Normal file
214
src/apps/RogueGen/fitness.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
655
src/apps/RogueGen/generator.ts
Normal file
655
src/apps/RogueGen/generator.ts
Normal file
@@ -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<count; i++) {
|
||||||
|
const w = Math.floor(Math.random() * (max - min + 1)) + min;
|
||||||
|
const h = Math.floor(Math.random() * (max - min + 1)) + min;
|
||||||
|
const x = Math.floor(Math.random() * (width - w - 2)) + 1;
|
||||||
|
const y = Math.floor(Math.random() * (height - h - 2)) + 1;
|
||||||
|
|
||||||
|
// Stamp Room (Floor 0)
|
||||||
|
for(let ry = 0; ry < h; ry++) {
|
||||||
|
for(let rx = 0; rx < w; rx++) {
|
||||||
|
if (y+ry < height-1 && x+rx < width-1) {
|
||||||
|
map[(y+ry)*width + (x+rx)] = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Step 3: Cellular Automata ---
|
||||||
|
// Double buffer allocation ONCE
|
||||||
|
let buffer = new Uint8Array(width * height);
|
||||||
|
|
||||||
|
for (let s = 0; s < genome.steps; s++) {
|
||||||
|
// Copy map to buffer? Or just read from map write to buffer?
|
||||||
|
// Must handle edges.
|
||||||
|
// Optimization: Just swap references.
|
||||||
|
// Read from 'map', write to 'buffer'.
|
||||||
|
|
||||||
|
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 (map[idx] === 1) {
|
||||||
|
// Wall logic
|
||||||
|
if (neighbors < genome.deathLimit) buffer[idx] = 0;
|
||||||
|
else buffer[idx] = 1;
|
||||||
|
} else {
|
||||||
|
// Floor logic
|
||||||
|
if (neighbors > 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<height-1; y++) {
|
||||||
|
for(let x=1; x<width-1; x++) {
|
||||||
|
const d = dists[y*width+x];
|
||||||
|
if (d >= 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<height-1; y++) {
|
||||||
|
for(let x=1; x<width-1; x++) {
|
||||||
|
if (map[y*width+x] === 0 || map[y*width+x] === 4) {
|
||||||
|
startX = x; startY = y;
|
||||||
|
break outer2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startX !== -1) {
|
||||||
|
// 2. Find furthest from A -> 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; k++) {
|
||||||
|
mainSamples.push(mainRegion.points[Math.floor(Math.random() * mainRegion.points.length)]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
mainSamples.push(...mainRegion.points);
|
||||||
|
}
|
||||||
|
|
||||||
|
const regionSamples = [];
|
||||||
|
if (region.points.length > sampleSize) {
|
||||||
|
for(let k=0; k<sampleSize; k++) {
|
||||||
|
regionSamples.push(region.points[Math.floor(Math.random() * region.points.length)]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
regionSamples.push(...region.points);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare samples
|
||||||
|
for(const pA of mainSamples) {
|
||||||
|
for(const pB of regionSamples) {
|
||||||
|
const dist = (pA.x-pB.x)**2 + (pA.y-pB.y)**2;
|
||||||
|
if (dist < minDistance) {
|
||||||
|
minDistance = dist;
|
||||||
|
startPoint = pA;
|
||||||
|
endPoint = pB;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw bridge - ORGANIC "DRUNKARD'S" LINE
|
||||||
|
let cursorX = startPoint.x;
|
||||||
|
let cursorY = startPoint.y;
|
||||||
|
|
||||||
|
const dx = endPoint.x - startPoint.x;
|
||||||
|
const dy = endPoint.y - startPoint.y;
|
||||||
|
const dist = Math.sqrt(dx*dx + dy*dy);
|
||||||
|
|
||||||
|
// Normalize direction
|
||||||
|
const stepX = dx / dist;
|
||||||
|
const stepY = dy / dist;
|
||||||
|
|
||||||
|
let steps = Math.floor(dist);
|
||||||
|
|
||||||
|
for(let s=0; s<=steps; s++) {
|
||||||
|
// Move generally towards target
|
||||||
|
cursorX += stepX;
|
||||||
|
cursorY += stepY;
|
||||||
|
|
||||||
|
// Add jitter
|
||||||
|
const jitter = (Math.random() - 0.5) * 1.5;
|
||||||
|
const px = Math.floor(cursorX + jitter);
|
||||||
|
const py = Math.floor(cursorY + jitter);
|
||||||
|
|
||||||
|
// Carve with brush size 2 for playability
|
||||||
|
for(let by=0; by<=1; by++) {
|
||||||
|
for(let bx=0; bx<=1; bx++) {
|
||||||
|
const carverY = py+by;
|
||||||
|
const carverX = px+bx;
|
||||||
|
if (carverY>0 && carverY<height-1 && carverX>0 && carverX<width-1) {
|
||||||
|
const idx = carverY * width + carverX;
|
||||||
|
// Overwrite anything that isn't already Floor/Veg
|
||||||
|
if (map[idx] !== 0 && map[idx] !== 4) {
|
||||||
|
map[idx] = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function countNeighbors(map: Uint8Array, width: number, height: number, x: number, y: number, targetInfo: number): number {
|
||||||
|
let count = 0;
|
||||||
|
// Inline checks for performance?
|
||||||
|
// 3x3 loop
|
||||||
|
for (let dy = -1; dy <= 1; dy++) {
|
||||||
|
for (let dx = -1; dx <= 1; dx++) {
|
||||||
|
if (dx === 0 && dy === 0) continue;
|
||||||
|
|
||||||
|
const nx = x + dx;
|
||||||
|
const ny = y + dy;
|
||||||
|
|
||||||
|
if (ny < 0 || ny >= 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;
|
||||||
|
}
|
||||||
61
src/apps/RogueGen/perlin.ts
Normal file
61
src/apps/RogueGen/perlin.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
46
src/apps/RogueGen/types.ts
Normal file
46
src/apps/RogueGen/types.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -29,7 +29,7 @@ export default function BestSnakeDisplay({ network, gridSize, fitness }: BestSna
|
|||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min="1"
|
min="1"
|
||||||
max="100"
|
max="200"
|
||||||
value={playbackSpeed}
|
value={playbackSpeed}
|
||||||
onChange={(e) => setPlaybackSpeed(Number(e.target.value))}
|
onChange={(e) => setPlaybackSpeed(Number(e.target.value))}
|
||||||
style={{ flex: 1, accentColor: '#4ecdc4' }}
|
style={{ flex: 1, accentColor: '#4ecdc4' }}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import Tips from './Tips';
|
|||||||
import BestSnakeDisplay from './BestSnakeDisplay';
|
import BestSnakeDisplay from './BestSnakeDisplay';
|
||||||
import {
|
import {
|
||||||
createPopulation,
|
createPopulation,
|
||||||
type Population,
|
|
||||||
} from '../../lib/snakeAI/evolution';
|
} from '../../lib/snakeAI/evolution';
|
||||||
import type { EvolutionConfig } from '../../lib/snakeAI/types';
|
import type { EvolutionConfig } from '../../lib/snakeAI/types';
|
||||||
import './SnakeAI.css';
|
import './SnakeAI.css';
|
||||||
@@ -20,7 +19,8 @@ const DEFAULT_CONFIG: EvolutionConfig = {
|
|||||||
maxGameSteps: 20000,
|
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() {
|
export default function SnakeAI() {
|
||||||
const [population, setPopulation] = useState<Population>(() =>
|
const [population, setPopulation] = useState<Population>(() =>
|
||||||
@@ -42,73 +42,64 @@ export default function SnakeAI() {
|
|||||||
const lastUpdateRef = useRef<number>(0);
|
const lastUpdateRef = useRef<number>(0);
|
||||||
|
|
||||||
// Compute derived values for display
|
// 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 currentBestFitness = population.lastGenerationStats?.bestFitness || 0;
|
||||||
const currentAverageFitness = population.lastGenerationStats?.averageFitness || 0;
|
const currentAverageFitness = population.lastGenerationStats?.averageFitness || 0;
|
||||||
|
|
||||||
const workerRef = useRef<Worker | null>(null);
|
const workerPoolRef = useRef<WorkerPool | null>(null);
|
||||||
const isProcessingRef = useRef(false);
|
const isProcessingRef = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
workerRef.current = new EvolutionWorker();
|
// Initialize Worker Pool with logical cores (default)
|
||||||
workerRef.current.onmessage = (e) => {
|
workerPoolRef.current = new WorkerPool();
|
||||||
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;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
workerRef.current?.terminate();
|
workerPoolRef.current?.terminate();
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const runGeneration = useCallback((generations: number = 1) => {
|
const runGeneration = useCallback(async (generations: number = 1) => {
|
||||||
if (isProcessingRef.current || !workerRef.current) return;
|
if (isProcessingRef.current || !workerPoolRef.current) return;
|
||||||
|
|
||||||
isProcessingRef.current = true;
|
isProcessingRef.current = true;
|
||||||
// We need to send the *current* population.
|
let currentPop = populationRef.current;
|
||||||
// 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's use a functional update approach? No, we need to SEND data.
|
try {
|
||||||
// We will use a ref to track current population for the worker to ensure we always send latest
|
for (let i = 0; i < generations; i++) {
|
||||||
// OR rely on the fact that 'population' is in dependency of runGeneration (it wasn't before).
|
// 1. Evaluate in parallel
|
||||||
|
let evaluatedPop = await workerPoolRef.current.evaluateParallel(currentPop, config);
|
||||||
|
|
||||||
// Wait, 'runGeneration' lines 43-58 previously used setPopulation(prev => ...).
|
// 1.5 Update Best Stats (Critical for UI)
|
||||||
// It didn't need 'population' in dependency.
|
evaluatedPop = updateBestStats(evaluatedPop);
|
||||||
// Now we need it.
|
|
||||||
|
|
||||||
workerRef.current.postMessage({
|
// 2. Evolve on main thread (fast)
|
||||||
population: populationRef.current, // Use a ref for latest population
|
currentPop = evolveGeneration(evaluatedPop, config);
|
||||||
config,
|
}
|
||||||
generations
|
|
||||||
});
|
// Update state
|
||||||
}, [config]); // populationRef will be handled separately
|
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
|
// Update stats when generation changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NavLink } from 'react-router-dom';
|
import { NavLink } from 'react-router-dom';
|
||||||
import './Sidebar.css';
|
import './Sidebar.css';
|
||||||
|
|
||||||
export type AppId = 'image-approx' | 'snake-ai';
|
export type AppId = 'image-approx' | 'snake-ai' | 'rogue-gen' | 'neat-arena';
|
||||||
|
|
||||||
export interface AppInfo {
|
export interface AppInfo {
|
||||||
id: AppId;
|
id: AppId;
|
||||||
@@ -26,6 +26,20 @@ export const APPS: AppInfo[] = [
|
|||||||
icon: '🐍',
|
icon: '🐍',
|
||||||
description: 'Evolve neural networks to play Snake',
|
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() {
|
export default function Sidebar() {
|
||||||
|
|||||||
184
src/lib/neatArena/arenaScene.ts
Normal file
184
src/lib/neatArena/arenaScene.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
60
src/lib/neatArena/baselineBots.ts
Normal file
60
src/lib/neatArena/baselineBots.ts
Normal file
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
76
src/lib/neatArena/crossover.ts
Normal file
76
src/lib/neatArena/crossover.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
154
src/lib/neatArena/evolution.ts
Normal file
154
src/lib/neatArena/evolution.ts
Normal file
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
120
src/lib/neatArena/exportImport.ts
Normal file
120
src/lib/neatArena/exportImport.ts
Normal file
@@ -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<ExportedGenome> {
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
}
|
||||||
86
src/lib/neatArena/fitness.ts
Normal file
86
src/lib/neatArena/fitness.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
214
src/lib/neatArena/genome.ts
Normal file
214
src/lib/neatArena/genome.ts
Normal file
@@ -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<string, number> = 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<number, number[]>();
|
||||||
|
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<number>();
|
||||||
|
const recStack = new Set<number>();
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
123
src/lib/neatArena/mapGenerator.ts
Normal file
123
src/lib/neatArena/mapGenerator.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
218
src/lib/neatArena/mutations.ts
Normal file
218
src/lib/neatArena/mutations.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
183
src/lib/neatArena/network.ts
Normal file
183
src/lib/neatArena/network.ts
Normal file
@@ -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<number, NetworkNode>;
|
||||||
|
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<number, number>();
|
||||||
|
const adj = new Map<number, number[]>();
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
160
src/lib/neatArena/reproduction.ts
Normal file
160
src/lib/neatArena/reproduction.ts
Normal file
@@ -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];
|
||||||
|
}
|
||||||
199
src/lib/neatArena/selfPlay.ts
Normal file
199
src/lib/neatArena/selfPlay.ts
Normal file
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
232
src/lib/neatArena/sensors.ts
Normal file
232
src/lib/neatArena/sensors.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
286
src/lib/neatArena/simulation.ts
Normal file
286
src/lib/neatArena/simulation.ts
Normal file
@@ -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<number>();
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
202
src/lib/neatArena/speciation.ts
Normal file
202
src/lib/neatArena/speciation.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
129
src/lib/neatArena/training.worker.ts
Normal file
129
src/lib/neatArena/training.worker.ts
Normal file
@@ -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<typeof getPopulationStats>;
|
||||||
|
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<TrainingWorkerMessage>) => {
|
||||||
|
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<ReturnType<typeof getPopulationStats> | 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<typeof getPopulationStats> | null) {
|
||||||
|
if (!population) return;
|
||||||
|
|
||||||
|
self.postMessage({
|
||||||
|
type: 'update',
|
||||||
|
population,
|
||||||
|
stats: stats || undefined,
|
||||||
|
} as TrainingWorkerResponse);
|
||||||
|
}
|
||||||
204
src/lib/neatArena/types.ts
Normal file
204
src/lib/neatArena/types.ts
Normal file
@@ -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';
|
||||||
42
src/lib/neatArena/utils.ts
Normal file
42
src/lib/neatArena/utils.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -60,19 +60,31 @@ export function evaluatePopulation(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update best ever
|
// Update best ever
|
||||||
|
return updateBestStats(
|
||||||
|
{
|
||||||
|
...population,
|
||||||
|
individuals: evaluatedIndividuals
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateBestStats(population: Population): Population {
|
||||||
let newBestEver = population.bestFitnessEver;
|
let newBestEver = population.bestFitnessEver;
|
||||||
let newBestNetwork = population.bestNetworkEver;
|
let newBestNetwork = population.bestNetworkEver;
|
||||||
|
let changed = false;
|
||||||
|
|
||||||
for (const individual of evaluatedIndividuals) {
|
for (const individual of population.individuals) {
|
||||||
if (individual.fitness > newBestEver) {
|
if (individual.fitness > newBestEver) {
|
||||||
newBestEver = individual.fitness;
|
newBestEver = individual.fitness;
|
||||||
newBestNetwork = cloneNetwork(individual.network);
|
newBestNetwork = cloneNetwork(individual.network);
|
||||||
|
changed = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!changed) return population;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...population,
|
...population,
|
||||||
individuals: evaluatedIndividuals,
|
|
||||||
bestFitnessEver: newBestEver,
|
bestFitnessEver: newBestEver,
|
||||||
bestNetworkEver: newBestNetwork,
|
bestNetworkEver: newBestNetwork,
|
||||||
};
|
};
|
||||||
@@ -152,28 +164,26 @@ function selectParent(sorted: Individual[]): Individual {
|
|||||||
return best;
|
return best;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function crossover(parent1: Network, parent2: Network): Network {
|
function crossover(parent1: Network, parent2: Network): Network {
|
||||||
const child = cloneNetwork(parent1);
|
const child = cloneNetwork(parent1);
|
||||||
child.id = Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
|
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;
|
const crossoverRate = 0.5;
|
||||||
|
|
||||||
// Crossover input-hidden weights
|
// Crossover input-hidden weights
|
||||||
for (let i = 0; i < child.weightsIH.length; i++) {
|
for (let i = 0; i < child.weightsIH.length; i++) {
|
||||||
for (let j = 0; j < child.weightsIH[i].length; j++) {
|
if (Math.random() < crossoverRate) {
|
||||||
if (Math.random() < crossoverRate) {
|
child.weightsIH[i] = parent2.weightsIH[i];
|
||||||
child.weightsIH[i][j] = parent2.weightsIH[i][j];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Crossover hidden-output weights
|
// Crossover hidden-output weights
|
||||||
for (let i = 0; i < child.weightsHO.length; i++) {
|
for (let i = 0; i < child.weightsHO.length; i++) {
|
||||||
for (let j = 0; j < child.weightsHO[i].length; j++) {
|
if (Math.random() < crossoverRate) {
|
||||||
if (Math.random() < crossoverRate) {
|
child.weightsHO[i] = parent2.weightsHO[i];
|
||||||
child.weightsHO[i][j] = parent2.weightsHO[i][j];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,22 +209,18 @@ function mutate(network: Network, mutationRate: number): Network {
|
|||||||
|
|
||||||
// Mutate input-hidden weights
|
// Mutate input-hidden weights
|
||||||
for (let i = 0; i < mutated.weightsIH.length; i++) {
|
for (let i = 0; i < mutated.weightsIH.length; i++) {
|
||||||
for (let j = 0; j < mutated.weightsIH[i].length; j++) {
|
if (Math.random() < mutationRate) {
|
||||||
if (Math.random() < mutationRate) {
|
mutated.weightsIH[i] += (Math.random() * 2 - 1) * 0.5;
|
||||||
mutated.weightsIH[i][j] += (Math.random() * 2 - 1) * 0.5;
|
|
||||||
// Clamp to reasonable range
|
// 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
|
// Mutate hidden-output weights
|
||||||
for (let i = 0; i < mutated.weightsHO.length; i++) {
|
for (let i = 0; i < mutated.weightsHO.length; i++) {
|
||||||
for (let j = 0; j < mutated.weightsHO[i].length; j++) {
|
if (Math.random() < mutationRate) {
|
||||||
if (Math.random() < mutationRate) {
|
mutated.weightsHO[i] += (Math.random() * 2 - 1) * 0.5;
|
||||||
mutated.weightsHO[i][j] += (Math.random() * 2 - 1) * 0.5;
|
mutated.weightsHO[i] = Math.max(-2, Math.min(2, mutated.weightsHO[i]));
|
||||||
mutated.weightsHO[i][j] = Math.max(-2, Math.min(2, mutated.weightsHO[i][j]));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
import type { EvolutionConfig } from './types';
|
||||||
|
|
||||||
self.onmessage = (e: MessageEvent) => {
|
self.onmessage = (e: MessageEvent) => {
|
||||||
const { population, config, generations = 1 } = e.data as {
|
const data = e.data;
|
||||||
population: Population;
|
|
||||||
config: EvolutionConfig;
|
|
||||||
generations?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
for (let i = 0; i < generations; i++) {
|
// Reconstruct a partial population object just for evaluation
|
||||||
// Run the heavy computation
|
// evaluatePopulation expects a Population, but only uses .individuals
|
||||||
const evaluated = evaluatePopulation(currentPop, config);
|
// actually it returns a Population.
|
||||||
currentPop = evolveGeneration(evaluated, config);
|
// 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
|
||||||
|
});
|
||||||
|
|
||||||
|
} 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) {
|
} catch (error) {
|
||||||
self.postMessage({ type: 'ERROR', payload: error });
|
self.postMessage({ type: 'ERROR', payload: error });
|
||||||
}
|
}
|
||||||
|
|||||||
102
src/lib/snakeAI/game.test.ts
Normal file
102
src/lib/snakeAI/game.test.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -131,58 +131,108 @@ function spawnFood(gridSize: number, snake: Position[]): Position {
|
|||||||
return food;
|
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[] {
|
export function getInputs(state: GameState): number[] {
|
||||||
const head = state.snake[0];
|
const head = state.snake[0];
|
||||||
const food = state.food;
|
const food = state.food;
|
||||||
|
const size = state.gridSize;
|
||||||
|
|
||||||
// Calculate relative direction vectors based on current direction
|
// Ensure buffers are ready
|
||||||
// If facing UP (0): Front=(0, -1), Left=(-1, 0), Right=(1, 0)
|
ensureBuffers(size);
|
||||||
// If facing RIGHT (1): Front=(1, 0), Left=(0, -1), Right=(0, 1)
|
const obstacles = cachedObstacles!;
|
||||||
// ...and so on
|
|
||||||
|
|
||||||
const frontVec = getDirectionVector(state.direction);
|
// 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 leftVec = getDirectionVector(((state.direction + 3) % 4) as Direction);
|
||||||
|
const frontVec = getDirectionVector(state.direction);
|
||||||
const rightVec = getDirectionVector(((state.direction + 1) % 4) as Direction);
|
const rightVec = getDirectionVector(((state.direction + 1) % 4) as Direction);
|
||||||
|
|
||||||
// 1. Danger Sensors (Relative)
|
const visionInputs: number[] = [];
|
||||||
// Is there danger immediately to my Left, Front, or Right?
|
const dirs = [leftVec, frontVec, rightVec];
|
||||||
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);
|
|
||||||
|
|
||||||
// 2. Food Direction (Relative)
|
// Total grid area for normalization
|
||||||
// We want to know if food is to our Left/Right or In Front/Behind relative to head
|
const totalArea = state.gridSize * state.gridSize;
|
||||||
// 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 relFoodX = food.x - head.x;
|
||||||
const relFoodY = food.y - head.y;
|
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 foodFront = relFoodX * frontVec.x + relFoodY * frontVec.y;
|
||||||
const foodSide = relFoodX * rightVec.x + relFoodY * rightVec.y;
|
const foodSide = relFoodX * rightVec.x + relFoodY * rightVec.y;
|
||||||
// foodSide: Positive = Right, Negative = Left
|
|
||||||
|
// Self Awareness (1 input)
|
||||||
|
const normLength = state.snake.length / totalArea;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
// Sensor 1: Danger Left
|
...visionInputs, // 6 inputs (3 * 2)
|
||||||
dangerLeft ? 1 : 0,
|
|
||||||
// Sensor 2: Danger Front
|
|
||||||
dangerFront ? 1 : 0,
|
|
||||||
// Sensor 3: Danger Right
|
|
||||||
dangerRight ? 1 : 0,
|
|
||||||
|
|
||||||
// Sensor 4: Food is to the Left
|
// Food (4 inputs)
|
||||||
foodSide < 0 ? 1 : 0,
|
foodSide < 0 ? 1 : 0, // Left
|
||||||
// Sensor 5: Food is to the Right
|
foodSide > 0 ? 1 : 0, // Right
|
||||||
foodSide > 0 ? 1 : 0,
|
foodFront > 0 ? 1 : 0, // Front
|
||||||
// Sensor 6: Food is Ahead
|
foodFront < 0 ? 1 : 0, // Back
|
||||||
foodFront > 0 ? 1 : 0,
|
|
||||||
// Sensor 7: Food is Behind
|
|
||||||
foodFront < 0 ? 1 : 0,
|
|
||||||
|
|
||||||
// Sensor 8: Normalized Length (Growth Sensor)
|
// Length (1 input)
|
||||||
state.snake.length / (state.gridSize * state.gridSize)
|
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 {
|
function getDirectionVector(dir: Direction): Position {
|
||||||
switch (dir) {
|
switch (dir) {
|
||||||
case Direction.UP: return { x: 0, y: -1 };
|
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 {
|
// Optimized, internal version calling shared buffers
|
||||||
// Check wall
|
function calculateAreaOptimized(size: number, obstacles: Int8Array, start: Position): number {
|
||||||
if (x < 0 || x >= state.gridSize || y < 0 || y >= state.gridSize) {
|
const stack = cachedStack!;
|
||||||
return true;
|
const visited = cachedVisited!;
|
||||||
}
|
|
||||||
// Check self-collision
|
// Reset visited for this run
|
||||||
return state.snake.some((seg) => seg.x === x && seg.y === y);
|
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 {
|
export function calculateFitness(state: GameState): number {
|
||||||
// Fitness formula balancing food collection and survival
|
// Fitness formula balancing food collection and survival
|
||||||
const foodScore = state.score * 100;
|
const foodScore = state.score * 100;
|
||||||
|
|||||||
@@ -5,15 +5,16 @@ export interface Network {
|
|||||||
inputSize: number;
|
inputSize: number;
|
||||||
hiddenSize: number;
|
hiddenSize: number;
|
||||||
outputSize: number;
|
outputSize: number;
|
||||||
weightsIH: number[][]; // Input to Hidden weights
|
// Flat buffers for better cache locality and performance
|
||||||
weightsHO: number[][]; // Hidden to Output weights
|
weightsIH: Float32Array; // Input -> Hidden weights
|
||||||
biasH: number[]; // Hidden layer biases
|
weightsHO: Float32Array; // Hidden -> Output weights
|
||||||
biasO: number[]; // Output layer biases
|
biasH: Float32Array; // Hidden layer biases
|
||||||
|
biasO: Float32Array; // Output layer biases
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createNetwork(
|
export function createNetwork(
|
||||||
inputSize: number = 8,
|
inputSize: number = 11,
|
||||||
hiddenSize: number = 18,
|
hiddenSize: number = 24,
|
||||||
outputSize: number = 3
|
outputSize: number = 3
|
||||||
): Network {
|
): Network {
|
||||||
return {
|
return {
|
||||||
@@ -21,8 +22,8 @@ export function createNetwork(
|
|||||||
inputSize,
|
inputSize,
|
||||||
hiddenSize,
|
hiddenSize,
|
||||||
outputSize,
|
outputSize,
|
||||||
weightsIH: createRandomMatrix(inputSize, hiddenSize),
|
weightsIH: createRandomArray(inputSize * hiddenSize),
|
||||||
weightsHO: createRandomMatrix(hiddenSize, outputSize),
|
weightsHO: createRandomArray(hiddenSize * outputSize),
|
||||||
biasH: createRandomArray(hiddenSize),
|
biasH: createRandomArray(hiddenSize),
|
||||||
biasO: createRandomArray(outputSize),
|
biasO: createRandomArray(outputSize),
|
||||||
};
|
};
|
||||||
@@ -32,76 +33,128 @@ function generateId(): string {
|
|||||||
return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
|
return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
|
||||||
}
|
}
|
||||||
|
|
||||||
function createRandomMatrix(rows: number, cols: number): number[][] {
|
function createRandomArray(size: number): Float32Array {
|
||||||
const matrix: number[][] = [];
|
const array = new Float32Array(size);
|
||||||
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[] = [];
|
|
||||||
for (let i = 0; i < size; i++) {
|
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;
|
return array;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function forward(network: Network, inputs: number[]): number[] {
|
// Pre-allocated buffers for inference to avoid garbage collection
|
||||||
// Hidden layer activation
|
// Note: This makes 'forward' not thread-safe if called concurrently on the SAME thread.
|
||||||
const hidden: number[] = [];
|
// Since JS is single-threaded, this is safe unless we use async/await inside (which we don't).
|
||||||
for (let h = 0; h < network.hiddenSize; h++) {
|
// However, distinct workers have their own memory, so it's safe for workers too.
|
||||||
let sum = network.biasH[h];
|
let cachedHidden: Float32Array | null = null;
|
||||||
for (let i = 0; i < network.inputSize; i++) {
|
let cachedOutputs: Float32Array | null = null;
|
||||||
sum += inputs[i] * network.weightsIH[i][h];
|
let maxHiddenSize = 0;
|
||||||
|
let maxOutputSize = 0;
|
||||||
|
|
||||||
|
function ensureBuffers(hiddenSize: number, outputSize: number) {
|
||||||
|
if (!cachedHidden || hiddenSize > maxHiddenSize) {
|
||||||
|
cachedHidden = new Float32Array(hiddenSize);
|
||||||
|
maxHiddenSize = hiddenSize;
|
||||||
}
|
}
|
||||||
// ReLU activation for hidden layer: f(x) = max(0, x)
|
if (!cachedOutputs || outputSize > maxOutputSize) {
|
||||||
// Faster and solves vanishing gradient better than tanh
|
cachedOutputs = new Float32Array(outputSize);
|
||||||
hidden[h] = Math.max(0, sum);
|
maxOutputSize = outputSize;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Output layer activation
|
// ReLU Activation
|
||||||
const outputs: number[] = [];
|
for (let h = 0; h < hiddenSize; h++) {
|
||||||
for (let o = 0; o < network.outputSize; o++) {
|
if (hidden[h] < 0) hidden[h] = 0;
|
||||||
let sum = network.biasO[o];
|
}
|
||||||
for (let h = 0; h < network.hiddenSize; h++) {
|
|
||||||
sum += hidden[h] * network.weightsHO[h][o];
|
// 2. Output Layer
|
||||||
}
|
// outputs[o] = tanh(bias[o] + sum(hidden[h] * weights[h][o]))
|
||||||
outputs[o] = tanh(sum);
|
|
||||||
|
// 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;
|
return outputs;
|
||||||
}
|
}
|
||||||
|
|
||||||
function tanh(x: number): number {
|
|
||||||
return Math.tanh(x);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getAction(network: Network, inputs: number[]): Action {
|
export function getAction(network: Network, inputs: number[]): Action {
|
||||||
const outputs = forward(network, inputs);
|
const outputs = forward(network, inputs);
|
||||||
|
|
||||||
// Find index of maximum output
|
// Find index of maximum output
|
||||||
let maxIndex = 0;
|
let maxIndex = 0;
|
||||||
for (let i = 1; i < outputs.length; i++) {
|
let maxVal = outputs[0];
|
||||||
if (outputs[i] > outputs[maxIndex]) {
|
|
||||||
maxIndex = i;
|
// 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
|
// Map output index to action
|
||||||
switch (maxIndex) {
|
switch (maxIndex) {
|
||||||
case 0:
|
case 0: return Action.TURN_LEFT;
|
||||||
return Action.TURN_LEFT;
|
case 1: return Action.STRAIGHT;
|
||||||
case 1:
|
case 2: return Action.TURN_RIGHT;
|
||||||
return Action.STRAIGHT;
|
default: 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,
|
inputSize: network.inputSize,
|
||||||
hiddenSize: network.hiddenSize,
|
hiddenSize: network.hiddenSize,
|
||||||
outputSize: network.outputSize,
|
outputSize: network.outputSize,
|
||||||
weightsIH: network.weightsIH.map((row) => [...row]),
|
// Float32Array has a fast .slice() method to copy
|
||||||
weightsHO: network.weightsHO.map((row) => [...row]),
|
weightsIH: network.weightsIH.slice(),
|
||||||
biasH: [...network.biasH],
|
weightsHO: network.weightsHO.slice(),
|
||||||
biasO: [...network.biasO],
|
biasH: network.biasH.slice(),
|
||||||
|
biasO: network.biasO.slice(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
70
src/lib/snakeAI/workerPool.ts
Normal file
70
src/lib/snakeAI/workerPool.ts
Normal file
@@ -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<Population> {
|
||||||
|
// 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<Individual[]>((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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user