Files
evolution/src/apps/NeatArena/NeatArena.tsx
2026-01-14 11:13:33 +11:00

389 lines
16 KiB
TypeScript

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 best vs second-best AI)
useEffect(() => {
if (!phaserGameRef.current) return;
const interval = setInterval(() => {
if (!simulationRef.current) return;
const sim = simulationRef.current;
if (sim.isOver) {
simulationRef.current = createSimulation(mapSeed, 0);
return;
}
// Get best and second-best genomes
const sortedGenomes = [...population.genomes].sort((a, b) => b.fitness - a.fitness);
const genome0 = importedGenome || sortedGenomes[0] || null;
const genome1 = sortedGenomes.length > 1 ? sortedGenomes[1] : null;
// Agent 0: Best AI
let action0: AgentAction;
if (genome0) {
const network = createNetwork(genome0);
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: Second-best AI (or spinner if not enough genomes)
let action1: AgentAction;
if (genome1) {
const network = createNetwork(genome1);
const obs = generateObservation(1, sim);
const inputs = observationToInputs(obs);
const outputs = network.activate(inputs);
action1 = {
moveX: outputs[0],
moveY: outputs[1],
turn: outputs[2],
shoot: outputs[3],
};
} else {
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);
}, [showRays, mapSeed, population.genomes, 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 Gen best'
: population.genomes.length > 1
? `🎮 Watching Gen ${stats.generation}: Best vs 2nd-Best AI`
: '⚪ Need at least 2 genomes for exhibition'}
</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>
);
}