Add neat based lunar landing

This commit is contained in:
Peter Stockings
2026-01-14 11:14:06 +11:00
parent 60d4583323
commit 863f563a01
16 changed files with 1330 additions and 1 deletions

View File

@@ -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=="],

View File

@@ -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",

View File

@@ -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>

View 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;
}
}

View 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;
}
}
}
}

View 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;
}

View 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,
];
}
}

View 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;
}

View 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>
);
}

View 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);
});
});

View 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);
}
});
});

View 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);
}

View 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

View 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);
}

View 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
};
}

View File

@@ -1,7 +1,7 @@
import { NavLink } from 'react-router-dom';
import './Sidebar.css';
export type AppId = 'image-approx' | 'snake-ai' | 'rogue-gen' | 'neat-arena';
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() {