diff --git a/.gitignore b/.gitignore index 8f1cf88..36f000d 100644 --- a/.gitignore +++ b/.gitignore @@ -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 - diff --git a/client/package.json b/client/package.json index 276e23c..f23c388 100644 --- a/client/package.json +++ b/client/package.json @@ -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" } } - diff --git a/client/src/App.tsx b/client/src/App.tsx index 80ca186..25a6291 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -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(defaultGameState); + return (
@@ -13,16 +17,15 @@ export default function App() {
- 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.
- - + +
); } - diff --git a/client/src/components/Hud.test.tsx b/client/src/components/Hud.test.tsx index 5455138..52fe0fc 100644 --- a/client/src/components/Hud.test.tsx +++ b/client/src/components/Hud.test.tsx @@ -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(); + render(); - 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(); }); }); diff --git a/client/src/components/Hud.tsx b/client/src/components/Hud.tsx index 1b00e0c..6c20b95 100644 --- a/client/src/components/Hud.tsx +++ b/client/src/components/Hud.tsx @@ -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 ( ); } - diff --git a/client/src/game/GameShell.tsx b/client/src/game/GameShell.tsx index bdbe73b..61cd6b9 100644 --- a/client/src/game/GameShell.tsx +++ b/client/src/game/GameShell.tsx @@ -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(null); useEffect(() => { @@ -11,17 +17,19 @@ export function GameShell() { return; } - return initPlaceholderScene(canvas); - }, []); + return initMatterScene(canvas, onStateChange); + }, [onStateChange]); return (
Simulation viewport - Placeholder render + {gameState.tier} +
+
+ Drag inside the black hole to steer. Dust caught inside the event horizon increases mass and radius.
); } - diff --git a/client/src/game/matterScene.ts b/client/src/game/matterScene.ts new file mode 100644 index 0000000..bce80e4 --- /dev/null +++ b/client/src/game/matterScene.ts @@ -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(); + const consumedBodies = new WeakSet(); + 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) => { + 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); + }; +} diff --git a/client/src/game/placeholderScene.ts b/client/src/game/placeholderScene.ts deleted file mode 100644 index 2b0e5f0..0000000 --- a/client/src/game/placeholderScene.ts +++ /dev/null @@ -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); - }; -} - diff --git a/client/src/game/progression.test.ts b/client/src/game/progression.test.ts new file mode 100644 index 0000000..7acd0fe --- /dev/null +++ b/client/src/game/progression.test.ts @@ -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"); + }); +}); diff --git a/client/src/game/progression.ts b/client/src/game/progression.ts new file mode 100644 index 0000000..9b1db56 --- /dev/null +++ b/client/src/game/progression.ts @@ -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"; +} diff --git a/client/src/game/types.ts b/client/src/game/types.ts new file mode 100644 index 0000000..df04ad8 --- /dev/null +++ b/client/src/game/types.ts @@ -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 +}; diff --git a/client/tsconfig.tsbuildinfo b/client/tsconfig.tsbuildinfo deleted file mode 100644 index 8d9a5ef..0000000 --- a/client/tsconfig.tsbuildinfo +++ /dev/null @@ -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"} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index d21ff97..da4774b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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",