Add neat based lunar landing
This commit is contained in:
6
bun.lock
6
bun.lock
@@ -5,6 +5,8 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "evolution",
|
"name": "evolution",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@types/matter-js": "^0.20.2",
|
||||||
|
"matter-js": "^0.20.0",
|
||||||
"phaser": "^3.90.0",
|
"phaser": "^3.90.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^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/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/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=="],
|
"@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=="],
|
"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=="],
|
"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=="],
|
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||||
|
|||||||
@@ -10,6 +10,8 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@types/matter-js": "^0.20.2",
|
||||||
|
"matter-js": "^0.20.0",
|
||||||
"phaser": "^3.90.0",
|
"phaser": "^3.90.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^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 SnakeAI from './apps/SnakeAI/SnakeAI';
|
||||||
import RogueGenApp from './apps/RogueGen/RogueGenApp';
|
import RogueGenApp from './apps/RogueGen/RogueGenApp';
|
||||||
import NeatArena from './apps/NeatArena/NeatArena';
|
import NeatArena from './apps/NeatArena/NeatArena';
|
||||||
|
import LunarLanderApp from './apps/LunarLander/LunarLanderApp';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
@@ -17,6 +18,7 @@ function App() {
|
|||||||
<Route path="/snake-ai" element={<SnakeAI />} />
|
<Route path="/snake-ai" element={<SnakeAI />} />
|
||||||
<Route path="/rogue-gen" element={<RogueGenApp />} />
|
<Route path="/rogue-gen" element={<RogueGenApp />} />
|
||||||
<Route path="/neat-arena" element={<NeatArena />} />
|
<Route path="/neat-arena" element={<NeatArena />} />
|
||||||
|
<Route path="/lunar-lander" element={<LunarLanderApp />} />
|
||||||
<Route path="*" element={<div>App not found</div>} />
|
<Route path="*" element={<div>App not found</div>} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</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 { NavLink } from 'react-router-dom';
|
||||||
import './Sidebar.css';
|
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 {
|
export interface AppInfo {
|
||||||
id: AppId;
|
id: AppId;
|
||||||
@@ -40,6 +40,13 @@ export const APPS: AppInfo[] = [
|
|||||||
icon: '⚔️',
|
icon: '⚔️',
|
||||||
description: 'Evolve AI agents to fight in a top-down shooter',
|
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() {
|
export default function Sidebar() {
|
||||||
|
|||||||
Reference in New Issue
Block a user