m2
Some checks failed
CI / validate (push) Has been cancelled

This commit is contained in:
2026-03-23 00:06:01 -05:00
parent 23ac496963
commit 1a9209431b
14 changed files with 258 additions and 83 deletions

View File

@@ -7,6 +7,6 @@ describe("Hud", () => {
render(<Hud gameState={defaultGameState} />);
expect(screen.getByText("Sector Absorption")).toBeTruthy();
expect(screen.getByText("Reach mass 40 to stabilize your pull")).toBeTruthy();
expect(screen.getByText("Clear lower-tier debris to unlock heavier targets")).toBeTruthy();
});
});

View File

@@ -1,8 +1,8 @@
import { type GameState } from "../game/types";
const objectives = [
"Reach mass 40 to stabilize your pull",
"Keep sweeping the field to chain dust pickups",
"Clear lower-tier debris to unlock heavier targets",
"Meteorites and larger bodies only absorb after their mass gate",
"Persist profile data in M3"
];
@@ -37,11 +37,19 @@ export function Hud({ gameState }: HudProps) {
</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>
<dd className="mt-1 text-xl font-semibold">{gameState.consumedObjects}</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>
<dt className="text-slate-400">Objects Left</dt>
<dd className="mt-1 text-xl font-semibold">{gameState.objectsRemaining}</dd>
</div>
<div className="rounded-2xl bg-black/20 p-3">
<dt className="text-slate-400">Max Prey</dt>
<dd className="mt-1 text-xl font-semibold">{gameState.preyTier}</dd>
</div>
<div className="rounded-2xl bg-black/20 p-3">
<dt className="text-slate-400">Next Unlock</dt>
<dd className="mt-1 text-xl font-semibold">{gameState.nextUnlock ?? "Complete"}</dd>
</div>
</dl>

View File

@@ -1,7 +1,6 @@
import {
Bodies,
Body,
Composite,
Engine,
Events,
Vector,
@@ -9,18 +8,17 @@ import {
type Body as MatterBody,
type IEventCollision
} from "matter-js";
import { getTierForMass } from "./progression";
import { getCurrentPreyTier, getNextUnlock, getTierForMass, getUnlockedKinds } from "./progression";
import { CELESTIAL_OBJECTS, type CelestialKind, type CelestialObjectDefinition } from "./spaceObjects";
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 {
interface CelestialBody extends MatterBody {
plugin: {
isDust?: boolean;
celestialKind?: CelestialKind;
};
}
@@ -43,7 +41,7 @@ export function initMatterScene(canvas: HTMLCanvasElement, onStateChange: (state
}
});
const boundaries: MatterBody[] = [];
const dustBodies = new Set<DustBody>();
const celestialBodies = new Set<CelestialBody>();
const consumedBodies = new WeakSet<MatterBody>();
const pointer = { x: 0, y: 0 };
const worldSize = { width: 0, height: 0 };
@@ -54,7 +52,7 @@ export function initMatterScene(canvas: HTMLCanvasElement, onStateChange: (state
let animationFrame = 0;
let lastTimestamp = 0;
let fps = 60;
let consumedDust = 0;
let consumedObjects = 0;
let lastEmit = 0;
World.add(engine.world, blackHole);
@@ -63,14 +61,24 @@ export function initMatterScene(canvas: HTMLCanvasElement, onStateChange: (state
const getPullRadius = () => radius * 3.1;
const getRadiusForMass = (nextMass: number) => STARTING_RADIUS + Math.sqrt(nextMass - STARTING_MASS) * 2.6;
const getObjectDef = (kind: CelestialKind) =>
CELESTIAL_OBJECTS.find((objectDef) => objectDef.kind === kind) as CelestialObjectDefinition;
const getFieldCount = (kind: CelestialKind) =>
Array.from(celestialBodies).filter((body) => body.plugin.celestialKind === kind).length;
const emitState = () => {
onStateChange({
mass,
radius,
pullRadius: getPullRadius(),
tier: getTierForMass(mass),
consumedDust,
dustRemaining: dustBodies.size,
consumedObjects,
objectsRemaining: celestialBodies.size,
preyTier: getCurrentPreyTier(mass),
nextUnlock: getNextUnlock(mass),
fps
});
};
@@ -111,7 +119,7 @@ export function initMatterScene(canvas: HTMLCanvasElement, onStateChange: (state
World.add(engine.world, boundaries);
};
const createDust = () => {
const createCelestialObject = (objectDef: CelestialObjectDefinition) => {
const padding = 48;
let attempts = 0;
let position = { x: worldSize.width / 2, y: worldSize.height / 2 };
@@ -129,33 +137,35 @@ export function initMatterScene(canvas: HTMLCanvasElement, onStateChange: (state
attempts += 1;
}
const dust = Bodies.circle(position.x, position.y, 4 + Math.random() * 4, {
restitution: 0.95,
frictionAir: 0.01,
const body = Bodies.circle(position.x, position.y, objectDef.radius + (Math.random() - 0.5) * objectDef.radius * 0.2, {
restitution: 0.92,
frictionAir: objectDef.frictionAir,
friction: 0,
density: 0.0008,
density: objectDef.density,
render: {
visible: false
}
}) as DustBody;
}) as CelestialBody;
dust.plugin = {
...dust.plugin,
isDust: true
body.plugin = {
...body.plugin,
celestialKind: objectDef.kind
};
Body.setVelocity(dust, {
x: (Math.random() - 0.5) * 1.8,
y: (Math.random() - 0.5) * 1.8
Body.setVelocity(body, {
x: (Math.random() - 0.5) * (1.9 - objectDef.radius * 0.02),
y: (Math.random() - 0.5) * (1.9 - objectDef.radius * 0.02)
});
dustBodies.add(dust);
World.add(engine.world, dust);
celestialBodies.add(body);
World.add(engine.world, body);
};
const topOffDust = () => {
while (dustBodies.size < DUST_COUNT) {
createDust();
const topOffField = () => {
for (const objectDef of CELESTIAL_OBJECTS) {
while (getFieldCount(objectDef.kind) < objectDef.fieldTarget) {
createCelestialObject(objectDef);
}
}
};
@@ -165,31 +175,43 @@ export function initMatterScene(canvas: HTMLCanvasElement, onStateChange: (state
Body.scale(blackHole, scale, scale);
};
const absorbDust = (dust: DustBody) => {
if (consumedBodies.has(dust)) {
const canConsume = (kind: CelestialKind) => getUnlockedKinds(mass).includes(kind);
const rejectBody = (body: CelestialBody) => {
const direction = Vector.normalise(Vector.sub(body.position, blackHole.position));
Body.setVelocity(body, Vector.mult(direction, 5.5));
};
const absorbBody = (body: CelestialBody) => {
if (consumedBodies.has(body)) {
return;
}
consumedBodies.add(dust);
dustBodies.delete(dust);
World.remove(engine.world, dust);
consumedBodies.add(body);
celestialBodies.delete(body);
World.remove(engine.world, body);
consumedDust += 1;
mass += DUST_REWARD;
consumedObjects += 1;
mass += getObjectDef(body.plugin.celestialKind as CelestialKind).rewardMass;
const targetRadius = STARTING_RADIUS + Math.sqrt(mass - STARTING_MASS) * 2.6;
syncHoleScale(targetRadius);
syncHoleScale(getRadiusForMass(mass));
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;
const [bodyA, bodyB] = [pair.bodyA as CelestialBody, pair.bodyB as CelestialBody];
const body = bodyA.plugin?.celestialKind ? bodyA : bodyB.plugin?.celestialKind ? bodyB : null;
const other = body === bodyA ? bodyB : bodyA;
if (dust && other === blackHole) {
absorbDust(dust);
if (body && other === blackHole) {
const kind = body.plugin.celestialKind as CelestialKind;
if (canConsume(kind)) {
absorbBody(body);
} else {
rejectBody(body);
}
}
}
};
@@ -264,11 +286,31 @@ export function initMatterScene(canvas: HTMLCanvasElement, onStateChange: (state
context.stroke();
}
for (const dust of dustBodies) {
context.fillStyle = "rgba(226, 232, 240, 0.95)";
for (const body of celestialBodies) {
const objectDef = getObjectDef(body.plugin.celestialKind as CelestialKind);
const isUnlocked = canConsume(objectDef.kind);
const bodyRadius = body.circleRadius ?? objectDef.radius;
context.fillStyle = objectDef.color;
context.beginPath();
context.arc(dust.position.x, dust.position.y, dust.circleRadius ?? 5, 0, Math.PI * 2);
context.arc(body.position.x, body.position.y, bodyRadius, 0, Math.PI * 2);
context.fill();
if (objectDef.kind !== "dust") {
context.strokeStyle = objectDef.accent;
context.lineWidth = objectDef.kind === "star" ? 3 : 2;
context.beginPath();
context.arc(body.position.x, body.position.y, bodyRadius + (objectDef.kind === "planet" ? 5 : 3), 0, Math.PI * 2);
context.stroke();
}
if (!isUnlocked) {
context.strokeStyle = "rgba(248, 113, 113, 0.8)";
context.lineWidth = 2;
context.beginPath();
context.arc(body.position.x, body.position.y, bodyRadius + 7, 0, Math.PI * 2);
context.stroke();
}
}
const pullRadius = getPullRadius();
@@ -314,21 +356,26 @@ export function initMatterScene(canvas: HTMLCanvasElement, onStateChange: (state
Body.setVelocity(blackHole, { x: 0, y: 0 });
}
for (const dust of dustBodies) {
const toHole = Vector.sub(blackHole.position, dust.position);
for (const body of celestialBodies) {
const kind = body.plugin.celestialKind as CelestialKind;
const objectDef = getObjectDef(kind);
const toHole = Vector.sub(blackHole.position, body.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));
const strength =
clamp((getPullRadius() - distance) / getPullRadius(), 0, 1) *
(canConsume(kind) ? 0.00016 : 0.00008) *
(1 / Math.max(1, objectDef.radius * 0.08));
Body.applyForce(body, body.position, Vector.mult(direction, strength * body.mass));
}
}
Engine.update(engine, delta);
if (timestamp - lastEmit > EMIT_INTERVAL_MS) {
topOffDust();
topOffField();
emitState();
lastEmit = timestamp;
}
@@ -338,7 +385,7 @@ export function initMatterScene(canvas: HTMLCanvasElement, onStateChange: (state
};
resizeCanvas();
topOffDust();
topOffField();
emitState();
Events.on(engine, "collisionStart", handleCollision);

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { getTierForMass } from "./progression";
import { getCurrentPreyTier, getNextUnlock, getTierForMass } from "./progression";
describe("getTierForMass", () => {
it("maps early mass to Dustling", () => {
@@ -8,8 +8,25 @@ describe("getTierForMass", () => {
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");
expect(getTierForMass(24)).toBe("Event Core");
expect(getTierForMass(50)).toBe("World Eater");
expect(getTierForMass(95)).toBe("Planet Breaker");
expect(getTierForMass(160)).toBe("Star Forger");
});
it("tracks prey unlocks by mass", () => {
expect(getCurrentPreyTier(12)).toBe("Dust");
expect(getCurrentPreyTier(24)).toBe("Meteorite");
expect(getCurrentPreyTier(50)).toBe("Asteroid");
expect(getCurrentPreyTier(95)).toBe("Planet");
expect(getCurrentPreyTier(160)).toBe("Star");
});
it("returns the next unlock target", () => {
expect(getNextUnlock(12)).toBe("Meteorite");
expect(getNextUnlock(24)).toBe("Asteroid");
expect(getNextUnlock(50)).toBe("Planet");
expect(getNextUnlock(95)).toBe("Star");
expect(getNextUnlock(200)).toBeNull();
});
});

View File

@@ -1,13 +1,19 @@
import { CELESTIAL_OBJECTS, type CelestialKind } from "./spaceObjects";
export function getTierForMass(mass: number) {
if (mass >= 110) {
return "Proto Star";
if (mass >= 160) {
return "Star Forger";
}
if (mass >= 70) {
if (mass >= 95) {
return "Planet Breaker";
}
if (mass >= 50) {
return "World Eater";
}
if (mass >= 40) {
if (mass >= 24) {
return "Event Core";
}
@@ -17,3 +23,17 @@ export function getTierForMass(mass: number) {
return "Dustling";
}
export function getUnlockedKinds(mass: number): CelestialKind[] {
return CELESTIAL_OBJECTS.filter((objectDef) => mass >= objectDef.unlockMass).map(
(objectDef) => objectDef.kind
);
}
export function getCurrentPreyTier(mass: number) {
return CELESTIAL_OBJECTS.filter((objectDef) => mass >= objectDef.unlockMass).at(-1)?.label ?? "Dust";
}
export function getNextUnlock(mass: number) {
return CELESTIAL_OBJECTS.find((objectDef) => mass < objectDef.unlockMass)?.label ?? null;
}

View File

@@ -0,0 +1,77 @@
export type CelestialKind = "dust" | "meteorite" | "asteroid" | "planet" | "star";
export interface CelestialObjectDefinition {
kind: CelestialKind;
label: string;
radius: number;
rewardMass: number;
unlockMass: number;
density: number;
frictionAir: number;
color: string;
accent: string;
fieldTarget: number;
}
export const CELESTIAL_OBJECTS: CelestialObjectDefinition[] = [
{
kind: "dust",
label: "Dust",
radius: 5,
rewardMass: 2,
unlockMass: 0,
density: 0.0008,
frictionAir: 0.01,
color: "#e2e8f0",
accent: "#ffffff",
fieldTarget: 24
},
{
kind: "meteorite",
label: "Meteorite",
radius: 10,
rewardMass: 6,
unlockMass: 24,
density: 0.0015,
frictionAir: 0.012,
color: "#f97316",
accent: "#fdba74",
fieldTarget: 10
},
{
kind: "asteroid",
label: "Asteroid",
radius: 16,
rewardMass: 14,
unlockMass: 50,
density: 0.0026,
frictionAir: 0.013,
color: "#94a3b8",
accent: "#cbd5e1",
fieldTarget: 6
},
{
kind: "planet",
label: "Planet",
radius: 24,
rewardMass: 30,
unlockMass: 95,
density: 0.0035,
frictionAir: 0.014,
color: "#38bdf8",
accent: "#a5f3fc",
fieldTarget: 3
},
{
kind: "star",
label: "Star",
radius: 34,
rewardMass: 60,
unlockMass: 160,
density: 0.0042,
frictionAir: 0.015,
color: "#facc15",
accent: "#fde68a",
fieldTarget: 1
}
];

View File

@@ -5,8 +5,10 @@ export interface GameState {
radius: number;
pullRadius: number;
tier: string;
consumedDust: number;
dustRemaining: number;
consumedObjects: number;
objectsRemaining: number;
preyTier: string;
nextUnlock: string | null;
fps: number;
}
@@ -15,7 +17,9 @@ export const defaultGameState: GameState = {
radius: 24,
pullRadius: 74.4,
tier: getTierForMass(12),
consumedDust: 0,
dustRemaining: 0,
consumedObjects: 0,
objectsRemaining: 0,
preyTier: "Dust",
nextUnlock: "Meteorite",
fps: 60
};