2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -34,4 +34,4 @@ jobs:
|
|||||||
run: npm run build
|
run: npm run build
|
||||||
|
|
||||||
- name: Build Docker image
|
- name: Build Docker image
|
||||||
run: docker compose -f docker/docker-compose.yml build
|
run: docker compose build
|
||||||
|
|||||||
@@ -23,3 +23,4 @@ COPY --from=builder /app/client/dist ./client/dist
|
|||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
CMD ["node", "server/dist/server.js"]
|
CMD ["node", "server/dist/server.js"]
|
||||||
|
|
||||||
@@ -31,6 +31,7 @@ npm run docker-up
|
|||||||
|
|
||||||
This starts the single production-style container on `http://localhost:8080`.
|
This starts the single production-style container on `http://localhost:8080`.
|
||||||
Static assets and API routes are served by the same Express process.
|
Static assets and API routes are served by the same Express process.
|
||||||
|
You can also run `docker build .` directly from the repo root.
|
||||||
|
|
||||||
## Scripts
|
## Scripts
|
||||||
|
|
||||||
@@ -46,7 +47,7 @@ npm run docker-build
|
|||||||
|
|
||||||
- `client/` React frontend and game runtime
|
- `client/` React frontend and game runtime
|
||||||
- `server/` Express API, static asset host, and future persistence layer
|
- `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
|
- `docs/` architecture and contributor docs
|
||||||
- `tests/` reserved for shared test assets and future end-to-end coverage
|
- `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 lint`
|
||||||
- `npm run test`
|
- `npm run test`
|
||||||
- `npm run build`
|
- `npm run build`
|
||||||
- `docker compose -f docker/docker-compose.yml build`
|
- `docker compose build`
|
||||||
|
|||||||
12
UNRAID.md
12
UNRAID.md
@@ -25,7 +25,7 @@ cd stellar
|
|||||||
Build the image from the repo root:
|
Build the image from the repo root:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker build -f docker/Dockerfile -t stellar:local .
|
docker build -t stellar:local .
|
||||||
```
|
```
|
||||||
|
|
||||||
Start the container:
|
Start the container:
|
||||||
@@ -51,11 +51,11 @@ http://<your-unraid-ip>:8080
|
|||||||
From the repo root:
|
From the repo root:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose -f docker/docker-compose.yml build
|
docker compose build
|
||||||
docker compose -f docker/docker-compose.yml up -d
|
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
|
## Option 3: Pull A Prebuilt Image
|
||||||
|
|
||||||
@@ -99,7 +99,7 @@ If building from source:
|
|||||||
```bash
|
```bash
|
||||||
cd /mnt/user/appdata/stellar
|
cd /mnt/user/appdata/stellar
|
||||||
git pull
|
git pull
|
||||||
docker build -f docker/Dockerfile -t stellar:local .
|
docker build -t stellar:local .
|
||||||
docker rm -f stellar
|
docker rm -f stellar
|
||||||
docker run -d \
|
docker run -d \
|
||||||
--name stellar \
|
--name stellar \
|
||||||
@@ -115,7 +115,7 @@ If using compose:
|
|||||||
```bash
|
```bash
|
||||||
cd /mnt/user/appdata/stellar
|
cd /mnt/user/appdata/stellar
|
||||||
git pull
|
git pull
|
||||||
docker compose -f docker/docker-compose.yml up -d --build
|
docker compose up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|||||||
@@ -7,6 +7,6 @@ describe("Hud", () => {
|
|||||||
render(<Hud gameState={defaultGameState} />);
|
render(<Hud gameState={defaultGameState} />);
|
||||||
|
|
||||||
expect(screen.getByText("Sector Absorption")).toBeTruthy();
|
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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { type GameState } from "../game/types";
|
import { type GameState } from "../game/types";
|
||||||
|
|
||||||
const objectives = [
|
const objectives = [
|
||||||
"Reach mass 40 to stabilize your pull",
|
"Clear lower-tier debris to unlock heavier targets",
|
||||||
"Keep sweeping the field to chain dust pickups",
|
"Meteorites and larger bodies only absorb after their mass gate",
|
||||||
"Persist profile data in M3"
|
"Persist profile data in M3"
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -37,11 +37,19 @@ export function Hud({ gameState }: HudProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="rounded-2xl bg-black/20 p-3">
|
<div className="rounded-2xl bg-black/20 p-3">
|
||||||
<dt className="text-slate-400">Consumed</dt>
|
<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>
|
||||||
<div className="rounded-2xl bg-black/20 p-3">
|
<div className="rounded-2xl bg-black/20 p-3">
|
||||||
<dt className="text-slate-400">Dust Left</dt>
|
<dt className="text-slate-400">Objects Left</dt>
|
||||||
<dd className="mt-1 text-xl font-semibold">{gameState.dustRemaining}</dd>
|
<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>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
Bodies,
|
Bodies,
|
||||||
Body,
|
Body,
|
||||||
Composite,
|
|
||||||
Engine,
|
Engine,
|
||||||
Events,
|
Events,
|
||||||
Vector,
|
Vector,
|
||||||
@@ -9,18 +8,17 @@ import {
|
|||||||
type Body as MatterBody,
|
type Body as MatterBody,
|
||||||
type IEventCollision
|
type IEventCollision
|
||||||
} from "matter-js";
|
} 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";
|
import { defaultGameState, type GameState } from "./types";
|
||||||
|
|
||||||
const STARTING_MASS = defaultGameState.mass;
|
const STARTING_MASS = defaultGameState.mass;
|
||||||
const STARTING_RADIUS = defaultGameState.radius;
|
const STARTING_RADIUS = defaultGameState.radius;
|
||||||
const DUST_COUNT = 36;
|
|
||||||
const DUST_REWARD = 2;
|
|
||||||
const EMIT_INTERVAL_MS = 900;
|
const EMIT_INTERVAL_MS = 900;
|
||||||
|
|
||||||
interface DustBody extends MatterBody {
|
interface CelestialBody extends MatterBody {
|
||||||
plugin: {
|
plugin: {
|
||||||
isDust?: boolean;
|
celestialKind?: CelestialKind;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,7 +41,7 @@ export function initMatterScene(canvas: HTMLCanvasElement, onStateChange: (state
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
const boundaries: MatterBody[] = [];
|
const boundaries: MatterBody[] = [];
|
||||||
const dustBodies = new Set<DustBody>();
|
const celestialBodies = new Set<CelestialBody>();
|
||||||
const consumedBodies = new WeakSet<MatterBody>();
|
const consumedBodies = new WeakSet<MatterBody>();
|
||||||
const pointer = { x: 0, y: 0 };
|
const pointer = { x: 0, y: 0 };
|
||||||
const worldSize = { width: 0, height: 0 };
|
const worldSize = { width: 0, height: 0 };
|
||||||
@@ -54,7 +52,7 @@ export function initMatterScene(canvas: HTMLCanvasElement, onStateChange: (state
|
|||||||
let animationFrame = 0;
|
let animationFrame = 0;
|
||||||
let lastTimestamp = 0;
|
let lastTimestamp = 0;
|
||||||
let fps = 60;
|
let fps = 60;
|
||||||
let consumedDust = 0;
|
let consumedObjects = 0;
|
||||||
let lastEmit = 0;
|
let lastEmit = 0;
|
||||||
|
|
||||||
World.add(engine.world, blackHole);
|
World.add(engine.world, blackHole);
|
||||||
@@ -63,14 +61,24 @@ export function initMatterScene(canvas: HTMLCanvasElement, onStateChange: (state
|
|||||||
|
|
||||||
const getPullRadius = () => radius * 3.1;
|
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 = () => {
|
const emitState = () => {
|
||||||
onStateChange({
|
onStateChange({
|
||||||
mass,
|
mass,
|
||||||
radius,
|
radius,
|
||||||
pullRadius: getPullRadius(),
|
pullRadius: getPullRadius(),
|
||||||
tier: getTierForMass(mass),
|
tier: getTierForMass(mass),
|
||||||
consumedDust,
|
consumedObjects,
|
||||||
dustRemaining: dustBodies.size,
|
objectsRemaining: celestialBodies.size,
|
||||||
|
preyTier: getCurrentPreyTier(mass),
|
||||||
|
nextUnlock: getNextUnlock(mass),
|
||||||
fps
|
fps
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -111,7 +119,7 @@ export function initMatterScene(canvas: HTMLCanvasElement, onStateChange: (state
|
|||||||
World.add(engine.world, boundaries);
|
World.add(engine.world, boundaries);
|
||||||
};
|
};
|
||||||
|
|
||||||
const createDust = () => {
|
const createCelestialObject = (objectDef: CelestialObjectDefinition) => {
|
||||||
const padding = 48;
|
const padding = 48;
|
||||||
let attempts = 0;
|
let attempts = 0;
|
||||||
let position = { x: worldSize.width / 2, y: worldSize.height / 2 };
|
let position = { x: worldSize.width / 2, y: worldSize.height / 2 };
|
||||||
@@ -129,33 +137,35 @@ export function initMatterScene(canvas: HTMLCanvasElement, onStateChange: (state
|
|||||||
attempts += 1;
|
attempts += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
const dust = Bodies.circle(position.x, position.y, 4 + Math.random() * 4, {
|
const body = Bodies.circle(position.x, position.y, objectDef.radius + (Math.random() - 0.5) * objectDef.radius * 0.2, {
|
||||||
restitution: 0.95,
|
restitution: 0.92,
|
||||||
frictionAir: 0.01,
|
frictionAir: objectDef.frictionAir,
|
||||||
friction: 0,
|
friction: 0,
|
||||||
density: 0.0008,
|
density: objectDef.density,
|
||||||
render: {
|
render: {
|
||||||
visible: false
|
visible: false
|
||||||
}
|
}
|
||||||
}) as DustBody;
|
}) as CelestialBody;
|
||||||
|
|
||||||
dust.plugin = {
|
body.plugin = {
|
||||||
...dust.plugin,
|
...body.plugin,
|
||||||
isDust: true
|
celestialKind: objectDef.kind
|
||||||
};
|
};
|
||||||
|
|
||||||
Body.setVelocity(dust, {
|
Body.setVelocity(body, {
|
||||||
x: (Math.random() - 0.5) * 1.8,
|
x: (Math.random() - 0.5) * (1.9 - objectDef.radius * 0.02),
|
||||||
y: (Math.random() - 0.5) * 1.8
|
y: (Math.random() - 0.5) * (1.9 - objectDef.radius * 0.02)
|
||||||
});
|
});
|
||||||
|
|
||||||
dustBodies.add(dust);
|
celestialBodies.add(body);
|
||||||
World.add(engine.world, dust);
|
World.add(engine.world, body);
|
||||||
};
|
};
|
||||||
|
|
||||||
const topOffDust = () => {
|
const topOffField = () => {
|
||||||
while (dustBodies.size < DUST_COUNT) {
|
for (const objectDef of CELESTIAL_OBJECTS) {
|
||||||
createDust();
|
while (getFieldCount(objectDef.kind) < objectDef.fieldTarget) {
|
||||||
|
createCelestialObject(objectDef);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -165,31 +175,43 @@ export function initMatterScene(canvas: HTMLCanvasElement, onStateChange: (state
|
|||||||
Body.scale(blackHole, scale, scale);
|
Body.scale(blackHole, scale, scale);
|
||||||
};
|
};
|
||||||
|
|
||||||
const absorbDust = (dust: DustBody) => {
|
const canConsume = (kind: CelestialKind) => getUnlockedKinds(mass).includes(kind);
|
||||||
if (consumedBodies.has(dust)) {
|
|
||||||
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
consumedBodies.add(dust);
|
consumedBodies.add(body);
|
||||||
dustBodies.delete(dust);
|
celestialBodies.delete(body);
|
||||||
World.remove(engine.world, dust);
|
World.remove(engine.world, body);
|
||||||
|
|
||||||
consumedDust += 1;
|
consumedObjects += 1;
|
||||||
mass += DUST_REWARD;
|
mass += getObjectDef(body.plugin.celestialKind as CelestialKind).rewardMass;
|
||||||
|
|
||||||
const targetRadius = STARTING_RADIUS + Math.sqrt(mass - STARTING_MASS) * 2.6;
|
syncHoleScale(getRadiusForMass(mass));
|
||||||
syncHoleScale(targetRadius);
|
|
||||||
emitState();
|
emitState();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCollision = (event: IEventCollision<Engine>) => {
|
const handleCollision = (event: IEventCollision<Engine>) => {
|
||||||
for (const pair of event.pairs) {
|
for (const pair of event.pairs) {
|
||||||
const [bodyA, bodyB] = [pair.bodyA as DustBody, pair.bodyB as DustBody];
|
const [bodyA, bodyB] = [pair.bodyA as CelestialBody, pair.bodyB as CelestialBody];
|
||||||
const dust = bodyA.plugin?.isDust ? bodyA : bodyB.plugin?.isDust ? bodyB : null;
|
const body = bodyA.plugin?.celestialKind ? bodyA : bodyB.plugin?.celestialKind ? bodyB : null;
|
||||||
const other = dust === bodyA ? bodyB : bodyA;
|
const other = body === bodyA ? bodyB : bodyA;
|
||||||
|
|
||||||
if (dust && other === blackHole) {
|
if (body && other === blackHole) {
|
||||||
absorbDust(dust);
|
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();
|
context.stroke();
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const dust of dustBodies) {
|
for (const body of celestialBodies) {
|
||||||
context.fillStyle = "rgba(226, 232, 240, 0.95)";
|
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.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();
|
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();
|
const pullRadius = getPullRadius();
|
||||||
@@ -314,21 +356,26 @@ export function initMatterScene(canvas: HTMLCanvasElement, onStateChange: (state
|
|||||||
Body.setVelocity(blackHole, { x: 0, y: 0 });
|
Body.setVelocity(blackHole, { x: 0, y: 0 });
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const dust of dustBodies) {
|
for (const body of celestialBodies) {
|
||||||
const toHole = Vector.sub(blackHole.position, dust.position);
|
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));
|
const distance = Math.max(1, Vector.magnitude(toHole));
|
||||||
|
|
||||||
if (distance < getPullRadius()) {
|
if (distance < getPullRadius()) {
|
||||||
const direction = Vector.normalise(toHole);
|
const direction = Vector.normalise(toHole);
|
||||||
const strength = clamp((getPullRadius() - distance) / getPullRadius(), 0, 1) * 0.00016;
|
const strength =
|
||||||
Body.applyForce(dust, dust.position, Vector.mult(direction, strength * dust.mass));
|
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);
|
Engine.update(engine, delta);
|
||||||
|
|
||||||
if (timestamp - lastEmit > EMIT_INTERVAL_MS) {
|
if (timestamp - lastEmit > EMIT_INTERVAL_MS) {
|
||||||
topOffDust();
|
topOffField();
|
||||||
emitState();
|
emitState();
|
||||||
lastEmit = timestamp;
|
lastEmit = timestamp;
|
||||||
}
|
}
|
||||||
@@ -338,7 +385,7 @@ export function initMatterScene(canvas: HTMLCanvasElement, onStateChange: (state
|
|||||||
};
|
};
|
||||||
|
|
||||||
resizeCanvas();
|
resizeCanvas();
|
||||||
topOffDust();
|
topOffField();
|
||||||
emitState();
|
emitState();
|
||||||
|
|
||||||
Events.on(engine, "collisionStart", handleCollision);
|
Events.on(engine, "collisionStart", handleCollision);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { getTierForMass } from "./progression";
|
import { getCurrentPreyTier, getNextUnlock, getTierForMass } from "./progression";
|
||||||
|
|
||||||
describe("getTierForMass", () => {
|
describe("getTierForMass", () => {
|
||||||
it("maps early mass to Dustling", () => {
|
it("maps early mass to Dustling", () => {
|
||||||
@@ -8,8 +8,25 @@ describe("getTierForMass", () => {
|
|||||||
|
|
||||||
it("promotes mass thresholds into later tiers", () => {
|
it("promotes mass thresholds into later tiers", () => {
|
||||||
expect(getTierForMass(20)).toBe("Rift Seed");
|
expect(getTierForMass(20)).toBe("Rift Seed");
|
||||||
expect(getTierForMass(40)).toBe("Event Core");
|
expect(getTierForMass(24)).toBe("Event Core");
|
||||||
expect(getTierForMass(70)).toBe("World Eater");
|
expect(getTierForMass(50)).toBe("World Eater");
|
||||||
expect(getTierForMass(110)).toBe("Proto Star");
|
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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
|
import { CELESTIAL_OBJECTS, type CelestialKind } from "./spaceObjects";
|
||||||
|
|
||||||
export function getTierForMass(mass: number) {
|
export function getTierForMass(mass: number) {
|
||||||
if (mass >= 110) {
|
if (mass >= 160) {
|
||||||
return "Proto Star";
|
return "Star Forger";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mass >= 70) {
|
if (mass >= 95) {
|
||||||
|
return "Planet Breaker";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mass >= 50) {
|
||||||
return "World Eater";
|
return "World Eater";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mass >= 40) {
|
if (mass >= 24) {
|
||||||
return "Event Core";
|
return "Event Core";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -17,3 +23,17 @@ export function getTierForMass(mass: number) {
|
|||||||
|
|
||||||
return "Dustling";
|
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;
|
||||||
|
}
|
||||||
|
|||||||
77
client/src/game/spaceObjects.ts
Normal file
77
client/src/game/spaceObjects.ts
Normal 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
|
||||||
|
}
|
||||||
|
];
|
||||||
@@ -5,8 +5,10 @@ export interface GameState {
|
|||||||
radius: number;
|
radius: number;
|
||||||
pullRadius: number;
|
pullRadius: number;
|
||||||
tier: string;
|
tier: string;
|
||||||
consumedDust: number;
|
consumedObjects: number;
|
||||||
dustRemaining: number;
|
objectsRemaining: number;
|
||||||
|
preyTier: string;
|
||||||
|
nextUnlock: string | null;
|
||||||
fps: number;
|
fps: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -15,7 +17,9 @@ export const defaultGameState: GameState = {
|
|||||||
radius: 24,
|
radius: 24,
|
||||||
pullRadius: 74.4,
|
pullRadius: 74.4,
|
||||||
tier: getTierForMass(12),
|
tier: getTierForMass(12),
|
||||||
consumedDust: 0,
|
consumedObjects: 0,
|
||||||
dustRemaining: 0,
|
objectsRemaining: 0,
|
||||||
|
preyTier: "Dust",
|
||||||
|
nextUnlock: "Meteorite",
|
||||||
fps: 60
|
fps: 60
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
build:
|
build:
|
||||||
context: ..
|
context: .
|
||||||
dockerfile: docker/Dockerfile
|
dockerfile: Dockerfile
|
||||||
environment:
|
environment:
|
||||||
PORT: "3000"
|
PORT: "3000"
|
||||||
LOG_LEVEL: info
|
LOG_LEVEL: info
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
- `client/` hosts the React application and game shell.
|
- `client/` hosts the React application and game shell.
|
||||||
- `server/` hosts the Express API, production static asset serving, and future persistence services.
|
- `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
|
## Immediate Direction
|
||||||
|
|
||||||
|
|||||||
@@ -18,9 +18,9 @@
|
|||||||
"lint": "npm run lint:client && npm run lint:server",
|
"lint": "npm run lint:client && npm run lint:server",
|
||||||
"lint:client": "npm --workspace client run lint",
|
"lint:client": "npm --workspace client run lint",
|
||||||
"lint:server": "npm --workspace server run lint",
|
"lint:server": "npm --workspace server run lint",
|
||||||
"docker-build": "docker compose -f docker/docker-compose.yml build",
|
"docker-build": "docker compose build",
|
||||||
"docker-up": "docker compose -f docker/docker-compose.yml up --build",
|
"docker-up": "docker compose up --build",
|
||||||
"docker-down": "docker compose -f docker/docker-compose.yml down"
|
"docker-down": "docker compose down"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"concurrently": "^9.2.1"
|
"concurrently": "^9.2.1"
|
||||||
|
|||||||
Reference in New Issue
Block a user