Add fitness graph

This commit is contained in:
Peter Stockings
2026-01-10 11:49:28 +11:00
parent 246a4a14e3
commit de1563dae6
8 changed files with 292 additions and 42 deletions

View File

@@ -7,10 +7,6 @@ import Tips from './Tips';
import BestSnakeDisplay from './BestSnakeDisplay';
import {
createPopulation,
evaluatePopulation,
evolveGeneration,
getBestIndividual,
getAverageFitness,
type Population,
} from '../../lib/snakeAI/evolution';
import type { EvolutionConfig } from '../../lib/snakeAI/types';
@@ -24,6 +20,8 @@ const DEFAULT_CONFIG: EvolutionConfig = {
maxGameSteps: 20000,
};
import EvolutionWorker from '../../lib/snakeAI/evolution.worker?worker';
export default function SnakeAI() {
const [population, setPopulation] = useState<Population>(() =>
createPopulation(DEFAULT_CONFIG)
@@ -32,30 +30,85 @@ export default function SnakeAI() {
const [isRunning, setIsRunning] = useState(false);
const [speed, setSpeed] = useState(5);
const [gamesPlayed, setGamesPlayed] = useState(0);
const [fitnessHistory, setFitnessHistory] = useState<Array<{ generation: number, best: number, average: number }>>([]);
// Compute derived values from population
const bestIndividual = getBestIndividual(population);
const averageFitness = getAverageFitness(population);
// Keep a ref to population for the worker
const populationRef = useRef(population);
useEffect(() => {
populationRef.current = population;
}, [population]);
const animationFrameRef = useRef<number>();
const lastUpdateRef = useRef<number>(0);
const runGeneration = useCallback(() => {
setPopulation((prev) => {
try {
// Evaluate current generation
const evaluated = evaluatePopulation(prev, config);
// Compute derived values for display
// If we have stats from the last generation, use them. Otherwise default to 0.
const currentBestFitness = population.lastGenerationStats?.bestFitness || 0;
const currentAverageFitness = population.lastGenerationStats?.averageFitness || 0;
// Evolve to next generation
const nextGen = evolveGeneration(evaluated, config);
const workerRef = useRef<Worker | null>(null);
const isProcessingRef = useRef(false);
return nextGen;
} catch (error) {
console.error("SnakeAI: Generation update failed", error);
return prev;
useEffect(() => {
workerRef.current = new EvolutionWorker();
workerRef.current.onmessage = (e) => {
const { type, payload } = e.data; // payload is the NEW population
if (type === 'SUCCESS') {
// Critical: Update ref immediately to prevent race condition with next animation frame
populationRef.current = payload;
setPopulation(payload);
// Update history if we have stats
if (payload.lastGenerationStats) {
setFitnessHistory(prev => {
const newEntry = {
generation: payload.generation - 1, // The stats are for the gen that just finished
best: payload.lastGenerationStats!.bestFitness,
average: payload.lastGenerationStats!.averageFitness
};
// Keep last 100 generations to avoid memory issues if running for eternity
const newHistory = [...prev, newEntry];
if (newHistory.length > 100) return newHistory.slice(newHistory.length - 100);
return newHistory;
});
}
isProcessingRef.current = false;
} else {
console.error("Worker error:", payload);
isProcessingRef.current = false;
}
};
return () => {
workerRef.current?.terminate();
};
}, []);
const runGeneration = useCallback((generations: number = 1) => {
if (isProcessingRef.current || !workerRef.current) return;
isProcessingRef.current = true;
// We need to send the *current* population.
// Since this is inside a callback, we need to be careful about closure staleness.
// However, we can't easily access the "latest" state inside a callback without refs or dependency.
// But 'population' is in the dependency array of the effect calling this? No.
// The animate loop calls this.
// Let's use a functional update approach? No, we need to SEND data.
// We will use a ref to track current population for the worker to ensure we always send latest
// OR rely on the fact that 'population' is in dependency of runGeneration (it wasn't before).
// Wait, 'runGeneration' lines 43-58 previously used setPopulation(prev => ...).
// It didn't need 'population' in dependency.
// Now we need it.
workerRef.current.postMessage({
population: populationRef.current, // Use a ref for latest population
config,
generations
});
}, [config]);
}, [config]); // populationRef will be handled separately
// Update stats when generation changes
useEffect(() => {
@@ -93,7 +146,7 @@ export default function SnakeAI() {
}
if (elapsed >= updateInterval) {
runGeneration();
runGeneration(1);
lastUpdateRef.current = timestamp;
}
} else {
@@ -102,9 +155,9 @@ export default function SnakeAI() {
// Speed 100 -> 10 gens per frame (~600 eps)
const gensPerFrame = Math.floor((speed - 10) / 10);
for (let i = 0; i < gensPerFrame; i++) {
runGeneration();
}
// For turbo mode, we just fire once per frame (or whenever the worker is ready)
// asking for multiple generations
runGeneration(gensPerFrame);
lastUpdateRef.current = timestamp;
}
@@ -122,7 +175,9 @@ export default function SnakeAI() {
const handleReset = () => {
setIsRunning(false);
setPopulation(createPopulation(config));
const newPop = createPopulation(config);
populationRef.current = newPop;
setPopulation(newPop);
setGamesPlayed(0);
};
@@ -162,10 +217,11 @@ export default function SnakeAI() {
<Stats
generation={population.generation}
bestFitness={bestIndividual.fitness}
bestFitness={currentBestFitness}
bestFitnessEver={population.bestFitnessEver}
averageFitness={averageFitness}
averageFitness={currentAverageFitness}
gamesPlayed={gamesPlayed}
history={fitnessHistory}
/>
<Tips />