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