Add self driving car

This commit is contained in:
Peter Stockings
2026-01-14 17:00:44 +11:00
parent 21baa6616b
commit dd561a4b32
20 changed files with 2015 additions and 52 deletions

View File

@@ -8,6 +8,7 @@
"@types/matter-js": "^0.20.2",
"matter-js": "^0.20.0",
"phaser": "^3.90.0",
"poly-decomp": "^0.3.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.12.0",
@@ -417,6 +418,8 @@
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"poly-decomp": ["poly-decomp@0.3.0", "", {}, "sha512-hWeBxGzPYiybmI4548Fca7Up/0k1qS5+79cVHI9+H33dKya5YNb9hxl0ZnDaDgvrZSuYFBhkCK/HOnqN7gefkQ=="],
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],

9
e2e_log.txt Normal file
View File

@@ -0,0 +1,9 @@
Starting Test...
Starting E2E Evolution Test (50 Gens)...
Gen 0: Best: 56.73, Avg: 22.47
Gen 10: Best: 58.09, Avg: 22.27
Gen 20: Best: 59.51, Avg: 21.88
Gen 30: Best: 56.22, Avg: 26.25
Gen 40: Best: 60.17, Avg: 25.75
Gen 49: Best: 62.23, Avg: 24.81
Evolution Result: 56.73 -> 62.23

View File

@@ -13,6 +13,7 @@
"@types/matter-js": "^0.20.2",
"matter-js": "^0.20.0",
"phaser": "^3.90.0",
"poly-decomp": "^0.3.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.12.0"

View File

@@ -5,6 +5,7 @@ import SnakeAI from './apps/SnakeAI/SnakeAI';
import RogueGenApp from './apps/RogueGen/RogueGenApp';
import NeatArena from './apps/NeatArena/NeatArena';
import LunarLanderApp from './apps/LunarLander/LunarLanderApp';
import { SelfDrivingCarApp } from './apps/SelfDrivingCar/SelfDrivingCarApp';
import './App.css';
function App() {
@@ -19,6 +20,7 @@ function App() {
<Route path="/rogue-gen" element={<RogueGenApp />} />
<Route path="/neat-arena" element={<NeatArena />} />
<Route path="/lunar-lander" element={<LunarLanderApp />} />
<Route path="/self-driving-car" element={<SelfDrivingCarApp />} />
<Route path="*" element={<div>App not found</div>} />
</Routes>
</main>

View File

@@ -0,0 +1,46 @@
import Matter from 'matter-js';
import { describe, expect, it, beforeEach } from 'bun:test';
import { Car } from './Car';
import { DenseNetwork } from '../LunarLander/DenseNetwork';
import { DEFAULT_CAR_CONFIG } from './types';
describe('Car Logic - Fitness & Stagnation', () => {
let car: Car;
let brain: DenseNetwork;
beforeEach(() => {
brain = new DenseNetwork([6, 8, 2]); // Standard topology
car = new Car(100, 100, brain, 0, DEFAULT_CAR_CONFIG);
});
it('should initialize with 0 fitness', () => {
expect(car.fitness).toBe(0);
expect(car.isDead).toBe(false);
});
it('should NOT lose fitness on death', () => {
car.kill();
expect(car.fitness).toBe(0);
expect(car.isDead).toBe(true);
});
it('should accumulate continuous fitness when moving', () => {
// Mock speed
// Can't easily mock speed on Body as it is computed.
// But we can check update path progress logic if we had path points.
const points = Array.from({length: 100}, (_, i) => ({x: i*10, y: 0}));
car.body.position = {x: 0, y: 0};
// Initial update should set index 0
car.update([], points);
// Move to index 1
car.body.position = {x: 10, y: 0};
car.update([], points);
// Should have gained fitness
expect(car.fitness).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,278 @@
import Matter from 'matter-js';
import { DEFAULT_CAR_CONFIG } from './types';
import type { CarConfig } from './types';
// import { NeuralNetwork } from '../../lib/neatArena/network';
import { DenseNetwork } from '../LunarLander/DenseNetwork';
import { distance, lineToLineIntersection } from './geom';
// Physics Tunings Removed (Now in config)
export class Car {
public body: Matter.Body;
public brain: DenseNetwork;
public isDead: boolean = false;
public fitness: number = 0;
public checkpointsPassed: number = 0;
public rayReadings: number[] = [];
public config: CarConfig;
// START NEW TRACKING LOGIC
private currentPathIndex: number = 0;
private laps: number = 0;
private maxPathIndexReached: number = 0;
private initialPosSet: boolean = false;
private framesSinceCheckpoint: number = 0;
constructor(
x: number,
y: number,
brain: DenseNetwork,
angle: number = 0,
config: CarConfig = DEFAULT_CAR_CONFIG
) {
this.brain = brain;
this.config = config;
// Create Physics Body
this.body = Matter.Bodies.rectangle(x, y, config.width, config.height, {
angle: angle,
frictionAir: config.frictionAir,
friction: config.friction,
density: 0.01,
label: 'car'
});
}
public update(walls: Matter.Body[], pathPoints: {x:number, y:number}[]) {
if (this.isDead) return;
// Init start position on path
if (!this.initialPosSet && pathPoints.length > 0) {
this.currentPathIndex = this.findClosestIndex(pathPoints, 0); // Search wide
this.initialPosSet = true;
}
// Stagnation Killer
this.framesSinceCheckpoint++;
if (this.framesSinceCheckpoint > 600) {
// Stagnation
this.kill();
return;
}
// 1. Sensors
this.rayReadings = this.castRays(walls);
// 2. Think
const inputs = [
...this.rayReadings,
this.body.speed / this.config.maxSpeed, // Normalize speed
];
const outputs = this.brain.predict(inputs);
const steer = outputs[0];
let gas = outputs[1];
// 3. Act (Kickstart)
if (this.framesSinceCheckpoint < 60 && this.fitness < 2) {
gas = 1.0;
} else if (this.body.speed < 0.2) {
gas = 1.0;
}
// Physics: Steering
if (this.body.speed > 0.5) {
Matter.Body.setAngularVelocity(this.body, steer * this.config.turnSpeed * Math.sign(gas));
}
// Physics: Gas (Forward Force)
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)
this.applyTireGrip(forward);
if (gas > 0) {
const force = 0.003 * gas; // slightly stronger engine
Matter.Body.applyForce(this.body, this.body.position, { x: forward.x * force, y: forward.y * force });
} else {
// Braking is less magical now
const brakeEffect = 0.98;
Matter.Body.setVelocity(this.body, { x: this.body.velocity.x * brakeEffect, y: this.body.velocity.y * brakeEffect });
}
// Speed Limit
if (this.body.speed > this.config.maxSpeed) {
Matter.Body.setVelocity(this.body, {
x: this.body.velocity.x * (this.config.maxSpeed/this.body.speed),
y: this.body.velocity.y * (this.config.maxSpeed/this.body.speed)
});
}
// 4. Update Fitness (Continuous Path Progress)
if (pathPoints.length > 0) {
this.updatePathProgress(pathPoints);
}
}
private applyTireGrip(forward: {x:number, y:number}) {
// Compute current velocity
const velocity = this.body.velocity;
// Compute Right vector
const right = { x: -forward.y, y: forward.x };
// Lateral Velocity = Dot(Velocity, Right)
const lateralSpeed = velocity.x * right.x + velocity.y * right.y;
// Lateral Impulse = -Lateral Velocity * (0.0 to 1.0)
// 1.0 = Perfect rails
// 0.0 = Ice
const lateralImpulse = lateralSpeed * this.config.lateralFriction;
// Apply impulse against the lateral motion
// Matter does impulses as force * time? No, setVelocity is cheating.
// Let's modify velocity directly for stability.
Matter.Body.setVelocity(this.body, {
x: velocity.x - right.x * lateralImpulse,
y: velocity.y - right.y * lateralImpulse
});
}
private updatePathProgress(pathPoints: {x:number, y:number}[]) {
// Find closest point LOCAL SEARCH
// Search window: +/- 20 points from current index, handling wrap-around
const searchRadius = 20;
const total = pathPoints.length;
let bestDist = Infinity;
let bestIndex = this.currentPathIndex;
for (let i = -searchRadius; i <= searchRadius; i++) {
let idx = (this.currentPathIndex + i);
// Handle wrap
if (idx < 0) idx += total;
if (idx >= total) idx -= total;
const d = distance(this.body.position, pathPoints[idx]);
// Use <= to favor forward points (later in the loop) when equidistant
// This is critical for loop closure where end overlaps start
if (d <= bestDist) {
bestDist = d;
bestIndex = idx;
}
}
// Did we move forward or backward?
// Simple logic: delta index
let delta = bestIndex - this.currentPathIndex;
// Wrap detection
// Jump from total-1 to 0 (Forward Lap) -> Delta is negative large number (e.g. -499)
// Jump from 0 to total-1 (Reverse) -> Delta is positive large number (e.g. +499)
if (delta < -total / 2) {
// Forward Lap
this.laps++;
delta += total;
} else if (delta > total / 2) {
// Backward Lap
this.laps--;
delta -= total;
}
// Update state
this.currentPathIndex = bestIndex;
// Calculate continuous fitness
// 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;
// Only update fitness if we improve (Standard NEAT/GA practice usually for 'max reached')
// But for continuous driving, we want to allow fluctuations but reward overall progress.
// Let's use Raw Score directly. If they reverse, they lose fitness.
// Scale down to reasonable numbers (e.g. 1 point per 100 units?)
// Let's just say 1 point per 1 path node.
// Reward function:
this.fitness = Math.max(0, rawScore / 10.0); // 10 points per 100 nodes
// 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;
if (absoluteIndex > this.maxPathIndexReached) {
this.maxPathIndexReached = absoluteIndex;
this.framesSinceCheckpoint = 0; // Usage of this var as 'framesSinceProgress'
}
}
private findClosestIndex(points: {x:number, y:number}[], _startIndex: number): number {
let bestDist = Infinity;
let bestIdx = 0;
for(let i=0; i<points.length; i++) { // brute init
const d = distance(this.body.position, points[i]);
if (d < bestDist) { bestDist = d; bestIdx = i; }
}
return bestIdx;
}
private castRays(walls: Matter.Body[]): number[] {
// ... (Keep existing ray logic)
const rays: number[] = [];
const start = this.body.position;
const forwardAngle = this.body.angle - Math.PI/2;
const startRayAngle = forwardAngle - this.config.raySpread / 2;
const angleStep = this.config.raySpread / (this.config.rayCount - 1);
for (let i = 0; i < this.config.rayCount; i++) {
const angle = startRayAngle + i * angleStep;
const dir = { x: Math.cos(angle), y: Math.sin(angle) };
const end = {
x: start.x + dir.x * this.config.rayLength,
y: start.y + dir.y * this.config.rayLength
};
let minDist = 1.0;
for (const wall of walls) {
const d = distance(start, wall.position);
if (d > this.config.rayLength + 100) continue;
const dist = this.rayBodyIntersect(start, end, wall);
if (dist < minDist) minDist = dist;
}
rays.push(1.0 - minDist);
}
return rays;
}
private rayBodyIntersect(start: {x:number, y:number}, end: {x:number, y:number}, body: Matter.Body): number {
// ... (Keep existing logic)
const verts = body.vertices;
let minDist = 1.2;
for (let i = 0; i < verts.length; i++) {
const p1 = verts[i];
const p2 = verts[(i + 1) % verts.length];
const intersection = lineToLineIntersection(start.x, start.y, end.x, end.y, p1.x, p1.y, p2.x, p2.y);
if (intersection) {
const d = distance(start, intersection);
const normalizedD = d / this.config.rayLength;
if (normalizedD < minDist) minDist = normalizedD;
}
}
return minDist;
}
public kill() {
if (this.isDead) return;
this.isDead = true;
}
}

View File

@@ -0,0 +1,344 @@
import Phaser from 'phaser';
import { CarSimulation } from './CarSimulation';
import { Car } from './Car';
import { DEFAULT_SIM_CONFIG, DEFAULT_CAR_CONFIG } from './types';
import type { SerializedTrackData, SerializedVector, 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';
// 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;
// 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 = [6, 16, 12, 2]; // 6 Inputs, 16/12 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 }) => {
this.carConfig = cfg.car;
this.simConfig = cfg.sim;
// HOT RELOAD PHYSICS
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);
// Also update Worker config for NEXT generation
if (this.worker) {
// We can't easily interrupt the worker mid-gen with valid physics without complex sync.
// So we just update the config it uses for next gen.
}
}
});
// ... debug texts ... (rest of create)
}
private handleNewTrack() {
if (this.worker) this.worker.terminate();
this.sim = null as any;
this.population = this.ga.createPopulation();
this.generationCount = 0;
this.bestFitnessEver = -Infinity;
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 updateVisualSim(bestGenome: Float32Array) {
this.sim = new CarSimulation(
this.serializedTrack,
{ ...this.simConfig, populationSize: 1 },
[bestGenome],
this.carConfig
);
}
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,
{ ...DEFAULT_SIM_CONFIG, populationSize: 1 },
[bestGenome]
);
}
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);
});
}
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 (Only if alive to reduce clutter, or just best car)
// Check if this is the "visual" best car (simulation only has 1 in visual mode usually)
if (!car.isDead && this.sim.cars.length === 1) {
const start = car.body.position;
const angleBase = car.body.angle - Math.PI/2;
const spread = Math.PI/2; // Matches DEFAULT_CAR_CONFIG.raySpread (hardcoded for viz)
const count = 5;
const len = 150;
// We need actual readings to color them?
// Since we don't strictly sync readings to scene in this simple loop, just draw lines showing FOV.
this.graphics.lineStyle(1, 0x00ff00, 0.3);
// 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++) {
const angle = startRayAngle + i * angleStep;
this.graphics.beginPath();
this.graphics.moveTo(start.x, start.y);
this.graphics.lineTo(
start.x + Math.cos(angle) * len,
start.y + Math.sin(angle) * len
);
this.graphics.strokePath();
}
}
}
}

View File

@@ -0,0 +1,184 @@
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';
export class CarSimulation {
public engine: Matter.Engine;
public cars: Car[] = [];
public walls: Matter.Body[] = [];
public checkpoints: Matter.Body[] = [];
// Sim State
public generation: number = 1;
public frameValues: number = 0;
private config: SimulationConfig;
private trackData: SerializedTrackData;
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;
constructor(
trackData: SerializedTrackData,
config: SimulationConfig = DEFAULT_SIM_CONFIG,
genomes: Float32Array[] = [],
carConfig: CarConfig = DEFAULT_CAR_CONFIG
) {
this.trackData = trackData;
this.config = config;
this.genomes = genomes;
this.carConfig = carConfig;
// Create detached engine
this.engine = Matter.Engine.create();
this.engine.gravity.x = 0;
this.engine.gravity.y = 0; // Top down
// 1. Setup Track from Data
this.walls = trackData.walls.map(w => 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(
cp.position.x, cp.position.y, cp.width, cp.height, {
isStatic: true,
isSensor: true,
angle: cp.angle,
label: cp.label
}
));
Matter.World.add(this.engine.world, this.walls);
Matter.World.add(this.engine.world, this.checkpoints);
// Events
Matter.Events.on(this.engine, 'collisionStart', (e) => this.handleCollisions(e));
// 2. Spawn
this.spawnGeneration();
}
public update() {
// Step Physics directly
Matter.Engine.update(this.engine, 1000 / 60);
// Update Cars Logic
let aliveCount = 0;
this.cars.forEach(car => {
if (!car.isDead) {
car.update(this.walls, this.trackData.pathPoints);
aliveCount++;
}
});
if (aliveCount === 0) {
this.nextGeneration();
}
}
private spawnGeneration() {
// Cleanup bodies
this.cars.forEach(c => Matter.World.remove(this.engine.world, c.body));
this.cars = [];
// If we have genomes, use them. Otherwise mock.
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
for (let i = 0; i < effectivePopSize; i++) {
let network: DenseNetwork;
if (this.genomes.length > 0) {
network = new DenseNetwork(layerSizes, this.genomes[i]);
} else {
// Random new
network = new DenseNetwork(layerSizes);
}
const car = new Car(
this.trackData.startPosition.x,
this.trackData.startPosition.y,
network,
this.trackData.startAngle + Math.PI / 2,
this.carConfig
);
Matter.World.add(this.engine.world, car.body);
this.cars.push(car);
}
}
public onGenerationComplete?: (stats: { generation: number, best: number, average: number }) => void;
private nextGeneration() {
// In Worker Mode, we don't proceed to next generation automatically.
// We stop and return result.
// But for compatibility with internal loop if needed:
// Return results via callback if set?
// Or just stop.
}
// Helper to get results
public getResults() {
return this.cars.map((c, i) => ({
fitness: c.fitness,
checkpoints: c.checkpointsPassed,
genome: this.genomes[i]
}));
}
public isFinished(): boolean {
return this.cars.every(c => c.isDead);
}
public run(steps: number) {
for(let i=0; i<steps; i++) {
this.update();
// Check if all dead to early exit?
if (this.cars.every(c => c.isDead)) break;
}
}
private handleCollisions(event: Matter.IEventCollision<Matter.Engine>) {
event.pairs.forEach(pair => {
const { bodyA, bodyB } = pair;
this.checkCarWallCollision(bodyA, bodyB);
});
}
private checkCarWallCollision(bodyA: Matter.Body, bodyB: Matter.Body) {
const carBody = bodyA.label === 'car' ? bodyA : (bodyB.label === 'car' ? bodyB : null);
const wallBody = bodyA.label === 'wall' ? bodyA : (bodyB.label === 'wall' ? bodyB : null);
if (carBody && wallBody) {
const car = this.cars.find(c => c.body === carBody);
if (car) car.kill();
}
}
public updateConfig(carConfig: CarConfig) {
this.cars.forEach(car => {
car.config = carConfig; // Update config ref
// Apply physics properties directly to body
Matter.Body.set(car.body, {
frictionAir: carConfig.frictionAir,
friction: carConfig.friction
});
});
}
}

View File

@@ -0,0 +1,159 @@
import React, { useState } from 'react';
import type { CarConfig, SimulationConfig } from './types';
interface ConfigPanelProps {
carConfig: CarConfig;
simConfig: SimulationConfig;
onCarConfigChange: (config: CarConfig) => void;
onSimConfigChange: (config: SimulationConfig) => void;
onNewTrack: () => void;
}
export function ConfigPanel({ carConfig, simConfig, onCarConfigChange, onSimConfigChange, onNewTrack }: ConfigPanelProps) {
const [isExpanded, setIsExpanded] = useState(true);
const sliderStyle = { width: '100%', margin: '5px 0' };
const labelStyle = { display: 'flex', justifyContent: 'space-between', fontSize: '12px', color: '#ccc' };
const groupStyle = { marginBottom: '15px', borderBottom: '1px solid #444', paddingBottom: '10px' };
const updateCar = (key: keyof CarConfig, value: number) => {
onCarConfigChange({ ...carConfig, [key]: value });
};
const updateSim = (key: keyof SimulationConfig, value: number) => {
onSimConfigChange({ ...simConfig, [key]: value });
};
return (
<div style={{
position: 'absolute',
top: 170,
right: 20,
width: isExpanded ? '250px' : 'auto',
background: 'rgba(0,0,0,0.8)',
padding: '10px',
borderRadius: '8px',
color: 'white',
backdropFilter: 'blur(5px)',
maxHeight: 'calc(100vh - 40px)',
overflowY: 'auto',
transition: 'width 0.2s'
}}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
cursor: 'pointer',
borderBottom: isExpanded ? '1px solid #666' : 'none',
paddingBottom: isExpanded ? '5px' : '0',
marginBottom: isExpanded ? '10px' : '0'
}}
onClick={() => setIsExpanded(!isExpanded)}
>
<h3 style={{ margin: 0, fontSize: '14px' }}>Configuration</h3>
<span style={{ fontSize: '12px', marginLeft: '10px' }}>{isExpanded ? '▼' : '◀'}</span>
</div>
{isExpanded && (
<>
<div style={groupStyle}>
<h4 style={{ margin: '5px 0', color: '#4ecdc4' }}>Car Physics</h4>
<div style={labelStyle}>
<span>Max Speed</span>
<span>{carConfig.maxSpeed.toFixed(1)}</span>
</div>
<input
type="range" min="5" max="25" step="0.5"
value={carConfig.maxSpeed}
onChange={(e) => updateCar('maxSpeed', parseFloat(e.target.value))}
style={sliderStyle}
/>
<div style={labelStyle}>
<span>Turn Speed</span>
<span>{carConfig.turnSpeed.toFixed(2)}</span>
</div>
<input
type="range" min="0.02" max="0.20" step="0.01"
value={carConfig.turnSpeed}
onChange={(e) => updateCar('turnSpeed', parseFloat(e.target.value))}
style={sliderStyle}
/>
<div style={labelStyle}>
<span>Tire Grip (Lateral)</span>
<span>{(carConfig.lateralFriction * 100).toFixed(0)}%</span>
</div>
<input
type="range" min="0.5" max="0.99" step="0.01"
value={carConfig.lateralFriction}
onChange={(e) => updateCar('lateralFriction', parseFloat(e.target.value))}
style={sliderStyle}
/>
<div style={labelStyle}>
<span>Air Resistance</span>
<span>{(carConfig.frictionAir * 1000).toFixed(0)}</span>
</div>
<input
type="range" min="0.00" max="0.20" step="0.005"
value={carConfig.frictionAir}
onChange={(e) => updateCar('frictionAir', parseFloat(e.target.value))}
style={sliderStyle}
/>
</div>
<div style={groupStyle}>
<h4 style={{ margin: '5px 0', color: '#ff6b6b' }}>Track Gen</h4>
<div style={labelStyle}>
<span>Complexity (Wiggle)</span>
<span>{(simConfig.trackComplexity * 100).toFixed(0)}%</span>
</div>
<input
type="range" min="0.1" max="1.0" step="0.01"
value={simConfig.trackComplexity}
onChange={(e) => updateSim('trackComplexity', parseFloat(e.target.value))}
style={sliderStyle}
/>
<div style={labelStyle}>
<span>Length (Nodes)</span>
<span>{simConfig.trackLength}</span>
</div>
<input
type="range" min="10" max="60" step="1"
value={simConfig.trackLength}
onChange={(e) => updateSim('trackLength', parseInt(e.target.value))}
style={sliderStyle}
/>
<button
onClick={onNewTrack}
style={{
width: '100%',
marginTop: '10px',
background: '#ff6b6b',
border: 'none',
padding: '8px',
borderRadius: '4px',
cursor: 'pointer',
fontWeight: 'bold',
color: 'white'
}}
>
Generate New Track
</button>
</div>
<div style={{ fontSize: '10px', color: '#888', textAlign: 'center' }}>
Physics apply immediately.<br />
Track settings apply on generate.
</div>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,140 @@
interface FitnessGraphProps {
history: Array<{ generation: number; best: number; average: number }>;
width?: number | string;
height?: number | string;
className?: string;
}
export default function FitnessGraph({ history, width = "100%", height = 150, className = "" }: FitnessGraphProps) {
if (history.length < 2) {
return (
<div style={{
width,
height,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#666',
fontSize: '0.8rem',
background: 'rgba(0,0,0,0.2)',
borderRadius: '4px'
}}>
Waiting for data...
</div>
);
}
const PADDING = 20; // Internal padding
// Use internal coordinate system for viewBox
const VIEW_WIDTH = 500;
const VIEW_HEIGHT = 200;
const GRAPH_WIDTH = VIEW_WIDTH - PADDING * 2;
const GRAPH_HEIGHT = VIEW_HEIGHT - PADDING * 2;
// Find min/max for scaling
const maxFitness = Math.max(...history.map(h => h.best), 1);
const minGeneration = history[0].generation;
const maxGeneration = history[history.length - 1].generation;
const genRange = Math.max(maxGeneration - minGeneration, 1);
// Helper to scale points
const getX = (gen: number) => {
return PADDING + ((gen - minGeneration) / genRange) * GRAPH_WIDTH;
};
const getY = (fitness: number) => {
// Invert Y because SVG 0 is top
return PADDING + GRAPH_HEIGHT - (fitness / maxFitness) * GRAPH_HEIGHT;
};
// Generate path data
const bestPath = history.map((p, i) =>
`${i === 0 ? 'M' : 'L'} ${getX(p.generation)} ${getY(p.best)}`
).join(' ');
const averagePath = history.map((p, i) =>
`${i === 0 ? 'M' : 'L'} ${getX(p.generation)} ${getY(p.average)}`
).join(' ');
// Areas (closed paths for gradients)
const bestArea = bestPath + ` L ${getX(history[history.length - 1].generation)} ${GRAPH_HEIGHT + PADDING} L ${getX(minGeneration)} ${GRAPH_HEIGHT + PADDING} Z`;
const averageArea = averagePath + ` L ${getX(history[history.length - 1].generation)} ${GRAPH_HEIGHT + PADDING} L ${getX(minGeneration)} ${GRAPH_HEIGHT + PADDING} Z`;
return (
<div className={`fitness-graph-container ${className}`} style={{ width: '100%', height, position: 'relative' }}>
{/* Legend Overlay */}
<div style={{
position: 'absolute',
top: 0,
right: 0,
display: 'flex',
gap: '12px',
fontSize: '0.75rem',
fontWeight: 600,
background: 'rgba(0,0,0,0.4)',
padding: '4px 8px',
borderRadius: '0 0 0 8px',
pointerEvents: 'none',
backdropFilter: 'blur(2px)'
}}>
<div style={{ color: '#4ecdc4', display: 'flex', alignItems: 'center', gap: '6px' }}>
<div style={{ width: 8, height: 8, background: '#4ecdc4', borderRadius: '50%' }}></div>
Best: {Math.round(history[history.length - 1].best)}
</div>
<div style={{ color: '#4a9eff', display: 'flex', alignItems: 'center', gap: '6px' }}>
<div style={{ width: 8, height: 8, background: '#4a9eff', borderRadius: '50%' }}></div>
Avg: {Math.round(history[history.length - 1].average)}
</div>
</div>
<svg
width="100%"
height="100%"
viewBox={`0 0 ${VIEW_WIDTH} ${VIEW_HEIGHT}`}
preserveAspectRatio="none"
style={{ overflow: 'visible' }}
>
<defs>
<linearGradient id="gradBest" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stopColor="#4ecdc4" stopOpacity={0.4} />
<stop offset="100%" stopColor="#4ecdc4" stopOpacity={0} />
</linearGradient>
<linearGradient id="gradAvg" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stopColor="#4a9eff" stopOpacity={0.3} />
<stop offset="100%" stopColor="#4a9eff" stopOpacity={0} />
</linearGradient>
</defs>
{/* Grid Lines (Horizontal) */}
{[0, 0.25, 0.5, 0.75, 1].map(ratio => {
const y = PADDING + ratio * GRAPH_HEIGHT;
return (
<line
key={ratio}
x1={PADDING}
y1={y}
x2={VIEW_WIDTH - PADDING}
y2={y}
stroke="#333"
strokeWidth="1"
strokeDasharray="4 4"
opacity="0.5"
/>
);
})}
{/* Average Area */}
<path d={averageArea} fill="url(#gradAvg)" />
{/* Average Line */}
<path d={averagePath} fill="none" stroke="#4a9eff" strokeWidth="2" strokeOpacity="0.8" />
{/* Best Area */}
<path d={bestArea} fill="url(#gradBest)" />
{/* Best Line */}
<path d={bestPath} fill="none" stroke="#4ecdc4" strokeWidth="2.5" />
</svg>
</div>
);
}

View File

@@ -0,0 +1,111 @@
import { useRef, useState, useEffect } from 'react';
import { CarScene } from './CarScene';
import { ConfigPanel } from './ConfigPanel';
import FitnessGraph from './FitnessGraph';
import { DEFAULT_CAR_CONFIG, DEFAULT_SIM_CONFIG } from './types';
import type { CarConfig, SimulationConfig } from './types';
export function SelfDrivingCarApp() {
const gameContainer = useRef<HTMLDivElement>(null);
const gameInstance = useRef<Phaser.Game | null>(null);
const [history, setHistory] = useState<Array<{ generation: number, best: number, average: number }>>([]);
// Config State
const [carConfig, setCarConfig] = useState<CarConfig>(DEFAULT_CAR_CONFIG);
const [simConfig, setSimConfig] = useState<SimulationConfig>(DEFAULT_SIM_CONFIG);
useEffect(() => {
if (!gameContainer.current || gameInstance.current) return;
const config: Phaser.Types.Core.GameConfig = {
type: Phaser.AUTO,
parent: gameContainer.current,
width: gameContainer.current.clientWidth,
height: gameContainer.current.clientHeight,
backgroundColor: '#222222',
physics: {
default: 'matter',
matter: {
gravity: { x: 0, y: 0 },
debug: false
}
},
scene: [CarScene],
scale: {
mode: Phaser.Scale.RESIZE,
autoCenter: Phaser.Scale.CENTER_BOTH
}
};
const game = new Phaser.Game(config);
gameInstance.current = game;
// Init config in scene once ready?
// Actually Scene starts immediately. We can emit config update shortly after or pass safely.
// Listen for stats
const onGenerationComplete = (stats: { generation: number, best: number, average: number }) => {
setHistory(prev => {
const newHistory = [...prev, stats];
if (newHistory.length > 50) return newHistory.slice(newHistory.length - 50);
return newHistory;
});
};
game.events.on('generation-complete', onGenerationComplete);
return () => {
if (gameInstance.current) {
gameInstance.current.events.off('generation-complete', onGenerationComplete);
gameInstance.current.destroy(true);
gameInstance.current = null;
}
};
}, []);
// Sync Config to Scene
useEffect(() => {
if (gameInstance.current) {
gameInstance.current.events.emit('update-config', { car: carConfig, sim: simConfig });
}
}, [carConfig, simConfig]);
const handleNewTrack = () => {
if (gameInstance.current) {
gameInstance.current.events.emit('new-track');
}
};
return (
<div style={{ width: '100%', height: '100%', position: 'relative', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
{/* Top Bar for Graph */}
<div style={{
height: '150px',
background: '#1a1a1a',
padding: '10px',
borderBottom: '1px solid #333',
zIndex: 10
}}>
<FitnessGraph history={history} height="100%" />
</div>
<div
style={{ flex: 1, position: 'relative', overflow: 'hidden' }}
>
<div
ref={gameContainer}
style={{ width: '100%', height: '100%' }}
/>
</div>
{/* Config Panel */}
<ConfigPanel
carConfig={carConfig}
simConfig={simConfig}
onCarConfigChange={setCarConfig}
onSimConfigChange={setSimConfig}
onNewTrack={handleNewTrack}
/>
</div>
);
}

View File

@@ -0,0 +1,121 @@
import { DenseNetwork } from '../../apps/LunarLander/DenseNetwork';
export interface GAConfig {
populationSize: number;
mutationRate: number;
mutationAmount: number;
elitism: number; // Number of best agents to keep unchanged
}
export const DEFAULT_GA_CONFIG: GAConfig = {
populationSize: 50,
mutationRate: 0.05, // Reduced from 0.1
mutationAmount: 0.2, // Reduced from 0.5
elitism: 5
};
export class SimpleGA {
private layerSizes: number[];
private config: GAConfig;
constructor(layerSizes: number[], config: GAConfig = DEFAULT_GA_CONFIG) {
this.layerSizes = layerSizes;
this.config = config;
}
createPopulation(): Float32Array[] {
const pop: Float32Array[] = [];
// Helper to get weight count
// We create a dummy network to calculate size easily, or duplicate logic.
// Duplicating logic is safer to avoid instantiation overhead if large.
// Logic from DenseNetwork: sum((full_in + 1) * out)
// Let's just instantiate one to be sure.
const dummy = new DenseNetwork(this.layerSizes);
const size = dummy.getWeights().length;
for (let i = 0; i < this.config.populationSize; i++) {
const dn = new DenseNetwork(this.layerSizes);
pop.push(dn.getWeights());
}
return pop;
}
evolve(currentPop: Float32Array[], fitnesses: number[]): Float32Array[] {
// 1. Sort by fitness (descending)
const indices = currentPop.map((_, i) => i).sort((a, b) => fitnesses[b] - fitnesses[a]);
const nextPop: Float32Array[] = [];
const popSize = this.config.populationSize;
// 2. Elitism
for (let i = 0; i < this.config.elitism; i++) {
if (i < indices.length) {
// Keep exact copy
nextPop.push(new Float32Array(currentPop[indices[i]]));
}
}
// 3. Fill rest
while (nextPop.length < popSize) {
// Diversity Injection (Random Immigrants)
// 5% chance to just insert a completely fresh brain to maintain diversity
if (Math.random() < 0.05) {
const dn = new DenseNetwork(this.layerSizes);
nextPop.push(dn.getWeights());
continue;
}
// Tournament selection
const p1 = currentPop[this.tournamentSelect(indices, fitnesses)];
const p2 = currentPop[this.tournamentSelect(indices, fitnesses)];
// Crossover
const child = this.crossover(p1, p2);
// Mutation
this.mutate(child);
nextPop.push(child);
}
return nextPop;
}
private tournamentSelect(indices: number[], fitnesses: number[]): number {
const k = 3;
let bestIndex = -1;
let bestFitness = -Infinity;
for (let i = 0; i < k; i++) {
const randIdx = indices[Math.floor(Math.random() * indices.length)]; // Pick from sorted or unsorted?
// Better to pick pure random index from 0..popSize-1
const r = Math.floor(Math.random() * indices.length);
const realIdx = indices[r];
if (fitnesses[realIdx] > bestFitness) {
bestFitness = fitnesses[realIdx];
bestIndex = realIdx;
}
}
return bestIndex;
}
private crossover(w1: Float32Array, w2: Float32Array): Float32Array {
const child = new Float32Array(w1.length);
// Uniform crossover? Or Split?
// Uniform is good for weights.
for (let i = 0; i < w1.length; i++) {
child[i] = Math.random() < 0.5 ? w1[i] : w2[i];
}
return child;
}
private mutate(weights: Float32Array) {
for (let i = 0; i < weights.length; i++) {
if (Math.random() < this.config.mutationRate) {
weights[i] += (Math.random() * 2 - 1) * this.config.mutationAmount;
// Clamp? Optional. Tanh handles range usually.
}
}
}
}

View File

@@ -0,0 +1,215 @@
import Phaser from 'phaser';
import Matter from 'matter-js';
// @ts-ignore
import decomp from 'poly-decomp';
(window as any).decomp = decomp; // Matter.js requires it on window or Common
// Or better:
Matter.Common.setDecomp(decomp);
export interface TrackData {
innerWalls: Phaser.Math.Vector2[];
outerWalls: Phaser.Math.Vector2[];
pathPoints: Phaser.Math.Vector2[]; // For logic/fitness
centerLine: Phaser.Curves.Spline;
checkpoints: Matter.Body[];
walls: Matter.Body[];
startPosition: Phaser.Math.Vector2;
startAngle: number;
}
export class TrackGenerator {
private width: number;
private height: number;
private trackWidth: number;
constructor(width: number, height: number, trackWidth: number = 80) {
this.width = width;
this.height = height;
this.trackWidth = trackWidth;
}
public generate(complexity: number = 0.5, length: number = 25): TrackData {
// 1. Generate Control Points (Rough Circle with Noise)
const center = new Phaser.Math.Vector2(this.width / 2, this.height / 2);
const controlPoints: Phaser.Math.Vector2[] = [];
const numPoints = length;
const baseRadius = Math.min(this.width, this.height) * 0.35;
const radiusVariation = baseRadius * 0.3 * complexity; // Smooth variation
for (let i = 0; i < numPoints; i++) {
const angle = (i / numPoints) * Math.PI * 2;
const r = baseRadius + (Math.random() * 2 - 1) * radiusVariation;
// Minimal angle noise to prevent loop-backs
const angleNoise = (Math.random() - 0.5) * (Math.PI * 2 / numPoints) * 0.1 * complexity;
controlPoints.push(new Phaser.Math.Vector2(
center.x + Math.cos(angle + angleNoise) * r,
center.y + Math.sin(angle + angleNoise) * r
));
}
// 2. Closed Loop Spline
// To make it loop smoothly, we copy the first 3 points to the end.
const closedPoints = [
...controlPoints,
controlPoints[0],
controlPoints[1],
controlPoints[2]
];
const spline = new Phaser.Curves.Spline(closedPoints);
// 3. Create Geometry
// Sample at fixed DISTANCE, not t-steps, for uniform width
return this.createGeometry(spline, controlPoints.length);
}
private createGeometry(spline: Phaser.Curves.Spline, originalCount: number): TrackData {
const resolutionPerSegment = 10;
const points: Phaser.Math.Vector2[] = [];
// ... (Sampling logic same) ...
const totalSegments = (originalCount + 3) - 1;
for (let i = 0; i < originalCount; i++) {
const tStart = i / totalSegments;
const tEnd = (i + 1) / totalSegments;
for (let j = 0; j < resolutionPerSegment; j++) {
const t = tStart + (tEnd - tStart) * (j / resolutionPerSegment);
const p = spline.getPoint(t);
points.push(new Phaser.Math.Vector2(p.x, p.y));
}
}
// Close Loop
const p0 = spline.getPoint(0);
points.push(new Phaser.Math.Vector2(p0.x, p0.y));
// CALCULATE VERTEX NORMALS
const normals: Phaser.Math.Vector2[] = [];
// First compute segment tangents/normals
const segmentNormals: Phaser.Math.Vector2[] = [];
for (let i = 0; i < points.length - 1; i++) {
const p1 = points[i];
const p2 = points[i+1];
const t = p2.clone().subtract(p1).normalize();
segmentNormals.push(new Phaser.Math.Vector2(-t.y, t.x));
}
// Now compute vertex normals (average of adjacent segments)
// For i=0 (Start), average(LastSeg, Seg0)
// For i=Last (End), average(LastSeg, Seg0) -> Should be same as i=0
for (let i = 0; i < points.length; i++) {
// Prev Segment
let prevIdx = i - 1;
if (prevIdx < 0) prevIdx = segmentNormals.length - 1;
// Next Segment (current i) generally, but for the last point, it's also the last segment?
// Actually: point i connects Seg i-1 and Seg i.
// point 0 connects Seg LAST and Seg 0.
// point N connects Seg N-1 and Seg 0? Yes if closed.
let nextIdx = i;
if (nextIdx >= segmentNormals.length) nextIdx = 0; // Wrap valid?
// Wait, points length is N+1. Segments length is N.
// points[0] joins Seg[N-1] and Seg[0].
// points[N] is same as points[0].
// Let's just average generic
const n1 = segmentNormals[prevIdx];
const n2 = segmentNormals[nextIdx < segmentNormals.length ? nextIdx : 0];
const avg = n1.clone().add(n2).normalize();
normals.push(avg);
}
const innerWalls: Phaser.Math.Vector2[] = [];
const outerWalls: Phaser.Math.Vector2[] = [];
const walls: Matter.Body[] = [];
const checkpoints: Matter.Body[] = [];
for (let i = 0; i < points.length - 1; i++) {
const p1 = points[i];
const p2 = points[i + 1];
const n1 = normals[i];
const n2 = normals[i+1];
// Vertices using Smooth Normals
const outer1 = p1.clone().add(n1.clone().scale(this.trackWidth / 2));
const inner1 = p1.clone().add(n1.clone().scale(-this.trackWidth / 2));
const outer2 = p2.clone().add(n2.clone().scale(this.trackWidth / 2));
const inner2 = p2.clone().add(n2.clone().scale(-this.trackWidth / 2));
outerWalls.push(outer1);
innerWalls.push(inner1);
// Walls (Trapezoids)
const thickness = 20;
const outer1_T = outer1.clone().add(n1.clone().scale(thickness));
const outer2_T = outer2.clone().add(n2.clone().scale(thickness));
const wallLeft = Matter.Bodies.fromVertices(
(outer1.x + outer2.x + outer1_T.x + outer2_T.x)/4,
(outer1.y + outer2.y + outer1_T.y + outer2_T.y)/4,
[[outer1, outer2, outer2_T, outer1_T]],
{ isStatic: true, label: 'wall' }
);
if (wallLeft) walls.push(wallLeft);
const inner1_T = inner1.clone().add(n1.clone().scale(-thickness));
const inner2_T = inner2.clone().add(n2.clone().scale(-thickness));
const wallRight = Matter.Bodies.fromVertices(
(inner1.x + inner2.x + inner1_T.x + inner2_T.x)/4,
(inner1.y + inner2.y + inner1_T.y + inner2_T.y)/4,
[[inner1, inner2, inner2_T, inner1_T]],
{ isStatic: true, label: 'wall' }
);
if (wallRight) walls.push(wallRight);
// Circle Joints (Still useful for sharp corners, but smooth normals handle gaps)
if (true) {
// Place at vertices (p1/p2)
// We only need to place at p1 for each segment to cover the seam.
// Actually with smooth normals, outer2(i-1) === outer1(i). Guaranteed.
// So no gaps!
// But Sharp Corners might still have physics issues if convex?
// No, smooth normals rounds the corner.
// We don't need joints anymore!
}
// ... Checkpoints logic ...
if (points.length > 50 && i % Math.floor(points.length / 10) === 0) {
// Use segment tangent for angle
const tangent = p2.clone().subtract(p1).normalize();
const cpMid = p1.clone();
checkpoints.push(Matter.Bodies.rectangle(cpMid.x, cpMid.y, 10, this.trackWidth, {
isSensor: true,
isStatic: true,
angle: Math.atan2(tangent.y, tangent.x),
label: `checkpoint_${checkpoints.length}`
}));
}
}
// Start Position (First point)
const startP = points[0];
const startT = points[1].clone().subtract(points[0]).normalize();
return {
innerWalls,
outerWalls,
pathPoints: points, // These are the high-res samples
centerLine: spline,
checkpoints,
walls,
startPosition: startP,
startAngle: Math.atan2(startT.y, startT.x)
};
}
}

View File

@@ -0,0 +1,91 @@
import { describe, expect, it } from 'bun:test';
import { SimpleGA, DEFAULT_GA_CONFIG } from './SimpleGA';
import { CarSimulation } from './CarSimulation';
// import { TrackGenerator } from './Track';
import { DEFAULT_SIM_CONFIG } from './types';
import type { SerializedTrackData } from './types';
describe('Car Evolution E2E', () => {
// Hardcoded simple square track (cw)
// 0,0 -> 800,0 -> 800,600 -> 0,600 -> 0,0 (Outline)
// 100,100 -> 700,100 -> 700,500 -> 100,500 -> 100,100 (Inner)
const serializedTrack: SerializedTrackData = {
innerWalls: [
{x: 100, y: 100}, {x: 700, y: 100}, {x: 700, y: 500}, {x: 100, y: 500}
],
outerWalls: [
{x: 0, y: 0}, {x: 800, y: 0}, {x: 800, y: 600}, {x: 0, y: 600}
],
startPosition: { x: 400, y: 50 }, // Top middle
startAngle: 0, // Facing right?
walls: [
// Top wall
{ position: {x: 400, y: 0}, width: 800, height: 20, angle: 0, label: 'wall', isSensor: false},
// Bottom wall
{ position: {x: 400, y: 600}, width: 800, height: 20, angle: 0, label: 'wall', isSensor: false},
// Left wall
{ position: {x: 0, y: 300}, width: 20, height: 600, angle: 0, label: 'wall', isSensor: false},
// Right wall
{ position: {x: 800, y: 300}, width: 20, height: 600, angle: 0, label: 'wall', isSensor: false},
// Inner box (mocking just center block)
{ position: {x: 400, y: 300}, width: 200, height: 200, angle: 0, label: 'wall', isSensor: false}
],
checkpoints: [
// Start
{ position: {x: 400, y: 50}, width: 200, height: 20, angle: 0, label: 'checkpoint_0', isSensor: true},
// Corner 1 (Right)
{ position: {x: 750, y: 50}, width: 20, height: 200, angle: 0, label: 'checkpoint_1', isSensor: true},
// Corner 2 (Right Bottom)
{ position: {x: 750, y: 550}, width: 20, height: 200, angle: 0, label: 'checkpoint_2', isSensor: true}
]
};
it('should improve fitness over 50 generations', async () => {
const fs = require('fs');
const logFile = 'e2e_log.txt';
fs.writeFileSync(logFile, 'Starting Test...\n');
const log = (msg: string) => fs.appendFileSync(logFile, msg + '\n');
try {
const ga = new SimpleGA([6, 16, 12, 2], DEFAULT_GA_CONFIG);
let population = ga.createPopulation();
let initialBest = 0;
let finalBest = 0;
log('Starting E2E Evolution Test (50 Gens)...');
for (let gen = 0; gen < 50; gen++) {
// Run Simulation
const sim = new CarSimulation(serializedTrack, DEFAULT_SIM_CONFIG, population);
sim.run(1000);
const results = sim.getResults();
const fitnesses = results.map(r => r.fitness);
const best = Math.max(...fitnesses);
const avg = fitnesses.reduce((a,b)=>a+b, 0) / fitnesses.length;
if (gen === 0) initialBest = best;
if (gen === 49) finalBest = best;
if (gen % 10 === 0 || gen === 49) {
log(`Gen ${gen}: Best: ${best.toFixed(2)}, Avg: ${avg.toFixed(2)}`);
}
population = ga.evolve(population, fitnesses);
}
log(`Evolution Result: ${initialBest.toFixed(2)} -> ${finalBest.toFixed(2)}`);
expect(finalBest).toBeGreaterThan(10);
} catch (e) {
log(`ERROR: ${e}`);
throw e;
}
});
});

View File

@@ -0,0 +1,32 @@
export interface Point {
x: number;
y: number;
}
export function distance(p1: Point, p2: Point): number {
const dx = p1.x - p2.x;
const dy = p1.y - p2.y;
return Math.sqrt(dx * dx + dy * dy);
}
export function lineToLineIntersection(
x1: number, y1: number, x2: number, y2: number,
x3: number, y3: number, x4: number, y4: number
): Point | null {
const denom = (y4 - y3) * (x2 - x1) - (x4 - x3) * (y2 - y1);
if (denom === 0) return null; // Parallel
const ua = ((x4 - x3) * (y1 - y3) - (y4 - y3) * (x1 - x3)) / denom;
const ub = ((x2 - x1) * (y1 - y3) - (y2 - y1) * (x1 - x3)) / denom;
if (ua >= 0 && ua <= 1 && ub >= 0 && ub <= 1) {
return {
x: x1 + ua * (x2 - x1),
y: y1 + ua * (y2 - y1)
};
}
return null;
}

View File

@@ -0,0 +1,41 @@
import { CarSimulation } from './CarSimulation';
import type { SerializedTrackData, SimulationConfig, CarConfig } from './types';
interface WorkerMessage {
type: 'TRAIN';
trackData: SerializedTrackData;
genomes: Float32Array[]; // Was Genome[]
config: SimulationConfig;
carConfig: CarConfig;
steps?: number;
}
self.onmessage = (e: MessageEvent<WorkerMessage>) => {
const { type, trackData, genomes, config, carConfig, steps = 3600 } = e.data; // 60s default
if (type === 'TRAIN') {
console.log(`Worker: Starting generation step. Pop: ${genomes.length || config.populationSize}, Steps: ${steps}`);
const sim = new CarSimulation(trackData, config, genomes, carConfig);
const startTime = performance.now();
sim.run(steps);
const duration = performance.now() - startTime;
console.log(`Worker: Generation complete in ${duration.toFixed(2)}ms. Cars alive: ${sim.cars.filter(c => !c.isDead).length}`);
const results = sim.getResults();
// Send back fitnesses
// We map results to simple array to reduce transfer cost, or send objects
const fitnessMap = results.map(r => ({
fitness: r.fitness,
checkpoints: r.checkpoints,
// We don't need to send genome back if Main thread kept it,
// but sender might need to know which is which.
// Order is preserved.
}));
self.postMessage({ type: 'TRAIN_COMPLETE', results: fitnessMap });
}
};

View File

@@ -0,0 +1,70 @@
// import { Vector } from 'matter-js';
export interface CarConfig {
width: number;
height: number;
maxSpeed: number;
turnSpeed: number;
rayCount: number;
rayLength: number;
raySpread: number; // FOV in radians
// Physics
frictionAir: number; // 0.0-1.0 (Air Resistance/Drag)
friction: number; // 0.0-1.0 (Wall Friction)
lateralFriction: number; // 0.0-1.0 (Tire Grip. 1.0=Rails, 0.0=Ice)
}
export interface SimulationConfig {
populationSize: number;
mutationRate: number;
trackComplexity: number; // 0.0-1.0 (Noise/Wiggle)
trackLength: number; // 10-100 (Approx number of control points)
}
export interface SerializedVector { x: number, y: number }
export interface SerializedBody {
position: SerializedVector;
angle: number;
width: number;
height: number;
label: string;
isSensor: boolean;
vertices?: SerializedVector[];
}
export interface SerializedTrackData {
innerWalls: SerializedVector[];
outerWalls: SerializedVector[];
pathPoints: SerializedVector[]; // Center line points for fitness tracking
walls: SerializedBody[];
checkpoints: SerializedBody[];
startPosition: SerializedVector;
startAngle: number;
}
// Physics Tunings Removed (Now in config)
export const DEFAULT_CAR_CONFIG: CarConfig = {
width: 20,
height: 40,
maxSpeed: 12,
turnSpeed: 0.15, // Increased from 0.08 for sharper turning
rayCount: 5,
rayLength: 150,
raySpread: Math.PI / 2,
// Default Physics (Drifty)
frictionAir: 0.02,
friction: 0.1,
lateralFriction: 0.90
};
export const DEFAULT_SIM_CONFIG: SimulationConfig = {
populationSize: 50,
mutationRate: 0.1,
trackComplexity: 0.2,
trackLength: 25 // Default length
};

View File

@@ -1,32 +1,48 @@
.sidebar {
width: 100%;
height: 64px;
background: var(--bg-darker);
border-bottom: 1px solid var(--border-color);
height: 72px;
background: var(--glass-bg);
backdrop-filter: var(--backdrop-blur);
-webkit-backdrop-filter: var(--backdrop-blur);
border-bottom: var(--glass-border);
display: flex;
align-items: center;
padding: 0 1.5rem;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.2);
padding: 0 2rem;
box-shadow: var(--glass-shadow);
z-index: 100;
flex-shrink: 0;
position: relative;
}
/* Add a subtle top highlight line */
.sidebar::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
}
.sidebar-header {
padding: 0;
margin-right: 3rem;
margin-right: 4rem;
border: none;
display: flex;
align-items: center;
}
.sidebar-logo {
font-size: 1.5rem;
font-size: 1.75rem;
font-weight: 700;
margin: 0;
background: linear-gradient(135deg, var(--primary) 0%, var(--accent) 100%);
background: linear-gradient(135deg, var(--text-primary) 0%, var(--text-secondary) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
letter-spacing: -0.02em;
text-shadow: 0 0 30px rgba(124, 58, 237, 0.3);
}
.sidebar-nav {
@@ -34,39 +50,72 @@
display: flex;
flex-direction: row;
align-items: center;
gap: 0.5rem;
gap: 0.75rem;
height: 100%;
overflow-x: auto;
/* Hide scrollbar */
-ms-overflow-style: none;
scrollbar-width: none;
}
.sidebar-nav::-webkit-scrollbar {
display: none;
}
.nav-item {
display: flex;
align-items: center;
justify-content: center;
padding: 0.5rem 1rem;
padding: 0.6rem 1.25rem;
background: transparent;
border: 1px solid transparent;
border-radius: 8px;
color: rgba(255, 255, 255, 0.7);
border-radius: 99px;
/* Pill shape */
color: var(--text-secondary);
cursor: pointer;
transition: all 0.2s ease;
font-size: 0.9rem;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
font-size: 0.95rem;
text-decoration: none;
white-space: nowrap;
}
.nav-item:hover {
background: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.9);
}
.nav-item.active {
background: rgba(99, 102, 241, 0.1);
border-color: rgba(99, 102, 241, 0.3);
color: var(--primary);
font-weight: 500;
letter-spacing: 0.01em;
position: relative;
overflow: hidden;
}
/* Hover effects */
.nav-item:hover {
color: var(--text-primary);
background: rgba(255, 255, 255, 0.03);
box-shadow: 0 0 15px rgba(255, 255, 255, 0.05);
}
/* Active State */
.nav-item.active {
color: var(--text-primary);
background: rgba(255, 255, 255, 0.08);
/* Lighter bg for active */
border-color: rgba(255, 255, 255, 0.1);
box-shadow:
0 0 0 1px rgba(0, 0, 0, 0.2),
inset 0 1px 1px rgba(255, 255, 255, 0.1);
}
/* Adding a glow dot for active items */
.nav-item.active::after {
content: '';
position: absolute;
bottom: 0px;
left: 50%;
transform: translateX(-50%);
width: 40%;
height: 3px;
background: var(--primary);
border-radius: 4px 4px 0 0;
box-shadow: 0 -2px 8px var(--primary-glow);
}
.nav-name {
font-weight: 500;
position: relative;
z-index: 2;
}

View File

@@ -1,7 +1,7 @@
import { NavLink } from 'react-router-dom';
import './Sidebar.css';
export type AppId = 'image-approx' | 'snake-ai' | 'rogue-gen' | 'neat-arena' | 'lunar-lander';
export type AppId = 'image-approx' | 'snake-ai' | 'rogue-gen' | 'neat-arena' | 'lunar-lander' | 'self-driving-car';
export interface AppInfo {
id: AppId;
@@ -41,6 +41,12 @@ export const APPS: AppInfo[] = [
name: 'Lunar Lander',
description: 'Evolve a spaceship to land safely',
},
{
id: 'self-driving-car',
path: '/self-driving-car',
name: 'Self-Driving Car',
description: 'Evolve cars to navigate a track',
},
];
export default function Sidebar() {

View File

@@ -1,30 +1,50 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;700&display=swap');
:root {
/* Color palette - lighter, less dark */
--primary: #6366f1;
--primary-dark: #4f46e5;
--primary-light: #818cf8;
--accent: #8b5cf6;
--bg-dark: #1a1a2e;
--bg-darker: #0f1729;
--bg-card: rgba(255, 255, 255, 0.05);
--text-primary: rgba(255, 255, 255, 0.95);
--text-secondary: rgba(255, 255, 255, 0.7);
--text-muted: rgba(255, 255, 255, 0.5);
--border-color: rgba(255, 255, 255, 0.12);
/* Premium Dark Sci-Fi Palette */
--bg-dark: #030305;
/* Deepest void black */
--bg-darker: #000000;
/* Pure black for contrast */
--bg-card: rgba(20, 20, 35, 0.4);
/* Glassy panel background */
--bg-card-hover: rgba(30, 30, 50, 0.6);
/* Accents */
--primary: #7c3aed;
/* Electric Violet */
--primary-glow: rgba(124, 58, 237, 0.5);
--accent: #06b6d4;
/* Cyan/Teal */
--accent-glow: rgba(6, 182, 212, 0.5);
--success: #10b981;
/* Emerald */
--danger: #ef4444;
/* Red */
/* Text elements */
--text-primary: #f8fafc;
/* Bright white */
--text-secondary: #94a3b8;
/* Blue-grey */
--text-muted: #475569;
/* Darker grey */
/* Structural */
--border-color: rgba(255, 255, 255, 0.08);
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 16px;
/* Glassmorphism */
--glass-bg: rgba(10, 10, 15, 0.75);
--glass-border: 1px solid rgba(255, 255, 255, 0.05);
--glass-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.36);
--backdrop-blur: blur(12px);
/* Typography */
font-family: 'Inter', system-ui, -apple-system, sans-serif;
line-height: 1.6;
font-weight: 400;
color: var(--text-primary);
/* Rendering */
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
--font-main: 'Outfit', system-ui, -apple-system, sans-serif;
--font-mono: 'JetBrains Mono', monospace;
}
* {
@@ -35,9 +55,16 @@
body {
margin: 0;
background: var(--bg-dark);
background-color: var(--bg-dark);
background-image:
radial-gradient(circle at 15% 50%, rgba(124, 58, 237, 0.08), transparent 25%),
radial-gradient(circle at 85% 30%, rgba(6, 182, 212, 0.08), transparent 25%);
color: var(--text-primary);
font-family: var(--font-main);
line-height: 1.6;
overflow: hidden;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
#root {
@@ -45,11 +72,45 @@ body {
height: 100vh;
}
/* Scrollbar Styling */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--bg-darker);
}
::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}
button {
font-family: inherit;
cursor: pointer;
}
input {
input,
select,
textarea {
font-family: inherit;
background: var(--bg-card);
border: 1px solid var(--border-color);
color: var(--text-primary);
border-radius: var(--radius-sm);
padding: 0.5rem;
}
input:focus,
select:focus,
textarea:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 2px rgba(124, 58, 237, 0.2);
}