Add neat based lunar landing
This commit is contained in:
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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user