@@ -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
33
client/src/api/player.ts
Normal 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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user