396 lines
15 KiB
TypeScript
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);
|
|
}
|
|
}
|
|
}
|