This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -3,6 +3,7 @@ dist/
|
|||||||
coverage/
|
coverage/
|
||||||
.vite/
|
.vite/
|
||||||
*.log
|
*.log
|
||||||
|
*.tsbuildinfo
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
server/data/
|
server/data/
|
||||||
@@ -12,4 +13,3 @@ server/data/
|
|||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
"@testing-library/jest-dom": "^6.6.3",
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
"@testing-library/react": "^16.3.0",
|
"@testing-library/react": "^16.3.0",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
|
"@types/matter-js": "^0.20.2",
|
||||||
"@types/react": "^18.3.18",
|
"@types/react": "^18.3.18",
|
||||||
"@types/react-dom": "^18.3.5",
|
"@types/react-dom": "^18.3.5",
|
||||||
"@vitejs/plugin-react": "^4.4.1",
|
"@vitejs/plugin-react": "^4.4.1",
|
||||||
@@ -31,4 +32,3 @@
|
|||||||
"vitest": "^3.0.8"
|
"vitest": "^3.0.8"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
|
import { useState } from "react";
|
||||||
import { Hud } from "./components/Hud";
|
import { Hud } from "./components/Hud";
|
||||||
import { GameShell } from "./game/GameShell";
|
import { GameShell } from "./game/GameShell";
|
||||||
|
import { defaultGameState, type GameState } from "./game/types";
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
const [gameState, setGameState] = useState<GameState>(defaultGameState);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-ink text-slate-100">
|
<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">
|
<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>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="max-w-sm text-right text-sm text-slate-300">
|
<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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="grid flex-1 gap-6 lg:grid-cols-[1fr_320px]">
|
<div className="grid flex-1 gap-6 lg:grid-cols-[1fr_320px]">
|
||||||
<GameShell />
|
<GameShell gameState={gameState} onStateChange={setGameState} />
|
||||||
<Hud />
|
<Hud gameState={gameState} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen } from "@testing-library/react";
|
||||||
import { Hud } from "./Hud";
|
import { Hud } from "./Hud";
|
||||||
|
import { defaultGameState } from "../game/types";
|
||||||
|
|
||||||
describe("Hud", () => {
|
describe("Hud", () => {
|
||||||
it("renders the boot sequence panel", () => {
|
it("renders the boot sequence panel", () => {
|
||||||
render(<Hud />);
|
render(<Hud gameState={defaultGameState} />);
|
||||||
|
|
||||||
expect(screen.getByText("Boot Sequence")).toBeTruthy();
|
expect(screen.getByText("Sector Absorption")).toBeTruthy();
|
||||||
expect(screen.getByText("Bootstrap Matter.js world")).toBeTruthy();
|
expect(screen.getByText("Reach mass 40 to stabilize your pull")).toBeTruthy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,33 +1,47 @@
|
|||||||
|
import { type GameState } from "../game/types";
|
||||||
|
|
||||||
const objectives = [
|
const objectives = [
|
||||||
"Bootstrap Matter.js world",
|
"Reach mass 40 to stabilize your pull",
|
||||||
"Wire player state to the API",
|
"Keep sweeping the field to chain dust pickups",
|
||||||
"Start M1 absorber loop"
|
"Persist profile data in M3"
|
||||||
];
|
];
|
||||||
|
|
||||||
export function Hud() {
|
interface HudProps {
|
||||||
|
gameState: GameState;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Hud({ gameState }: HudProps) {
|
||||||
return (
|
return (
|
||||||
<aside className="rounded-3xl border border-white/10 bg-white/5 p-5 shadow-2xl shadow-cyan-950/30 backdrop-blur">
|
<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">
|
<div className="mb-6">
|
||||||
<p className="text-xs uppercase tracking-[0.3em] text-glow/70">Mission Board</p>
|
<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>
|
</div>
|
||||||
|
|
||||||
<dl className="grid grid-cols-2 gap-3 text-sm">
|
<dl className="grid grid-cols-2 gap-3 text-sm">
|
||||||
<div className="rounded-2xl bg-black/20 p-3">
|
<div className="rounded-2xl bg-black/20 p-3">
|
||||||
<dt className="text-slate-400">Mass</dt>
|
<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>
|
||||||
<div className="rounded-2xl bg-black/20 p-3">
|
<div className="rounded-2xl bg-black/20 p-3">
|
||||||
<dt className="text-slate-400">Tier</dt>
|
<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>
|
||||||
<div className="rounded-2xl bg-black/20 p-3">
|
<div className="rounded-2xl bg-black/20 p-3">
|
||||||
<dt className="text-slate-400">Pull Radius</dt>
|
<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>
|
||||||
<div className="rounded-2xl bg-black/20 p-3">
|
<div className="rounded-2xl bg-black/20 p-3">
|
||||||
<dt className="text-slate-400">Mode</dt>
|
<dt className="text-slate-400">FPS</dt>
|
||||||
<dd className="mt-1 text-xl font-semibold">Sandbox</dd>
|
<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>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
@@ -44,4 +58,3 @@ export function Hud() {
|
|||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import { useEffect, useRef } from "react";
|
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);
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -11,17 +17,19 @@ export function GameShell() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
return initPlaceholderScene(canvas);
|
return initMatterScene(canvas, onStateChange);
|
||||||
}, []);
|
}, [onStateChange]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="relative overflow-hidden rounded-[2rem] border border-white/10 bg-nebula shadow-2xl shadow-slate-950/40">
|
<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" />
|
<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">
|
<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>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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
365
client/src/game/matterScene.ts
Normal file
365
client/src/game/matterScene.ts
Normal 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);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
15
client/src/game/progression.test.ts
Normal file
15
client/src/game/progression.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
19
client/src/game/progression.ts
Normal file
19
client/src/game/progression.ts
Normal 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
21
client/src/game/types.ts
Normal 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
|
||||||
|
};
|
||||||
@@ -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
8
package-lock.json
generated
@@ -29,6 +29,7 @@
|
|||||||
"@testing-library/jest-dom": "^6.6.3",
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
"@testing-library/react": "^16.3.0",
|
"@testing-library/react": "^16.3.0",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
|
"@types/matter-js": "^0.20.2",
|
||||||
"@types/react": "^18.3.18",
|
"@types/react": "^18.3.18",
|
||||||
"@types/react-dom": "^18.3.5",
|
"@types/react-dom": "^18.3.5",
|
||||||
"@vitejs/plugin-react": "^4.4.1",
|
"@vitejs/plugin-react": "^4.4.1",
|
||||||
@@ -1613,6 +1614,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/node": {
|
||||||
"version": "22.19.15",
|
"version": "22.19.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz",
|
||||||
|
|||||||
Reference in New Issue
Block a user