Add self driving car app
This commit is contained in:
@@ -7,7 +7,8 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/matter-js": "^0.20.2",
|
"@types/matter-js": "^0.20.2",
|
||||||
|
|||||||
@@ -24,6 +24,12 @@ export class Car {
|
|||||||
private maxPathIndexReached: number = 0;
|
private maxPathIndexReached: number = 0;
|
||||||
private initialPosSet: boolean = false;
|
private initialPosSet: boolean = false;
|
||||||
private framesSinceCheckpoint: number = 0;
|
private framesSinceCheckpoint: number = 0;
|
||||||
|
|
||||||
|
// Fitness tracking
|
||||||
|
private totalFrames: number = 0;
|
||||||
|
private speedSum: number = 0;
|
||||||
|
private lastSteer: number = 0;
|
||||||
|
private steeringChangeSum: number = 0;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
x: number,
|
x: number,
|
||||||
@@ -54,26 +60,57 @@ export class Car {
|
|||||||
this.initialPosSet = true;
|
this.initialPosSet = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stagnation Killer
|
// Stagnation Killer - TIGHTENED to prevent local minima loops
|
||||||
this.framesSinceCheckpoint++;
|
this.framesSinceCheckpoint++;
|
||||||
if (this.framesSinceCheckpoint > 600) {
|
if (this.framesSinceCheckpoint > 300) { // 5 seconds without progress
|
||||||
// Stagnation
|
|
||||||
this.kill();
|
this.kill();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ANTI-EXPLOIT: Minimum progress requirements
|
||||||
|
// Must reach checkpoint 8 within first 8 seconds (stricter than before)
|
||||||
|
if (this.totalFrames > 480 && this.maxPathIndexReached < 8) {
|
||||||
|
this.kill();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must reach checkpoint 3 within first 3 seconds (catches immediate crashers)
|
||||||
|
if (this.totalFrames > 180 && this.maxPathIndexReached < 3) {
|
||||||
|
this.kill();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 1. Sensors
|
// 1. Sensors
|
||||||
this.rayReadings = this.castRays(walls);
|
this.rayReadings = this.castRays(walls);
|
||||||
|
|
||||||
// 2. Think
|
// 2. Think - Expanded inputs for better control awareness
|
||||||
|
const forward = {
|
||||||
|
x: Math.cos(this.body.angle - Math.PI/2),
|
||||||
|
y: Math.sin(this.body.angle - Math.PI/2)
|
||||||
|
};
|
||||||
|
const right = { x: -forward.y, y: forward.x };
|
||||||
|
|
||||||
|
// Velocity in car's local frame (for drift detection)
|
||||||
|
const localVelX = this.body.velocity.x * forward.x + this.body.velocity.y * forward.y;
|
||||||
|
const localVelY = this.body.velocity.x * right.x + this.body.velocity.y * right.y;
|
||||||
|
|
||||||
const inputs = [
|
const inputs = [
|
||||||
...this.rayReadings,
|
...this.rayReadings, // 7 rays
|
||||||
this.body.speed / this.config.maxSpeed, // Normalize speed
|
localVelX / this.config.maxSpeed, // Normalize forward/backward velocity
|
||||||
|
localVelY / this.config.maxSpeed, // Normalize lateral velocity (drift)
|
||||||
|
this.body.angularVelocity / this.config.turnSpeed, // Normalize rotation rate
|
||||||
|
this.body.speed / this.config.maxSpeed, // Normalize speed magnitude
|
||||||
];
|
];
|
||||||
|
|
||||||
const outputs = this.brain.predict(inputs);
|
const outputs = this.brain.predict(inputs);
|
||||||
const steer = outputs[0];
|
const steer = outputs[0];
|
||||||
let gas = outputs[1];
|
let gas = outputs[1];
|
||||||
|
|
||||||
|
// Track metrics for fitness calculation
|
||||||
|
this.totalFrames++;
|
||||||
|
this.speedSum += this.body.speed;
|
||||||
|
this.steeringChangeSum += Math.abs(steer - this.lastSteer);
|
||||||
|
this.lastSteer = steer;
|
||||||
|
|
||||||
// 3. Act (Kickstart)
|
// 3. Act (Kickstart)
|
||||||
if (this.framesSinceCheckpoint < 60 && this.fitness < 2) {
|
if (this.framesSinceCheckpoint < 60 && this.fitness < 2) {
|
||||||
@@ -87,12 +124,7 @@ export class Car {
|
|||||||
Matter.Body.setAngularVelocity(this.body, steer * this.config.turnSpeed * Math.sign(gas));
|
Matter.Body.setAngularVelocity(this.body, steer * this.config.turnSpeed * Math.sign(gas));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Physics: Gas (Forward Force)
|
// Physics: Gas (Forward Force) - reuse forward vector from input calculation
|
||||||
const forward = {
|
|
||||||
x: Math.cos(this.body.angle - Math.PI/2),
|
|
||||||
y: Math.sin(this.body.angle - Math.PI/2)
|
|
||||||
};
|
|
||||||
|
|
||||||
// Physics: Lateral Friction (Tire Grip)
|
// Physics: Lateral Friction (Tire Grip)
|
||||||
this.applyTireGrip(forward);
|
this.applyTireGrip(forward);
|
||||||
|
|
||||||
@@ -186,33 +218,68 @@ export class Car {
|
|||||||
delta -= total;
|
delta -= total;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ANTI-EXPLOIT: Only reward progress if moving forward
|
||||||
|
// Check if velocity is aligned with path direction
|
||||||
|
if (delta > 0) {
|
||||||
|
// Calculate expected direction to next checkpoint
|
||||||
|
const nextIdx = (bestIndex + 1) % total;
|
||||||
|
const pathDir = {
|
||||||
|
x: pathPoints[nextIdx].x - pathPoints[bestIndex].x,
|
||||||
|
y: pathPoints[nextIdx].y - pathPoints[bestIndex].y
|
||||||
|
};
|
||||||
|
const pathDirMag = Math.sqrt(pathDir.x * pathDir.x + pathDir.y * pathDir.y);
|
||||||
|
|
||||||
|
if (pathDirMag > 0.1) {
|
||||||
|
// Normalize
|
||||||
|
pathDir.x /= pathDirMag;
|
||||||
|
pathDir.y /= pathDirMag;
|
||||||
|
|
||||||
|
// Dot product with velocity
|
||||||
|
const velDot = this.body.velocity.x * pathDir.x + this.body.velocity.y * pathDir.y;
|
||||||
|
|
||||||
|
// Only allow progress if moving roughly forward (dot > 0)
|
||||||
|
if (velDot < 0) {
|
||||||
|
// Moving backward relative to path - REJECT progress
|
||||||
|
delta = 0;
|
||||||
|
bestIndex = this.currentPathIndex; // Don't update position
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Update state
|
// Update state
|
||||||
this.currentPathIndex = bestIndex;
|
this.currentPathIndex = bestIndex;
|
||||||
|
|
||||||
// Calculate continuous fitness
|
// Calculate continuous fitness with bonuses
|
||||||
// Base Fitness: Laps * Total + CurrentIndex
|
|
||||||
// Sub-step Fitness: 1.0 - (Dist to BestIndex / MaxDist)?
|
|
||||||
// Simpler: Just (Laps * Total) + CurrentIndex
|
|
||||||
|
|
||||||
const rawScore = (this.laps * total) + this.currentPathIndex;
|
const rawScore = (this.laps * total) + this.currentPathIndex;
|
||||||
|
|
||||||
// Only update fitness if we improve (Standard NEAT/GA practice usually for 'max reached')
|
// Base fitness from progress
|
||||||
// But for continuous driving, we want to allow fluctuations but reward overall progress.
|
let baseFitness = Math.max(0, rawScore / 10.0);
|
||||||
// Let's use Raw Score directly. If they reverse, they lose fitness.
|
|
||||||
|
|
||||||
// Scale down to reasonable numbers (e.g. 1 point per 100 units?)
|
// Speed bonus: reward faster completion
|
||||||
// Let's just say 1 point per 1 path node.
|
const avgSpeed = this.totalFrames > 0 ? this.speedSum / this.totalFrames : 0;
|
||||||
|
const speedBonus = (avgSpeed / this.config.maxSpeed) * 0.2 * baseFitness; // Up to 20% bonus
|
||||||
|
|
||||||
// Reward function:
|
// Smoothness penalty: penalize jerky steering
|
||||||
this.fitness = Math.max(0, rawScore / 10.0); // 10 points per 100 nodes
|
const avgSteeringChange = this.totalFrames > 0 ? this.steeringChangeSum / this.totalFrames : 0;
|
||||||
|
const smoothnessPenalty = avgSteeringChange * 0.1 * baseFitness; // Up to 10% penalty
|
||||||
|
|
||||||
|
// ANTI-EXPLOIT: Early death penalty
|
||||||
|
// Cars must survive at least 3 seconds to get any fitness at all
|
||||||
|
let finalFitness = baseFitness + speedBonus - smoothnessPenalty;
|
||||||
|
if (this.totalFrames < 180) { // Less than 3 seconds survived
|
||||||
|
finalFitness = 0; // No fitness for instant crashes
|
||||||
|
} else if (this.totalFrames < 300) { // Less than 5 seconds
|
||||||
|
// Strong penalty for early deaths (50% reduction)
|
||||||
|
finalFitness *= 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.fitness = Math.max(0, finalFitness);
|
||||||
|
|
||||||
// Stagnation Check
|
// Stagnation Check
|
||||||
// If we haven't reached a NEW max path index in X frames, die.
|
|
||||||
// Convert laps+index to absolute
|
|
||||||
const absoluteIndex = (this.laps * total) + this.currentPathIndex;
|
const absoluteIndex = (this.laps * total) + this.currentPathIndex;
|
||||||
if (absoluteIndex > this.maxPathIndexReached) {
|
if (absoluteIndex > this.maxPathIndexReached) {
|
||||||
this.maxPathIndexReached = absoluteIndex;
|
this.maxPathIndexReached = absoluteIndex;
|
||||||
this.framesSinceCheckpoint = 0; // Usage of this var as 'framesSinceProgress'
|
this.framesSinceCheckpoint = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,13 +2,14 @@ import Phaser from 'phaser';
|
|||||||
import { CarSimulation } from './CarSimulation';
|
import { CarSimulation } from './CarSimulation';
|
||||||
import { Car } from './Car';
|
import { Car } from './Car';
|
||||||
import { DEFAULT_SIM_CONFIG, DEFAULT_CAR_CONFIG } from './types';
|
import { DEFAULT_SIM_CONFIG, DEFAULT_CAR_CONFIG } from './types';
|
||||||
import type { SerializedTrackData, SerializedVector, SerializedBody, CarConfig, SimulationConfig } from './types';
|
import type { SerializedTrackData, SerializedBody, CarConfig, SimulationConfig } from './types';
|
||||||
import { TrackGenerator } from './Track';
|
import { TrackGenerator } from './Track';
|
||||||
|
|
||||||
// NEAT Imports REMOVED
|
// NEAT Imports REMOVED
|
||||||
// import { createPopulation, evolveGeneration, DEFAULT_EVOLUTION_CONFIG, type Population, type EvolutionConfig } from '../../lib/neatArena/evolution';
|
// import { createPopulation, evolveGeneration, DEFAULT_EVOLUTION_CONFIG, type Population, type EvolutionConfig } from '../../lib/neatArena/evolution';
|
||||||
// import type { Genome } from '../../lib/neatArena/genome';
|
// import type { Genome } from '../../lib/neatArena/genome';
|
||||||
import { SimpleGA, DEFAULT_GA_CONFIG } from './SimpleGA';
|
import { SimpleGA, DEFAULT_GA_CONFIG } from './SimpleGA';
|
||||||
|
import type { GAConfig } from './SimpleGA';
|
||||||
|
|
||||||
// Worker Import (Vite/Bun compatible)
|
// Worker Import (Vite/Bun compatible)
|
||||||
import TrainingWorker from './training.worker.ts?worker';
|
import TrainingWorker from './training.worker.ts?worker';
|
||||||
@@ -17,6 +18,10 @@ export class CarScene extends Phaser.Scene {
|
|||||||
private sim!: CarSimulation;
|
private sim!: CarSimulation;
|
||||||
private graphics!: Phaser.GameObjects.Graphics;
|
private graphics!: Phaser.GameObjects.Graphics;
|
||||||
|
|
||||||
|
// UI Text
|
||||||
|
private statsText!: Phaser.GameObjects.Text;
|
||||||
|
private fitnessText!: Phaser.GameObjects.Text;
|
||||||
|
|
||||||
// Training State
|
// Training State
|
||||||
private worker!: Worker;
|
private worker!: Worker;
|
||||||
private population: Float32Array[] = [];
|
private population: Float32Array[] = [];
|
||||||
@@ -28,7 +33,7 @@ export class CarScene extends Phaser.Scene {
|
|||||||
private bestFitnessEver = -Infinity;
|
private bestFitnessEver = -Infinity;
|
||||||
|
|
||||||
private serializedTrack!: SerializedTrackData;
|
private serializedTrack!: SerializedTrackData;
|
||||||
private layerSizes = [6, 16, 12, 2]; // 6 Inputs, 16/12 Hidden, 2 Outputs
|
private layerSizes = [11, 24, 16, 2]; // 11 Inputs (7 rays + 4 dynamics), 24/16 Hidden, 2 Outputs
|
||||||
|
|
||||||
// Config
|
// Config
|
||||||
private carConfig: CarConfig = DEFAULT_CAR_CONFIG;
|
private carConfig: CarConfig = DEFAULT_CAR_CONFIG;
|
||||||
@@ -59,44 +64,70 @@ export class CarScene extends Phaser.Scene {
|
|||||||
this.events.on('destroy', this.shutdown, this);
|
this.events.on('destroy', this.shutdown, this);
|
||||||
|
|
||||||
// Listen for Config Updates
|
// Listen for Config Updates
|
||||||
this.game.events.on('update-config', (cfg: { car: CarConfig, sim: SimulationConfig }) => {
|
this.game.events.on('update-config', (cfg: { car: CarConfig, sim: SimulationConfig, ga?: GAConfig }) => {
|
||||||
this.carConfig = cfg.car;
|
this.carConfig = cfg.car;
|
||||||
this.simConfig = cfg.sim;
|
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
|
// HOT RELOAD PHYSICS
|
||||||
if (this.sim) {
|
if (this.sim) {
|
||||||
// We need to pass this into the sim, and the cars.
|
|
||||||
// Since cars are recreated often, we update the Sim's reference config?
|
|
||||||
// Or force update current cars.
|
|
||||||
|
|
||||||
// Let's implement a 'updateConfig' method on Simulation
|
|
||||||
// For now, simpler: just iterate cars directly if possible or restart
|
|
||||||
|
|
||||||
// But wait, the Sim class owns the cars.
|
|
||||||
// Let's just update the config the Sim holds (if we add it there)
|
|
||||||
// For now, let's just force a track regen if track params change?
|
|
||||||
// No, user wants realtime physics sliders.
|
|
||||||
|
|
||||||
// Direct update for now:
|
|
||||||
this.sim.updateConfig(this.carConfig);
|
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
|
// Also update Worker config for NEXT generation
|
||||||
if (this.worker) {
|
if (this.worker) {
|
||||||
// We can't easily interrupt the worker mid-gen with valid physics without complex sync.
|
// We can't interrupt the worker mid-gen
|
||||||
// So we just update the config it uses for next 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)
|
// ... debug texts ... (rest of create)
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleNewTrack() {
|
private handleNewTrack() {
|
||||||
if (this.worker) this.worker.terminate();
|
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;
|
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.population = this.ga.createPopulation();
|
||||||
|
|
||||||
this.generationCount = 0;
|
this.generationCount = 0;
|
||||||
this.bestFitnessEver = -Infinity;
|
this.bestFitnessEver = -Infinity;
|
||||||
|
this.bestGenomeEver = null;
|
||||||
this.game.events.emit('generation-complete', { generation: 0, best: 0, average: 0 });
|
this.game.events.emit('generation-complete', { generation: 0, best: 0, average: 0 });
|
||||||
this.startTraining();
|
this.startTraining();
|
||||||
}
|
}
|
||||||
@@ -167,15 +198,6 @@ export class CarScene extends Phaser.Scene {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateVisualSim(bestGenome: Float32Array) {
|
|
||||||
this.sim = new CarSimulation(
|
|
||||||
this.serializedTrack,
|
|
||||||
{ ...this.simConfig, populationSize: 1 },
|
|
||||||
[bestGenome],
|
|
||||||
this.carConfig
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleTrainingComplete(results: { fitness: number, checkpoints: number }[]) {
|
private handleTrainingComplete(results: { fitness: number, checkpoints: number }[]) {
|
||||||
// 1. Assign Fitness
|
// 1. Assign Fitness
|
||||||
const fitnesses = results.map(r => r.fitness);
|
const fitnesses = results.map(r => r.fitness);
|
||||||
@@ -222,8 +244,9 @@ export class CarScene extends Phaser.Scene {
|
|||||||
// We reuse the track data
|
// We reuse the track data
|
||||||
this.sim = new CarSimulation(
|
this.sim = new CarSimulation(
|
||||||
this.serializedTrack,
|
this.serializedTrack,
|
||||||
{ ...DEFAULT_SIM_CONFIG, populationSize: 1 },
|
{ ...this.simConfig, populationSize: 1 },
|
||||||
[bestGenome]
|
[bestGenome],
|
||||||
|
this.carConfig // FIXED: Use current carConfig, not default
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,6 +272,17 @@ export class CarScene extends Phaser.Scene {
|
|||||||
this.sim.cars.forEach(car => {
|
this.sim.cars.forEach(car => {
|
||||||
this.drawCar(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() {
|
private drawTrack() {
|
||||||
@@ -312,33 +346,50 @@ export class CarScene extends Phaser.Scene {
|
|||||||
this.graphics.rotateCanvas(-car.body.angle);
|
this.graphics.rotateCanvas(-car.body.angle);
|
||||||
this.graphics.translateCanvas(-p.x, -p.y);
|
this.graphics.translateCanvas(-p.x, -p.y);
|
||||||
|
|
||||||
// Draw Rays (Only if alive to reduce clutter, or just best car)
|
// Draw Rays with color-coding (Only for the best car in visual mode)
|
||||||
// Check if this is the "visual" best car (simulation only has 1 in visual mode usually)
|
|
||||||
if (!car.isDead && this.sim.cars.length === 1) {
|
if (!car.isDead && this.sim.cars.length === 1) {
|
||||||
const start = car.body.position;
|
const start = car.body.position;
|
||||||
const angleBase = car.body.angle - Math.PI/2;
|
const angleBase = car.body.angle - Math.PI/2;
|
||||||
const spread = Math.PI/2; // Matches DEFAULT_CAR_CONFIG.raySpread (hardcoded for viz)
|
const raySpread = this.carConfig.raySpread;
|
||||||
const count = 5;
|
const rayCount = this.carConfig.rayCount;
|
||||||
const len = 150;
|
const rayLen = this.carConfig.rayLength;
|
||||||
|
|
||||||
// We need actual readings to color them?
|
// Use actual ray readings for color-coding
|
||||||
// Since we don't strictly sync readings to scene in this simple loop, just draw lines showing FOV.
|
const readings = car.rayReadings;
|
||||||
this.graphics.lineStyle(1, 0x00ff00, 0.3);
|
const startRayAngle = angleBase - raySpread / 2;
|
||||||
|
const angleStep = raySpread / (rayCount - 1);
|
||||||
// Recalculate angles to match Car.ts logic roughly for visualization
|
|
||||||
const startRayAngle = angleBase - spread / 2;
|
|
||||||
const angleStep = spread / (count - 1);
|
|
||||||
|
|
||||||
for(let i=0; i<count; i++) {
|
for(let i=0; i<rayCount; i++) {
|
||||||
const angle = startRayAngle + i * angleStep;
|
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.beginPath();
|
||||||
this.graphics.moveTo(start.x, start.y);
|
this.graphics.moveTo(start.x, start.y);
|
||||||
this.graphics.lineTo(
|
this.graphics.lineTo(
|
||||||
start.x + Math.cos(angle) * len,
|
start.x + Math.cos(angle) * rayLen,
|
||||||
start.y + Math.sin(angle) * len
|
start.y + Math.sin(angle) * rayLen
|
||||||
);
|
);
|
||||||
this.graphics.strokePath();
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
|
// @ts-ignore
|
||||||
|
import decomp from 'poly-decomp';
|
||||||
|
|
||||||
|
// Register decomp for Worker
|
||||||
import Matter from 'matter-js';
|
import Matter from 'matter-js';
|
||||||
// import { TrackGenerator } from './Track';
|
|
||||||
// REMOVED TrackGenerator for Worker Safety
|
|
||||||
import { Car } from './Car';
|
|
||||||
import { DEFAULT_SIM_CONFIG, DEFAULT_CAR_CONFIG } from './types';
|
|
||||||
import type { SimulationConfig, SerializedTrackData, CarConfig } from './types';
|
|
||||||
// import { NeuralNetwork } from '../../lib/neatArena/network';
|
|
||||||
// import type { Genome } from '../../lib/neatArena/genome';
|
|
||||||
import { DenseNetwork } from '../LunarLander/DenseNetwork';
|
import { DenseNetwork } from '../LunarLander/DenseNetwork';
|
||||||
|
import { Car } from './Car';
|
||||||
|
import type { SimulationConfig, SerializedTrackData, CarConfig } from './types';
|
||||||
|
import { DEFAULT_SIM_CONFIG, DEFAULT_CAR_CONFIG } from './types';
|
||||||
|
Matter.Common.setDecomp(decomp);
|
||||||
|
|
||||||
|
// ... (other imports)
|
||||||
|
|
||||||
export class CarSimulation {
|
export class CarSimulation {
|
||||||
public engine: Matter.Engine;
|
public engine: Matter.Engine;
|
||||||
@@ -23,10 +25,6 @@ export class CarSimulation {
|
|||||||
private trackData: SerializedTrackData;
|
private trackData: SerializedTrackData;
|
||||||
private genomes: Float32Array[] = [];
|
private genomes: Float32Array[] = [];
|
||||||
|
|
||||||
// public onGenerationComplete?: (stats: { generation: number, best: number, average: number }) => void;
|
|
||||||
// We can't pass functions to worker easily, but for now this class is used in Main too.
|
|
||||||
// We will just expose a method to get stats.
|
|
||||||
|
|
||||||
private carConfig: CarConfig;
|
private carConfig: CarConfig;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -46,13 +44,34 @@ export class CarSimulation {
|
|||||||
this.engine.gravity.y = 0; // Top down
|
this.engine.gravity.y = 0; // Top down
|
||||||
|
|
||||||
// 1. Setup Track from Data
|
// 1. Setup Track from Data
|
||||||
this.walls = trackData.walls.map(w => Matter.Bodies.rectangle(
|
this.walls = trackData.walls.map(w => {
|
||||||
w.position.x, w.position.y, w.width, w.height, {
|
if (w.vertices && w.vertices.length > 0) {
|
||||||
isStatic: true,
|
return Matter.Bodies.fromVertices(
|
||||||
angle: w.angle,
|
w.position.x, w.position.y,
|
||||||
label: w.label
|
[w.vertices],
|
||||||
|
{
|
||||||
|
isStatic: true,
|
||||||
|
label: w.label,
|
||||||
|
// Restore angle if needed, but fromVertices might bake it?
|
||||||
|
// Actually Track.ts creates from global coords, so angle is implicit in vertices?
|
||||||
|
// No, Matter bodies created from vertices are centered.
|
||||||
|
// Track.ts: `Bodies.fromVertices(center, ..., [[v1, v2...]])`.
|
||||||
|
// The vertices passed to Track.ts are GLOBAL.
|
||||||
|
// Matter.fromVertices recalculates center and translates vertices to local.
|
||||||
|
// Serialized vertices should be consistent with this.
|
||||||
|
// We should pass vertices as they are.
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return Matter.Bodies.rectangle(
|
||||||
|
w.position.x, w.position.y, w.width, w.height, {
|
||||||
|
isStatic: true,
|
||||||
|
angle: w.angle,
|
||||||
|
label: w.label
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
));
|
});
|
||||||
|
|
||||||
this.checkpoints = trackData.checkpoints.map(cp => Matter.Bodies.rectangle(
|
this.checkpoints = trackData.checkpoints.map(cp => Matter.Bodies.rectangle(
|
||||||
cp.position.x, cp.position.y, cp.width, cp.height, {
|
cp.position.x, cp.position.y, cp.width, cp.height, {
|
||||||
@@ -98,7 +117,7 @@ export class CarSimulation {
|
|||||||
|
|
||||||
// If we have genomes, use them. Otherwise mock.
|
// If we have genomes, use them. Otherwise mock.
|
||||||
const effectivePopSize = this.genomes.length > 0 ? this.genomes.length : this.config.populationSize;
|
const effectivePopSize = this.genomes.length > 0 ? this.genomes.length : this.config.populationSize;
|
||||||
const layerSizes = [6, 16, 12, 2]; // Input (5 rays + 1 speed), Hidden, Output
|
const layerSizes = [11, 24, 16, 2]; // Input (7 rays + vel x/y + angular vel + speed), Hidden layers, Output (steer, gas)
|
||||||
|
|
||||||
for (let i = 0; i < effectivePopSize; i++) {
|
for (let i = 0; i < effectivePopSize; i++) {
|
||||||
let network: DenseNetwork;
|
let network: DenseNetwork;
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import type { CarConfig, SimulationConfig } from './types';
|
import type { CarConfig, SimulationConfig } from './types';
|
||||||
|
import type { GAConfig } from './SimpleGA';
|
||||||
|
|
||||||
interface ConfigPanelProps {
|
interface ConfigPanelProps {
|
||||||
carConfig: CarConfig;
|
carConfig: CarConfig;
|
||||||
simConfig: SimulationConfig;
|
simConfig: SimulationConfig;
|
||||||
|
gaConfig: GAConfig;
|
||||||
onCarConfigChange: (config: CarConfig) => void;
|
onCarConfigChange: (config: CarConfig) => void;
|
||||||
onSimConfigChange: (config: SimulationConfig) => void;
|
onSimConfigChange: (config: SimulationConfig) => void;
|
||||||
|
onGAConfigChange: (config: GAConfig) => void;
|
||||||
onNewTrack: () => void;
|
onNewTrack: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ConfigPanel({ carConfig, simConfig, onCarConfigChange, onSimConfigChange, onNewTrack }: ConfigPanelProps) {
|
export function ConfigPanel({ carConfig, simConfig, gaConfig, onCarConfigChange, onSimConfigChange, onGAConfigChange, onNewTrack }: ConfigPanelProps) {
|
||||||
const [isExpanded, setIsExpanded] = useState(true);
|
const [isExpanded, setIsExpanded] = useState(true);
|
||||||
|
|
||||||
const sliderStyle = { width: '100%', margin: '5px 0' };
|
const sliderStyle = { width: '100%', margin: '5px 0' };
|
||||||
@@ -24,6 +27,10 @@ export function ConfigPanel({ carConfig, simConfig, onCarConfigChange, onSimConf
|
|||||||
onSimConfigChange({ ...simConfig, [key]: value });
|
onSimConfigChange({ ...simConfig, [key]: value });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateGA = (key: keyof GAConfig, value: number) => {
|
||||||
|
onGAConfigChange({ ...gaConfig, [key]: value });
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
@@ -105,6 +112,43 @@ export function ConfigPanel({ carConfig, simConfig, onCarConfigChange, onSimConf
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div style={groupStyle}>
|
||||||
|
<h4 style={{ margin: '5px 0', color: '#ffa726' }}>Sensors</h4>
|
||||||
|
|
||||||
|
<div style={labelStyle}>
|
||||||
|
<span>Ray Count</span>
|
||||||
|
<span>{carConfig.rayCount}</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range" min="3" max="11" step="2"
|
||||||
|
value={carConfig.rayCount}
|
||||||
|
onChange={(e) => updateCar('rayCount', parseInt(e.target.value))}
|
||||||
|
style={sliderStyle}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div style={labelStyle}>
|
||||||
|
<span>FOV (Field of View)</span>
|
||||||
|
<span>{(carConfig.raySpread * 180 / Math.PI).toFixed(0)}°</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range" min="60" max="180" step="10"
|
||||||
|
value={carConfig.raySpread * 180 / Math.PI}
|
||||||
|
onChange={(e) => updateCar('raySpread', parseFloat(e.target.value) * Math.PI / 180)}
|
||||||
|
style={sliderStyle}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div style={labelStyle}>
|
||||||
|
<span>Ray Length</span>
|
||||||
|
<span>{carConfig.rayLength}</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range" min="50" max="300" step="10"
|
||||||
|
value={carConfig.rayLength}
|
||||||
|
onChange={(e) => updateCar('rayLength', parseInt(e.target.value))}
|
||||||
|
style={sliderStyle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style={groupStyle}>
|
<div style={groupStyle}>
|
||||||
<h4 style={{ margin: '5px 0', color: '#ff6b6b' }}>Track Gen</h4>
|
<h4 style={{ margin: '5px 0', color: '#ff6b6b' }}>Track Gen</h4>
|
||||||
|
|
||||||
@@ -148,6 +192,58 @@ export function ConfigPanel({ carConfig, simConfig, onCarConfigChange, onSimConf
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div style={groupStyle}>
|
||||||
|
<h4 style={{ margin: '5px 0', color: '#a855f7' }}>Evolution (GA)</h4>
|
||||||
|
|
||||||
|
<div style={labelStyle}>
|
||||||
|
<span>Population Size</span>
|
||||||
|
<span>{gaConfig.populationSize}</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range" min="10" max="200" step="10"
|
||||||
|
value={gaConfig.populationSize}
|
||||||
|
onChange={(e) => updateGA('populationSize', parseInt(e.target.value))}
|
||||||
|
style={sliderStyle}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div style={labelStyle}>
|
||||||
|
<span>Mutation Rate</span>
|
||||||
|
<span>{(gaConfig.mutationRate * 100).toFixed(1)}%</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range" min="0.01" max="0.20" step="0.01"
|
||||||
|
value={gaConfig.mutationRate}
|
||||||
|
onChange={(e) => updateGA('mutationRate', parseFloat(e.target.value))}
|
||||||
|
style={sliderStyle}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div style={labelStyle}>
|
||||||
|
<span>Mutation Amount</span>
|
||||||
|
<span>{gaConfig.mutationAmount.toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range" min="0.05" max="1.0" step="0.05"
|
||||||
|
value={gaConfig.mutationAmount}
|
||||||
|
onChange={(e) => updateGA('mutationAmount', parseFloat(e.target.value))}
|
||||||
|
style={sliderStyle}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div style={labelStyle}>
|
||||||
|
<span>Elitism (Keep Best)</span>
|
||||||
|
<span>{gaConfig.elitism}</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range" min="0" max="20" step="1"
|
||||||
|
value={gaConfig.elitism}
|
||||||
|
onChange={(e) => updateGA('elitism', parseInt(e.target.value))}
|
||||||
|
style={sliderStyle}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div style={{ fontSize: '10px', color: '#f59e0b', marginTop: '8px', textAlign: 'center' }}>
|
||||||
|
⚠️ GA changes restart training
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style={{ fontSize: '10px', color: '#888', textAlign: 'center' }}>
|
<div style={{ fontSize: '10px', color: '#888', textAlign: 'center' }}>
|
||||||
Physics apply immediately.<br />
|
Physics apply immediately.<br />
|
||||||
Track settings apply on generate.
|
Track settings apply on generate.
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import { CarScene } from './CarScene';
|
|||||||
import { ConfigPanel } from './ConfigPanel';
|
import { ConfigPanel } from './ConfigPanel';
|
||||||
import FitnessGraph from './FitnessGraph';
|
import FitnessGraph from './FitnessGraph';
|
||||||
import { DEFAULT_CAR_CONFIG, DEFAULT_SIM_CONFIG } from './types';
|
import { DEFAULT_CAR_CONFIG, DEFAULT_SIM_CONFIG } from './types';
|
||||||
|
import { DEFAULT_GA_CONFIG } from './SimpleGA';
|
||||||
import type { CarConfig, SimulationConfig } from './types';
|
import type { CarConfig, SimulationConfig } from './types';
|
||||||
|
import type { GAConfig } from './SimpleGA';
|
||||||
|
|
||||||
export function SelfDrivingCarApp() {
|
export function SelfDrivingCarApp() {
|
||||||
const gameContainer = useRef<HTMLDivElement>(null);
|
const gameContainer = useRef<HTMLDivElement>(null);
|
||||||
@@ -13,6 +15,7 @@ export function SelfDrivingCarApp() {
|
|||||||
// Config State
|
// Config State
|
||||||
const [carConfig, setCarConfig] = useState<CarConfig>(DEFAULT_CAR_CONFIG);
|
const [carConfig, setCarConfig] = useState<CarConfig>(DEFAULT_CAR_CONFIG);
|
||||||
const [simConfig, setSimConfig] = useState<SimulationConfig>(DEFAULT_SIM_CONFIG);
|
const [simConfig, setSimConfig] = useState<SimulationConfig>(DEFAULT_SIM_CONFIG);
|
||||||
|
const [gaConfig, setGAConfig] = useState<GAConfig>(DEFAULT_GA_CONFIG);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!gameContainer.current || gameInstance.current) return;
|
if (!gameContainer.current || gameInstance.current) return;
|
||||||
@@ -47,7 +50,6 @@ export function SelfDrivingCarApp() {
|
|||||||
const onGenerationComplete = (stats: { generation: number, best: number, average: number }) => {
|
const onGenerationComplete = (stats: { generation: number, best: number, average: number }) => {
|
||||||
setHistory(prev => {
|
setHistory(prev => {
|
||||||
const newHistory = [...prev, stats];
|
const newHistory = [...prev, stats];
|
||||||
if (newHistory.length > 50) return newHistory.slice(newHistory.length - 50);
|
|
||||||
return newHistory;
|
return newHistory;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -66,16 +68,23 @@ export function SelfDrivingCarApp() {
|
|||||||
// Sync Config to Scene
|
// Sync Config to Scene
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (gameInstance.current) {
|
if (gameInstance.current) {
|
||||||
gameInstance.current.events.emit('update-config', { car: carConfig, sim: simConfig });
|
gameInstance.current.events.emit('update-config', { car: carConfig, sim: simConfig, ga: gaConfig });
|
||||||
}
|
}
|
||||||
}, [carConfig, simConfig]);
|
}, [carConfig, simConfig, gaConfig]);
|
||||||
|
|
||||||
const handleNewTrack = () => {
|
const handleNewTrack = () => {
|
||||||
if (gameInstance.current) {
|
if (gameInstance.current) {
|
||||||
gameInstance.current.events.emit('new-track');
|
gameInstance.current.events.emit('new-track');
|
||||||
|
setHistory([]); // Clear fitness history on restart
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Restart training when GA config changes
|
||||||
|
const handleGAConfigChange = (newConfig: GAConfig) => {
|
||||||
|
setGAConfig(newConfig);
|
||||||
|
handleNewTrack(); // Restart training with new GA settings
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ width: '100%', height: '100%', position: 'relative', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
<div style={{ width: '100%', height: '100%', position: 'relative', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||||
{/* Top Bar for Graph */}
|
{/* Top Bar for Graph */}
|
||||||
@@ -102,8 +111,10 @@ export function SelfDrivingCarApp() {
|
|||||||
<ConfigPanel
|
<ConfigPanel
|
||||||
carConfig={carConfig}
|
carConfig={carConfig}
|
||||||
simConfig={simConfig}
|
simConfig={simConfig}
|
||||||
|
gaConfig={gaConfig}
|
||||||
onCarConfigChange={setCarConfig}
|
onCarConfigChange={setCarConfig}
|
||||||
onSimConfigChange={setSimConfig}
|
onSimConfigChange={setSimConfig}
|
||||||
|
onGAConfigChange={handleGAConfigChange}
|
||||||
onNewTrack={handleNewTrack}
|
onNewTrack={handleNewTrack}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -59,8 +59,8 @@ export class SimpleGA {
|
|||||||
// 3. Fill rest
|
// 3. Fill rest
|
||||||
while (nextPop.length < popSize) {
|
while (nextPop.length < popSize) {
|
||||||
// Diversity Injection (Random Immigrants)
|
// Diversity Injection (Random Immigrants)
|
||||||
// 5% chance to just insert a completely fresh brain to maintain diversity
|
// Increased from 5% to 15% to combat stagnation
|
||||||
if (Math.random() < 0.05) {
|
if (Math.random() < 0.15) {
|
||||||
const dn = new DenseNetwork(this.layerSizes);
|
const dn = new DenseNetwork(this.layerSizes);
|
||||||
nextPop.push(dn.getWeights());
|
nextPop.push(dn.getWeights());
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -52,9 +52,9 @@ export const DEFAULT_CAR_CONFIG: CarConfig = {
|
|||||||
height: 40,
|
height: 40,
|
||||||
maxSpeed: 12,
|
maxSpeed: 12,
|
||||||
turnSpeed: 0.15, // Increased from 0.08 for sharper turning
|
turnSpeed: 0.15, // Increased from 0.08 for sharper turning
|
||||||
rayCount: 5,
|
rayCount: 7, // Increased from 5 for better peripheral vision
|
||||||
rayLength: 150,
|
rayLength: 150,
|
||||||
raySpread: Math.PI / 2,
|
raySpread: Math.PI * 5 / 6, // 150° FOV (increased from 90°)
|
||||||
|
|
||||||
// Default Physics (Drifty)
|
// Default Physics (Drifty)
|
||||||
frictionAir: 0.02,
|
frictionAir: 0.02,
|
||||||
|
|||||||
Reference in New Issue
Block a user