Add neat based lunar landing
This commit is contained in:
6
bun.lock
6
bun.lock
@@ -5,6 +5,8 @@
|
||||
"": {
|
||||
"name": "evolution",
|
||||
"dependencies": {
|
||||
"@types/matter-js": "^0.20.2",
|
||||
"matter-js": "^0.20.0",
|
||||
"phaser": "^3.90.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
@@ -217,6 +219,8 @@
|
||||
|
||||
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
|
||||
|
||||
"@types/matter-js": ["@types/matter-js@0.20.2", "", {}, "sha512-3PPKy3QxvZ89h9+wdBV2488I1JLVs7DEpIkPvgO8JC1mUdiVSO37ZIvVctOTD7hIq8OAL2gJ3ugGSuUip6DhCw=="],
|
||||
|
||||
"@types/node": ["@types/node@24.10.4", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg=="],
|
||||
|
||||
"@types/react": ["@types/react@19.2.7", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg=="],
|
||||
@@ -383,6 +387,8 @@
|
||||
|
||||
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||
|
||||
"matter-js": ["matter-js@0.20.0", "", {}, "sha512-iC9fYR7zVT3HppNnsFsp9XOoQdQN2tUyfaKg4CHLH8bN+j6GT4Gw7IH2rP0tflAebrHFw730RR3DkVSZRX8hwA=="],
|
||||
|
||||
"minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/matter-js": "^0.20.2",
|
||||
"matter-js": "^0.20.0",
|
||||
"phaser": "^3.90.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
|
||||
@@ -4,6 +4,7 @@ import ImageApprox from './apps/ImageApprox/ImageApprox';
|
||||
import SnakeAI from './apps/SnakeAI/SnakeAI';
|
||||
import RogueGenApp from './apps/RogueGen/RogueGenApp';
|
||||
import NeatArena from './apps/NeatArena/NeatArena';
|
||||
import LunarLanderApp from './apps/LunarLander/LunarLanderApp';
|
||||
import './App.css';
|
||||
|
||||
function App() {
|
||||
@@ -17,6 +18,7 @@ function App() {
|
||||
<Route path="/snake-ai" element={<SnakeAI />} />
|
||||
<Route path="/rogue-gen" element={<RogueGenApp />} />
|
||||
<Route path="/neat-arena" element={<NeatArena />} />
|
||||
<Route path="/lunar-lander" element={<LunarLanderApp />} />
|
||||
<Route path="*" element={<div>App not found</div>} />
|
||||
</Routes>
|
||||
</main>
|
||||
|
||||
69
src/apps/LunarLander/DenseNetwork.ts
Normal file
69
src/apps/LunarLander/DenseNetwork.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
|
||||
export class DenseNetwork {
|
||||
private weights: Float32Array;
|
||||
private layerSizes: number[];
|
||||
|
||||
constructor(layerSizes: number[], weights?: Float32Array) {
|
||||
this.layerSizes = layerSizes;
|
||||
const totalWeights = this.calculateTotalWeights(layerSizes);
|
||||
|
||||
if (weights) {
|
||||
if (weights.length !== totalWeights) {
|
||||
throw new Error(`Expected ${totalWeights} weights, got ${weights.length}`);
|
||||
}
|
||||
this.weights = weights;
|
||||
} else {
|
||||
this.weights = new Float32Array(totalWeights);
|
||||
this.randomize();
|
||||
}
|
||||
}
|
||||
|
||||
private calculateTotalWeights(sizes: number[]): number {
|
||||
let total = 0;
|
||||
for (let i = 0; i < sizes.length - 1; i++) {
|
||||
// Weights + Bias for each next-layer neuron
|
||||
// (Input + 1) * Output
|
||||
total += (sizes[i] + 1) * sizes[i + 1];
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
private randomize() {
|
||||
for (let i = 0; i < this.weights.length; i++) {
|
||||
this.weights[i] = (Math.random() * 2 - 1); // -1 to 1 simplified initialization
|
||||
}
|
||||
}
|
||||
|
||||
public predict(inputs: number[]): number[] {
|
||||
let currentValues = inputs;
|
||||
|
||||
let weightIndex = 0;
|
||||
for (let i = 0; i < this.layerSizes.length - 1; i++) {
|
||||
const inputSize = this.layerSizes[i];
|
||||
const outputSize = this.layerSizes[i + 1];
|
||||
const nextValues = new Array(outputSize).fill(0);
|
||||
|
||||
for (let out = 0; out < outputSize; out++) {
|
||||
let sum = 0;
|
||||
// Weights
|
||||
for (let inp = 0; inp < inputSize; inp++) {
|
||||
sum += currentValues[inp] * this.weights[weightIndex++];
|
||||
}
|
||||
// Bias (last weight for this neuron)
|
||||
sum += this.weights[weightIndex++];
|
||||
|
||||
// Activation
|
||||
// Output layer (last layer) -> Tanh for action outputs (-1 to 1)
|
||||
// Hidden layers -> ReLU or Tanh. Let's use Tanh everywhere for simplicity/stability in evolution.
|
||||
nextValues[out] = Math.tanh(sum);
|
||||
}
|
||||
currentValues = nextValues;
|
||||
}
|
||||
|
||||
return currentValues;
|
||||
}
|
||||
|
||||
public getWeights(): Float32Array {
|
||||
return this.weights;
|
||||
}
|
||||
}
|
||||
120
src/apps/LunarLander/GeneticAlgo.ts
Normal file
120
src/apps/LunarLander/GeneticAlgo.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
|
||||
import { DenseNetwork } from './DenseNetwork';
|
||||
|
||||
export interface Genome {
|
||||
weights: Float32Array;
|
||||
fitness: number;
|
||||
}
|
||||
|
||||
export class GeneticAlgo {
|
||||
private population: Genome[] = [];
|
||||
private layerSizes: number[];
|
||||
private popSize: number;
|
||||
private mutationRate: number;
|
||||
private mutationScale: number;
|
||||
public generation = 0;
|
||||
|
||||
// Track best ever
|
||||
public bestGenome: Genome | null = null;
|
||||
public bestFitness = -Infinity;
|
||||
|
||||
constructor(
|
||||
popSize: number,
|
||||
layerSizes: number[],
|
||||
mutationRate = 0.1, // Chance per weight (increased for diversity)
|
||||
mutationScale = 0.5 // Gaussian/random perturbation amount (increased)
|
||||
) {
|
||||
this.popSize = popSize;
|
||||
this.layerSizes = layerSizes;
|
||||
this.mutationRate = mutationRate;
|
||||
this.mutationScale = mutationScale;
|
||||
|
||||
// Init population
|
||||
for (let i = 0; i < popSize; i++) {
|
||||
const net = new DenseNetwork(layerSizes);
|
||||
this.population.push({
|
||||
weights: net.getWeights(), // Actually reference, careful on mutation, should clone on breed
|
||||
fitness: 0
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public getPopulation() {
|
||||
return this.population;
|
||||
}
|
||||
|
||||
public evolve() {
|
||||
// 1. Sort by fitness
|
||||
this.population.sort((a, b) => b.fitness - a.fitness);
|
||||
|
||||
// Update best
|
||||
if (this.population[0].fitness > this.bestFitness) {
|
||||
this.bestFitness = this.population[0].fitness;
|
||||
// Clone best weights to save safe
|
||||
this.bestGenome = {
|
||||
weights: new Float32Array(this.population[0].weights),
|
||||
fitness: this.population[0].fitness
|
||||
};
|
||||
}
|
||||
|
||||
const newPop: Genome[] = [];
|
||||
|
||||
// 2. Elitism (Keep top 5)
|
||||
const ELITE_COUNT = 5;
|
||||
for (let i = 0; i < ELITE_COUNT; i++) {
|
||||
newPop.push({
|
||||
weights: new Float32Array(this.population[i].weights),
|
||||
fitness: 0
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Breed rest
|
||||
while (newPop.length < this.popSize) {
|
||||
// Tournament Select
|
||||
const p1 = this.tournamentSelect();
|
||||
const p2 = this.tournamentSelect();
|
||||
|
||||
// Crossover
|
||||
const childWeights = this.crossover(p1.weights, p2.weights);
|
||||
|
||||
// Mutate
|
||||
this.mutate(childWeights);
|
||||
|
||||
newPop.push({
|
||||
weights: childWeights,
|
||||
fitness: 0
|
||||
});
|
||||
}
|
||||
|
||||
this.population = newPop;
|
||||
this.generation++;
|
||||
}
|
||||
|
||||
private tournamentSelect(): Genome {
|
||||
const pool = 5;
|
||||
let best = this.population[Math.floor(Math.random() * this.population.length)];
|
||||
for (let i = 0; i < pool - 1; i++) {
|
||||
const cand = this.population[Math.floor(Math.random() * this.population.length)];
|
||||
if (cand.fitness > best.fitness) best = cand;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
private crossover(w1: Float32Array, w2: Float32Array): Float32Array {
|
||||
const child = new Float32Array(w1.length);
|
||||
// Uniform crossover
|
||||
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.mutationRate) {
|
||||
// Add noise
|
||||
weights[i] += (Math.random() * 2 - 1) * this.mutationScale;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
224
src/apps/LunarLander/LanderScene.ts
Normal file
224
src/apps/LunarLander/LanderScene.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
|
||||
import Phaser from 'phaser';
|
||||
import Matter from 'matter-js';
|
||||
import { LanderSimulation, WORLD_WIDTH, WORLD_HEIGHT } from './LanderSimulation';
|
||||
import { DenseNetwork } from './DenseNetwork';
|
||||
|
||||
export class LanderScene extends Phaser.Scene {
|
||||
private sim!: LanderSimulation;
|
||||
private network!: DenseNetwork;
|
||||
private landerGraphics!: Phaser.GameObjects.Graphics;
|
||||
private terrainGraphics!: Phaser.GameObjects.Graphics;
|
||||
private flameParticles!: Phaser.GameObjects.Particles.ParticleEmitter;
|
||||
|
||||
constructor() {
|
||||
super({ key: 'LanderScene' });
|
||||
}
|
||||
|
||||
preload() {
|
||||
// Generate a simple particle texture programmatically
|
||||
const gfx = this.make.graphics({ x: 0, y: 0, add: false });
|
||||
gfx.fillStyle(0xffffff);
|
||||
gfx.fillCircle(4, 4, 4); // 8x8 circle
|
||||
gfx.generateTexture('flame', 8, 8);
|
||||
gfx.destroy();
|
||||
}
|
||||
|
||||
create() {
|
||||
this.landerGraphics = this.add.graphics();
|
||||
this.terrainGraphics = this.add.graphics();
|
||||
this.cameras.main.setBackgroundColor('#111122');
|
||||
|
||||
// Add some stars
|
||||
const stars = this.add.graphics();
|
||||
stars.fillStyle(0xffffff, 0.5);
|
||||
for(let i=0; i<100; i++) {
|
||||
stars.fillPoint(Math.random() * WORLD_WIDTH, Math.random() * WORLD_HEIGHT, 1);
|
||||
}
|
||||
|
||||
// Setup simple particles
|
||||
this.flameParticles = this.add.particles(0, 0, 'flame', {
|
||||
speed: 100,
|
||||
scale: { start: 1, end: 0 },
|
||||
blendMode: 'ADD',
|
||||
lifespan: 200,
|
||||
emitting: false
|
||||
});
|
||||
|
||||
// Add visual info
|
||||
this.add.text(10, 10, 'Lunar Lander', { color: '#ffffff', fontSize: '14px', fontStyle: 'bold' });
|
||||
|
||||
this.statsText = this.add.text(10, 30, '', { color: '#aaaaaa', fontSize: '12px' });
|
||||
}
|
||||
|
||||
private statsText!: Phaser.GameObjects.Text;
|
||||
|
||||
public startMatch(genomeData: any, seed: number = 0) {
|
||||
// genomeData is now { weights: number[] }
|
||||
const weights = new Float32Array(genomeData.weights);
|
||||
// Architecture must match worker
|
||||
this.network = new DenseNetwork([8, 16, 16, 2], weights);
|
||||
|
||||
// Ensure visual matches what the agent trained on
|
||||
this.sim = new LanderSimulation(seed);
|
||||
}
|
||||
|
||||
update() {
|
||||
if (!this.sim || !this.network || this.sim.isGameOver) return;
|
||||
|
||||
// Step Sim
|
||||
const inputs = this.sim.getObservation();
|
||||
const outputs = this.network.predict(inputs);
|
||||
this.sim.update(outputs);
|
||||
|
||||
// Render methods
|
||||
this.drawScene();
|
||||
this.updateStats();
|
||||
}
|
||||
|
||||
private drawScene() {
|
||||
this.landerGraphics.clear();
|
||||
this.terrainGraphics.clear();
|
||||
|
||||
// Static
|
||||
this.terrainGraphics.fillStyle(0x555555);
|
||||
this.drawBody(this.sim.ground, this.terrainGraphics);
|
||||
this.terrainGraphics.fillStyle(0x00ff00);
|
||||
this.drawBody(this.sim.pad, this.terrainGraphics);
|
||||
|
||||
// Dynamic
|
||||
this.landerGraphics.fillStyle(0xcccccc);
|
||||
this.drawBody(this.sim.lander, this.landerGraphics);
|
||||
|
||||
this.drawFlames();
|
||||
}
|
||||
|
||||
private updateStats() {
|
||||
// Text
|
||||
const { currentWind, fuel, lander } = this.sim;
|
||||
const color = Math.abs(currentWind) > 1.0 ? '#ff5555' : '#aaaaaa';
|
||||
|
||||
this.statsText.setText([
|
||||
`Fuel: ${Math.round(fuel)}`,
|
||||
`Mass: ${lander.mass.toFixed(1)}kg`,
|
||||
`Wind: ${currentWind.toFixed(2)}`,
|
||||
`Gimbal: ${(this.sim.currentNozzleAngle * 180 / Math.PI).toFixed(1)}°`
|
||||
]).setColor(color);
|
||||
|
||||
// Visuals
|
||||
this.drawWindIndicator();
|
||||
this.drawThrustGauge();
|
||||
}
|
||||
|
||||
private drawBody(body: Matter.Body, graphics: Phaser.GameObjects.Graphics) {
|
||||
graphics.beginPath();
|
||||
const verts = body.vertices;
|
||||
graphics.moveTo(verts[0].x, verts[0].y);
|
||||
for (let i = 1; i < verts.length; i++) {
|
||||
graphics.lineTo(verts[i].x, verts[i].y);
|
||||
}
|
||||
graphics.closePath();
|
||||
graphics.fillPath();
|
||||
graphics.lineStyle(1, 0x000000).strokePath();
|
||||
}
|
||||
|
||||
private drawFlames() {
|
||||
const { currentMainPower, currentNozzleAngle, lander } = this.sim;
|
||||
if (currentMainPower <= 0.1) return;
|
||||
|
||||
this.landerGraphics.save();
|
||||
|
||||
const totalAngle = lander.angle + currentNozzleAngle;
|
||||
const offset = { x: 0, y: 20 }; // Nozzle relative pos
|
||||
const nozzlePos = Matter.Vector.add(lander.position, Matter.Vector.rotate(offset, lander.angle));
|
||||
|
||||
// 1. Particles
|
||||
const emitAngleDeg = Math.atan2(Math.cos(totalAngle), -Math.sin(totalAngle)) * (180/Math.PI);
|
||||
this.flameParticles.setAngle(emitAngleDeg); // Simple angle setting
|
||||
this.flameParticles.emitParticleAt(nozzlePos.x, nozzlePos.y);
|
||||
|
||||
// 2. Vector
|
||||
const arrowLength = currentMainPower * 60;
|
||||
const endX = nozzlePos.x - Math.sin(totalAngle) * arrowLength;
|
||||
const endY = nozzlePos.y + Math.cos(totalAngle) * arrowLength;
|
||||
|
||||
this.landerGraphics.lineStyle(2, 0xffff00, 0.8);
|
||||
this.landerGraphics.beginPath();
|
||||
this.landerGraphics.moveTo(nozzlePos.x, nozzlePos.y);
|
||||
this.landerGraphics.lineTo(endX, endY);
|
||||
this.landerGraphics.strokePath();
|
||||
|
||||
this.landerGraphics.restore();
|
||||
}
|
||||
|
||||
private drawWindIndicator() {
|
||||
const { currentWind, lander } = this.sim;
|
||||
if (Math.abs(currentWind) <= 0.1) return;
|
||||
|
||||
const startX = lander.position.x;
|
||||
const startY = lander.position.y - 40;
|
||||
|
||||
const length = Math.abs(currentWind * 20);
|
||||
const angle = currentWind > 0 ? 0 : Math.PI;
|
||||
|
||||
this.landerGraphics.lineStyle(2, 0x00ffff);
|
||||
this.drawArrow(startX, startY, length, angle);
|
||||
}
|
||||
|
||||
private drawArrow(x: number, y: number, length: number, angle: number) {
|
||||
const endX = x + Math.cos(angle) * length;
|
||||
const endY = y + Math.sin(angle) * length;
|
||||
|
||||
this.landerGraphics.beginPath();
|
||||
this.landerGraphics.moveTo(x, y);
|
||||
this.landerGraphics.lineTo(endX, endY);
|
||||
|
||||
// Arrow head
|
||||
const headSize = 5;
|
||||
this.landerGraphics.moveTo(endX, endY);
|
||||
this.landerGraphics.lineTo(endX - headSize * Math.cos(angle - Math.PI / 6), endY - headSize * Math.sin(angle - Math.PI / 6));
|
||||
this.landerGraphics.moveTo(endX, endY);
|
||||
this.landerGraphics.lineTo(endX - headSize * Math.cos(angle + Math.PI / 6), endY - headSize * Math.sin(angle + Math.PI / 6));
|
||||
|
||||
this.landerGraphics.strokePath();
|
||||
}
|
||||
|
||||
private drawThrustGauge() {
|
||||
const { currentMainPower, lastActions } = this.sim;
|
||||
const barX = 10, barY = 90, barW = 100, barH = 10;
|
||||
const g = this.terrainGraphics;
|
||||
|
||||
g.fillStyle(0x333333).fillRect(barX, barY, barW, barH);
|
||||
g.fillStyle(0x00ff00).fillRect(barX, barY, currentMainPower * barW, barH);
|
||||
|
||||
const cmdW = Math.max(0, lastActions[0]) * barW;
|
||||
g.fillStyle(0xff0000).fillRect(barX + cmdW, barY - 2, 2, barH + 4);
|
||||
}
|
||||
}
|
||||
|
||||
export function createLanderViewer(container: HTMLElement) {
|
||||
return new Phaser.Game({
|
||||
type: Phaser.AUTO,
|
||||
width: WORLD_WIDTH,
|
||||
height: WORLD_HEIGHT,
|
||||
parent: container,
|
||||
scene: LanderScene,
|
||||
transparent: false,
|
||||
backgroundColor: '#111122',
|
||||
scale: {
|
||||
mode: Phaser.Scale.FIT,
|
||||
autoCenter: Phaser.Scale.CENTER_BOTH
|
||||
},
|
||||
physics: {
|
||||
default: 'matter',
|
||||
matter: {
|
||||
gravity: { x: 0, y: 0 },
|
||||
debug: false
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function getLanderScene(game: Phaser.Game): LanderScene {
|
||||
return game.scene.getScene('LanderScene') as LanderScene;
|
||||
}
|
||||
186
src/apps/LunarLander/LanderSimulation.ts
Normal file
186
src/apps/LunarLander/LanderSimulation.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import Matter from 'matter-js';
|
||||
|
||||
export const WORLD_WIDTH = 800;
|
||||
export const WORLD_HEIGHT = 600;
|
||||
const LANDER_WIDTH = 30;
|
||||
const LANDER_HEIGHT = 40;
|
||||
const PAD_WIDTH = 80;
|
||||
|
||||
export class LanderSimulation {
|
||||
public engine: Matter.Engine;
|
||||
public lander!: Matter.Body;
|
||||
public ground!: Matter.Body;
|
||||
public pad!: Matter.Body;
|
||||
|
||||
public isGameOver = false;
|
||||
public result: 'FLYING' | 'CRASHED' | 'LANDED' | 'TIMEOUT' = 'FLYING';
|
||||
|
||||
// State
|
||||
public fuel = 1000;
|
||||
public readonly maxFuel = 1000;
|
||||
public timeSteps = 0;
|
||||
public readonly maxTimeSteps = 60 * 20; // 20s
|
||||
public currentWind = 0;
|
||||
public currentMainPower = 0;
|
||||
public currentNozzleAngle = 0;
|
||||
public lastActions: number[] = [0, 0];
|
||||
|
||||
// Config
|
||||
private readonly DRY_MASS = 10;
|
||||
private readonly FUEL_MASS_CAPACITY = 10;
|
||||
private readonly LAG_FACTOR = 0.05;
|
||||
private readonly GIMBAL_SPEED = 0.05;
|
||||
private windTime = Math.random() * 100;
|
||||
|
||||
constructor(seed: number = 0) {
|
||||
this.engine = Matter.Engine.create({ enableSleeping: false });
|
||||
this.engine.gravity.y = 0.5;
|
||||
|
||||
// Custom PRNG
|
||||
let s = seed;
|
||||
const random = () => {
|
||||
s = (s * 9301 + 49297) % 233280;
|
||||
return s / 233280;
|
||||
};
|
||||
|
||||
this.setupWorld(random);
|
||||
Matter.Events.on(this.engine, 'collisionStart', (e) => this.handleCollisions(e));
|
||||
}
|
||||
|
||||
private setupWorld(random: () => number) {
|
||||
// Bodies
|
||||
this.ground = Matter.Bodies.rectangle(WORLD_WIDTH/2, WORLD_HEIGHT, WORLD_WIDTH, 20, {
|
||||
isStatic: true, label: 'ground', friction: 1, render: { fillStyle: '#555555' }
|
||||
});
|
||||
|
||||
this.pad = Matter.Bodies.rectangle(WORLD_WIDTH/2, WORLD_HEIGHT - 30, PAD_WIDTH, 10, {
|
||||
isStatic: true, label: 'pad', render: { fillStyle: '#00ff00' }
|
||||
});
|
||||
|
||||
const startX = 100 + random() * (WORLD_WIDTH - 200);
|
||||
const startY = 50 + random() * 100;
|
||||
this.lander = Matter.Bodies.trapezoid(startX, startY, LANDER_WIDTH, LANDER_HEIGHT, 0.5, {
|
||||
friction: 0.1, frictionAir: 0.02, restitution: 0, label: 'lander', angle: 0
|
||||
});
|
||||
|
||||
Matter.Body.setMass(this.lander, this.DRY_MASS + this.FUEL_MASS_CAPACITY);
|
||||
Matter.Body.setVelocity(this.lander, { x: (random() - 0.5) * 4, y: 0 });
|
||||
|
||||
Matter.World.add(this.engine.world, [this.ground, this.pad, this.lander]);
|
||||
}
|
||||
|
||||
private handleCollisions(event: Matter.IEventCollision<Matter.Engine>) {
|
||||
if (this.isGameOver) return;
|
||||
|
||||
event.pairs.forEach(pair => {
|
||||
const other = pair.bodyA === this.lander ? pair.bodyB : (pair.bodyB === this.lander ? pair.bodyA : null);
|
||||
if (!other) return;
|
||||
|
||||
if (other.label === 'pad') this.checkLanding();
|
||||
else if (other.label === 'ground') this.crash("Hit ground");
|
||||
});
|
||||
}
|
||||
|
||||
private checkLanding() {
|
||||
const { position, velocity, angle, angularVelocity } = this.lander;
|
||||
const speed = Math.hypot(velocity.x, velocity.y);
|
||||
|
||||
// Strict Bounds Check
|
||||
const isAbovePad = position.y < (this.pad.position.y - 15);
|
||||
const isOnPad = Math.abs(position.x - this.pad.position.x) < 35; // Inside pad width
|
||||
|
||||
if (!isAbovePad) return this.crash("Hit side of pad");
|
||||
if (!isOnPad) return this.crash("Missed center");
|
||||
|
||||
// Landing Criteria
|
||||
if (speed < 2.5 && Math.abs(angle) < 0.25 && Math.abs(angularVelocity) < 0.15) {
|
||||
this.result = 'LANDED';
|
||||
this.isGameOver = true;
|
||||
} else {
|
||||
this.crash(`Too fast/tilted: Spd=${speed.toFixed(1)}`);
|
||||
}
|
||||
}
|
||||
|
||||
private crash(reason: string) {
|
||||
this.result = 'CRASHED';
|
||||
this.isGameOver = true;
|
||||
}
|
||||
|
||||
public update(actions: number[]): boolean {
|
||||
this.lastActions = actions;
|
||||
if (this.isGameOver) return false;
|
||||
if (++this.timeSteps > this.maxTimeSteps) {
|
||||
this.result = 'TIMEOUT';
|
||||
this.isGameOver = true;
|
||||
return false;
|
||||
}
|
||||
|
||||
this.applyWind();
|
||||
this.updateMass();
|
||||
this.applyControls(actions);
|
||||
this.checkBounds();
|
||||
|
||||
Matter.Engine.update(this.engine, 1000 / 60);
|
||||
return !this.isGameOver;
|
||||
}
|
||||
|
||||
private applyWind() {
|
||||
this.windTime += 0.01;
|
||||
this.currentWind = Math.sin(this.windTime) + Math.sin(this.windTime * 3.2) * 0.5 + Math.sin(this.windTime * 0.7) * 2.0;
|
||||
Matter.Body.applyForce(this.lander, this.lander.position, { x: this.currentWind * 0.002, y: 0 });
|
||||
}
|
||||
|
||||
private updateMass() {
|
||||
const expectedMass = this.DRY_MASS + (this.fuel / this.maxFuel) * this.FUEL_MASS_CAPACITY;
|
||||
if (Math.abs(this.lander.mass - expectedMass) > 0.01) {
|
||||
Matter.Body.setMass(this.lander, expectedMass);
|
||||
}
|
||||
}
|
||||
|
||||
private applyControls(actions: number[]) {
|
||||
// [0: Thrust (-1..1), 1: Nozzle (-1..1)]
|
||||
let targetMainPower = Math.max(0, Math.min(1, (actions[0] + 1) / 2));
|
||||
const targetNozzleAngle = actions[1] * 0.5; // Max 0.5 rad (~28 deg)
|
||||
|
||||
// Lag & Inertia
|
||||
this.currentMainPower += Math.sign(targetMainPower - this.currentMainPower) * Math.min(Math.abs(targetMainPower - this.currentMainPower), this.LAG_FACTOR);
|
||||
this.currentNozzleAngle += Math.sign(targetNozzleAngle - this.currentNozzleAngle) * Math.min(Math.abs(targetNozzleAngle - this.currentNozzleAngle), this.GIMBAL_SPEED);
|
||||
|
||||
// Fuel
|
||||
if (this.fuel <= 0) this.currentMainPower = 0;
|
||||
else this.fuel -= this.currentMainPower * 0.5;
|
||||
|
||||
// Apply Force
|
||||
if (this.currentMainPower > 0.01) {
|
||||
const force = 0.0005 * 20 * 2.5 * this.currentMainPower; // 2.5TWR approx
|
||||
const totalAngle = this.lander.angle + this.currentNozzleAngle;
|
||||
const forceVector = { x: Math.sin(totalAngle) * force, y: -Math.cos(totalAngle) * force };
|
||||
|
||||
// Offset point (bottom of lander)
|
||||
const appPos = Matter.Vector.add(this.lander.position, Matter.Vector.rotate({ x: 0, y: 20 }, this.lander.angle));
|
||||
Matter.Body.applyForce(this.lander, appPos, forceVector);
|
||||
}
|
||||
}
|
||||
|
||||
private checkBounds() {
|
||||
if (this.lander.position.x < -100 || this.lander.position.x > WORLD_WIDTH + 100 ||
|
||||
this.lander.position.y < -500 || this.lander.position.y > WORLD_HEIGHT + 100) {
|
||||
this.crash("Out of bounds");
|
||||
}
|
||||
}
|
||||
|
||||
public getObservation(): number[] {
|
||||
const { velocity, angularVelocity, position, angle } = this.lander;
|
||||
return [
|
||||
velocity.x / 10.0,
|
||||
velocity.y / 10.0,
|
||||
angle / 3.14,
|
||||
angularVelocity / 0.5,
|
||||
(position.x - this.pad.position.x) / WORLD_WIDTH,
|
||||
(position.y - this.pad.position.y) / WORLD_HEIGHT,
|
||||
this.currentWind / 5.0,
|
||||
this.currentNozzleAngle / 0.5,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
129
src/apps/LunarLander/LunarLander.css
Normal file
129
src/apps/LunarLander/LunarLander.css
Normal file
@@ -0,0 +1,129 @@
|
||||
.lunar-app-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
background: #0d1117;
|
||||
color: #c9d1d9;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.top-bar {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 20px;
|
||||
background: #161b22;
|
||||
border-bottom: 1px solid #30363d;
|
||||
flex-shrink: 0;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.controls-section {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.stats-section {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
background: #21262d;
|
||||
padding: 5px 15px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #30363d;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.75em;
|
||||
color: #8b949e;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.1em;
|
||||
font-weight: bold;
|
||||
color: #c9d1d9;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.stat-value.highlight {
|
||||
color: #58a6ff;
|
||||
}
|
||||
|
||||
.vis-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.graph-panel {
|
||||
height: 180px;
|
||||
/* Slightly shorter */
|
||||
background: #161b22;
|
||||
border-top: 1px solid #30363d;
|
||||
padding: 10px 15px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.graph-panel h3 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 5px;
|
||||
font-size: 0.9em;
|
||||
color: #58a6ff;
|
||||
}
|
||||
|
||||
.main-view {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
/* Controls */
|
||||
.btn-toggle {
|
||||
padding: 8px 16px;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 6px;
|
||||
background: #238636;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
font-size: 0.9em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.btn-toggle.active {
|
||||
background: #da3633;
|
||||
}
|
||||
|
||||
.btn-toggle:hover {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.btn-reset {
|
||||
background: #30363d;
|
||||
border-color: #30363d;
|
||||
}
|
||||
80
src/apps/LunarLander/LunarLanderApp.tsx
Normal file
80
src/apps/LunarLander/LunarLanderApp.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import { useRef, useEffect } from 'react';
|
||||
import AppContainer from '../../components/AppContainer';
|
||||
import { createLanderViewer, getLanderScene } from './LanderScene';
|
||||
import FitnessGraph from '../NeatArena/FitnessGraph';
|
||||
import { useEvolutionWorker } from './useEvolutionWorker';
|
||||
import './LunarLander.css';
|
||||
|
||||
export default function LunarLanderApp() {
|
||||
const { isTraining, stats, fitnessHistory, bestGenome, toggleTraining, handleReset } = useEvolutionWorker();
|
||||
const phaserContainerRef = useRef<HTMLDivElement>(null);
|
||||
const phaserGameRef = useRef<Phaser.Game | null>(null);
|
||||
|
||||
// Phaser Initialization
|
||||
useEffect(() => {
|
||||
if (!phaserContainerRef.current) return;
|
||||
const game = createLanderViewer(phaserContainerRef.current);
|
||||
phaserGameRef.current = game;
|
||||
return () => {
|
||||
game.destroy(true);
|
||||
phaserGameRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Exhibition Loop
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
if (!phaserGameRef.current) return;
|
||||
const scene = getLanderScene(phaserGameRef.current);
|
||||
if (!scene) return;
|
||||
|
||||
// Start new match if game over and we have a genome
|
||||
// Accessing private sim via any cast for simplicity without exposing public property
|
||||
const sceneAny = scene as any;
|
||||
if (bestGenome && (!sceneAny.sim || sceneAny.sim.isGameOver)) {
|
||||
scene.startMatch(bestGenome, stats.generation);
|
||||
}
|
||||
}, 100);
|
||||
return () => clearInterval(interval);
|
||||
}, [bestGenome, stats.generation]);
|
||||
|
||||
return (
|
||||
<AppContainer title="Lunar Lander (Dense NN)">
|
||||
<div className="lunar-app-layout">
|
||||
<div className="top-bar">
|
||||
<div className="controls-section">
|
||||
<button className={`btn-toggle ${isTraining ? 'active' : ''}`} onClick={toggleTraining}>
|
||||
{isTraining ? '⏸ Pause' : '▶ Start Evolution'}
|
||||
</button>
|
||||
<button className="btn-toggle btn-reset" onClick={handleReset}>
|
||||
🔄 Reset
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="stats-section">
|
||||
<StatCard label="Generation" value={stats.generation} />
|
||||
<StatCard label="Best Fit" value={stats.maxFitness.toFixed(2)} highlight />
|
||||
<StatCard label="Avg Fit" value={stats.avgFitness.toFixed(2)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="graph-panel">
|
||||
<FitnessGraph history={fitnessHistory} />
|
||||
</div>
|
||||
|
||||
<div className="vis-column">
|
||||
<div className="main-view" ref={phaserContainerRef} />
|
||||
</div>
|
||||
</div>
|
||||
</AppContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({ label, value, highlight = false }: { label: string, value: string | number, highlight?: boolean }) {
|
||||
return (
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">{label}</div>
|
||||
<div className={`stat-value ${highlight ? 'highlight' : ''}`}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
60
src/apps/LunarLander/debug.test.ts
Normal file
60
src/apps/LunarLander/debug.test.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import { LanderSimulation } from './LanderSimulation';
|
||||
import Matter from 'matter-js';
|
||||
|
||||
describe('Lunar Lander Physics Debug', () => {
|
||||
it('should have enough thrust to lift off', () => {
|
||||
const sim = new LanderSimulation(0);
|
||||
const lander = sim.lander;
|
||||
|
||||
console.log('--- Physics Debug Info ---');
|
||||
console.log(`Lander Mass: ${lander.mass.toFixed(4)}`);
|
||||
console.log(`Gravity Y: ${sim.engine.gravity.y}`);
|
||||
console.log(`Gravity Scale: ${sim.engine.gravity.scale}`); // Default 0.001?
|
||||
|
||||
// Matter.js gravity force = mass * gravity.y * gravity.scale
|
||||
// (Wait, Matter applies gravity as acceleration? F = m * a)
|
||||
// Standard gravity force per tick approx:
|
||||
// Force = mass * gravity.y * 0.001 (default scale)
|
||||
|
||||
const gravityForce = lander.mass * sim.engine.gravity.y * (sim.engine.gravity.scale || 0.001);
|
||||
console.log(`Calculated Gravity Force (approx): ${gravityForce.toFixed(6)}`);
|
||||
|
||||
// My Max Thrust
|
||||
const mainPower = 1.0;
|
||||
const thrustForce = 0.002 * mainPower;
|
||||
console.log(`Max Thrust Force: ${thrustForce.toFixed(6)}`);
|
||||
|
||||
const ratio = thrustForce / gravityForce;
|
||||
console.log(`Thrust/Gravity Ratio: ${ratio.toFixed(2)}`);
|
||||
|
||||
// We expect Ratio > 1.0 to hover
|
||||
if (ratio < 1.0) {
|
||||
console.warn('⚠️ WARNING: THRUST IS TOO WEAK TO LIFT OFF! ⚠️');
|
||||
} else {
|
||||
console.log('✅ Thrust is sufficient.');
|
||||
}
|
||||
|
||||
expect(ratio).toBeGreaterThan(1.2); // Should have some margin
|
||||
});
|
||||
|
||||
it('should update inputs correctly', () => {
|
||||
const sim = new LanderSimulation(0);
|
||||
|
||||
// Initial state
|
||||
const initialObs = sim.getObservation();
|
||||
console.log('Initial Obs:', initialObs);
|
||||
|
||||
// Fall for a bit
|
||||
sim.update([0, 0]); // No thrust
|
||||
sim.update([0, 0]);
|
||||
sim.update([0, 0]); // 3 ticks
|
||||
|
||||
const obs = sim.getObservation();
|
||||
console.log('Obs after 3 ticks fall:', obs);
|
||||
|
||||
// Velocity Y should be positive (down)
|
||||
expect(obs[1]).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
114
src/apps/LunarLander/e2e.test.ts
Normal file
114
src/apps/LunarLander/e2e.test.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { LanderSimulation } from "./LanderSimulation";
|
||||
import { LANDER_NEAT_CONFIG, calculateFitness } from "./neatConfig";
|
||||
import { createPopulation, evolveGeneration } from "../../lib/neatArena/evolution";
|
||||
import { createNetwork } from "../../lib/neatArena/network";
|
||||
|
||||
describe("Lunar Lander E2E & Stability", () => {
|
||||
|
||||
test("Simulation Determinism (Same Actions)", () => {
|
||||
// Run two sims side-by-side with identical actions
|
||||
const sim1 = new LanderSimulation(0);
|
||||
const sim2 = new LanderSimulation(0);
|
||||
|
||||
const actions = [1.0, 0.5]; // Full thrust, turn right
|
||||
|
||||
for(let i=0; i<100; i++) {
|
||||
sim1.update(actions);
|
||||
sim2.update(actions);
|
||||
|
||||
expect(sim1.lander.position.x).toBe(sim2.lander.position.x);
|
||||
expect(sim1.lander.position.y).toBe(sim2.lander.position.y);
|
||||
expect(sim1.lander.angle).toBe(sim2.lander.angle);
|
||||
}
|
||||
});
|
||||
|
||||
test("Evaluation Determinism (Same Genome)", () => {
|
||||
const population = createPopulation(LANDER_NEAT_CONFIG);
|
||||
const genome = population.genomes[0];
|
||||
|
||||
// Evaluate once
|
||||
const runSim = (g: typeof genome) => {
|
||||
const sim = new LanderSimulation(0);
|
||||
const net = createNetwork(g);
|
||||
|
||||
while(!sim.isGameOver) {
|
||||
const inputs = sim.getObservation();
|
||||
const outputs = net.activate(inputs);
|
||||
sim.update(outputs);
|
||||
}
|
||||
return calculateFitness(sim);
|
||||
};
|
||||
|
||||
const fit1 = runSim(genome);
|
||||
const fit2 = runSim(genome);
|
||||
const fit3 = runSim(genome);
|
||||
|
||||
console.log(`Determinism Check: ${fit1}, ${fit2}, ${fit3}`);
|
||||
expect(fit1).toBe(fit2);
|
||||
expect(fit2).toBe(fit3);
|
||||
});
|
||||
|
||||
test("Long Flight Stability (Mock Pilot)", () => {
|
||||
// A simple pilot that pushes up if falling
|
||||
const runPilot = () => {
|
||||
const sim = new LanderSimulation(0);
|
||||
let steps = 0;
|
||||
while (!sim.isGameOver && steps < 1000) {
|
||||
const obs = sim.getObservation();
|
||||
// obs[1] is velY (scaled / 10). Positive = Falling.
|
||||
// If falling > 0.1 (vel 1.0), thrust!
|
||||
const mainThrust = obs[1] > 0.1 ? 1.0 : 0.0;
|
||||
sim.update([mainThrust, 0]);
|
||||
steps++;
|
||||
}
|
||||
return { fitness: calculateFitness(sim), steps };
|
||||
};
|
||||
|
||||
const result1 = runPilot();
|
||||
const result2 = runPilot();
|
||||
const result3 = runPilot();
|
||||
|
||||
console.log(`Pilot Results: ${result1.fitness.toFixed(2)} (${result1.steps}), ${result2.fitness.toFixed(2)} (${result2.steps}), ${result3.fitness.toFixed(2)} (${result3.steps})`);
|
||||
|
||||
expect(result1.fitness).toBe(result2.fitness);
|
||||
expect(result1.steps).toBe(result2.steps);
|
||||
});
|
||||
|
||||
test("Evolution Progress (Mock)", () => {
|
||||
let population = createPopulation({
|
||||
...LANDER_NEAT_CONFIG,
|
||||
populationSize: 20 // Smaller for speed
|
||||
});
|
||||
|
||||
let bestFit = -Infinity;
|
||||
|
||||
// Run 5 generations
|
||||
for(let gen=0; gen<5; gen++) {
|
||||
// Evaluate
|
||||
for(const g of population.genomes) {
|
||||
const sim = new LanderSimulation(0);
|
||||
const net = createNetwork(g);
|
||||
while(!sim.isGameOver) {
|
||||
const inputs = sim.getObservation();
|
||||
const outputs = net.activate(inputs);
|
||||
sim.update(outputs);
|
||||
}
|
||||
g.fitness = calculateFitness(sim);
|
||||
}
|
||||
|
||||
// Stats
|
||||
const currentBest = Math.max(...population.genomes.map(g => g.fitness));
|
||||
console.log(`Gen ${gen}: Best = ${currentBest.toFixed(2)}`);
|
||||
|
||||
// Elitism check: Best fitness should NEVER decrease
|
||||
if (gen > 0) {
|
||||
expect(currentBest).toBeGreaterThanOrEqual(bestFit - 0.001); // Tiny float tolerance
|
||||
}
|
||||
bestFit = currentBest;
|
||||
|
||||
population = evolveGeneration(population, LANDER_NEAT_CONFIG);
|
||||
}
|
||||
});
|
||||
});
|
||||
71
src/apps/LunarLander/neatConfig.ts
Normal file
71
src/apps/LunarLander/neatConfig.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
|
||||
import { LanderSimulation } from './LanderSimulation';
|
||||
import type { EvolutionConfig } from '../../lib/neatArena/evolution';
|
||||
import { DEFAULT_COMPATIBILITY_CONFIG } from '../../lib/neatArena/speciation';
|
||||
import { DEFAULT_REPRODUCTION_CONFIG } from '../../lib/neatArena/reproduction';
|
||||
|
||||
export const LANDER_NEAT_CONFIG: EvolutionConfig = {
|
||||
inputCount: 8, // velX, velY, angle, angVel, dx, dy, WIND, nozzleAngle
|
||||
outputCount: 2, // mainThrust, sideThrust
|
||||
populationSize: 150,
|
||||
compatibilityConfig: DEFAULT_COMPATIBILITY_CONFIG,
|
||||
reproductionConfig: {
|
||||
...DEFAULT_REPRODUCTION_CONFIG,
|
||||
elitePerSpecies: 2, // Keep top 2 per species to prevent losing best genes
|
||||
},
|
||||
};
|
||||
|
||||
export function calculateFitness(sim: LanderSimulation): number {
|
||||
let fitness = 0;
|
||||
|
||||
// 1. Distance Reward (Closer to pad is better)
|
||||
const distX = Math.abs(sim.lander.position.x - sim.pad.position.x);
|
||||
const distY = Math.abs(sim.lander.position.y - sim.pad.position.y);
|
||||
const dist = Math.sqrt(distX * distX + distY * distY);
|
||||
|
||||
// Normalize distance reward (0 to 100)
|
||||
fitness += Math.max(0, 500 - dist) * 0.1;
|
||||
|
||||
// Common metrics
|
||||
const speed = Math.hypot(sim.lander.velocity.x, sim.lander.velocity.y);
|
||||
const angle = Math.abs(sim.lander.angle);
|
||||
|
||||
// 2. Landing Reward
|
||||
if (sim.result === 'LANDED') {
|
||||
fitness += 200; // Base success reward
|
||||
|
||||
// SOFT LANDING BONUS
|
||||
// Speed limit is 2.5. We reward being significantly below that.
|
||||
// If speed is 0.0 -> +250 pts
|
||||
// If speed is 2.5 -> +0 pts
|
||||
fitness += Math.max(0, 2.5 - speed) * 100;
|
||||
|
||||
// Alignment Bonus (Dead center upright)
|
||||
fitness += Math.max(0, 0.25 - angle) * 100;
|
||||
|
||||
// Efficiency Bonus
|
||||
fitness += sim.fuel * 0.2;
|
||||
}
|
||||
// 3. Near-Miss / Crash partial rewards
|
||||
else if (sim.result === 'CRASHED') {
|
||||
// Continuous reward: Encourage getting CLOSER to valid landing state
|
||||
|
||||
// Speed: Reward any braking. Freefall ~20.
|
||||
// At 20 -> 0 pts
|
||||
// At 0 -> 60 pts
|
||||
fitness += Math.max(0, 20.0 - speed) * 3.0;
|
||||
|
||||
// Angle: Upright is better.
|
||||
fitness += Math.max(0, 1.0 - angle) * 30;
|
||||
|
||||
// Penalty for doing NOTHING (Full Fuel)
|
||||
if (sim.fuel >= 999) {
|
||||
fitness -= 30;
|
||||
}
|
||||
|
||||
} else if (sim.result === 'TIMEOUT') {
|
||||
fitness -= 50;
|
||||
}
|
||||
|
||||
return Math.max(0.1, fitness);
|
||||
}
|
||||
72
src/apps/LunarLander/stagnation.test.ts
Normal file
72
src/apps/LunarLander/stagnation.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
|
||||
import { test, expect } from 'bun:test';
|
||||
import { GeneticAlgo } from './GeneticAlgo';
|
||||
import { DenseNetwork } from './DenseNetwork';
|
||||
import { calculateFitness } from './neatConfig';
|
||||
import { LanderSimulation } from './LanderSimulation';
|
||||
|
||||
test('Run 150 generations of Dense GA to verify learning progress', () => {
|
||||
// 1. Setup
|
||||
const LAYER_SIZES = [7, 16, 16, 2];
|
||||
const POPULATION_SIZE = 150;
|
||||
const ga = new GeneticAlgo(POPULATION_SIZE, LAYER_SIZES);
|
||||
|
||||
const TOTAL_GENS = 150;
|
||||
const SCENARIOS = 5;
|
||||
|
||||
console.log("Generation, MaxFitness, AvgFitness");
|
||||
|
||||
for (let gen = 0; gen < TOTAL_GENS; gen++) {
|
||||
// 2. Evaluate
|
||||
const population = ga.getPopulation();
|
||||
|
||||
for (const genome of population) {
|
||||
const network = new DenseNetwork(LAYER_SIZES, genome.weights);
|
||||
let totalFitness = 0;
|
||||
|
||||
for (let i = 0; i < SCENARIOS; i++) {
|
||||
const seed = (ga.generation * SCENARIOS) + i;
|
||||
const sim = new LanderSimulation(seed);
|
||||
|
||||
let safety = 0;
|
||||
while (!sim.isGameOver && safety < 5000) {
|
||||
const inputs = sim.getObservation();
|
||||
const outputs = network.predict(inputs);
|
||||
sim.update(outputs);
|
||||
safety++;
|
||||
}
|
||||
|
||||
totalFitness += calculateFitness(sim);
|
||||
}
|
||||
genome.fitness = totalFitness / SCENARIOS;
|
||||
}
|
||||
|
||||
// 3. Log Stats every 10 gens
|
||||
if (gen % 10 === 0) {
|
||||
let maxFitness = -Infinity;
|
||||
let sumFitness = 0;
|
||||
for (const g of population) {
|
||||
if (g.fitness > maxFitness) maxFitness = g.fitness;
|
||||
sumFitness += g.fitness;
|
||||
}
|
||||
const avgFitness = sumFitness / population.length;
|
||||
|
||||
console.log(`${ga.generation}, ${maxFitness.toFixed(2)}, ${avgFitness.toFixed(2)}`);
|
||||
}
|
||||
|
||||
// 4. Evolve
|
||||
ga.evolve();
|
||||
}
|
||||
|
||||
const finalPop = ga.getPopulation();
|
||||
let maxFitness = -Infinity;
|
||||
for (const g of finalPop) {
|
||||
if (g.fitness > maxFitness) maxFitness = g.fitness;
|
||||
}
|
||||
|
||||
console.log(`Final Gen ${ga.generation}: Max ${maxFitness.toFixed(2)}`);
|
||||
|
||||
// Expect significant improvement.
|
||||
// Landing is > 200.
|
||||
expect(maxFitness).toBeGreaterThan(150);
|
||||
}, { timeout: 300000 }); // 5 min timeout
|
||||
105
src/apps/LunarLander/training.worker.ts
Normal file
105
src/apps/LunarLander/training.worker.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { LanderSimulation } from './LanderSimulation';
|
||||
import { calculateFitness } from './neatConfig';
|
||||
import { GeneticAlgo } from './GeneticAlgo';
|
||||
import { DenseNetwork } from './DenseNetwork';
|
||||
|
||||
// Define the fixed architecture
|
||||
// 6 Input -> 16 Hidden -> 16 Hidden -> 2 Output
|
||||
const LAYER_SIZES = [8, 16, 16, 2];
|
||||
const POPULATION_SIZE = 150;
|
||||
|
||||
let ga: GeneticAlgo | null = null;
|
||||
let isRunning = false;
|
||||
|
||||
self.onmessage = (e: MessageEvent) => {
|
||||
const { type } = e.data;
|
||||
|
||||
switch (type) {
|
||||
case 'start':
|
||||
case 'reset':
|
||||
console.log('Worker: Initializing Fixed Topology GA');
|
||||
ga = new GeneticAlgo(POPULATION_SIZE, LAYER_SIZES);
|
||||
isRunning = true;
|
||||
runGeneration();
|
||||
break;
|
||||
case 'pause':
|
||||
isRunning = false;
|
||||
break;
|
||||
case 'resume':
|
||||
if (!isRunning) {
|
||||
isRunning = true;
|
||||
runGeneration();
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
function runGeneration() {
|
||||
if (!ga || !isRunning) return;
|
||||
|
||||
const population = ga.getPopulation();
|
||||
|
||||
// 1. Evaluate Fitness
|
||||
const SCENARIOS = 5;
|
||||
|
||||
for (const genome of population) {
|
||||
let totalFitness = 0;
|
||||
const network = new DenseNetwork(LAYER_SIZES, genome.weights);
|
||||
|
||||
for (let i = 0; i < SCENARIOS; i++) {
|
||||
// Seed logic: (Gen * Scenarios) + i
|
||||
const seed = (ga.generation * SCENARIOS) + i;
|
||||
const sim = new LanderSimulation(seed);
|
||||
|
||||
// Simulation Loop
|
||||
// Safety break just in case
|
||||
let step = 0;
|
||||
while (!sim.isGameOver && step < 5000) {
|
||||
const inputs = sim.getObservation();
|
||||
const outputs = network.predict(inputs);
|
||||
sim.update(outputs);
|
||||
step++;
|
||||
}
|
||||
if (step >= 5000) {
|
||||
// penalty for timeout? Or just let calcFitness handle it.
|
||||
// calculateFitness handles timeout result.
|
||||
// Force result if not set
|
||||
if (!sim.isGameOver) sim.result = 'TIMEOUT';
|
||||
}
|
||||
|
||||
totalFitness += calculateFitness(sim);
|
||||
}
|
||||
|
||||
genome.fitness = totalFitness / SCENARIOS;
|
||||
}
|
||||
|
||||
// Calculate stats before evolution
|
||||
let sumFitness = 0;
|
||||
let maxFitness = -Infinity;
|
||||
for (const genome of population) {
|
||||
sumFitness += genome.fitness;
|
||||
if (genome.fitness > maxFitness) maxFitness = genome.fitness;
|
||||
}
|
||||
const avgFitness = sumFitness / population.length;
|
||||
|
||||
// Send update to main thread
|
||||
// The main thread expects bestGenome object.
|
||||
// We'll send the weights of the current champion (maxFitness)
|
||||
const bestOfGen = population.find(g => g.fitness === maxFitness) || population[0];
|
||||
|
||||
self.postMessage({
|
||||
type: 'generationParams',
|
||||
payload: {
|
||||
generation: ga.generation,
|
||||
maxFitness: maxFitness,
|
||||
avgFitness: avgFitness,
|
||||
bestGenome: { weights: Array.from(bestOfGen.weights) } // Custom payload
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Evolve to next generation
|
||||
ga.evolve();
|
||||
|
||||
// Schedule next gen
|
||||
setTimeout(runGeneration, 0);
|
||||
}
|
||||
82
src/apps/LunarLander/useEvolutionWorker.ts
Normal file
82
src/apps/LunarLander/useEvolutionWorker.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
|
||||
export interface Stats {
|
||||
generation: number;
|
||||
maxFitness: number;
|
||||
avgFitness: number;
|
||||
}
|
||||
|
||||
export interface HistoryItem {
|
||||
generation: number;
|
||||
best: number;
|
||||
avg: number;
|
||||
}
|
||||
|
||||
export function useEvolutionWorker() {
|
||||
const [isTraining, setIsTraining] = useState(false);
|
||||
const [stats, setStats] = useState<Stats>({ generation: 0, maxFitness: 0, avgFitness: 0 });
|
||||
const [fitnessHistory, setFitnessHistory] = useState<HistoryItem[]>([]);
|
||||
const [bestGenome, setBestGenome] = useState<any>(null);
|
||||
|
||||
const workerRef = useRef<Worker | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const worker = new Worker(new URL('./training.worker.ts', import.meta.url), { type: 'module' });
|
||||
workerRef.current = worker;
|
||||
|
||||
worker.onmessage = (e: MessageEvent<any>) => {
|
||||
const { type, payload, error } = e.data;
|
||||
|
||||
if (type === 'generationParams') {
|
||||
setStats({
|
||||
generation: payload.generation,
|
||||
maxFitness: payload.maxFitness,
|
||||
avgFitness: payload.avgFitness
|
||||
});
|
||||
|
||||
if (payload.bestGenome) {
|
||||
setBestGenome(payload.bestGenome);
|
||||
}
|
||||
|
||||
setFitnessHistory(prev => [...prev, {
|
||||
generation: payload.generation,
|
||||
best: payload.maxFitness,
|
||||
avg: payload.avgFitness
|
||||
}]);
|
||||
} else if (type === 'error') {
|
||||
console.error("Worker Error:", error);
|
||||
setIsTraining(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Initial reset to setup GA
|
||||
worker.postMessage({ type: 'reset' });
|
||||
|
||||
return () => worker.terminate();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!workerRef.current) return;
|
||||
workerRef.current.postMessage({ type: isTraining ? 'resume' : 'pause' });
|
||||
}, [isTraining]);
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
setStats({ generation: 0, maxFitness: 0, avgFitness: 0 });
|
||||
setFitnessHistory([]);
|
||||
setBestGenome(null);
|
||||
workerRef.current?.postMessage({ type: 'reset' });
|
||||
}, []);
|
||||
|
||||
const toggleTraining = useCallback(() => {
|
||||
setIsTraining(prev => !prev);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
isTraining,
|
||||
stats,
|
||||
fitnessHistory,
|
||||
bestGenome,
|
||||
toggleTraining,
|
||||
handleReset
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import './Sidebar.css';
|
||||
|
||||
export type AppId = 'image-approx' | 'snake-ai' | 'rogue-gen' | 'neat-arena';
|
||||
export type AppId = 'image-approx' | 'snake-ai' | 'rogue-gen' | 'neat-arena' | 'lunar-lander';
|
||||
|
||||
export interface AppInfo {
|
||||
id: AppId;
|
||||
@@ -40,6 +40,13 @@ export const APPS: AppInfo[] = [
|
||||
icon: '⚔️',
|
||||
description: 'Evolve AI agents to fight in a top-down shooter',
|
||||
},
|
||||
{
|
||||
id: 'lunar-lander',
|
||||
path: '/lunar-lander',
|
||||
name: 'Lunar Lander',
|
||||
icon: '🚀',
|
||||
description: 'Evolve a spaceship to land safely',
|
||||
},
|
||||
];
|
||||
|
||||
export default function Sidebar() {
|
||||
|
||||
Reference in New Issue
Block a user