Files
evolution/src/apps/SelfDrivingCar/CarScene.ts
2026-01-15 18:13:48 +11:00

396 lines
15 KiB
TypeScript

import Phaser from 'phaser';
import { CarSimulation } from './CarSimulation';
import { Car } from './Car';
import { DEFAULT_SIM_CONFIG, DEFAULT_CAR_CONFIG } from './types';
import type { SerializedTrackData, SerializedBody, CarConfig, SimulationConfig } from './types';
import { TrackGenerator } from './Track';
// NEAT Imports REMOVED
// import { createPopulation, evolveGeneration, DEFAULT_EVOLUTION_CONFIG, type Population, type EvolutionConfig } from '../../lib/neatArena/evolution';
// import type { Genome } from '../../lib/neatArena/genome';
import { SimpleGA, DEFAULT_GA_CONFIG } from './SimpleGA';
import type { GAConfig } from './SimpleGA';
// Worker Import (Vite/Bun compatible)
import TrainingWorker from './training.worker.ts?worker';
export class CarScene extends Phaser.Scene {
private sim!: CarSimulation;
private graphics!: Phaser.GameObjects.Graphics;
// UI Text
private statsText!: Phaser.GameObjects.Text;
private fitnessText!: Phaser.GameObjects.Text;
// Training State
private worker!: Worker;
private population: Float32Array[] = [];
private gaConfig = DEFAULT_GA_CONFIG;
private ga: SimpleGA;
private generationCount = 0;
private bestGenomeEver: Float32Array | null = null;
private bestFitnessEver = -Infinity;
private serializedTrack!: SerializedTrackData;
private layerSizes = [11, 24, 16, 2]; // 11 Inputs (7 rays + 4 dynamics), 24/16 Hidden, 2 Outputs
// Config
private carConfig: CarConfig = DEFAULT_CAR_CONFIG;
private simConfig: SimulationConfig = DEFAULT_SIM_CONFIG;
private instanceId: string;
constructor() {
super({ key: 'CarScene' });
this.instanceId = Math.random().toString(36).substring(7);
console.log(`[CarScene:${this.instanceId}] Constructor called`);
this.ga = new SimpleGA(this.layerSizes, this.gaConfig);
}
create() {
// ... (Keep existing setup)
this.cameras.main.setBackgroundColor('#222222');
this.graphics = this.add.graphics();
this.startTraining();
// Listen for new track request
this.game.events.on('new-track', () => this.handleNewTrack()); // Refactored handler
// Cleanup
this.events.on('shutdown', this.shutdown, this);
this.events.on('destroy', this.shutdown, this);
// Listen for Config Updates
this.game.events.on('update-config', (cfg: { car: CarConfig, sim: SimulationConfig, ga?: GAConfig }) => {
this.carConfig = cfg.car;
this.simConfig = cfg.sim;
// Update GA config if provided
if (cfg.ga) {
this.gaConfig = cfg.ga;
this.ga = new SimpleGA(this.layerSizes, this.gaConfig);
}
// HOT RELOAD PHYSICS
if (this.sim) {
this.sim.updateConfig(this.carConfig);
// Restart visual sim with updated config so changes apply immediately
if (this.bestGenomeEver) {
this.sim = new CarSimulation(
this.serializedTrack,
{ ...this.simConfig, populationSize: 1 },
[this.bestGenomeEver],
this.carConfig
);
}
// Also update Worker config for NEXT generation
if (this.worker) {
// We can't interrupt the worker mid-gen
// Config updates apply on next generation
}
}
});
// Create stats text overlay
this.statsText = this.add.text(20, 170, '', {
fontSize: '14px',
color: '#ffffff',
backgroundColor: '#000000aa',
padding: { x: 8, y: 6 }
}).setDepth(100);
this.fitnessText = this.add.text(20, 210, '', {
fontSize: '12px',
color: '#4ecdc4',
backgroundColor: '#000000aa',
padding: { x: 8, y: 6 }
}).setDepth(100);
// ... debug texts ... (rest of create)
}
private handleNewTrack() {
if (this.worker) {
this.worker.terminate();
this.worker = null as any; // CRITICAL: Set to null so startTraining creates new worker
}
this.sim = null as any;
// Recreate GA with current config (important for population size changes)
this.ga = new SimpleGA(this.layerSizes, this.gaConfig);
this.population = this.ga.createPopulation();
this.generationCount = 0;
this.bestFitnessEver = -Infinity;
this.bestGenomeEver = null;
this.game.events.emit('generation-complete', { generation: 0, best: 0, average: 0 });
this.startTraining();
}
private startTraining() {
// 1. Generate Track (Main Thread)
const generator = new TrackGenerator(this.scale.width, this.scale.height);
// Use current Sim Config for Complexity/Length
const rawTrack = generator.generate(this.simConfig.trackComplexity, this.simConfig.trackLength);
// 2. Serialize Track
const serializedTrack: SerializedTrackData = {
innerWalls: rawTrack.innerWalls.map(v => ({ x: v.x, y: v.y })),
outerWalls: rawTrack.outerWalls.map(v => ({ x: v.x, y: v.y })),
pathPoints: rawTrack.pathPoints.map(v => ({ x: v.x, y: v.y })),
startPosition: { x: rawTrack.startPosition.x, y: rawTrack.startPosition.y },
startAngle: rawTrack.startAngle,
walls: rawTrack.walls.map(b => ({
position: { x: b.position.x, y: b.position.y },
angle: b.angle,
width: b.bounds.max.x - b.bounds.min.x,
height: b.bounds.max.y - b.bounds.min.y,
label: b.label,
isSensor: b.isSensor
})),
checkpoints: rawTrack.checkpoints.map(b => ({
position: { x: b.position.x, y: b.position.y },
angle: b.angle,
width: b.bounds.max.x - b.bounds.min.x,
height: b.bounds.max.y - b.bounds.min.y,
label: b.label,
isSensor: b.isSensor
}))
};
this.serializedTrack = serializedTrack;
// 3. Initialize Population
if (this.population.length === 0) {
this.population = this.ga.createPopulation();
}
// 4. Initialize Worker
if (!this.worker) { // Only create if missing (or terminated)
this.worker = new TrainingWorker();
this.worker.onmessage = (e) => {
if (e.data.type === 'TRAIN_COMPLETE') {
this.handleTrainingComplete(e.data.results);
}
};
}
// 5. Start First Generation (Worker)
this.startWorkerGeneration();
// 6. Initialize Visual Sim
this.sim = new CarSimulation(this.serializedTrack, { ...this.simConfig, populationSize: 1 }, [], this.carConfig);
}
private startWorkerGeneration() {
if (!this.worker) return;
this.worker.postMessage({
type: 'TRAIN',
trackData: this.serializedTrack,
genomes: this.population,
config: this.simConfig, // Pass latest sim config
carConfig: this.carConfig, // Pass latest car config
steps: 60 * 60
});
}
private handleTrainingComplete(results: { fitness: number, checkpoints: number }[]) {
// 1. Assign Fitness
const fitnesses = results.map(r => r.fitness);
// Stats
const bestGenFit = Math.max(...fitnesses);
const avgGenFit = fitnesses.reduce((a,b) => a+b, 0) / fitnesses.length;
this.generationCount++;
let newChampionFound = false;
if (bestGenFit > this.bestFitnessEver) {
this.bestFitnessEver = bestGenFit;
const bestIdx = fitnesses.indexOf(bestGenFit);
this.bestGenomeEver = this.population[bestIdx];
newChampionFound = true;
}
// 2. Evolve
this.population = this.ga.evolve(this.population, fitnesses);
// 3. Emit Stats
const stats = {
generation: this.generationCount,
best: this.bestFitnessEver,
average: avgGenFit
};
console.log(`[CarScene:${this.instanceId}] Generation ${this.generationCount} complete. Emitting stats:`, stats);
this.game.events.emit('generation-complete', stats);
// 4. Update Visual Sim ONLY if we found a better car
// If we didn't improve, we let the current one keep running (it will loop itself)
if (newChampionFound && this.bestGenomeEver) {
// Visual feedback of new record?
this.updateVisualSim(this.bestGenomeEver);
}
// 5. Loop Internal Training
this.startWorkerGeneration();
}
private updateVisualSim(bestGenome: Float32Array) {
// Restart sim with just 1 car (The Champion)
// We reuse the track data
this.sim = new CarSimulation(
this.serializedTrack,
{ ...this.simConfig, populationSize: 1 },
[bestGenome],
this.carConfig // FIXED: Use current carConfig, not default
);
}
update(_time: number, _delta: number) {
// Step Simulation (Visual Only)
this.sim.update();
// Check if visual car crashed/finished
// If so, respawn it (Infinite Loop of Fame)
if (this.sim.isFinished()) {
if (this.bestGenomeEver) {
this.updateVisualSim(this.bestGenomeEver);
} else {
// Should imply we are in init state, just restart whatever we have
// (or wait for gen 1)
}
}
// Render
this.graphics.clear();
this.drawTrack();
this.sim.cars.forEach(car => {
this.drawCar(car);
});
// Update stats text
const aliveCount = this.sim.cars.filter(c => !c.isDead).length;
this.statsText.setText(`Gen: ${this.generationCount} | Alive: ${aliveCount}/${this.sim.cars.length}`);
if (this.sim.cars.length > 0) {
const bestCar = this.sim.cars[0];
this.fitnessText.setText(
`Fitness: ${bestCar.fitness.toFixed(2)} | Speed: ${bestCar.body.speed.toFixed(1)}`
);
}
}
private drawTrack() {
if (!this.serializedTrack) return;
// Draw Smooth Track Surface (Dark Grey Road)
this.graphics.fillStyle(0x333333);
const outer = this.serializedTrack.outerWalls;
const inner = this.serializedTrack.innerWalls;
this.graphics.fillStyle(0x333333);
this.graphics.lineStyle(2, 0x555555); // Wall edges
for (let i = 0; i < outer.length - 1; i++) {
this.graphics.beginPath();
this.graphics.moveTo(inner[i].x, inner[i].y);
this.graphics.lineTo(outer[i].x, outer[i].y);
this.graphics.lineTo(outer[i+1].x, outer[i+1].y);
this.graphics.lineTo(inner[i+1].x, inner[i+1].y);
this.graphics.closePath();
this.graphics.fillPath();
this.graphics.strokePath();
}
// PHYSICS DEBUG: Draw actual physical bodies in Red/Blue to check alignment
this.sim.walls.forEach(wall => {
this.graphics.lineStyle(1, 0xff0000, 0.5); // Red Walls
this.graphics.beginPath();
const v = wall.vertices;
this.graphics.moveTo(v[0].x, v[0].y);
for(let k=1; k<v.length; k++) this.graphics.lineTo(v[k].x, v[k].y);
this.graphics.closePath();
this.graphics.strokePath();
});
this.sim.checkpoints.forEach((cp, i) => {
if (i===0) this.graphics.fillStyle(0x00ff00, 0.5);
else this.graphics.fillStyle(0x00ffff, 0.3); // Cyan checkpoints
this.graphics.beginPath();
const v = cp.vertices;
this.graphics.moveTo(v[0].x, v[0].y);
for(let k=1; k<v.length; k++) this.graphics.lineTo(v[k].x, v[k].y);
this.graphics.closePath();
this.graphics.fillPath();
});
}
shutdown() {
if (this.worker) this.worker.terminate();
}
private drawCar(car: Car) {
const p = car.body.position;
// Body
this.graphics.fillStyle(car.isDead ? 0x550000 : 0x00ff00);
this.graphics.translateCanvas(p.x, p.y);
this.graphics.rotateCanvas(car.body.angle);
this.graphics.fillRect(-10, -20, 20, 40); // Approx size
this.graphics.rotateCanvas(-car.body.angle);
this.graphics.translateCanvas(-p.x, -p.y);
// Draw Rays with color-coding (Only for the best car in visual mode)
if (!car.isDead && this.sim.cars.length === 1) {
const start = car.body.position;
const angleBase = car.body.angle - Math.PI/2;
const raySpread = this.carConfig.raySpread;
const rayCount = this.carConfig.rayCount;
const rayLen = this.carConfig.rayLength;
// Use actual ray readings for color-coding
const readings = car.rayReadings;
const startRayAngle = angleBase - raySpread / 2;
const angleStep = raySpread / (rayCount - 1);
for(let i=0; i<rayCount; i++) {
const angle = startRayAngle + i * angleStep;
const reading = readings[i] || 0; // 0 = far, 1 = close
// Color interpolation: Green (far) -> Yellow -> Red (close)
const r = Math.floor(reading * 255);
const g = Math.floor((1 - reading) * 255);
const color = (r << 16) | (g << 8) | 0;
this.graphics.lineStyle(2, color, 0.6);
this.graphics.beginPath();
this.graphics.moveTo(start.x, start.y);
this.graphics.lineTo(
start.x + Math.cos(angle) * rayLen,
start.y + Math.sin(angle) * rayLen
);
this.graphics.strokePath();
// Draw hit point if detected
if (reading > 0.1) {
const hitDist = (1 - reading) * rayLen;
const hitX = start.x + Math.cos(angle) * hitDist;
const hitY = start.y + Math.sin(angle) * hitDist;
this.graphics.fillStyle(color, 0.8);
this.graphics.fillCircle(hitX, hitY, 3);
}
}
// Draw fitness overlay
this.graphics.fillStyle(0xffffff);
this.graphics.generateTexture('text', 200, 50);
}
}
}