Implement M1 Matter.js absorber loop
Some checks failed
CI / validate (push) Has been cancelled

This commit is contained in:
2026-03-22 23:57:14 -05:00
parent 177be6332b
commit fde065d7ef
13 changed files with 479 additions and 96 deletions

2
.gitignore vendored
View File

@@ -3,6 +3,7 @@ dist/
coverage/
.vite/
*.log
*.tsbuildinfo
.DS_Store
Thumbs.db
server/data/
@@ -12,4 +13,3 @@ server/data/
.env
.env.*
!.env.example

View File

@@ -19,6 +19,7 @@
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/matter-js": "^0.20.2",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^4.4.1",
@@ -31,4 +32,3 @@
"vitest": "^3.0.8"
}
}

View File

@@ -1,7 +1,11 @@
import { useState } from "react";
import { Hud } from "./components/Hud";
import { GameShell } from "./game/GameShell";
import { defaultGameState, type GameState } from "./game/types";
export default function App() {
const [gameState, setGameState] = useState<GameState>(defaultGameState);
return (
<main className="min-h-screen bg-ink text-slate-100">
<div className="mx-auto flex min-h-screen max-w-7xl flex-col px-4 py-6 sm:px-6 lg:px-8">
@@ -13,16 +17,15 @@ export default function App() {
</h1>
</div>
<div className="max-w-sm text-right text-sm text-slate-300">
M0 scaffold: UI shell, Matter.js mount point, and API-ready project structure.
M1 loop: drag the singularity, consume dust, and grow your event horizon.
</div>
</header>
<div className="grid flex-1 gap-6 lg:grid-cols-[1fr_320px]">
<GameShell />
<Hud />
<GameShell gameState={gameState} onStateChange={setGameState} />
<Hud gameState={gameState} />
</div>
</div>
</main>
);
}

View File

@@ -1,11 +1,12 @@
import { render, screen } from "@testing-library/react";
import { Hud } from "./Hud";
import { defaultGameState } from "../game/types";
describe("Hud", () => {
it("renders the boot sequence panel", () => {
render(<Hud />);
render(<Hud gameState={defaultGameState} />);
expect(screen.getByText("Boot Sequence")).toBeTruthy();
expect(screen.getByText("Bootstrap Matter.js world")).toBeTruthy();
expect(screen.getByText("Sector Absorption")).toBeTruthy();
expect(screen.getByText("Reach mass 40 to stabilize your pull")).toBeTruthy();
});
});

View File

@@ -1,33 +1,47 @@
import { type GameState } from "../game/types";
const objectives = [
"Bootstrap Matter.js world",
"Wire player state to the API",
"Start M1 absorber loop"
"Reach mass 40 to stabilize your pull",
"Keep sweeping the field to chain dust pickups",
"Persist profile data in M3"
];
export function Hud() {
interface HudProps {
gameState: GameState;
}
export function Hud({ gameState }: HudProps) {
return (
<aside className="rounded-3xl border border-white/10 bg-white/5 p-5 shadow-2xl shadow-cyan-950/30 backdrop-blur">
<div className="mb-6">
<p className="text-xs uppercase tracking-[0.3em] text-glow/70">Mission Board</p>
<h2 className="mt-2 text-2xl font-semibold text-white">Boot Sequence</h2>
<h2 className="mt-2 text-2xl font-semibold text-white">Sector Absorption</h2>
</div>
<dl className="grid grid-cols-2 gap-3 text-sm">
<div className="rounded-2xl bg-black/20 p-3">
<dt className="text-slate-400">Mass</dt>
<dd className="mt-1 text-xl font-semibold">12</dd>
<dd className="mt-1 text-xl font-semibold">{gameState.mass.toFixed(0)}</dd>
</div>
<div className="rounded-2xl bg-black/20 p-3">
<dt className="text-slate-400">Tier</dt>
<dd className="mt-1 text-xl font-semibold">Dustling</dd>
<dd className="mt-1 text-xl font-semibold">{gameState.tier}</dd>
</div>
<div className="rounded-2xl bg-black/20 p-3">
<dt className="text-slate-400">Pull Radius</dt>
<dd className="mt-1 text-xl font-semibold">48m</dd>
<dd className="mt-1 text-xl font-semibold">{gameState.pullRadius.toFixed(0)}m</dd>
</div>
<div className="rounded-2xl bg-black/20 p-3">
<dt className="text-slate-400">Mode</dt>
<dd className="mt-1 text-xl font-semibold">Sandbox</dd>
<dt className="text-slate-400">FPS</dt>
<dd className="mt-1 text-xl font-semibold">{gameState.fps.toFixed(0)}</dd>
</div>
<div className="rounded-2xl bg-black/20 p-3">
<dt className="text-slate-400">Consumed</dt>
<dd className="mt-1 text-xl font-semibold">{gameState.consumedDust}</dd>
</div>
<div className="rounded-2xl bg-black/20 p-3">
<dt className="text-slate-400">Dust Left</dt>
<dd className="mt-1 text-xl font-semibold">{gameState.dustRemaining}</dd>
</div>
</dl>
@@ -44,4 +58,3 @@ export function Hud() {
</aside>
);
}

View File

@@ -1,7 +1,13 @@
import { useEffect, useRef } from "react";
import { initPlaceholderScene } from "./placeholderScene";
import { initMatterScene } from "./matterScene";
import { type GameState } from "./types";
export function GameShell() {
interface GameShellProps {
gameState: GameState;
onStateChange: (state: GameState) => void;
}
export function GameShell({ gameState, onStateChange }: GameShellProps) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
@@ -11,17 +17,19 @@ export function GameShell() {
return;
}
return initPlaceholderScene(canvas);
}, []);
return initMatterScene(canvas, onStateChange);
}, [onStateChange]);
return (
<section className="relative overflow-hidden rounded-[2rem] border border-white/10 bg-nebula shadow-2xl shadow-slate-950/40">
<canvas ref={canvasRef} className="h-[560px] w-full" />
<div className="pointer-events-none absolute inset-x-0 top-0 flex items-center justify-between p-4 text-xs uppercase tracking-[0.25em] text-slate-300/70">
<span>Simulation viewport</span>
<span>Placeholder render</span>
<span>{gameState.tier}</span>
</div>
<div className="pointer-events-none absolute inset-x-0 bottom-0 p-4 text-sm text-slate-300/80">
Drag inside the black hole to steer. Dust caught inside the event horizon increases mass and radius.
</div>
</section>
);
}

View File

@@ -0,0 +1,365 @@
import {
Bodies,
Body,
Composite,
Engine,
Events,
Vector,
World,
type Body as MatterBody,
type IEventCollision
} from "matter-js";
import { getTierForMass } from "./progression";
import { defaultGameState, type GameState } from "./types";
const STARTING_MASS = defaultGameState.mass;
const STARTING_RADIUS = defaultGameState.radius;
const DUST_COUNT = 36;
const DUST_REWARD = 2;
const EMIT_INTERVAL_MS = 900;
interface DustBody extends MatterBody {
plugin: {
isDust?: boolean;
};
}
export function initMatterScene(canvas: HTMLCanvasElement, onStateChange: (state: GameState) => void) {
const context = canvas.getContext("2d");
if (!context) {
return;
}
const engine = Engine.create({
gravity: { x: 0, y: 0 }
});
const blackHole = Bodies.circle(0, 0, STARTING_RADIUS, {
isStatic: true,
isSensor: true,
frictionAir: 0,
render: {
visible: false
}
});
const boundaries: MatterBody[] = [];
const dustBodies = new Set<DustBody>();
const consumedBodies = new WeakSet<MatterBody>();
const pointer = { x: 0, y: 0 };
const worldSize = { width: 0, height: 0 };
let mass = STARTING_MASS;
let radius = STARTING_RADIUS;
let dragging = false;
let animationFrame = 0;
let lastTimestamp = 0;
let fps = 60;
let consumedDust = 0;
let lastEmit = 0;
World.add(engine.world, blackHole);
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
const getPullRadius = () => radius * 3.1;
const emitState = () => {
onStateChange({
mass,
radius,
pullRadius: getPullRadius(),
tier: getTierForMass(mass),
consumedDust,
dustRemaining: dustBodies.size,
fps
});
};
const resizeCanvas = () => {
const rect = canvas.getBoundingClientRect();
const pixelRatio = window.devicePixelRatio || 1;
canvas.width = Math.floor(rect.width * pixelRatio);
canvas.height = Math.floor(rect.height * pixelRatio);
context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
worldSize.width = rect.width;
worldSize.height = rect.height;
Body.setPosition(blackHole, {
x: clamp(blackHole.position.x || rect.width / 2, radius + 12, rect.width - radius - 12),
y: clamp(blackHole.position.y || rect.height / 2, radius + 12, rect.height - radius - 12)
});
if (boundaries.length > 0) {
World.remove(engine.world, boundaries);
boundaries.length = 0;
}
const wallThickness = 80;
boundaries.push(
Bodies.rectangle(rect.width / 2, -wallThickness / 2, rect.width, wallThickness, { isStatic: true }),
Bodies.rectangle(rect.width / 2, rect.height + wallThickness / 2, rect.width, wallThickness, {
isStatic: true
}),
Bodies.rectangle(-wallThickness / 2, rect.height / 2, wallThickness, rect.height, { isStatic: true }),
Bodies.rectangle(rect.width + wallThickness / 2, rect.height / 2, wallThickness, rect.height, {
isStatic: true
})
);
World.add(engine.world, boundaries);
};
const createDust = () => {
const padding = 48;
let attempts = 0;
let position = { x: worldSize.width / 2, y: worldSize.height / 2 };
while (attempts < 20) {
position = {
x: padding + Math.random() * Math.max(1, worldSize.width - padding * 2),
y: padding + Math.random() * Math.max(1, worldSize.height - padding * 2)
};
if (Vector.magnitude(Vector.sub(position, blackHole.position)) > radius * 4) {
break;
}
attempts += 1;
}
const dust = Bodies.circle(position.x, position.y, 4 + Math.random() * 4, {
restitution: 0.95,
frictionAir: 0.01,
friction: 0,
density: 0.0008,
render: {
visible: false
}
}) as DustBody;
dust.plugin = {
...dust.plugin,
isDust: true
};
Body.setVelocity(dust, {
x: (Math.random() - 0.5) * 1.8,
y: (Math.random() - 0.5) * 1.8
});
dustBodies.add(dust);
World.add(engine.world, dust);
};
const topOffDust = () => {
while (dustBodies.size < DUST_COUNT) {
createDust();
}
};
const syncHoleScale = (nextRadius: number) => {
const scale = nextRadius / radius;
radius = nextRadius;
Body.scale(blackHole, scale, scale);
};
const absorbDust = (dust: DustBody) => {
if (consumedBodies.has(dust)) {
return;
}
consumedBodies.add(dust);
dustBodies.delete(dust);
World.remove(engine.world, dust);
consumedDust += 1;
mass += DUST_REWARD;
const targetRadius = STARTING_RADIUS + Math.sqrt(mass - STARTING_MASS) * 2.6;
syncHoleScale(targetRadius);
emitState();
};
const handleCollision = (event: IEventCollision<Engine>) => {
for (const pair of event.pairs) {
const [bodyA, bodyB] = [pair.bodyA as DustBody, pair.bodyB as DustBody];
const dust = bodyA.plugin?.isDust ? bodyA : bodyB.plugin?.isDust ? bodyB : null;
const other = dust === bodyA ? bodyB : bodyA;
if (dust && other === blackHole) {
absorbDust(dust);
}
}
};
const updatePointer = (event: PointerEvent) => {
const rect = canvas.getBoundingClientRect();
pointer.x = event.clientX - rect.left;
pointer.y = event.clientY - rect.top;
};
const onPointerDown = (event: PointerEvent) => {
updatePointer(event);
if (Vector.magnitude(Vector.sub(pointer, blackHole.position)) <= radius * 1.35) {
dragging = true;
canvas.setPointerCapture(event.pointerId);
}
};
const onPointerMove = (event: PointerEvent) => {
updatePointer(event);
};
const endDrag = (event?: PointerEvent) => {
dragging = false;
if (event) {
updatePointer(event);
if (canvas.hasPointerCapture(event.pointerId)) {
canvas.releasePointerCapture(event.pointerId);
}
}
};
const drawBackground = (width: number, height: number) => {
const gradient = context.createLinearGradient(0, 0, width, height);
gradient.addColorStop(0, "#020617");
gradient.addColorStop(0.45, "#0f172a");
gradient.addColorStop(1, "#082f49");
context.fillStyle = gradient;
context.fillRect(0, 0, width, height);
for (let i = 0; i < 40; i += 1) {
const x = (i * 137.5) % width;
const y = (i * 79.2) % height;
context.fillStyle = i % 7 === 0 ? "rgba(249, 115, 22, 0.65)" : "rgba(226, 232, 240, 0.35)";
context.beginPath();
context.arc(x, y, i % 3 === 0 ? 1.8 : 1.1, 0, Math.PI * 2);
context.fill();
}
};
const renderScene = () => {
const width = worldSize.width;
const height = worldSize.height;
drawBackground(width, height);
context.strokeStyle = "rgba(125, 211, 252, 0.18)";
context.lineWidth = 1;
for (let x = 0; x < width; x += 56) {
context.beginPath();
context.moveTo(x, 0);
context.lineTo(x, height);
context.stroke();
}
for (let y = 0; y < height; y += 56) {
context.beginPath();
context.moveTo(0, y);
context.lineTo(width, y);
context.stroke();
}
for (const dust of dustBodies) {
context.fillStyle = "rgba(226, 232, 240, 0.95)";
context.beginPath();
context.arc(dust.position.x, dust.position.y, dust.circleRadius ?? 5, 0, Math.PI * 2);
context.fill();
}
const pullRadius = getPullRadius();
const blackHoleGradient = context.createRadialGradient(
blackHole.position.x,
blackHole.position.y,
radius * 0.4,
blackHole.position.x,
blackHole.position.y,
pullRadius
);
blackHoleGradient.addColorStop(0, "rgba(2, 6, 23, 1)");
blackHoleGradient.addColorStop(0.3, "rgba(8, 47, 73, 0.75)");
blackHoleGradient.addColorStop(1, "rgba(14, 165, 233, 0)");
context.fillStyle = blackHoleGradient;
context.beginPath();
context.arc(blackHole.position.x, blackHole.position.y, pullRadius, 0, Math.PI * 2);
context.fill();
context.strokeStyle = dragging ? "rgba(249, 115, 22, 0.95)" : "rgba(125, 211, 252, 0.65)";
context.lineWidth = dragging ? 7 : 5;
context.beginPath();
context.arc(blackHole.position.x, blackHole.position.y, radius + 10, 0, Math.PI * 2);
context.stroke();
context.fillStyle = "#020617";
context.beginPath();
context.arc(blackHole.position.x, blackHole.position.y, radius, 0, Math.PI * 2);
context.fill();
};
const tick = (timestamp: number) => {
const delta = lastTimestamp === 0 ? 16.6667 : Math.min(33.333, timestamp - lastTimestamp);
lastTimestamp = timestamp;
fps = fps * 0.9 + (1000 / delta) * 0.1;
if (dragging) {
Body.setPosition(blackHole, {
x: clamp(pointer.x, radius + 12, worldSize.width - radius - 12),
y: clamp(pointer.y, radius + 12, worldSize.height - radius - 12)
});
Body.setVelocity(blackHole, { x: 0, y: 0 });
}
for (const dust of dustBodies) {
const toHole = Vector.sub(blackHole.position, dust.position);
const distance = Math.max(1, Vector.magnitude(toHole));
if (distance < getPullRadius()) {
const direction = Vector.normalise(toHole);
const strength = clamp((getPullRadius() - distance) / getPullRadius(), 0, 1) * 0.00016;
Body.applyForce(dust, dust.position, Vector.mult(direction, strength * dust.mass));
}
}
Engine.update(engine, delta);
if (timestamp - lastEmit > EMIT_INTERVAL_MS) {
topOffDust();
emitState();
lastEmit = timestamp;
}
renderScene();
animationFrame = window.requestAnimationFrame(tick);
};
resizeCanvas();
topOffDust();
emitState();
Events.on(engine, "collisionStart", handleCollision);
canvas.addEventListener("pointerdown", onPointerDown);
canvas.addEventListener("pointermove", onPointerMove);
canvas.addEventListener("pointerup", endDrag);
canvas.addEventListener("pointerleave", endDrag);
window.addEventListener("resize", resizeCanvas);
animationFrame = window.requestAnimationFrame(tick);
return () => {
window.cancelAnimationFrame(animationFrame);
Events.off(engine, "collisionStart", handleCollision);
canvas.removeEventListener("pointerdown", onPointerDown);
canvas.removeEventListener("pointermove", onPointerMove);
canvas.removeEventListener("pointerup", endDrag);
canvas.removeEventListener("pointerleave", endDrag);
window.removeEventListener("resize", resizeCanvas);
World.clear(engine.world, false);
Engine.clear(engine);
};
}

View File

@@ -1,69 +0,0 @@
export function initPlaceholderScene(canvas: HTMLCanvasElement) {
const context = canvas.getContext("2d");
if (!context) {
return;
}
let animationFrame = 0;
const resize = () => {
const rect = canvas.getBoundingClientRect();
canvas.width = Math.floor(rect.width * window.devicePixelRatio);
canvas.height = Math.floor(rect.height * window.devicePixelRatio);
context.setTransform(window.devicePixelRatio, 0, 0, window.devicePixelRatio, 0, 0);
};
const render = (time: number) => {
const width = canvas.width / window.devicePixelRatio;
const height = canvas.height / window.devicePixelRatio;
const cx = width / 2;
const cy = height / 2;
context.clearRect(0, 0, width, height);
const gradient = context.createRadialGradient(cx, cy, 24, cx, cy, Math.max(width, height) * 0.45);
gradient.addColorStop(0, "rgba(14, 165, 233, 0.35)");
gradient.addColorStop(0.35, "rgba(15, 23, 42, 0.4)");
gradient.addColorStop(1, "rgba(2, 6, 23, 1)");
context.fillStyle = gradient;
context.fillRect(0, 0, width, height);
for (let i = 0; i < 60; i += 1) {
const orbit = 40 + i * 6;
const angle = time * 0.0002 * (1 + i * 0.01) + i;
const x = cx + Math.cos(angle) * orbit;
const y = cy + Math.sin(angle) * orbit * 0.55;
const size = 1 + (i % 3);
context.fillStyle = i % 8 === 0 ? "#f97316" : "#e2e8f0";
context.beginPath();
context.arc(x, y, size, 0, Math.PI * 2);
context.fill();
}
context.fillStyle = "#020617";
context.beginPath();
context.arc(cx, cy, 38, 0, Math.PI * 2);
context.fill();
context.strokeStyle = "rgba(125, 211, 252, 0.45)";
context.lineWidth = 8;
context.beginPath();
context.arc(cx, cy, 54 + Math.sin(time * 0.002) * 4, 0, Math.PI * 2);
context.stroke();
animationFrame = window.requestAnimationFrame(render);
};
resize();
animationFrame = window.requestAnimationFrame(render);
window.addEventListener("resize", resize);
return () => {
window.cancelAnimationFrame(animationFrame);
window.removeEventListener("resize", resize);
};
}

View File

@@ -0,0 +1,15 @@
import { describe, expect, it } from "vitest";
import { getTierForMass } from "./progression";
describe("getTierForMass", () => {
it("maps early mass to Dustling", () => {
expect(getTierForMass(12)).toBe("Dustling");
});
it("promotes mass thresholds into later tiers", () => {
expect(getTierForMass(20)).toBe("Rift Seed");
expect(getTierForMass(40)).toBe("Event Core");
expect(getTierForMass(70)).toBe("World Eater");
expect(getTierForMass(110)).toBe("Proto Star");
});
});

View File

@@ -0,0 +1,19 @@
export function getTierForMass(mass: number) {
if (mass >= 110) {
return "Proto Star";
}
if (mass >= 70) {
return "World Eater";
}
if (mass >= 40) {
return "Event Core";
}
if (mass >= 20) {
return "Rift Seed";
}
return "Dustling";
}

21
client/src/game/types.ts Normal file
View File

@@ -0,0 +1,21 @@
import { getTierForMass } from "./progression";
export interface GameState {
mass: number;
radius: number;
pullRadius: number;
tier: string;
consumedDust: number;
dustRemaining: number;
fps: number;
}
export const defaultGameState: GameState = {
mass: 12,
radius: 24,
pullRadius: 74.4,
tier: getTierForMass(12),
consumedDust: 0,
dustRemaining: 0,
fps: 60
};

View File

@@ -1 +0,0 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/vitest.setup.ts","./src/components/hud.test.tsx","./src/components/hud.tsx","./src/game/gameshell.tsx","./src/game/placeholderscene.ts","./src/utils/constants.ts"],"version":"5.9.3"}

8
package-lock.json generated
View File

@@ -29,6 +29,7 @@
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/matter-js": "^0.20.2",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^4.4.1",
@@ -1613,6 +1614,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/matter-js": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@types/matter-js/-/matter-js-0.20.2.tgz",
"integrity": "sha512-3PPKy3QxvZ89h9+wdBV2488I1JLVs7DEpIkPvgO8JC1mUdiVSO37ZIvVctOTD7hIq8OAL2gJ3ugGSuUip6DhCw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "22.19.15",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz",