Add self driving car
This commit is contained in:
3
bun.lock
3
bun.lock
@@ -8,6 +8,7 @@
|
|||||||
"@types/matter-js": "^0.20.2",
|
"@types/matter-js": "^0.20.2",
|
||||||
"matter-js": "^0.20.0",
|
"matter-js": "^0.20.0",
|
||||||
"phaser": "^3.90.0",
|
"phaser": "^3.90.0",
|
||||||
|
"poly-decomp": "^0.3.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-router-dom": "^7.12.0",
|
"react-router-dom": "^7.12.0",
|
||||||
@@ -417,6 +418,8 @@
|
|||||||
|
|
||||||
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
"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=="],
|
"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=="],
|
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
|
||||||
|
|||||||
9
e2e_log.txt
Normal file
9
e2e_log.txt
Normal 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
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
"@types/matter-js": "^0.20.2",
|
"@types/matter-js": "^0.20.2",
|
||||||
"matter-js": "^0.20.0",
|
"matter-js": "^0.20.0",
|
||||||
"phaser": "^3.90.0",
|
"phaser": "^3.90.0",
|
||||||
|
"poly-decomp": "^0.3.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-router-dom": "^7.12.0"
|
"react-router-dom": "^7.12.0"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import SnakeAI from './apps/SnakeAI/SnakeAI';
|
|||||||
import RogueGenApp from './apps/RogueGen/RogueGenApp';
|
import RogueGenApp from './apps/RogueGen/RogueGenApp';
|
||||||
import NeatArena from './apps/NeatArena/NeatArena';
|
import NeatArena from './apps/NeatArena/NeatArena';
|
||||||
import LunarLanderApp from './apps/LunarLander/LunarLanderApp';
|
import LunarLanderApp from './apps/LunarLander/LunarLanderApp';
|
||||||
|
import { SelfDrivingCarApp } from './apps/SelfDrivingCar/SelfDrivingCarApp';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
@@ -19,6 +20,7 @@ function App() {
|
|||||||
<Route path="/rogue-gen" element={<RogueGenApp />} />
|
<Route path="/rogue-gen" element={<RogueGenApp />} />
|
||||||
<Route path="/neat-arena" element={<NeatArena />} />
|
<Route path="/neat-arena" element={<NeatArena />} />
|
||||||
<Route path="/lunar-lander" element={<LunarLanderApp />} />
|
<Route path="/lunar-lander" element={<LunarLanderApp />} />
|
||||||
|
<Route path="/self-driving-car" element={<SelfDrivingCarApp />} />
|
||||||
<Route path="*" element={<div>App not found</div>} />
|
<Route path="*" element={<div>App not found</div>} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
46
src/apps/SelfDrivingCar/Car.test.ts
Normal file
46
src/apps/SelfDrivingCar/Car.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
278
src/apps/SelfDrivingCar/Car.ts
Normal file
278
src/apps/SelfDrivingCar/Car.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
344
src/apps/SelfDrivingCar/CarScene.ts
Normal file
344
src/apps/SelfDrivingCar/CarScene.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
184
src/apps/SelfDrivingCar/CarSimulation.ts
Normal file
184
src/apps/SelfDrivingCar/CarSimulation.ts
Normal 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
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
159
src/apps/SelfDrivingCar/ConfigPanel.tsx
Normal file
159
src/apps/SelfDrivingCar/ConfigPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
140
src/apps/SelfDrivingCar/FitnessGraph.tsx
Normal file
140
src/apps/SelfDrivingCar/FitnessGraph.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
111
src/apps/SelfDrivingCar/SelfDrivingCarApp.tsx
Normal file
111
src/apps/SelfDrivingCar/SelfDrivingCarApp.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
121
src/apps/SelfDrivingCar/SimpleGA.ts
Normal file
121
src/apps/SelfDrivingCar/SimpleGA.ts
Normal 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.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
215
src/apps/SelfDrivingCar/Track.ts
Normal file
215
src/apps/SelfDrivingCar/Track.ts
Normal 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)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
91
src/apps/SelfDrivingCar/e2e_evolution.test.ts
Normal file
91
src/apps/SelfDrivingCar/e2e_evolution.test.ts
Normal 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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
32
src/apps/SelfDrivingCar/geom.ts
Normal file
32
src/apps/SelfDrivingCar/geom.ts
Normal 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;
|
||||||
|
}
|
||||||
41
src/apps/SelfDrivingCar/training.worker.ts
Normal file
41
src/apps/SelfDrivingCar/training.worker.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
};
|
||||||
70
src/apps/SelfDrivingCar/types.ts
Normal file
70
src/apps/SelfDrivingCar/types.ts
Normal 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
|
||||||
|
};
|
||||||
@@ -1,32 +1,48 @@
|
|||||||
.sidebar {
|
.sidebar {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 64px;
|
height: 72px;
|
||||||
background: var(--bg-darker);
|
background: var(--glass-bg);
|
||||||
border-bottom: 1px solid var(--border-color);
|
backdrop-filter: var(--backdrop-blur);
|
||||||
|
-webkit-backdrop-filter: var(--backdrop-blur);
|
||||||
|
border-bottom: var(--glass-border);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0 1.5rem;
|
padding: 0 2rem;
|
||||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.2);
|
box-shadow: var(--glass-shadow);
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
flex-shrink: 0;
|
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 {
|
.sidebar-header {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin-right: 3rem;
|
margin-right: 4rem;
|
||||||
border: none;
|
border: none;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-logo {
|
.sidebar-logo {
|
||||||
font-size: 1.5rem;
|
font-size: 1.75rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
margin: 0;
|
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-background-clip: text;
|
||||||
-webkit-text-fill-color: transparent;
|
-webkit-text-fill-color: transparent;
|
||||||
background-clip: text;
|
background-clip: text;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
text-shadow: 0 0 30px rgba(124, 58, 237, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-nav {
|
.sidebar-nav {
|
||||||
@@ -34,39 +50,72 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.75rem;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
|
/* Hide scrollbar */
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item {
|
.nav-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 0.5rem 1rem;
|
padding: 0.6rem 1.25rem;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
border-radius: 8px;
|
border-radius: 99px;
|
||||||
color: rgba(255, 255, 255, 0.7);
|
/* Pill shape */
|
||||||
|
color: var(--text-secondary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
font-size: 0.9rem;
|
font-size: 0.95rem;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
white-space: nowrap;
|
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;
|
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 {
|
.nav-name {
|
||||||
font-weight: 500;
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NavLink } from 'react-router-dom';
|
import { NavLink } from 'react-router-dom';
|
||||||
import './Sidebar.css';
|
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 {
|
export interface AppInfo {
|
||||||
id: AppId;
|
id: AppId;
|
||||||
@@ -41,6 +41,12 @@ export const APPS: AppInfo[] = [
|
|||||||
name: 'Lunar Lander',
|
name: 'Lunar Lander',
|
||||||
description: 'Evolve a spaceship to land safely',
|
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() {
|
export default function Sidebar() {
|
||||||
|
|||||||
111
src/index.css
111
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 {
|
:root {
|
||||||
/* Color palette - lighter, less dark */
|
/* Premium Dark Sci-Fi Palette */
|
||||||
--primary: #6366f1;
|
--bg-dark: #030305;
|
||||||
--primary-dark: #4f46e5;
|
/* Deepest void black */
|
||||||
--primary-light: #818cf8;
|
--bg-darker: #000000;
|
||||||
--accent: #8b5cf6;
|
/* Pure black for contrast */
|
||||||
--bg-dark: #1a1a2e;
|
--bg-card: rgba(20, 20, 35, 0.4);
|
||||||
--bg-darker: #0f1729;
|
/* Glassy panel background */
|
||||||
--bg-card: rgba(255, 255, 255, 0.05);
|
--bg-card-hover: rgba(30, 30, 50, 0.6);
|
||||||
--text-primary: rgba(255, 255, 255, 0.95);
|
|
||||||
--text-secondary: rgba(255, 255, 255, 0.7);
|
/* Accents */
|
||||||
--text-muted: rgba(255, 255, 255, 0.5);
|
--primary: #7c3aed;
|
||||||
--border-color: rgba(255, 255, 255, 0.12);
|
/* 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 */
|
/* Typography */
|
||||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
--font-main: 'Outfit', system-ui, -apple-system, sans-serif;
|
||||||
line-height: 1.6;
|
--font-mono: 'JetBrains Mono', monospace;
|
||||||
font-weight: 400;
|
|
||||||
color: var(--text-primary);
|
|
||||||
|
|
||||||
/* Rendering */
|
|
||||||
font-synthesis: none;
|
|
||||||
text-rendering: optimizeLegibility;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
-moz-osx-font-smoothing: grayscale;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@@ -35,9 +55,16 @@
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
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);
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-main);
|
||||||
|
line-height: 1.6;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
#root {
|
#root {
|
||||||
@@ -45,11 +72,45 @@ body {
|
|||||||
height: 100vh;
|
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 {
|
button {
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
input {
|
input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
font-family: inherit;
|
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);
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user