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