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

This commit is contained in:
2026-03-23 00:16:50 -05:00
parent 1a9209431b
commit aeadb80fdb
22 changed files with 402 additions and 75 deletions

View File

@@ -1,10 +1,74 @@
import { useState } from "react";
import { useEffect, useRef, useState } from "react";
import { fetchPlayerProfile, savePlayerProfile, type PlayerProfile } from "./api/player";
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);
const [profile, setProfile] = useState<PlayerProfile | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const hasLoaded = useRef(false);
useEffect(() => {
let cancelled = false;
fetchPlayerProfile()
.then((nextProfile) => {
if (cancelled) {
return;
}
setProfile(nextProfile);
})
.catch((error: Error) => {
if (cancelled) {
return;
}
setLoadError(error.message);
setProfile({
id: "local-player",
mass: defaultGameState.mass,
xp: defaultGameState.xp,
evolutions: []
});
});
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
if (!profile) {
return;
}
if (!hasLoaded.current) {
hasLoaded.current = true;
return;
}
const timeoutId = window.setTimeout(() => {
void savePlayerProfile({
mass: gameState.mass,
xp: gameState.xp,
evolutions: profile.evolutions
}).catch(() => undefined);
}, 750);
return () => {
window.clearTimeout(timeoutId);
};
}, [gameState.mass, gameState.xp, profile]);
const initialProfile: PlayerProfile = profile ?? {
id: "local-player",
mass: defaultGameState.mass,
xp: defaultGameState.xp,
evolutions: []
};
return (
<main className="min-h-screen bg-ink text-slate-100">
@@ -17,12 +81,23 @@ export default function App() {
</h1>
</div>
<div className="max-w-sm text-right text-sm text-slate-300">
M1 loop: drag the singularity, consume dust, and grow your event horizon.
M3 persistence: profile state now reloads through the API-backed SQLite store.
</div>
</header>
{loadError ? (
<div className="mb-4 rounded-2xl border border-amber-400/30 bg-amber-500/10 px-4 py-3 text-sm text-amber-100">
Profile load fell back to local defaults: {loadError}
</div>
) : null}
<div className="grid flex-1 gap-6 lg:grid-cols-[1fr_320px]">
<GameShell gameState={gameState} onStateChange={setGameState} />
<GameShell
key={`${initialProfile.id}:${initialProfile.mass}:${initialProfile.xp}`}
gameState={gameState}
initialProfile={initialProfile}
onStateChange={setGameState}
/>
<Hud gameState={gameState} />
</div>
</div>

33
client/src/api/player.ts Normal file
View File

@@ -0,0 +1,33 @@
export interface PlayerProfile {
id: string;
mass: number;
xp: number;
evolutions: string[];
}
export async function fetchPlayerProfile() {
const response = await fetch("/api/player");
if (!response.ok) {
throw new Error(`Failed to load player profile: ${response.status}`);
}
return (await response.json()) as PlayerProfile;
}
export async function savePlayerProfile(profile: Pick<PlayerProfile, "mass" | "xp" | "evolutions">) {
const response = await fetch("/api/player", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(profile)
});
if (!response.ok) {
throw new Error(`Failed to save player profile: ${response.status}`);
}
return (await response.json()) as PlayerProfile;
}

View File

@@ -27,6 +27,10 @@ export function Hud({ gameState }: HudProps) {
<dt className="text-slate-400">Tier</dt>
<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">XP</dt>
<dd className="mt-1 text-xl font-semibold">{gameState.xp}</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">{gameState.pullRadius.toFixed(0)}m</dd>

View File

@@ -1,13 +1,15 @@
import { useEffect, useRef } from "react";
import { initMatterScene } from "./matterScene";
import { type GameState } from "./types";
import { type PlayerProfile } from "../api/player";
interface GameShellProps {
gameState: GameState;
onStateChange: (state: GameState) => void;
initialProfile: PlayerProfile;
}
export function GameShell({ gameState, onStateChange }: GameShellProps) {
export function GameShell({ gameState, onStateChange, initialProfile }: GameShellProps) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
@@ -17,8 +19,8 @@ export function GameShell({ gameState, onStateChange }: GameShellProps) {
return;
}
return initMatterScene(canvas, onStateChange);
}, [onStateChange]);
return initMatterScene(canvas, onStateChange, initialProfile);
}, [initialProfile, onStateChange]);
return (
<section className="relative overflow-hidden rounded-[2rem] border border-white/10 bg-nebula shadow-2xl shadow-slate-950/40">
@@ -28,7 +30,7 @@ export function GameShell({ gameState, onStateChange }: GameShellProps) {
<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.
Drag inside the black hole to steer. Profile progress now loads from and saves to the API-backed SQLite store.
</div>
</section>
);

View File

@@ -8,12 +8,11 @@ import {
type Body as MatterBody,
type IEventCollision
} from "matter-js";
import { type PlayerProfile } from "../api/player";
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 EMIT_INTERVAL_MS = 900;
interface CelestialBody extends MatterBody {
@@ -22,7 +21,11 @@ interface CelestialBody extends MatterBody {
};
}
export function initMatterScene(canvas: HTMLCanvasElement, onStateChange: (state: GameState) => void) {
export function initMatterScene(
canvas: HTMLCanvasElement,
onStateChange: (state: GameState) => void,
initialProfile: Pick<PlayerProfile, "mass" | "xp">
) {
const context = canvas.getContext("2d");
if (!context) {
@@ -32,7 +35,9 @@ export function initMatterScene(canvas: HTMLCanvasElement, onStateChange: (state
const engine = Engine.create({
gravity: { x: 0, y: 0 }
});
const blackHole = Bodies.circle(0, 0, STARTING_RADIUS, {
const startingMass = Math.max(defaultGameState.mass, initialProfile.mass);
const startingRadius = defaultGameState.radius + Math.sqrt(startingMass - defaultGameState.mass) * 2.6;
const blackHole = Bodies.circle(0, 0, defaultGameState.radius, {
isStatic: true,
isSensor: true,
frictionAir: 0,
@@ -46,8 +51,9 @@ export function initMatterScene(canvas: HTMLCanvasElement, onStateChange: (state
const pointer = { x: 0, y: 0 };
const worldSize = { width: 0, height: 0 };
let mass = STARTING_MASS;
let radius = STARTING_RADIUS;
let mass = startingMass;
let xp = initialProfile.xp;
let radius = startingRadius;
let dragging = false;
let animationFrame = 0;
let lastTimestamp = 0;
@@ -55,13 +61,15 @@ export function initMatterScene(canvas: HTMLCanvasElement, onStateChange: (state
let consumedObjects = 0;
let lastEmit = 0;
Body.scale(blackHole, startingRadius / defaultGameState.radius, startingRadius / defaultGameState.radius);
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 getRadiusForMass = (nextMass: number) => STARTING_RADIUS + Math.sqrt(nextMass - STARTING_MASS) * 2.6;
const getRadiusForMass = (nextMass: number) =>
defaultGameState.radius + Math.sqrt(nextMass - defaultGameState.mass) * 2.6;
const getObjectDef = (kind: CelestialKind) =>
CELESTIAL_OBJECTS.find((objectDef) => objectDef.kind === kind) as CelestialObjectDefinition;
@@ -72,6 +80,7 @@ export function initMatterScene(canvas: HTMLCanvasElement, onStateChange: (state
const emitState = () => {
onStateChange({
mass,
xp,
radius,
pullRadius: getPullRadius(),
tier: getTierForMass(mass),
@@ -192,7 +201,9 @@ export function initMatterScene(canvas: HTMLCanvasElement, onStateChange: (state
World.remove(engine.world, body);
consumedObjects += 1;
mass += getObjectDef(body.plugin.celestialKind as CelestialKind).rewardMass;
const rewardMass = getObjectDef(body.plugin.celestialKind as CelestialKind).rewardMass;
mass += rewardMass;
xp += rewardMass;
syncHoleScale(getRadiusForMass(mass));
emitState();
@@ -384,6 +395,7 @@ export function initMatterScene(canvas: HTMLCanvasElement, onStateChange: (state
animationFrame = window.requestAnimationFrame(tick);
};
mass = startingMass;
resizeCanvas();
topOffField();
emitState();

View File

@@ -2,6 +2,7 @@ import { getTierForMass } from "./progression";
export interface GameState {
mass: number;
xp: number;
radius: number;
pullRadius: number;
tier: string;
@@ -14,6 +15,7 @@ export interface GameState {
export const defaultGameState: GameState = {
mass: 12,
xp: 0,
radius: 24,
pullRadius: 74.4,
tier: getTierForMass(12),

File diff suppressed because one or more lines are too long

View File

@@ -3,7 +3,11 @@ import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
port: 5173
port: 5173,
proxy: {
"/api": "http://localhost:3000",
"/healthz": "http://localhost:3000"
}
},
test: {
environment: "jsdom",

View File

@@ -4,7 +4,11 @@ import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
port: 5173
port: 5173,
proxy: {
"/api": "http://localhost:3000",
"/healthz": "http://localhost:3000"
}
},
test: {
environment: "jsdom",