From dd561a4b32d1531765a0ff8af7a2ba24996f7489 Mon Sep 17 00:00:00 2001 From: Peter Stockings Date: Wed, 14 Jan 2026 17:00:44 +1100 Subject: [PATCH] Add self driving car --- bun.lock | 3 + e2e_log.txt | 9 + package.json | 1 + src/App.tsx | 2 + src/apps/SelfDrivingCar/Car.test.ts | 46 +++ src/apps/SelfDrivingCar/Car.ts | 278 ++++++++++++++ src/apps/SelfDrivingCar/CarScene.ts | 344 ++++++++++++++++++ src/apps/SelfDrivingCar/CarSimulation.ts | 184 ++++++++++ src/apps/SelfDrivingCar/ConfigPanel.tsx | 159 ++++++++ src/apps/SelfDrivingCar/FitnessGraph.tsx | 140 +++++++ src/apps/SelfDrivingCar/SelfDrivingCarApp.tsx | 111 ++++++ src/apps/SelfDrivingCar/SimpleGA.ts | 121 ++++++ src/apps/SelfDrivingCar/Track.ts | 215 +++++++++++ src/apps/SelfDrivingCar/e2e_evolution.test.ts | 91 +++++ src/apps/SelfDrivingCar/geom.ts | 32 ++ src/apps/SelfDrivingCar/training.worker.ts | 41 +++ src/apps/SelfDrivingCar/types.ts | 70 ++++ src/components/Sidebar.css | 101 +++-- src/components/Sidebar.tsx | 8 +- src/index.css | 111 ++++-- 20 files changed, 2015 insertions(+), 52 deletions(-) create mode 100644 e2e_log.txt create mode 100644 src/apps/SelfDrivingCar/Car.test.ts create mode 100644 src/apps/SelfDrivingCar/Car.ts create mode 100644 src/apps/SelfDrivingCar/CarScene.ts create mode 100644 src/apps/SelfDrivingCar/CarSimulation.ts create mode 100644 src/apps/SelfDrivingCar/ConfigPanel.tsx create mode 100644 src/apps/SelfDrivingCar/FitnessGraph.tsx create mode 100644 src/apps/SelfDrivingCar/SelfDrivingCarApp.tsx create mode 100644 src/apps/SelfDrivingCar/SimpleGA.ts create mode 100644 src/apps/SelfDrivingCar/Track.ts create mode 100644 src/apps/SelfDrivingCar/e2e_evolution.test.ts create mode 100644 src/apps/SelfDrivingCar/geom.ts create mode 100644 src/apps/SelfDrivingCar/training.worker.ts create mode 100644 src/apps/SelfDrivingCar/types.ts diff --git a/bun.lock b/bun.lock index d125e84..5ca975b 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/e2e_log.txt b/e2e_log.txt new file mode 100644 index 0000000..68020e8 --- /dev/null +++ b/e2e_log.txt @@ -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 diff --git a/package.json b/package.json index 596b980..6509353 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/src/App.tsx b/src/App.tsx index 925581a..d012b55 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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() { } /> } /> } /> + } /> App not found} /> diff --git a/src/apps/SelfDrivingCar/Car.test.ts b/src/apps/SelfDrivingCar/Car.test.ts new file mode 100644 index 0000000..c5624db --- /dev/null +++ b/src/apps/SelfDrivingCar/Car.test.ts @@ -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); + }); +}); diff --git a/src/apps/SelfDrivingCar/Car.ts b/src/apps/SelfDrivingCar/Car.ts new file mode 100644 index 0000000..a678730 --- /dev/null +++ b/src/apps/SelfDrivingCar/Car.ts @@ -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 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; + } +} diff --git a/src/apps/SelfDrivingCar/CarScene.ts b/src/apps/SelfDrivingCar/CarScene.ts new file mode 100644 index 0000000..344b118 --- /dev/null +++ b/src/apps/SelfDrivingCar/CarScene.ts @@ -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 { + 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 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 c.isDead)) break; + } + } + + private handleCollisions(event: Matter.IEventCollision) { + 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 + }); + }); + } +} diff --git a/src/apps/SelfDrivingCar/ConfigPanel.tsx b/src/apps/SelfDrivingCar/ConfigPanel.tsx new file mode 100644 index 0000000..1035244 --- /dev/null +++ b/src/apps/SelfDrivingCar/ConfigPanel.tsx @@ -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 ( +
+
setIsExpanded(!isExpanded)} + > +

Configuration

+ {isExpanded ? '▼' : '◀'} +
+ + {isExpanded && ( + <> +
+

Car Physics

+ +
+ Max Speed + {carConfig.maxSpeed.toFixed(1)} +
+ updateCar('maxSpeed', parseFloat(e.target.value))} + style={sliderStyle} + /> + +
+ Turn Speed + {carConfig.turnSpeed.toFixed(2)} +
+ updateCar('turnSpeed', parseFloat(e.target.value))} + style={sliderStyle} + /> + +
+ Tire Grip (Lateral) + {(carConfig.lateralFriction * 100).toFixed(0)}% +
+ updateCar('lateralFriction', parseFloat(e.target.value))} + style={sliderStyle} + /> + +
+ Air Resistance + {(carConfig.frictionAir * 1000).toFixed(0)} +
+ updateCar('frictionAir', parseFloat(e.target.value))} + style={sliderStyle} + /> +
+ +
+

Track Gen

+ +
+ Complexity (Wiggle) + {(simConfig.trackComplexity * 100).toFixed(0)}% +
+ updateSim('trackComplexity', parseFloat(e.target.value))} + style={sliderStyle} + /> + +
+ Length (Nodes) + {simConfig.trackLength} +
+ updateSim('trackLength', parseInt(e.target.value))} + style={sliderStyle} + /> + + +
+ +
+ Physics apply immediately.
+ Track settings apply on generate. +
+ + )} +
+ ); +} diff --git a/src/apps/SelfDrivingCar/FitnessGraph.tsx b/src/apps/SelfDrivingCar/FitnessGraph.tsx new file mode 100644 index 0000000..408d832 --- /dev/null +++ b/src/apps/SelfDrivingCar/FitnessGraph.tsx @@ -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 ( +
+ Waiting for data... +
+ ); + } + + 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 ( +
+ {/* Legend Overlay */} +
+
+
+ Best: {Math.round(history[history.length - 1].best)} +
+
+
+ Avg: {Math.round(history[history.length - 1].average)} +
+
+ + + + + + + + + + + + + + {/* Grid Lines (Horizontal) */} + {[0, 0.25, 0.5, 0.75, 1].map(ratio => { + const y = PADDING + ratio * GRAPH_HEIGHT; + return ( + + ); + })} + + {/* Average Area */} + + {/* Average Line */} + + + {/* Best Area */} + + {/* Best Line */} + + +
+ ); +} diff --git a/src/apps/SelfDrivingCar/SelfDrivingCarApp.tsx b/src/apps/SelfDrivingCar/SelfDrivingCarApp.tsx new file mode 100644 index 0000000..4f55996 --- /dev/null +++ b/src/apps/SelfDrivingCar/SelfDrivingCarApp.tsx @@ -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(null); + const gameInstance = useRef(null); + const [history, setHistory] = useState>([]); + + // Config State + const [carConfig, setCarConfig] = useState(DEFAULT_CAR_CONFIG); + const [simConfig, setSimConfig] = useState(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 ( +
+ {/* Top Bar for Graph */} +
+ +
+ +
+
+
+ + {/* Config Panel */} + +
+ ); +} diff --git a/src/apps/SelfDrivingCar/SimpleGA.ts b/src/apps/SelfDrivingCar/SimpleGA.ts new file mode 100644 index 0000000..1404aa2 --- /dev/null +++ b/src/apps/SelfDrivingCar/SimpleGA.ts @@ -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. + } + } + } +} diff --git a/src/apps/SelfDrivingCar/Track.ts b/src/apps/SelfDrivingCar/Track.ts new file mode 100644 index 0000000..c4e82fb --- /dev/null +++ b/src/apps/SelfDrivingCar/Track.ts @@ -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) + }; + } +} diff --git a/src/apps/SelfDrivingCar/e2e_evolution.test.ts b/src/apps/SelfDrivingCar/e2e_evolution.test.ts new file mode 100644 index 0000000..e995d63 --- /dev/null +++ b/src/apps/SelfDrivingCar/e2e_evolution.test.ts @@ -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; + } + }); +}); diff --git a/src/apps/SelfDrivingCar/geom.ts b/src/apps/SelfDrivingCar/geom.ts new file mode 100644 index 0000000..ce8a35a --- /dev/null +++ b/src/apps/SelfDrivingCar/geom.ts @@ -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; +} diff --git a/src/apps/SelfDrivingCar/training.worker.ts b/src/apps/SelfDrivingCar/training.worker.ts new file mode 100644 index 0000000..a673eaf --- /dev/null +++ b/src/apps/SelfDrivingCar/training.worker.ts @@ -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) => { + 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 }); + } +}; diff --git a/src/apps/SelfDrivingCar/types.ts b/src/apps/SelfDrivingCar/types.ts new file mode 100644 index 0000000..fe1c08e --- /dev/null +++ b/src/apps/SelfDrivingCar/types.ts @@ -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 +}; diff --git a/src/components/Sidebar.css b/src/components/Sidebar.css index 947e445..780bf68 100644 --- a/src/components/Sidebar.css +++ b/src/components/Sidebar.css @@ -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; } \ No newline at end of file diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index b0580e0..452222e 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -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() { diff --git a/src/index.css b/src/index.css index 57b3d0a..6c46327 100644 --- a/src/index.css +++ b/src/index.css @@ -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); } \ No newline at end of file