diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bfefbf8..f9803b8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,4 +34,4 @@ jobs: run: npm run build - name: Build Docker image - run: docker compose -f docker/docker-compose.yml build + run: docker compose build diff --git a/docker/Dockerfile b/Dockerfile similarity index 99% rename from docker/Dockerfile rename to Dockerfile index 263f398..e652e2d 100644 --- a/docker/Dockerfile +++ b/Dockerfile @@ -23,3 +23,4 @@ COPY --from=builder /app/client/dist ./client/dist EXPOSE 3000 CMD ["node", "server/dist/server.js"] + diff --git a/README.md b/README.md index 8472d2a..79aa7b7 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ npm run docker-up This starts the single production-style container on `http://localhost:8080`. Static assets and API routes are served by the same Express process. +You can also run `docker build .` directly from the repo root. ## Scripts @@ -46,7 +47,7 @@ npm run docker-build - `client/` React frontend and game runtime - `server/` Express API, static asset host, and future persistence layer -- `docker/` container definitions +- `Dockerfile` and `compose.yml` define the container runtime - `docs/` architecture and contributor docs - `tests/` reserved for shared test assets and future end-to-end coverage @@ -58,4 +59,4 @@ npm run docker-build - `npm run lint` - `npm run test` - `npm run build` -- `docker compose -f docker/docker-compose.yml build` +- `docker compose build` diff --git a/UNRAID.md b/UNRAID.md index e27b63f..066d914 100644 --- a/UNRAID.md +++ b/UNRAID.md @@ -25,7 +25,7 @@ cd stellar Build the image from the repo root: ```bash -docker build -f docker/Dockerfile -t stellar:local . +docker build -t stellar:local . ``` Start the container: @@ -51,11 +51,11 @@ http://:8080 From the repo root: ```bash -docker compose -f docker/docker-compose.yml build -docker compose -f docker/docker-compose.yml up -d +docker compose build +docker compose up -d ``` -This uses the repo’s single-service compose file and exposes the app on port `8080`. +This uses the repo's single-service compose file and exposes the app on port `8080`. ## Option 3: Pull A Prebuilt Image @@ -99,7 +99,7 @@ If building from source: ```bash cd /mnt/user/appdata/stellar git pull -docker build -f docker/Dockerfile -t stellar:local . +docker build -t stellar:local . docker rm -f stellar docker run -d \ --name stellar \ @@ -115,7 +115,7 @@ If using compose: ```bash cd /mnt/user/appdata/stellar git pull -docker compose -f docker/docker-compose.yml up -d --build +docker compose up -d --build ``` ## Notes diff --git a/client/src/components/Hud.test.tsx b/client/src/components/Hud.test.tsx index 52fe0fc..4bba4bb 100644 --- a/client/src/components/Hud.test.tsx +++ b/client/src/components/Hud.test.tsx @@ -7,6 +7,6 @@ describe("Hud", () => { render(); 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(); }); }); diff --git a/client/src/components/Hud.tsx b/client/src/components/Hud.tsx index 6c20b95..4b80d31 100644 --- a/client/src/components/Hud.tsx +++ b/client/src/components/Hud.tsx @@ -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) {
Consumed
-
{gameState.consumedDust}
+
{gameState.consumedObjects}
-
Dust Left
-
{gameState.dustRemaining}
+
Objects Left
+
{gameState.objectsRemaining}
+
+
+
Max Prey
+
{gameState.preyTier}
+
+
+
Next Unlock
+
{gameState.nextUnlock ?? "Complete"}
diff --git a/client/src/game/matterScene.ts b/client/src/game/matterScene.ts index bce80e4..3a47e73 100644 --- a/client/src/game/matterScene.ts +++ b/client/src/game/matterScene.ts @@ -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(); + const celestialBodies = new Set(); const consumedBodies = new WeakSet(); 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) => { 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); diff --git a/client/src/game/progression.test.ts b/client/src/game/progression.test.ts index 7acd0fe..8e9d704 100644 --- a/client/src/game/progression.test.ts +++ b/client/src/game/progression.test.ts @@ -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(); }); }); diff --git a/client/src/game/progression.ts b/client/src/game/progression.ts index 9b1db56..75237ec 100644 --- a/client/src/game/progression.ts +++ b/client/src/game/progression.ts @@ -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; +} diff --git a/client/src/game/spaceObjects.ts b/client/src/game/spaceObjects.ts new file mode 100644 index 0000000..e5674c6 --- /dev/null +++ b/client/src/game/spaceObjects.ts @@ -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 + } +]; diff --git a/client/src/game/types.ts b/client/src/game/types.ts index df04ad8..4b6af9f 100644 --- a/client/src/game/types.ts +++ b/client/src/game/types.ts @@ -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 }; diff --git a/docker/docker-compose.yml b/compose.yml similarity index 83% rename from docker/docker-compose.yml rename to compose.yml index 12ac474..a2f2fcf 100644 --- a/docker/docker-compose.yml +++ b/compose.yml @@ -1,8 +1,8 @@ services: app: build: - context: .. - dockerfile: docker/Dockerfile + context: . + dockerfile: Dockerfile environment: PORT: "3000" LOG_LEVEL: info diff --git a/docs/architecture.md b/docs/architecture.md index 63761a9..86f37ea 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -4,7 +4,7 @@ - `client/` hosts the React application and game shell. - `server/` hosts the Express API, production static asset serving, and future persistence services. -- `docker/` contains a single-container production build and local compose entrypoint. +- `Dockerfile` and `compose.yml` define the single-container production build and local compose entrypoint. ## Immediate Direction diff --git a/package.json b/package.json index 2a3573d..fd09be7 100644 --- a/package.json +++ b/package.json @@ -18,9 +18,9 @@ "lint": "npm run lint:client && npm run lint:server", "lint:client": "npm --workspace client run lint", "lint:server": "npm --workspace server run lint", - "docker-build": "docker compose -f docker/docker-compose.yml build", - "docker-up": "docker compose -f docker/docker-compose.yml up --build", - "docker-down": "docker compose -f docker/docker-compose.yml down" + "docker-build": "docker compose build", + "docker-up": "docker compose up --build", + "docker-down": "docker compose down" }, "devDependencies": { "concurrently": "^9.2.1"