Add neat arena
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user