This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
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"}
|
||||
Reference in New Issue
Block a user