This commit is contained in:
8
.dockerignore
Normal file
8
.dockerignore
Normal file
@@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
**/node_modules
|
||||
**/dist
|
||||
.git
|
||||
.DS_Store
|
||||
npm-debug.log*
|
||||
.env
|
||||
.env.*
|
||||
29
.gitea/workflows/build.yml
Normal file
29
.gitea/workflows/build.yml
Normal file
@@ -0,0 +1,29 @@
|
||||
name: build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
image:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Build production image in Kubernetes
|
||||
run: |
|
||||
set -euo pipefail
|
||||
image=registry.a2acloud.io/syncer/syncer
|
||||
docker build --platform linux/amd64 --pull \
|
||||
-t "$image:$GITHUB_SHA" \
|
||||
-t "$image:latest" \
|
||||
.
|
||||
|
||||
- name: Push immutable and release tags
|
||||
run: |
|
||||
set -euo pipefail
|
||||
image=registry.a2acloud.io/syncer/syncer
|
||||
docker push "$image:$GITHUB_SHA"
|
||||
docker push "$image:latest"
|
||||
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
**/node_modules/
|
||||
**/dist/
|
||||
.env
|
||||
.env.*
|
||||
.DS_Store
|
||||
npm-debug.log*
|
||||
38
Dockerfile
Normal file
38
Dockerfile
Normal file
@@ -0,0 +1,38 @@
|
||||
FROM --platform=$BUILDPLATFORM node:22-bookworm-slim AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
COPY apps/server/package.json apps/server/package.json
|
||||
COPY apps/web/package.json apps/web/package.json
|
||||
COPY packages/engine/package.json packages/engine/package.json
|
||||
COPY packages/shared/package.json packages/shared/package.json
|
||||
RUN npm ci
|
||||
|
||||
COPY tsconfig.base.json ./
|
||||
COPY apps ./apps
|
||||
COPY packages ./packages
|
||||
RUN npm run build
|
||||
|
||||
FROM node:22-bookworm-slim AS runtime
|
||||
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production \
|
||||
PORT=3001 \
|
||||
STATIC_ROOT=/app/apps/web/dist
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
COPY apps/server/package.json apps/server/package.json
|
||||
COPY apps/web/package.json apps/web/package.json
|
||||
COPY packages/engine/package.json packages/engine/package.json
|
||||
COPY packages/shared/package.json packages/shared/package.json
|
||||
RUN npm ci --omit=dev && npm cache clean --force
|
||||
|
||||
COPY --from=build /app/apps/server/dist ./apps/server/dist
|
||||
COPY --from=build /app/apps/web/dist ./apps/web/dist
|
||||
COPY --from=build /app/packages/engine/dist ./packages/engine/dist
|
||||
COPY --from=build /app/packages/shared/dist ./packages/shared/dist
|
||||
|
||||
USER node
|
||||
EXPOSE 3001
|
||||
CMD ["node", "apps/server/dist/index.js"]
|
||||
324
README.md
Normal file
324
README.md
Normal file
@@ -0,0 +1,324 @@
|
||||
# Syncer
|
||||
|
||||
Three playable browser games built on one generic TypeScript multiplayer higher-order configuration. The engine runs authoritative servers, predicted clients, clocks, reconciliation, validation, privacy-aware replication, and bandwidth-budgeted interest management around game-supplied rules.
|
||||
|
||||
## Structure
|
||||
|
||||
```text
|
||||
apps/
|
||||
server/ µWebSockets.js authoritative transport adapter
|
||||
web/ React + Vite predicted client
|
||||
packages/
|
||||
engine/ Generic clocks, authority, prediction, validation, and protocol
|
||||
shared/ Game definitions: Sync Arena, Flux Relay, and Syncer Royale
|
||||
```
|
||||
|
||||
## Play
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Open `http://localhost:5173`. Use the selector to switch between three games:
|
||||
|
||||
- **Sync Arena** — click **Deploy**, then use `WASD`, `Shift`, mouse look, left click, `R`, and weapon keys `1`–`3`.
|
||||
- **Flux Relay** — hold `Space` or the on-screen thruster to pull the shared core toward your team's gate while managing private energy.
|
||||
- **Syncer Royale** — click **Drop In**, then use `WASD`, `Shift`, mouse look, left click, and `R`. Survive 31 server bots, loot automatically, and stay inside the shrinking circle on a streamed 2 km island. Open it directly at `http://localhost:5173/#royale`.
|
||||
|
||||
The arena includes pickups, armor, three weapons, headshots, reloads, respawns, a scoreboard, filtered spatial sound, and killcams; its server bots are disabled for player-only office matches. Flux Relay is intentionally unrelated to a shooter: it has teams, a shared core, balanced server bots, exact private energy, per-viewer events, and no weapons, visibility cones, or 3D physics. Royale proves the same engine can drive a much larger world: the map is generated client-side from a public seed, while players, private loot, combat, and the storm remain server authoritative.
|
||||
|
||||
## Define a game
|
||||
|
||||
`defineMultiplayerGame()` is the main developer surface. A contract names the five types that cross the trust boundary once; every rule callback is then inferred. Private authority and client-visible state remain different types, and internal events must be converted into per-player perceptions before transport:
|
||||
|
||||
```ts
|
||||
interface MyGame {
|
||||
authority: AuthorityState;
|
||||
client: ClientState;
|
||||
input: Input;
|
||||
authorityEvent: AuthorityEvent;
|
||||
perceptionEvent: PerceptionEvent;
|
||||
}
|
||||
|
||||
export const game = defineMultiplayerGame<MyGame>({
|
||||
clock: {
|
||||
ticksPerSecond: 60,
|
||||
snapshotsPerSecond: 20,
|
||||
},
|
||||
authority: {
|
||||
createInitialState: () => createPrivateWorld(),
|
||||
cloneState: clonePrivateWorld,
|
||||
applyInput: applyAuthoritativeInput,
|
||||
step: (state, context) => {
|
||||
simulateAuthoritativePhysics(state, context.deltaSeconds);
|
||||
context.emit(createInternalWorldEvent(state));
|
||||
},
|
||||
},
|
||||
prediction: {
|
||||
createInitialState: () => createVisibleWorld(),
|
||||
cloneState: cloneVisibleWorld,
|
||||
applyInput: applyPredictedInput,
|
||||
step: simulatePredictedPhysics,
|
||||
mergeSnapshot: preserveLocalPresentationState,
|
||||
applyEvent: applySensoryCue,
|
||||
},
|
||||
visibility: {
|
||||
createSnapshot: (authority, viewer) =>
|
||||
projectOnlyWhatPlayerMaySee(authority, viewer.playerId),
|
||||
perceive: (authority, event, viewer) =>
|
||||
eventPlayerMaySense(authority, event, viewer.playerId),
|
||||
validateClientState: validateReportedVisibleState,
|
||||
// Optional: players with the same key share one encoded snapshot.
|
||||
groupKey: (authority, viewer) => visibilityGroup(authority, viewer.playerId),
|
||||
},
|
||||
input: {
|
||||
validate: (input, context) => isAllowed(input, context),
|
||||
},
|
||||
encoding: {
|
||||
input: inputCodec,
|
||||
clientState: visibleStateCodec,
|
||||
perception: perceptionEventCodec,
|
||||
},
|
||||
});
|
||||
|
||||
const server = game.createServer();
|
||||
const client = game.createClient();
|
||||
const protocol = game.protocol;
|
||||
```
|
||||
|
||||
`createJsonCodec()` is included for prototypes and low-volume messages; games can replace it with custom packed binary codecs without changing any rules. `defineNetworkedGame()` remains available as the lower-level surface used internally and by advanced integrations. Every existing HOF—lag compensation, time travel, and replay transport—accepts the result of either definition API.
|
||||
|
||||
Flux Relay in `packages/shared/src/flux-game.ts` is the compact reference implementation. Both games are mounted by the same `hostNetworkedGame()` transport adapter, demonstrating that the server loop has no shooter knowledge.
|
||||
|
||||
## Scale a game with spatial replication
|
||||
|
||||
`withSpatialReplication()` wraps any networked game with a deterministic grid index, per-viewer interest selection, priorities, and an explicit byte budget. The ordinary visibility callback still runs first, so this layer can reduce an already-safe projection but can never reveal private authority state:
|
||||
|
||||
```ts
|
||||
const largeWorldGame = withSpatialReplication(game, {
|
||||
cellSize: 100,
|
||||
bandwidthBudgetBytesPerSecond: 30_000,
|
||||
reservedBytesPerSnapshot: 300,
|
||||
viewerPosition: (authority, playerId) =>
|
||||
authority.players.get(playerId) ?? null,
|
||||
sources: [
|
||||
{
|
||||
category: "players",
|
||||
maximumDistance: 360,
|
||||
entities: (authority) => authority.players.values(),
|
||||
estimatedBytes: 76,
|
||||
priority: (_player, distance) => 1_000 - distance,
|
||||
required: (player, playerId) => player.id === playerId,
|
||||
},
|
||||
{
|
||||
category: "loot",
|
||||
maximumDistance: 165,
|
||||
entities: (authority) => authority.loot.values(),
|
||||
estimatedBytes: 44,
|
||||
},
|
||||
],
|
||||
projectSnapshot: (snapshot, selection) => ({
|
||||
...snapshot,
|
||||
players: snapshot.players.filter((player) =>
|
||||
selection.has("players", player.id),
|
||||
),
|
||||
loot: snapshot.loot.filter((item) => selection.has("loot", item.id)),
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
Syncer Royale in `packages/shared/src/royale-game.ts` is the large-world reference. The developer defines the categories, ranges, size estimates, priorities, required entities, and final projection; the HOF owns indexing and budget selection.
|
||||
|
||||
## Add action-specific lag compensation
|
||||
|
||||
`withLagCompensation()` owns one bounded private history ring for the game, but
|
||||
each classified action chooses its own policy. The developer captures only the
|
||||
authority data needed for validation—usually hitboxes rather than the entire
|
||||
world:
|
||||
|
||||
```ts
|
||||
const compensatedGame = withLagCompensation(game, {
|
||||
historySeconds: 2,
|
||||
captureState: (authority) => capturePlayerHitboxes(authority),
|
||||
cloneHistoricalState: cloneHitboxes,
|
||||
classifyAction(input) {
|
||||
if (input.fire) return { type: "hitscan", aim: input.aim };
|
||||
if (input.throw) return { type: "projectile", throw: input.throw };
|
||||
return null;
|
||||
},
|
||||
cloneAction: (action) => structuredClone(action),
|
||||
actions: {
|
||||
hitscan: {
|
||||
mode: "rewind",
|
||||
maximumRewindMs: 250,
|
||||
maximumFutureMs: 25,
|
||||
outOfWindow: "clamp",
|
||||
resolve: ({ currentState, historicalState, action, emit }) =>
|
||||
resolveHistoricalShot(
|
||||
currentState,
|
||||
historicalState,
|
||||
action,
|
||||
emit,
|
||||
),
|
||||
},
|
||||
projectile: {
|
||||
mode: "fast-forward",
|
||||
maximumRewindMs: 150,
|
||||
resolve: ({ historicalState, catchUpTicks, action }) =>
|
||||
spawnAndAdvanceProjectile(historicalState, action, catchUpTicks),
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Input packets carry separate `targetTick` and `observedTick` values. The former
|
||||
schedules smooth authoritative input; the latter identifies the presentation
|
||||
that produced an action. Because `observedTick` is client supplied, the server
|
||||
clamps or rejects it using the selected action policy. Resolution receives a
|
||||
cloned historical view and mutable current state: the live match itself is
|
||||
never rewound.
|
||||
|
||||
## Add deterministic time travel
|
||||
|
||||
`withTimeTravel()` is a higher-order layer over any networked game. Existing
|
||||
server calls keep working, but accepted inputs, joins, leaves, state hashes, and
|
||||
periodic private authority checkpoints are recorded automatically:
|
||||
|
||||
```ts
|
||||
import {
|
||||
defineNetworkedGame,
|
||||
deterministicHash,
|
||||
withLagCompensation,
|
||||
withReplayTransport,
|
||||
withTimeTravel,
|
||||
} from "@syncer/engine";
|
||||
|
||||
const game = withTimeTravel(
|
||||
defineNetworkedGame({
|
||||
// The normal generic server, client, replication, validation, and codecs.
|
||||
}),
|
||||
{
|
||||
createSeed: () => crypto.getRandomValues(new Uint32Array(1))[0]!,
|
||||
initializeState: (authority, seed) => {
|
||||
authority.random = createSeededRandom(seed);
|
||||
},
|
||||
hashState: deterministicHash,
|
||||
checkpointIntervalTicks: 120,
|
||||
verificationIntervalTicks: 1,
|
||||
},
|
||||
);
|
||||
|
||||
const server = game.createServer({ replaySeed: 42 });
|
||||
// Use addPlayer(), submitInput(), and step() normally.
|
||||
const recording = server.exportRecording();
|
||||
|
||||
const replay = game.createReplay(recording);
|
||||
replay.seek(8_420);
|
||||
|
||||
// This passes through the game's normal privacy projection.
|
||||
const playerSnapshot = replay.viewAs(playerId);
|
||||
const playerPerceptions = replay.perceptionsAs(playerId);
|
||||
|
||||
// Fork the authoritative timeline and continue with different inputs.
|
||||
const branch = replay.branch(8_420);
|
||||
branch.submitInput(playerId, alternateInputPacket);
|
||||
branch.step();
|
||||
```
|
||||
|
||||
Replay recordings contain private authoritative checkpoints and must stay on
|
||||
trusted infrastructure. A replay viewer should consume only `viewAs()` and
|
||||
`perceptionsAs()`. Deterministic games must keep all randomness inside seeded,
|
||||
cloned authority state; the replay verifier throws `ReplayDivergenceError` at
|
||||
the first mismatching recorded tick.
|
||||
|
||||
For long-running live matches, pass `recordingHistoryTicks` to keep a rolling
|
||||
private log. The exported recording exposes its first seekable `startTick` and
|
||||
remains deterministic from that checkpoint:
|
||||
|
||||
```ts
|
||||
const server = game.createServer({
|
||||
replaySeed: 42,
|
||||
recordingHistoryTicks: 60 * 30,
|
||||
});
|
||||
```
|
||||
|
||||
## Add secure replay transport
|
||||
|
||||
`withReplayTransport()` is the client-delivery layer. It retains a bounded ring
|
||||
of snapshots and events *after* the normal per-viewer projection has run. A
|
||||
ticket can therefore expose an attacker camera without exposing server truth or
|
||||
enemies that attacker could not see or identify:
|
||||
|
||||
```ts
|
||||
const liveGame = withReplayTransport(game, {
|
||||
historySeconds: 30,
|
||||
captureRateHz: 20,
|
||||
listPerspectives: (authority) => authority.players.keys(),
|
||||
createTickets(event, { tick, connectedPlayerIds }) {
|
||||
if (
|
||||
event.type !== "elimination" ||
|
||||
!connectedPlayerIds.includes(event.victimId)
|
||||
) return null;
|
||||
|
||||
return {
|
||||
requesterId: event.victimId,
|
||||
perspectiveId: event.killerId,
|
||||
fromTick: Math.max(0, tick - 60 * 4),
|
||||
toTick: tick,
|
||||
playbackRate: 0.72,
|
||||
};
|
||||
},
|
||||
authorizeReplay: ({ requesterId, perspectiveId, authorityState }) =>
|
||||
authorityState.players.has(requesterId) &&
|
||||
authorityState.players.has(perspectiveId),
|
||||
});
|
||||
```
|
||||
|
||||
The transport emits server-issued `replay-start`, `replay-frame`, and
|
||||
`replay-end` binary messages. There is intentionally no client message that can
|
||||
request an arbitrary perspective. Private `ReplayRecording` objects remain on
|
||||
the server; only codec-cloned `ClientState` and `PerceptionEvent` projections
|
||||
enter replay frames.
|
||||
|
||||
The shooter in `packages/shared/src/shooter-game.ts` is the large example. The server owns movement, collision, bot decisions, ammo, firing cadence, ray hits, damage, armor, eliminations, respawns, pickups, and scores. Clients predict only their permitted state.
|
||||
|
||||
Replication is perception-aware. A client snapshot includes itself, public pickups and scores, plus only opponents currently inside the server-approved field of view with line of sight. Hidden footsteps and gunfire become anonymous bearing/intensity/distance cues with no source ID or world coordinates.
|
||||
|
||||
## Engine responsibilities
|
||||
|
||||
- Fixed-step server and client simulation clocks with bounded catch-up.
|
||||
- Deterministic input ordering, sequence acknowledgement, rejection, and replay.
|
||||
- Private authoritative snapshot history and developer-defined client-state validation.
|
||||
- Client prediction and reconciliation from server snapshots.
|
||||
- Ping/pong RTT, jitter, clock-offset estimation, and adaptive input lead.
|
||||
- Per-viewer state projection and internal-event-to-perception filtering.
|
||||
- Generic binary message framing around developer-provided input, visible-state, and perception codecs.
|
||||
- Optional visibility grouping for sharing encoded snapshots without weakening privacy.
|
||||
- Bounded WebSocket backpressure and per-player message batching.
|
||||
- Higher-order deterministic recording, checkpoint seeking, state-hash verification, privacy-filtered playback, and alternate-timeline branching.
|
||||
- Bounded, policy-authorized replay transport with projected historical frames and instant attacker-perspective killcams.
|
||||
- Per-action current-state, rewind, and fast-forward policies with server-clamped observed ticks.
|
||||
|
||||
The trust rule is simple: clients send inputs and optional state reports, never authoritative facts. The server validates inputs, advances private state, decides what each player can observe, and sends only that projection. A determined cheater can inspect anything delivered to their machine, so secret information must never enter `ClientState` or `PerceptionEvent` in the first place.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The SPA runs at `http://localhost:5173`; in development, gameplay connects directly to the authoritative WebSocket server at `http://localhost:3001` to keep the Vite proxy out of latency measurements.
|
||||
|
||||
### Live multiplayer profiler
|
||||
|
||||
Open `http://localhost:3001/api/profile` (or replace `localhost` with the host's LAN address) for a one-second rolling JSON profile. It reports Node CPU, memory, event-loop utilization/delay, and a separate server profile for each mounted game. Per-game and per-connection metrics include tick rate, simulation/projection/encoding/send cost, input and snapshot rates, bytes per second, buffered bytes, backpressured sends, and dropped sends. Profiling stays on the authoritative server and adds no browser render-loop instrumentation.
|
||||
|
||||
## Commands
|
||||
|
||||
- `npm run dev` starts the engine, shared rules, server, and SPA in watch mode.
|
||||
- `npm run build` builds every workspace in dependency order.
|
||||
- `npm run typecheck` checks every workspace.
|
||||
- `npm test` runs engine tests plus deterministic backend-only shooter matches, codec checks, and privacy assertions.
|
||||
- `npm start` runs the built authoritative server.
|
||||
23
apps/server/package.json
Normal file
23
apps/server/package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "@syncer/server",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"start": "node dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@syncer/engine": "0.0.0",
|
||||
"@syncer/shared": "0.0.0",
|
||||
"uWebSockets.js": "https://github.com/uNetworking/uWebSockets.js/archive/refs/tags/v20.67.0.tar.gz"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^26.4.0",
|
||||
"tsx": "^4.23.12",
|
||||
"typescript": "^7.0.2"
|
||||
}
|
||||
}
|
||||
192
apps/server/src/client-profile-store.ts
Normal file
192
apps/server/src/client-profile-store.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
export interface ClientFrameProfile {
|
||||
fps: number;
|
||||
averageMs: number;
|
||||
p95Ms: number;
|
||||
maximumMs: number;
|
||||
slowFrames: number;
|
||||
longTasks: number;
|
||||
longTaskMs: number;
|
||||
}
|
||||
|
||||
export interface ClientNetworkProfile {
|
||||
roundTripTimeMs: number;
|
||||
jitterMs: number;
|
||||
clockOffsetMs: number;
|
||||
inputLeadTicks: number;
|
||||
connection: string;
|
||||
validation: string;
|
||||
}
|
||||
|
||||
export interface ClientRendererProfile {
|
||||
drawCalls: number;
|
||||
triangles: number;
|
||||
points: number;
|
||||
lines: number;
|
||||
geometries: number;
|
||||
textures: number;
|
||||
gpuVendor: string;
|
||||
gpuRenderer: string;
|
||||
}
|
||||
|
||||
export interface ClientPerformanceSample {
|
||||
sessionId: string;
|
||||
game: string;
|
||||
playerId: number | null;
|
||||
capturedAt: string;
|
||||
frame: ClientFrameProfile;
|
||||
network: ClientNetworkProfile;
|
||||
renderer: ClientRendererProfile | null;
|
||||
device: {
|
||||
viewportWidth: number;
|
||||
viewportHeight: number;
|
||||
devicePixelRatio: number;
|
||||
hardwareConcurrency: number;
|
||||
deviceMemoryGb: number | null;
|
||||
visibility: string;
|
||||
};
|
||||
heap: {
|
||||
usedBytes: number;
|
||||
totalBytes: number;
|
||||
limitBytes: number;
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface StoredSession {
|
||||
remoteAddress: string;
|
||||
lastSeenAt: number;
|
||||
samples: ClientPerformanceSample[];
|
||||
}
|
||||
|
||||
export interface ClientProfileSnapshot {
|
||||
sessionId: string;
|
||||
remoteAddress: string;
|
||||
game: string;
|
||||
playerId: number | null;
|
||||
lastSeenSecondsAgo: number;
|
||||
latest: ClientPerformanceSample;
|
||||
recent: {
|
||||
samples: number;
|
||||
averageFps: number;
|
||||
minimumFps: number;
|
||||
averageFrameMs: number;
|
||||
worstFrameMs: number;
|
||||
p95FrameMs: number;
|
||||
slowFrames: number;
|
||||
longTasks: number;
|
||||
longTaskMs: number;
|
||||
averageRoundTripTimeMs: number;
|
||||
maximumRoundTripTimeMs: number;
|
||||
averageJitterMs: number;
|
||||
};
|
||||
}
|
||||
|
||||
const maximumSamples = 30;
|
||||
const staleSessionMs = 30_000;
|
||||
|
||||
export class ClientProfileStore {
|
||||
private readonly sessions = new Map<string, StoredSession>();
|
||||
|
||||
record(sample: unknown, remoteAddress: string, now = performance.now()): boolean {
|
||||
if (!isClientSample(sample)) return false;
|
||||
let session = this.sessions.get(sample.sessionId);
|
||||
if (!session) {
|
||||
session = { remoteAddress, lastSeenAt: now, samples: [] };
|
||||
this.sessions.set(sample.sessionId, session);
|
||||
}
|
||||
session.remoteAddress = remoteAddress;
|
||||
session.lastSeenAt = now;
|
||||
session.samples.push(sample);
|
||||
if (session.samples.length > maximumSamples) session.samples.shift();
|
||||
return true;
|
||||
}
|
||||
|
||||
snapshot(now = performance.now()): ClientProfileSnapshot[] {
|
||||
for (const [sessionId, session] of this.sessions) {
|
||||
if (now - session.lastSeenAt > staleSessionMs) this.sessions.delete(sessionId);
|
||||
}
|
||||
return [...this.sessions.entries()]
|
||||
.map(([sessionId, session]) => summarize(sessionId, session, now))
|
||||
.sort((left, right) => left.game.localeCompare(right.game));
|
||||
}
|
||||
}
|
||||
|
||||
function summarize(
|
||||
sessionId: string,
|
||||
session: StoredSession,
|
||||
now: number,
|
||||
): ClientProfileSnapshot {
|
||||
const latest = session.samples[session.samples.length - 1]!;
|
||||
const frames = session.samples.map((sample) => sample.frame);
|
||||
const networks = session.samples.map((sample) => sample.network);
|
||||
const sum = (values: number[]) => values.reduce((total, value) => total + value, 0);
|
||||
const average = (values: number[]) => values.length === 0 ? 0 : sum(values) / values.length;
|
||||
return {
|
||||
sessionId,
|
||||
remoteAddress: session.remoteAddress,
|
||||
game: latest.game,
|
||||
playerId: latest.playerId,
|
||||
lastSeenSecondsAgo: round((now - session.lastSeenAt) / 1_000, 2),
|
||||
latest,
|
||||
recent: {
|
||||
samples: session.samples.length,
|
||||
averageFps: round(average(frames.map((frame) => frame.fps)), 1),
|
||||
minimumFps: round(Math.min(...frames.map((frame) => frame.fps)), 1),
|
||||
averageFrameMs: round(average(frames.map((frame) => frame.averageMs)), 2),
|
||||
worstFrameMs: round(Math.max(...frames.map((frame) => frame.maximumMs)), 2),
|
||||
p95FrameMs: round(average(frames.map((frame) => frame.p95Ms)), 2),
|
||||
slowFrames: sum(frames.map((frame) => frame.slowFrames)),
|
||||
longTasks: sum(frames.map((frame) => frame.longTasks)),
|
||||
longTaskMs: round(sum(frames.map((frame) => frame.longTaskMs)), 2),
|
||||
averageRoundTripTimeMs: round(
|
||||
average(networks.map((network) => network.roundTripTimeMs)),
|
||||
2,
|
||||
),
|
||||
maximumRoundTripTimeMs: round(
|
||||
Math.max(...networks.map((network) => network.roundTripTimeMs)),
|
||||
2,
|
||||
),
|
||||
averageJitterMs: round(average(networks.map((network) => network.jitterMs)), 2),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function isClientSample(value: unknown): value is ClientPerformanceSample {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const sample = value as Partial<ClientPerformanceSample>;
|
||||
return (
|
||||
typeof sample.sessionId === "string" &&
|
||||
sample.sessionId.length > 0 &&
|
||||
sample.sessionId.length <= 128 &&
|
||||
typeof sample.game === "string" &&
|
||||
sample.game.length <= 32 &&
|
||||
(sample.playerId === null || Number.isInteger(sample.playerId)) &&
|
||||
isFiniteRecord(sample.frame, [
|
||||
"fps",
|
||||
"averageMs",
|
||||
"p95Ms",
|
||||
"maximumMs",
|
||||
"slowFrames",
|
||||
"longTasks",
|
||||
"longTaskMs",
|
||||
]) &&
|
||||
isFiniteRecord(sample.network, [
|
||||
"roundTripTimeMs",
|
||||
"jitterMs",
|
||||
"clockOffsetMs",
|
||||
"inputLeadTicks",
|
||||
]) &&
|
||||
typeof sample.device === "object" &&
|
||||
sample.device !== null
|
||||
);
|
||||
}
|
||||
|
||||
function isFiniteRecord(value: unknown, keys: string[]): boolean {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const record = value as Record<string, unknown>;
|
||||
return keys.every((key) => typeof record[key] === "number" && Number.isFinite(record[key]));
|
||||
}
|
||||
|
||||
function round(value: number, digits: number): number {
|
||||
const scale = 10 ** digits;
|
||||
return Math.round(value * scale) / scale;
|
||||
}
|
||||
284
apps/server/src/game-profiler.ts
Normal file
284
apps/server/src/game-profiler.ts
Normal file
@@ -0,0 +1,284 @@
|
||||
export type InboundMessageKind = "input" | "ping" | "state-report";
|
||||
|
||||
export interface StepTimingSample {
|
||||
simulationMs: number;
|
||||
perceptionMs: number;
|
||||
snapshotMs: number;
|
||||
replayMs: number;
|
||||
sendMs: number;
|
||||
totalMs: number;
|
||||
authorityEvents: number;
|
||||
snapshotDue: boolean;
|
||||
}
|
||||
|
||||
interface MetricWindow {
|
||||
startedAt: number;
|
||||
elapsedMs: number;
|
||||
steps: number;
|
||||
simulationMs: number;
|
||||
perceptionMs: number;
|
||||
snapshotMs: number;
|
||||
replayMs: number;
|
||||
sendMs: number;
|
||||
totalStepMs: number;
|
||||
maximumStepMs: number;
|
||||
authorityEvents: number;
|
||||
snapshots: number;
|
||||
inputMessages: number;
|
||||
pingMessages: number;
|
||||
stateReports: number;
|
||||
inboundBytes: number;
|
||||
outboundFrames: number;
|
||||
outboundBytes: number;
|
||||
backpressuredSends: number;
|
||||
droppedSends: number;
|
||||
maximumBufferedBytes: number;
|
||||
}
|
||||
|
||||
interface ClientMetrics {
|
||||
playerId: number;
|
||||
remoteAddress: string;
|
||||
connectedAt: number;
|
||||
current: MetricWindow;
|
||||
previous: MetricWindow;
|
||||
bufferedBytes: number;
|
||||
}
|
||||
|
||||
export interface RateProfile {
|
||||
sampleSeconds: number;
|
||||
stepsPerSecond: number;
|
||||
averageStepMs: number;
|
||||
maximumStepMs: number;
|
||||
simulationMsPerStep: number;
|
||||
perceptionMsPerStep: number;
|
||||
snapshotMsPerStep: number;
|
||||
replayMsPerStep: number;
|
||||
sendMsPerStep: number;
|
||||
authorityEventsPerSecond: number;
|
||||
snapshotsPerSecond: number;
|
||||
inputsPerSecond: number;
|
||||
pingsPerSecond: number;
|
||||
stateReportsPerSecond: number;
|
||||
inboundBytesPerSecond: number;
|
||||
outboundFramesPerSecond: number;
|
||||
outboundBytesPerSecond: number;
|
||||
backpressuredSendsPerSecond: number;
|
||||
droppedSendsPerSecond: number;
|
||||
maximumBufferedBytes: number;
|
||||
}
|
||||
|
||||
export interface ClientProfileSnapshot {
|
||||
playerId: number;
|
||||
remoteAddress: string;
|
||||
connectedSeconds: number;
|
||||
bufferedBytes: number;
|
||||
recent: RateProfile;
|
||||
}
|
||||
|
||||
export interface GameProfileSnapshot {
|
||||
path: string;
|
||||
configuredTickRateHz: number;
|
||||
connectedPlayers: number;
|
||||
recent: RateProfile;
|
||||
clients: ClientProfileSnapshot[];
|
||||
}
|
||||
|
||||
/** Low-overhead one-second rolling profiler for a hosted networked game. */
|
||||
export class GameServerProfiler {
|
||||
private current: MetricWindow;
|
||||
private previous: MetricWindow;
|
||||
private readonly clients = new Map<number, ClientMetrics>();
|
||||
|
||||
constructor(
|
||||
readonly path: string,
|
||||
readonly tickRateHz: number,
|
||||
now = performance.now(),
|
||||
) {
|
||||
this.current = createWindow(now);
|
||||
this.previous = createWindow(now);
|
||||
}
|
||||
|
||||
openClient(playerId: number, remoteAddress: string, now = performance.now()): void {
|
||||
this.roll(now);
|
||||
this.clients.set(playerId, {
|
||||
playerId,
|
||||
remoteAddress,
|
||||
connectedAt: now,
|
||||
current: createWindow(now),
|
||||
previous: createWindow(now),
|
||||
bufferedBytes: 0,
|
||||
});
|
||||
}
|
||||
|
||||
closeClient(playerId: number): void {
|
||||
this.clients.delete(playerId);
|
||||
}
|
||||
|
||||
recordInbound(
|
||||
playerId: number,
|
||||
kind: InboundMessageKind,
|
||||
bytes: number,
|
||||
now = performance.now(),
|
||||
): void {
|
||||
this.roll(now);
|
||||
recordInbound(this.current, kind, bytes);
|
||||
const client = this.clients.get(playerId);
|
||||
if (client) recordInbound(client.current, kind, bytes);
|
||||
}
|
||||
|
||||
recordOutbound(
|
||||
playerId: number,
|
||||
bytes: number,
|
||||
sendResult: number,
|
||||
bufferedBytes: number,
|
||||
now = performance.now(),
|
||||
): void {
|
||||
this.roll(now);
|
||||
recordOutbound(this.current, bytes, sendResult, bufferedBytes);
|
||||
const client = this.clients.get(playerId);
|
||||
if (client) {
|
||||
client.bufferedBytes = bufferedBytes;
|
||||
recordOutbound(client.current, bytes, sendResult, bufferedBytes);
|
||||
}
|
||||
}
|
||||
|
||||
recordStep(sample: StepTimingSample, now = performance.now()): void {
|
||||
this.roll(now);
|
||||
const window = this.current;
|
||||
window.steps += 1;
|
||||
window.simulationMs += sample.simulationMs;
|
||||
window.perceptionMs += sample.perceptionMs;
|
||||
window.snapshotMs += sample.snapshotMs;
|
||||
window.replayMs += sample.replayMs;
|
||||
window.sendMs += sample.sendMs;
|
||||
window.totalStepMs += sample.totalMs;
|
||||
window.maximumStepMs = Math.max(window.maximumStepMs, sample.totalMs);
|
||||
window.authorityEvents += sample.authorityEvents;
|
||||
if (sample.snapshotDue) window.snapshots += 1;
|
||||
}
|
||||
|
||||
snapshot(now = performance.now()): GameProfileSnapshot {
|
||||
this.roll(now);
|
||||
return {
|
||||
path: this.path,
|
||||
configuredTickRateHz: this.tickRateHz,
|
||||
connectedPlayers: this.clients.size,
|
||||
recent: rateProfile(selectWindow(this.current, this.previous, now)),
|
||||
clients: [...this.clients.values()]
|
||||
.sort((left, right) => left.playerId - right.playerId)
|
||||
.map((client) => ({
|
||||
playerId: client.playerId,
|
||||
remoteAddress: client.remoteAddress,
|
||||
connectedSeconds: round((now - client.connectedAt) / 1_000, 2),
|
||||
bufferedBytes: client.bufferedBytes,
|
||||
recent: rateProfile(selectWindow(client.current, client.previous, now)),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
private roll(now: number): void {
|
||||
if (now - this.current.startedAt >= 1_000) {
|
||||
this.current.elapsedMs = Math.max(1, now - this.current.startedAt);
|
||||
this.previous = this.current;
|
||||
this.current = createWindow(now);
|
||||
}
|
||||
for (const client of this.clients.values()) {
|
||||
if (now - client.current.startedAt < 1_000) continue;
|
||||
client.current.elapsedMs = Math.max(1, now - client.current.startedAt);
|
||||
client.previous = client.current;
|
||||
client.current = createWindow(now);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createWindow(now: number): MetricWindow {
|
||||
return {
|
||||
startedAt: now,
|
||||
elapsedMs: 0,
|
||||
steps: 0,
|
||||
simulationMs: 0,
|
||||
perceptionMs: 0,
|
||||
snapshotMs: 0,
|
||||
replayMs: 0,
|
||||
sendMs: 0,
|
||||
totalStepMs: 0,
|
||||
maximumStepMs: 0,
|
||||
authorityEvents: 0,
|
||||
snapshots: 0,
|
||||
inputMessages: 0,
|
||||
pingMessages: 0,
|
||||
stateReports: 0,
|
||||
inboundBytes: 0,
|
||||
outboundFrames: 0,
|
||||
outboundBytes: 0,
|
||||
backpressuredSends: 0,
|
||||
droppedSends: 0,
|
||||
maximumBufferedBytes: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function recordInbound(window: MetricWindow, kind: InboundMessageKind, bytes: number): void {
|
||||
window.inboundBytes += bytes;
|
||||
if (kind === "input") window.inputMessages += 1;
|
||||
else if (kind === "ping") window.pingMessages += 1;
|
||||
else window.stateReports += 1;
|
||||
}
|
||||
|
||||
function recordOutbound(
|
||||
window: MetricWindow,
|
||||
bytes: number,
|
||||
sendResult: number,
|
||||
bufferedBytes: number,
|
||||
): void {
|
||||
window.outboundFrames += 1;
|
||||
window.outboundBytes += bytes;
|
||||
if (sendResult === 0) window.backpressuredSends += 1;
|
||||
if (sendResult === 2) window.droppedSends += 1;
|
||||
window.maximumBufferedBytes = Math.max(window.maximumBufferedBytes, bufferedBytes);
|
||||
}
|
||||
|
||||
function selectWindow(
|
||||
current: MetricWindow,
|
||||
previous: MetricWindow,
|
||||
now: number,
|
||||
): MetricWindow {
|
||||
const currentElapsed = now - current.startedAt;
|
||||
if (currentElapsed >= 350 || previous.elapsedMs === 0) {
|
||||
return { ...current, elapsedMs: Math.max(1, currentElapsed) };
|
||||
}
|
||||
return previous;
|
||||
}
|
||||
|
||||
function rateProfile(window: MetricWindow): RateProfile {
|
||||
const seconds = Math.max(0.001, window.elapsedMs / 1_000);
|
||||
const perSecond = (value: number) => round(value / seconds, 2);
|
||||
const perStep = (value: number) =>
|
||||
round(window.steps === 0 ? 0 : value / window.steps, 4);
|
||||
return {
|
||||
sampleSeconds: round(seconds, 3),
|
||||
stepsPerSecond: perSecond(window.steps),
|
||||
averageStepMs: perStep(window.totalStepMs),
|
||||
maximumStepMs: round(window.maximumStepMs, 4),
|
||||
simulationMsPerStep: perStep(window.simulationMs),
|
||||
perceptionMsPerStep: perStep(window.perceptionMs),
|
||||
snapshotMsPerStep: perStep(window.snapshotMs),
|
||||
replayMsPerStep: perStep(window.replayMs),
|
||||
sendMsPerStep: perStep(window.sendMs),
|
||||
authorityEventsPerSecond: perSecond(window.authorityEvents),
|
||||
snapshotsPerSecond: perSecond(window.snapshots),
|
||||
inputsPerSecond: perSecond(window.inputMessages),
|
||||
pingsPerSecond: perSecond(window.pingMessages),
|
||||
stateReportsPerSecond: perSecond(window.stateReports),
|
||||
inboundBytesPerSecond: perSecond(window.inboundBytes),
|
||||
outboundFramesPerSecond: perSecond(window.outboundFrames),
|
||||
outboundBytesPerSecond: perSecond(window.outboundBytes),
|
||||
backpressuredSendsPerSecond: perSecond(window.backpressuredSends),
|
||||
droppedSendsPerSecond: perSecond(window.droppedSends),
|
||||
maximumBufferedBytes: window.maximumBufferedBytes,
|
||||
};
|
||||
}
|
||||
|
||||
function round(value: number, digits: number): number {
|
||||
const scale = 10 ** digits;
|
||||
return Math.round(value * scale) / scale;
|
||||
}
|
||||
295
apps/server/src/host-networked-game.ts
Normal file
295
apps/server/src/host-networked-game.ts
Normal file
@@ -0,0 +1,295 @@
|
||||
import {
|
||||
FixedStepClock,
|
||||
type BinaryProtocol,
|
||||
type ClientStateReport,
|
||||
type InputDecision,
|
||||
type InputPacket,
|
||||
type NetworkedServerStepResult,
|
||||
type PlayerId,
|
||||
type ReplayTicket,
|
||||
type SnapshotBatch,
|
||||
type StateSnapshot,
|
||||
type ValidationResult,
|
||||
} from "@syncer/engine";
|
||||
import {
|
||||
DISABLED,
|
||||
type TemplatedApp,
|
||||
type WebSocket,
|
||||
} from "uWebSockets.js";
|
||||
import {
|
||||
GameServerProfiler,
|
||||
type GameProfileSnapshot,
|
||||
type InboundMessageKind,
|
||||
} from "./game-profiler.js";
|
||||
|
||||
interface HostableEngine<AuthorityState, ClientState, Input, AuthorityEvent, PerceptionEvent> {
|
||||
readonly playerIds: PlayerId[];
|
||||
addPlayer(playerId: PlayerId): void;
|
||||
removePlayer(playerId: PlayerId): void;
|
||||
submitInput(playerId: PlayerId, packet: InputPacket<Input>): InputDecision;
|
||||
submitStateReport(playerId: PlayerId, report: ClientStateReport<ClientState>): ValidationResult | null;
|
||||
step(): NetworkedServerStepResult<AuthorityEvent>;
|
||||
createSnapshot(playerId: PlayerId, serverTime: number): StateSnapshot<ClientState>;
|
||||
createSnapshotBatches(serverTime: number): SnapshotBatch<ClientState>[];
|
||||
createPerceptions(playerId: PlayerId, events: readonly AuthorityEvent[]): PerceptionEvent[];
|
||||
drainReplayTickets?(playerId: PlayerId): Array<ReplayTicket<ClientState, PerceptionEvent>>;
|
||||
}
|
||||
|
||||
interface HostableGame<AuthorityState, ClientState, Input, AuthorityEvent, PerceptionEvent> {
|
||||
tickRateHz: number;
|
||||
protocol: BinaryProtocol<Input, ClientState, PerceptionEvent>;
|
||||
createServer(): HostableEngine<AuthorityState, ClientState, Input, AuthorityEvent, PerceptionEvent>;
|
||||
}
|
||||
|
||||
export interface HostedGameLoop {
|
||||
readonly path: string;
|
||||
advance(now: number): void;
|
||||
profile(now?: number): GameProfileSnapshot;
|
||||
}
|
||||
|
||||
/** Mounts any defined networked game on the shared WebSocket server. */
|
||||
export function hostNetworkedGame<AuthorityState, ClientState, Input, AuthorityEvent, PerceptionEvent>(
|
||||
app: TemplatedApp,
|
||||
path: string,
|
||||
game: HostableGame<AuthorityState, ClientState, Input, AuthorityEvent, PerceptionEvent>,
|
||||
): HostedGameLoop {
|
||||
const engine = game.createServer();
|
||||
const protocol = game.protocol;
|
||||
const clock = new FixedStepClock({ rateHz: game.tickRateHz, maxCatchUpSteps: 5 });
|
||||
const profiler = new GameServerProfiler(path, game.tickRateHz);
|
||||
const socketPlayers = new Map<WebSocket<unknown>, PlayerId>();
|
||||
const playerSockets = new Map<PlayerId, WebSocket<unknown>>();
|
||||
let nextPlayerId = 1;
|
||||
|
||||
app.ws<unknown>(path, {
|
||||
compression: DISABLED,
|
||||
maxPayloadLength: 512 * 1_024,
|
||||
maxBackpressure: 256 * 1_024,
|
||||
closeOnBackpressureLimit: true,
|
||||
idleTimeout: 32,
|
||||
sendPingsAutomatically: true,
|
||||
open(socket) {
|
||||
const playerId = nextPlayerId;
|
||||
nextPlayerId = (nextPlayerId + 1) >>> 0;
|
||||
socketPlayers.set(socket, playerId);
|
||||
playerSockets.set(playerId, socket);
|
||||
profiler.openClient(playerId, readRemoteAddress(socket));
|
||||
engine.addPlayer(playerId);
|
||||
sendFrame(
|
||||
socket,
|
||||
playerId,
|
||||
protocol.encodeServer({
|
||||
kind: "welcome",
|
||||
playerId,
|
||||
snapshot: engine.createSnapshot(playerId, performance.now()),
|
||||
}),
|
||||
);
|
||||
},
|
||||
message(socket, payload) {
|
||||
const playerId = socketPlayers.get(socket);
|
||||
if (playerId === undefined) {
|
||||
socket.end(1011, "Missing player session");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const message = protocol.decodeClient(payload);
|
||||
profiler.recordInbound(
|
||||
playerId,
|
||||
message.kind as InboundMessageKind,
|
||||
payload.byteLength,
|
||||
);
|
||||
switch (message.kind) {
|
||||
case "input": {
|
||||
const decision = engine.submitInput(playerId, message.packet);
|
||||
if (!decision.accepted) {
|
||||
sendFrame(
|
||||
socket,
|
||||
playerId,
|
||||
protocol.encodeServer({ kind: "reject-input", sequence: message.packet.sequence }),
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "ping": {
|
||||
const serverReceivedAt = performance.now();
|
||||
sendFrame(
|
||||
socket,
|
||||
playerId,
|
||||
protocol.encodeServer({
|
||||
kind: "pong",
|
||||
pong: { ...message.ping, serverReceivedAt, serverSentAt: performance.now() },
|
||||
}),
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "state-report": {
|
||||
const validation = engine.submitStateReport(playerId, message.report);
|
||||
if (validation) sendValidation(validation);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
socket.end(1003, "Invalid game message");
|
||||
}
|
||||
},
|
||||
close(socket) {
|
||||
const playerId = socketPlayers.get(socket);
|
||||
if (playerId === undefined) return;
|
||||
socketPlayers.delete(socket);
|
||||
playerSockets.delete(playerId);
|
||||
profiler.closeClient(playerId);
|
||||
engine.removePlayer(playerId);
|
||||
},
|
||||
});
|
||||
|
||||
function runStep(): void {
|
||||
const startedAt = performance.now();
|
||||
const result = engine.step();
|
||||
const simulationEndedAt = performance.now();
|
||||
const outbound = new Map<PlayerId, ArrayBuffer[]>();
|
||||
|
||||
for (const acknowledgement of result.acknowledgements) {
|
||||
queueFrame(outbound, acknowledgement.playerId, protocol.encodeServer({
|
||||
kind: "acknowledge",
|
||||
sequence: acknowledgement.sequence,
|
||||
}));
|
||||
}
|
||||
for (const validation of result.validations) {
|
||||
queueFrame(outbound, validation.playerId, protocol.encodeServer({
|
||||
kind: "validation",
|
||||
tick: validation.tick,
|
||||
valid: validation.valid,
|
||||
}));
|
||||
}
|
||||
for (const playerId of engine.playerIds) {
|
||||
for (const event of engine.createPerceptions(playerId, result.events)) {
|
||||
queueFrame(outbound, playerId, protocol.encodeServer({ kind: "event", tick: result.tick, event }));
|
||||
}
|
||||
}
|
||||
const perceptionEndedAt = performance.now();
|
||||
|
||||
if (result.snapshotDue) {
|
||||
const serverTime = performance.now();
|
||||
for (const batch of engine.createSnapshotBatches(serverTime)) {
|
||||
const frame = protocol.encodeServer({
|
||||
kind: "snapshot",
|
||||
snapshot: { tick: result.tick, serverTime, state: batch.state },
|
||||
});
|
||||
for (const playerId of batch.playerIds) queueFrame(outbound, playerId, frame);
|
||||
}
|
||||
}
|
||||
const snapshotEndedAt = performance.now();
|
||||
|
||||
if (engine.drainReplayTickets) {
|
||||
for (const playerId of engine.playerIds) {
|
||||
for (const ticket of engine.drainReplayTickets(playerId)) queueReplay(outbound, playerId, ticket);
|
||||
}
|
||||
}
|
||||
const replayEndedAt = performance.now();
|
||||
|
||||
for (const [playerId, frames] of outbound) {
|
||||
const socket = playerSockets.get(playerId);
|
||||
socket?.cork(() => {
|
||||
for (const frame of frames) sendFrame(socket, playerId, frame);
|
||||
});
|
||||
}
|
||||
const endedAt = performance.now();
|
||||
profiler.recordStep({
|
||||
simulationMs: simulationEndedAt - startedAt,
|
||||
perceptionMs: perceptionEndedAt - simulationEndedAt,
|
||||
snapshotMs: snapshotEndedAt - perceptionEndedAt,
|
||||
replayMs: replayEndedAt - snapshotEndedAt,
|
||||
sendMs: endedAt - replayEndedAt,
|
||||
totalMs: endedAt - startedAt,
|
||||
authorityEvents: result.events.length,
|
||||
snapshotDue: result.snapshotDue,
|
||||
}, endedAt);
|
||||
}
|
||||
|
||||
function sendValidation(validation: ValidationResult): void {
|
||||
const socket = playerSockets.get(validation.playerId);
|
||||
if (!socket) return;
|
||||
sendFrame(
|
||||
socket,
|
||||
validation.playerId,
|
||||
protocol.encodeServer({ kind: "validation", tick: validation.tick, valid: validation.valid }),
|
||||
);
|
||||
}
|
||||
|
||||
function sendFrame(
|
||||
socket: WebSocket<unknown>,
|
||||
playerId: PlayerId,
|
||||
frame: ArrayBuffer,
|
||||
): void {
|
||||
const result = socket.send(frame, true, false);
|
||||
profiler.recordOutbound(
|
||||
playerId,
|
||||
frame.byteLength,
|
||||
result,
|
||||
socket.getBufferedAmount(),
|
||||
);
|
||||
}
|
||||
|
||||
function queueReplay(
|
||||
outbound: Map<PlayerId, ArrayBuffer[]>,
|
||||
playerId: PlayerId,
|
||||
ticket: ReplayTicket<ClientState, PerceptionEvent>,
|
||||
): void {
|
||||
queueFrame(outbound, playerId, protocol.encodeServer({
|
||||
kind: "replay-start",
|
||||
ticketId: ticket.ticketId,
|
||||
perspectiveId: ticket.perspectiveId,
|
||||
fromTick: ticket.fromTick,
|
||||
toTick: ticket.toTick,
|
||||
frameCount: ticket.frames.length,
|
||||
playbackRate: ticket.playbackRate,
|
||||
}));
|
||||
for (const frame of ticket.frames) {
|
||||
queueFrame(outbound, playerId, protocol.encodeServer({
|
||||
kind: "replay-frame",
|
||||
ticketId: ticket.ticketId,
|
||||
tick: frame.tick,
|
||||
state: frame.state,
|
||||
events: frame.events,
|
||||
}));
|
||||
}
|
||||
queueFrame(outbound, playerId, protocol.encodeServer({ kind: "replay-end", ticketId: ticket.ticketId }));
|
||||
}
|
||||
|
||||
return {
|
||||
path,
|
||||
advance(now) {
|
||||
clock.advance(now, runStep);
|
||||
},
|
||||
profile(now) {
|
||||
return profiler.snapshot(now);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function readRemoteAddress(socket: WebSocket<unknown>): string {
|
||||
const port = socket.getRemotePort();
|
||||
const text = new TextDecoder().decode(socket.getRemoteAddressAsText());
|
||||
if (text) return `${text}:${port}`;
|
||||
const bytes = new Uint8Array(socket.getRemoteAddress());
|
||||
if (bytes.length === 4) return `${[...bytes].join(".")}:${port}`;
|
||||
if (bytes.length === 16) {
|
||||
const groups: string[] = [];
|
||||
for (let index = 0; index < bytes.length; index += 2) {
|
||||
groups.push(((bytes[index] ?? 0) * 256 + (bytes[index + 1] ?? 0)).toString(16));
|
||||
}
|
||||
return `[${groups.join(":")}]:${port}`;
|
||||
}
|
||||
return port > 0 ? `unknown:${port}` : "unknown";
|
||||
}
|
||||
|
||||
function queueFrame(
|
||||
outbound: Map<PlayerId, ArrayBuffer[]>,
|
||||
playerId: PlayerId,
|
||||
frame: ArrayBuffer,
|
||||
): void {
|
||||
const frames = outbound.get(playerId);
|
||||
if (frames) frames.push(frame);
|
||||
else outbound.set(playerId, [frame]);
|
||||
}
|
||||
183
apps/server/src/index.ts
Normal file
183
apps/server/src/index.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import {
|
||||
API_PATHS,
|
||||
FLUX_SOCKET_PATH,
|
||||
GAME_SOCKET_PATH,
|
||||
ROYALE_SOCKET_PATH,
|
||||
createShooterGame,
|
||||
fluxGame,
|
||||
royaleGame,
|
||||
type ApiMessage,
|
||||
} from "@syncer/shared";
|
||||
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
||||
import { extname, join, relative } from "node:path";
|
||||
import { App, type HttpResponse } from "uWebSockets.js";
|
||||
import { ClientProfileStore } from "./client-profile-store.js";
|
||||
import { hostNetworkedGame } from "./host-networked-game.js";
|
||||
import { ServerProcessProfiler } from "./server-profiler.js";
|
||||
|
||||
const port = Number(process.env.PORT ?? 3001);
|
||||
const app = App();
|
||||
const processProfiler = new ServerProcessProfiler();
|
||||
const clientProfiles = new ClientProfileStore();
|
||||
const staticSite = loadStaticSite(process.env.STATIC_ROOT);
|
||||
const gameLoops = [
|
||||
hostNetworkedGame(app, GAME_SOCKET_PATH, createShooterGame({ botCount: 0 })),
|
||||
hostNetworkedGame(app, FLUX_SOCKET_PATH, fluxGame),
|
||||
hostNetworkedGame(app, ROYALE_SOCKET_PATH, royaleGame),
|
||||
];
|
||||
|
||||
app
|
||||
.post("/api/client-profile", (response) => {
|
||||
const chunks: Buffer[] = [];
|
||||
let bytes = 0;
|
||||
let finished = false;
|
||||
response.onAborted(() => {
|
||||
finished = true;
|
||||
});
|
||||
response.onData((chunk, isLast) => {
|
||||
if (finished) return;
|
||||
bytes += chunk.byteLength;
|
||||
if (bytes > 64 * 1_024) {
|
||||
finished = true;
|
||||
response.writeStatus("413 Payload Too Large").end();
|
||||
return;
|
||||
}
|
||||
chunks.push(Buffer.from(new Uint8Array(chunk)));
|
||||
if (!isLast) return;
|
||||
finished = true;
|
||||
try {
|
||||
const sample: unknown = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
||||
if (!clientProfiles.record(sample, readRemoteAddress(response))) {
|
||||
response.writeStatus("400 Bad Request").end("Invalid profile sample");
|
||||
return;
|
||||
}
|
||||
response
|
||||
.writeStatus("204 No Content")
|
||||
.writeHeader("Access-Control-Allow-Origin", "*")
|
||||
.end();
|
||||
} catch {
|
||||
response.writeStatus("400 Bad Request").end("Invalid JSON");
|
||||
}
|
||||
});
|
||||
})
|
||||
.get("/api/profile", (response) => {
|
||||
const now = performance.now();
|
||||
response
|
||||
.writeHeader("Content-Type", "application/json; charset=utf-8")
|
||||
.writeHeader("Access-Control-Allow-Origin", "*")
|
||||
.end(JSON.stringify({
|
||||
timestamp: new Date().toISOString(),
|
||||
process: processProfiler.snapshot(),
|
||||
clients: clientProfiles.snapshot(now),
|
||||
games: gameLoops.map((game) => game.profile(now)),
|
||||
}, null, 2));
|
||||
})
|
||||
.get(API_PATHS.message, (response) => {
|
||||
const body: ApiMessage = {
|
||||
message: "Authoritative multiplayer simulations online",
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
response
|
||||
.writeHeader("Content-Type", "application/json; charset=utf-8")
|
||||
.end(JSON.stringify(body));
|
||||
})
|
||||
.any("/*", (response, request) => {
|
||||
const url = request.getUrl();
|
||||
const method = request.getMethod().toUpperCase();
|
||||
const asset = staticSite?.files.get(url);
|
||||
const fallback =
|
||||
staticSite &&
|
||||
(method === "GET" || method === "HEAD") &&
|
||||
!url.startsWith("/api/") &&
|
||||
!url.startsWith("/ws/")
|
||||
? staticSite.index
|
||||
: undefined;
|
||||
const file = asset ?? fallback;
|
||||
if (!file || (method !== "GET" && method !== "HEAD")) {
|
||||
response.writeStatus("404 Not Found").end("Not found");
|
||||
return;
|
||||
}
|
||||
response
|
||||
.writeHeader("Content-Type", file.contentType)
|
||||
.writeHeader(
|
||||
"Cache-Control",
|
||||
asset && url.startsWith("/assets/")
|
||||
? "public, max-age=31536000, immutable"
|
||||
: "no-cache",
|
||||
);
|
||||
response.end(method === "HEAD" ? undefined : file.body);
|
||||
})
|
||||
.listen(port, (listenSocket) => {
|
||||
if (!listenSocket) {
|
||||
console.error(`Could not listen on port ${port}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Authoritative server listening on http://localhost:${port} (${gameLoops
|
||||
.map(({ path }) => path)
|
||||
.join(", ")}); profiler: /api/profile`,
|
||||
);
|
||||
const timer = setInterval(() => {
|
||||
const now = performance.now();
|
||||
for (const game of gameLoops) game.advance(now);
|
||||
}, 4);
|
||||
timer.unref();
|
||||
});
|
||||
|
||||
function readRemoteAddress(response: HttpResponse): string {
|
||||
const port = response.getRemotePort();
|
||||
const text = new TextDecoder().decode(response.getRemoteAddressAsText());
|
||||
return text ? `${text}:${port}` : port > 0 ? `unknown:${port}` : "unknown";
|
||||
}
|
||||
|
||||
interface StaticAsset {
|
||||
body: Buffer;
|
||||
contentType: string;
|
||||
}
|
||||
|
||||
interface StaticSite {
|
||||
files: Map<string, StaticAsset>;
|
||||
index: StaticAsset;
|
||||
}
|
||||
|
||||
function loadStaticSite(root: string | undefined): StaticSite | null {
|
||||
if (!root || !existsSync(root)) return null;
|
||||
const files = new Map<string, StaticAsset>();
|
||||
for (const filePath of walkFiles(root)) {
|
||||
const url = `/${relative(root, filePath).split("\\").join("/")}`;
|
||||
files.set(url, {
|
||||
body: readFileSync(filePath),
|
||||
contentType: contentType(filePath),
|
||||
});
|
||||
}
|
||||
const index = files.get("/index.html");
|
||||
if (!index) throw new Error(`STATIC_ROOT has no index.html: ${root}`);
|
||||
console.log(`Serving ${files.size} static files from ${root}`);
|
||||
return { files, index };
|
||||
}
|
||||
|
||||
function walkFiles(directory: string): string[] {
|
||||
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
||||
const path = join(directory, entry.name);
|
||||
return entry.isDirectory() ? walkFiles(path) : [path];
|
||||
});
|
||||
}
|
||||
|
||||
function contentType(filePath: string): string {
|
||||
switch (extname(filePath)) {
|
||||
case ".html": return "text/html; charset=utf-8";
|
||||
case ".js": return "text/javascript; charset=utf-8";
|
||||
case ".css": return "text/css; charset=utf-8";
|
||||
case ".json": return "application/json; charset=utf-8";
|
||||
case ".svg": return "image/svg+xml";
|
||||
case ".png": return "image/png";
|
||||
case ".jpg":
|
||||
case ".jpeg": return "image/jpeg";
|
||||
case ".webp": return "image/webp";
|
||||
case ".ico": return "image/x-icon";
|
||||
case ".woff2": return "font/woff2";
|
||||
default: return "application/octet-stream";
|
||||
}
|
||||
}
|
||||
101
apps/server/src/server-profiler.ts
Normal file
101
apps/server/src/server-profiler.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import {
|
||||
monitorEventLoopDelay,
|
||||
performance as nodePerformance,
|
||||
type EventLoopUtilization,
|
||||
} from "node:perf_hooks";
|
||||
|
||||
export interface ServerProcessProfile {
|
||||
uptimeSeconds: number;
|
||||
cpuPercent: number;
|
||||
eventLoopUtilization: number;
|
||||
eventLoopDelayMs: {
|
||||
mean: number;
|
||||
maximum: number;
|
||||
p95: number;
|
||||
p99: number;
|
||||
};
|
||||
memoryBytes: {
|
||||
residentSet: number;
|
||||
heapUsed: number;
|
||||
heapTotal: number;
|
||||
external: number;
|
||||
arrayBuffers: number;
|
||||
};
|
||||
}
|
||||
|
||||
/** Samples Node process pressure independently of any particular game. */
|
||||
export class ServerProcessProfiler {
|
||||
private readonly delay = monitorEventLoopDelay({ resolution: 1 });
|
||||
private previousCpu = process.cpuUsage();
|
||||
private previousSampleAt = nodePerformance.now();
|
||||
private previousUtilization: EventLoopUtilization =
|
||||
nodePerformance.eventLoopUtilization();
|
||||
private recent: ServerProcessProfile;
|
||||
|
||||
constructor() {
|
||||
this.delay.enable();
|
||||
this.recent = this.capture();
|
||||
const timer = setInterval(() => {
|
||||
this.recent = this.capture();
|
||||
}, 1_000);
|
||||
timer.unref();
|
||||
}
|
||||
|
||||
snapshot(): ServerProcessProfile {
|
||||
return {
|
||||
...this.recent,
|
||||
uptimeSeconds: round(process.uptime(), 2),
|
||||
memoryBytes: readMemory(),
|
||||
};
|
||||
}
|
||||
|
||||
private capture(): ServerProcessProfile {
|
||||
const now = nodePerformance.now();
|
||||
const elapsedMs = Math.max(1, now - this.previousSampleAt);
|
||||
const cpu = process.cpuUsage();
|
||||
const cpuMicroseconds =
|
||||
cpu.user - this.previousCpu.user + cpu.system - this.previousCpu.system;
|
||||
const utilization = nodePerformance.eventLoopUtilization();
|
||||
const utilizationDelta = nodePerformance.eventLoopUtilization(
|
||||
utilization,
|
||||
this.previousUtilization,
|
||||
);
|
||||
const profile: ServerProcessProfile = {
|
||||
uptimeSeconds: round(process.uptime(), 2),
|
||||
cpuPercent: round((cpuMicroseconds / (elapsedMs * 1_000)) * 100, 2),
|
||||
eventLoopUtilization: round(utilizationDelta.utilization * 100, 2),
|
||||
eventLoopDelayMs: {
|
||||
mean: nanosecondsToMilliseconds(this.delay.mean),
|
||||
maximum: nanosecondsToMilliseconds(this.delay.max),
|
||||
p95: nanosecondsToMilliseconds(this.delay.percentile(95)),
|
||||
p99: nanosecondsToMilliseconds(this.delay.percentile(99)),
|
||||
},
|
||||
memoryBytes: readMemory(),
|
||||
};
|
||||
this.previousCpu = cpu;
|
||||
this.previousSampleAt = now;
|
||||
this.previousUtilization = utilization;
|
||||
this.delay.reset();
|
||||
return profile;
|
||||
}
|
||||
}
|
||||
|
||||
function readMemory(): ServerProcessProfile["memoryBytes"] {
|
||||
const memory = process.memoryUsage();
|
||||
return {
|
||||
residentSet: memory.rss,
|
||||
heapUsed: memory.heapUsed,
|
||||
heapTotal: memory.heapTotal,
|
||||
external: memory.external,
|
||||
arrayBuffers: memory.arrayBuffers,
|
||||
};
|
||||
}
|
||||
|
||||
function nanosecondsToMilliseconds(value: number): number {
|
||||
return round(Number.isFinite(value) ? value / 1_000_000 : 0, 3);
|
||||
}
|
||||
|
||||
function round(value: number, digits: number): number {
|
||||
const scale = 10 ** digits;
|
||||
return Math.round(value * scale) / scale;
|
||||
}
|
||||
13
apps/server/tsconfig.json
Normal file
13
apps/server/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"sourceMap": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
17
apps/web/index.html
Normal file
17
apps/web/index.html
Normal file
@@ -0,0 +1,17 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Sync Arena — an authoritative multiplayer 3D browser shooter"
|
||||
/>
|
||||
<meta name="theme-color" content="#071019" />
|
||||
<title>Sync Arena</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
27
apps/web/package.json
Normal file
27
apps/web/package.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "@syncer/web",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@syncer/engine": "0.0.0",
|
||||
"@syncer/shared": "0.0.0",
|
||||
"@types/three": "^0.183.1",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"three": "^0.185.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.5",
|
||||
"@vitejs/plugin-react": "^6.1.0",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^8.2.2"
|
||||
}
|
||||
}
|
||||
BIN
apps/web/public/assets/arena-panels.png
Normal file
BIN
apps/web/public/assets/arena-panels.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.1 MiB |
BIN
apps/web/public/assets/arena-wall.png
Normal file
BIN
apps/web/public/assets/arena-wall.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.4 MiB |
241
apps/web/src/App.tsx
Normal file
241
apps/web/src/App.tsx
Normal file
@@ -0,0 +1,241 @@
|
||||
import {
|
||||
WEAPONS,
|
||||
Weapon,
|
||||
playerDisplayName,
|
||||
type PlayerKind,
|
||||
} from "@syncer/shared";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Arena3D } from "./Arena3D.js";
|
||||
import { FluxGame } from "./FluxGame.js";
|
||||
import { RoyaleGame } from "./RoyaleGame.js";
|
||||
import { useGameClient } from "./useGameClient.js";
|
||||
|
||||
export function App() {
|
||||
const [demo, setDemo] = useState<"arena" | "flux" | "royale">(() =>
|
||||
window.location.hash === "#royale"
|
||||
? "royale"
|
||||
: window.location.hash === "#flux"
|
||||
? "flux"
|
||||
: "arena",
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
window.history.replaceState(null, "", `#${demo}`);
|
||||
}, [demo]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{demo === "arena" ? <ShooterGame /> : demo === "flux" ? <FluxGame /> : <RoyaleGame />}
|
||||
<nav className="game-switcher" aria-label="Example game selector">
|
||||
<button className={demo === "arena" ? "is-active" : ""} onClick={() => setDemo("arena")} type="button">
|
||||
ARENA
|
||||
</button>
|
||||
<button className={demo === "flux" ? "is-active" : ""} onClick={() => setDemo("flux")} type="button">
|
||||
FLUX RELAY
|
||||
</button>
|
||||
<button className={demo === "royale" ? "is-active" : ""} onClick={() => setDemo("royale")} type="button">
|
||||
SYNCER ROYALE
|
||||
</button>
|
||||
</nav>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ShooterGame() {
|
||||
const client = useGameClient();
|
||||
const viewedId = client.cameraPlayerId;
|
||||
const local = viewedId === null ? undefined : client.world.players.get(viewedId);
|
||||
const recentEntries = [...client.world.events].reverse();
|
||||
const damageEntry = recentEntries.find((entry) => entry.event.type === "damage");
|
||||
const damageEvent = damageEntry?.event.type === "damage" ? damageEntry.event : null;
|
||||
const hitEntry = recentEntries.find(
|
||||
(entry) => entry.event.type === "hit",
|
||||
);
|
||||
const soundEntry = recentEntries.find((entry) => entry.event.type === "sound");
|
||||
const eliminationEntries = recentEntries
|
||||
.filter((entry) => entry.event.type === "elimination")
|
||||
.slice(0, 4);
|
||||
const weapon = WEAPONS[local?.weapon ?? Weapon.PulseRifle];
|
||||
const ammo = local?.ammo[local.weapon] ?? { magazine: 0, reserve: 0 };
|
||||
const matchSeconds = Math.floor(client.world.match.elapsedTicks / 60);
|
||||
const recentDamage = Boolean(
|
||||
damageEntry && damageEvent && client.tick - damageEntry.receivedTick < 18,
|
||||
);
|
||||
const recentHit = Boolean(hitEntry && client.tick - hitEntry.receivedTick < 12);
|
||||
const recentSound = Boolean(soundEntry && client.tick - soundEntry.receivedTick < 32);
|
||||
|
||||
return (
|
||||
<main className="game">
|
||||
<Arena3D
|
||||
world={client.world}
|
||||
playerId={viewedId}
|
||||
presentationKey={client.presentationKey}
|
||||
interactive={!client.replay}
|
||||
/>
|
||||
<div className="vignette" aria-hidden="true" />
|
||||
{recentDamage && damageEvent && (
|
||||
<div className="damage-flash" key={damageEvent.id} aria-hidden="true" />
|
||||
)}
|
||||
|
||||
<header className="topbar">
|
||||
<div className="brand">
|
||||
<span className="brand-mark">S//</span>
|
||||
<div>
|
||||
<strong>SYNC ARENA</strong>
|
||||
<small>AUTHORITATIVE 3D COMBAT</small>
|
||||
</div>
|
||||
</div>
|
||||
<div className="match-clock">
|
||||
<small>{client.replay ? "AUTHORIZED REPLAY" : "LIVE SIMULATION"}</small>
|
||||
<strong>{formatClock(matchSeconds)}</strong>
|
||||
</div>
|
||||
<div className={`connection connection--${client.connection}`}>
|
||||
<i />
|
||||
<span>{client.connection}</span>
|
||||
<b>{client.network.roundTripTime.toFixed(0)} MS</b>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="telemetry" aria-label="Network telemetry">
|
||||
<Telemetry label={client.replay ? "REPLAY" : "SERVER"} value={client.tick.toString().padStart(6, "0")} />
|
||||
<Telemetry label="JITTER" value={`${client.network.jitter.toFixed(1)} MS`} />
|
||||
<Telemetry label="LEAD" value={`${client.inputLeadTicks} T`} />
|
||||
<Telemetry label="STATE" value={client.validation.toUpperCase()} />
|
||||
<Telemetry label="VISIBLE" value={`${Math.max(0, client.world.players.size - 1)} HOSTILES`} />
|
||||
</section>
|
||||
|
||||
<section className="leaderboard" aria-label="Leaderboard">
|
||||
<small>ARENA LEADERS</small>
|
||||
{client.world.scoreboard.slice(0, 5).map((entry, index) => (
|
||||
<div
|
||||
className={entry.id === client.playerId ? "leaderboard__local" : undefined}
|
||||
key={entry.id}
|
||||
>
|
||||
<b>{index + 1}</b>
|
||||
<span>{playerDisplayName(entry.id, entry.kind)}</span>
|
||||
<strong>{entry.kills}</strong>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section className="kill-feed" aria-live="polite">
|
||||
{eliminationEntries.map((entry) => {
|
||||
if (entry.event.type !== "elimination") return null;
|
||||
return (
|
||||
<div key={entry.event.id}>
|
||||
<span>{labelFor(entry.event.killerId, client.world.scoreboard)}</span>
|
||||
<b>◆</b>
|
||||
<span>{labelFor(entry.event.victimId, client.world.scoreboard)}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
|
||||
{recentSound && soundEntry?.event.type === "sound" && (
|
||||
<div
|
||||
className="sound-indicator"
|
||||
style={{
|
||||
transform: `translate(-50%, -50%) rotate(${soundEntry.event.bearingRadians}rad)`,
|
||||
opacity: 0.35 + soundEntry.event.intensity * 0.65,
|
||||
}}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<i />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`crosshair${recentHit ? " crosshair--damage" : ""}`}>
|
||||
<i /><i /><i /><i />
|
||||
</div>
|
||||
|
||||
{recentHit && hitEntry?.event.type === "hit" && (
|
||||
<div className="hit-confirm" aria-hidden="true">
|
||||
{hitEntry.event.eliminated
|
||||
? "ELIMINATED"
|
||||
: hitEntry.event.critical
|
||||
? "CRITICAL"
|
||||
: "HIT"}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{client.replay && (
|
||||
<section className="killcam-panel" aria-label="Killcam playback">
|
||||
<div className="killcam-panel__title">
|
||||
<small>SERVER-AUTHORIZED PERSPECTIVE</small>
|
||||
<strong>
|
||||
KILLCAM // {labelFor(client.replay.perspectiveId, client.world.scoreboard)}
|
||||
</strong>
|
||||
</div>
|
||||
<div className="killcam-panel__timeline">
|
||||
<i style={{ width: `${client.replay.progress * 100}%` }} />
|
||||
</div>
|
||||
<div className="killcam-panel__meta">
|
||||
<span>TICK {client.replay.currentTick.toString().padStart(6, "0")}</span>
|
||||
<span>{client.replay.playbackRate.toFixed(2)}×</span>
|
||||
<span>PROJECTED VIEW · NO SERVER TRUTH</span>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{!client.replay && local && !local.alive ? (
|
||||
<section className="respawn-panel">
|
||||
<small>NEURAL LINK INTERRUPTED</small>
|
||||
<strong>REDEPLOYING {Math.ceil(local.respawnTicks / 60)}</strong>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<footer className="hud">
|
||||
<div className="health-block">
|
||||
<small>INTEGRITY · ARMOR {local?.armor ?? 0}</small>
|
||||
<strong>{(local?.health ?? 0).toString().padStart(3, "0")}</strong>
|
||||
<div className="health-track">
|
||||
<i style={{ width: `${local?.health ?? 0}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mission-note">
|
||||
<span>FREE FOR ALL · FIRST TO {client.world.match.scoreLimit}</span>
|
||||
<small>SERVER BOTS · PRIVATE VISIBILITY · SPATIAL AUDIO</small>
|
||||
</div>
|
||||
|
||||
<div className="weapon-block">
|
||||
<div>
|
||||
<small>{weapon.name.toUpperCase()}</small>
|
||||
<span>{local?.reloadTicks ? "RELOADING" : `${local?.weapon === Weapon.PulseRifle ? "01" : local?.weapon === Weapon.Scattergun ? "02" : "03"} / ${weapon.shortName}`}</span>
|
||||
</div>
|
||||
<strong>{ammo.magazine}<i>/ {ammo.reserve}</i></strong>
|
||||
</div>
|
||||
|
||||
<div className="score-block">
|
||||
<span><small>K</small>{scoreFor(client.playerId, client.world.scoreboard)?.kills ?? 0}</span>
|
||||
<span><small>D</small>{scoreFor(client.playerId, client.world.scoreboard)?.deaths ?? 0}</span>
|
||||
</div>
|
||||
</footer>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function Telemetry({ label, value }: { label: string; value: string }) {
|
||||
return <div><small>{label}</small><strong>{value}</strong></div>;
|
||||
}
|
||||
|
||||
function scoreFor(
|
||||
id: number | null,
|
||||
scoreboard: ReadonlyArray<{ id: number; kind: PlayerKind; kills: number; deaths: number }>,
|
||||
) {
|
||||
return id === null ? undefined : scoreboard.find((entry) => entry.id === id);
|
||||
}
|
||||
|
||||
function labelFor(
|
||||
id: number,
|
||||
scoreboard: ReadonlyArray<{ id: number; kind: PlayerKind }>,
|
||||
): string {
|
||||
const kind = scoreboard.find((entry) => entry.id === id)?.kind ?? "bot";
|
||||
return playerDisplayName(id, kind);
|
||||
}
|
||||
|
||||
function formatClock(totalSeconds: number): string {
|
||||
const minutes = Math.floor(totalSeconds / 60).toString().padStart(2, "0");
|
||||
const seconds = (totalSeconds % 60).toString().padStart(2, "0");
|
||||
return `${minutes}:${seconds}`;
|
||||
}
|
||||
756
apps/web/src/Arena3D.tsx
Normal file
756
apps/web/src/Arena3D.tsx
Normal file
@@ -0,0 +1,756 @@
|
||||
import { useEffect, useRef, useState, type MutableRefObject } from "react";
|
||||
import * as THREE from "three";
|
||||
import {
|
||||
ARENA_BLOCKS,
|
||||
ARENA_HALF_SIZE,
|
||||
PLAYER_EYE_HEIGHT,
|
||||
WEAPONS,
|
||||
Weapon,
|
||||
type PickupState,
|
||||
type PlayerState,
|
||||
type ShooterPerception,
|
||||
type ShooterWorldState,
|
||||
} from "@syncer/shared";
|
||||
import { ArenaAudio } from "./audio.js";
|
||||
|
||||
interface Arena3DProps {
|
||||
world: ShooterWorldState;
|
||||
playerId: number | null;
|
||||
presentationKey: string;
|
||||
interactive: boolean;
|
||||
}
|
||||
|
||||
interface TemporaryEffect {
|
||||
object: THREE.Object3D;
|
||||
expiresAt: number;
|
||||
createdAt?: number;
|
||||
floatDistance?: number;
|
||||
}
|
||||
|
||||
interface SceneRuntime {
|
||||
renderer: THREE.WebGLRenderer;
|
||||
scene: THREE.Scene;
|
||||
camera: THREE.PerspectiveCamera;
|
||||
players: Map<number, THREE.Group>;
|
||||
pickups: Map<number, THREE.Group>;
|
||||
effects: TemporaryEffect[];
|
||||
animated: THREE.Object3D[];
|
||||
weapon: THREE.Group;
|
||||
weaponAccent: THREE.MeshStandardMaterial;
|
||||
muzzle: THREE.PointLight;
|
||||
muzzleCore: THREE.Mesh;
|
||||
muzzleUntil: number;
|
||||
lastLocalImpact: { x: number; y: number; z: number; at: number } | null;
|
||||
audio: ArenaAudio;
|
||||
resizeObserver: ResizeObserver;
|
||||
animationFrame: number;
|
||||
}
|
||||
|
||||
export function Arena3D({
|
||||
world,
|
||||
playerId,
|
||||
presentationKey,
|
||||
interactive,
|
||||
}: Arena3DProps) {
|
||||
const hostRef = useRef<HTMLDivElement>(null);
|
||||
const worldRef = useRef(world);
|
||||
const playerIdRef = useRef(playerId);
|
||||
const lastEventIdRef = useRef(0);
|
||||
const presentationKeyRef = useRef("live");
|
||||
const runtimeRef = useRef<SceneRuntime | null>(null);
|
||||
const [locked, setLocked] = useState(false);
|
||||
|
||||
worldRef.current = world;
|
||||
playerIdRef.current = playerId;
|
||||
if (presentationKeyRef.current !== presentationKey) {
|
||||
lastEventIdRef.current = presentationKey === "live"
|
||||
? world.events.reduce(
|
||||
(latest, entry) => Math.max(latest, entry.event.id),
|
||||
0,
|
||||
)
|
||||
: 0;
|
||||
presentationKeyRef.current = presentationKey;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
if (!host) return;
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({
|
||||
antialias: true,
|
||||
powerPreference: "high-performance",
|
||||
});
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.6));
|
||||
renderer.shadowMap.enabled = true;
|
||||
renderer.shadowMap.type = THREE.PCFShadowMap;
|
||||
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||||
renderer.toneMappingExposure = 1.38;
|
||||
host.append(renderer.domElement);
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0x0b1822);
|
||||
scene.fog = new THREE.FogExp2(0x10212c, 0.014);
|
||||
const camera = new THREE.PerspectiveCamera(79, 1, 0.045, 100);
|
||||
camera.rotation.order = "YXZ";
|
||||
scene.add(camera);
|
||||
|
||||
buildLights(scene);
|
||||
const animated = buildArena(scene);
|
||||
const { group: weapon, muzzle, muzzleCore, accent } = buildWeapon();
|
||||
camera.add(weapon);
|
||||
|
||||
const runtime: SceneRuntime = {
|
||||
renderer,
|
||||
scene,
|
||||
camera,
|
||||
players: new Map(),
|
||||
pickups: new Map(),
|
||||
effects: [],
|
||||
animated,
|
||||
weapon,
|
||||
weaponAccent: accent,
|
||||
muzzle,
|
||||
muzzleCore,
|
||||
muzzleUntil: 0,
|
||||
lastLocalImpact: null,
|
||||
audio: new ArenaAudio(),
|
||||
resizeObserver: new ResizeObserver(() => resize(runtime, host)),
|
||||
animationFrame: 0,
|
||||
};
|
||||
runtimeRef.current = runtime;
|
||||
runtime.resizeObserver.observe(host);
|
||||
resize(runtime, host);
|
||||
|
||||
const animate = (time: number) => {
|
||||
updateRuntime(
|
||||
runtime,
|
||||
worldRef.current,
|
||||
playerIdRef.current,
|
||||
lastEventIdRef,
|
||||
time,
|
||||
);
|
||||
renderer.render(scene, camera);
|
||||
runtime.animationFrame = window.requestAnimationFrame(animate);
|
||||
};
|
||||
runtime.animationFrame = window.requestAnimationFrame(animate);
|
||||
|
||||
const pointerLockChanged = () =>
|
||||
setLocked(document.pointerLockElement === renderer.domElement);
|
||||
document.addEventListener("pointerlockchange", pointerLockChanged);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("pointerlockchange", pointerLockChanged);
|
||||
runtime.resizeObserver.disconnect();
|
||||
window.cancelAnimationFrame(runtime.animationFrame);
|
||||
runtime.audio.dispose();
|
||||
scene.traverse((object) => {
|
||||
if (object instanceof THREE.Sprite) {
|
||||
object.material.map?.dispose();
|
||||
object.material.dispose();
|
||||
return;
|
||||
}
|
||||
if (!(object instanceof THREE.Mesh || object instanceof THREE.Line)) return;
|
||||
object.geometry.dispose();
|
||||
const materials = Array.isArray(object.material)
|
||||
? object.material
|
||||
: [object.material];
|
||||
for (const material of materials) material.dispose();
|
||||
});
|
||||
renderer.dispose();
|
||||
renderer.domElement.remove();
|
||||
runtimeRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const deploy = () => {
|
||||
if (!interactive) return;
|
||||
const runtime = runtimeRef.current;
|
||||
const canvas = hostRef.current?.querySelector("canvas");
|
||||
if (!runtime || !canvas || !canvas.isConnected) return;
|
||||
runtime.audio.unlock();
|
||||
if (document.pointerLockElement) {
|
||||
setLocked(true);
|
||||
return;
|
||||
}
|
||||
void canvas.requestPointerLock().catch(() => setLocked(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="viewport" ref={hostRef}>
|
||||
{interactive && !locked && (
|
||||
<button className="deploy" type="button" onClick={deploy}>
|
||||
<span>CLICK TO DEPLOY</span>
|
||||
<small>WASD · SHIFT · MOUSE · FIRE · 1/2/3 WEAPONS · R RELOAD</small>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function buildLights(scene: THREE.Scene): void {
|
||||
scene.add(new THREE.AmbientLight(0x9bc8dc, 0.72));
|
||||
scene.add(new THREE.HemisphereLight(0xa8ddf4, 0x3a3028, 2.35));
|
||||
|
||||
const sun = new THREE.DirectionalLight(0xe8f6ff, 3.2);
|
||||
sun.position.set(-9, 19, 5);
|
||||
sun.castShadow = true;
|
||||
sun.shadow.mapSize.set(1_024, 1_024);
|
||||
sun.shadow.camera.left = -22;
|
||||
sun.shadow.camera.right = 22;
|
||||
sun.shadow.camera.top = 22;
|
||||
sun.shadow.camera.bottom = -22;
|
||||
scene.add(sun);
|
||||
|
||||
const fill = new THREE.DirectionalLight(0x78bcd6, 1.15);
|
||||
fill.position.set(11, 8, -12);
|
||||
scene.add(fill);
|
||||
|
||||
for (const [x, z] of [
|
||||
[0, -11],
|
||||
[0, 11],
|
||||
[-11, 0],
|
||||
[11, 0],
|
||||
] as const) {
|
||||
const light = new THREE.PointLight(0xb8e8ff, 18, 17, 1.8);
|
||||
light.position.set(x, 5.5, z);
|
||||
scene.add(light);
|
||||
}
|
||||
|
||||
for (const [x, z, color] of [
|
||||
[-15.5, -15.5, 0x17cfff],
|
||||
[15.5, 15.5, 0xff842b],
|
||||
[15.5, -15.5, 0x17cfff],
|
||||
[-15.5, 15.5, 0xff842b],
|
||||
] as const) {
|
||||
const light = new THREE.PointLight(color, 22, 16, 2);
|
||||
light.position.set(x, 2.2, z);
|
||||
scene.add(light);
|
||||
}
|
||||
}
|
||||
|
||||
function buildArena(scene: THREE.Scene): THREE.Object3D[] {
|
||||
const animated: THREE.Object3D[] = [];
|
||||
const textureLoader = new THREE.TextureLoader();
|
||||
const texture = textureLoader.load("/assets/arena-panels.png");
|
||||
texture.colorSpace = THREE.SRGBColorSpace;
|
||||
texture.wrapS = THREE.RepeatWrapping;
|
||||
texture.wrapT = THREE.RepeatWrapping;
|
||||
texture.anisotropy = 8;
|
||||
texture.repeat.set(2.5, 2.5);
|
||||
|
||||
const floorTexture = textureLoader.load("/assets/arena-panels.png");
|
||||
floorTexture.colorSpace = THREE.SRGBColorSpace;
|
||||
floorTexture.wrapS = THREE.RepeatWrapping;
|
||||
floorTexture.wrapT = THREE.RepeatWrapping;
|
||||
floorTexture.anisotropy = 8;
|
||||
floorTexture.repeat.set(5, 5);
|
||||
const floorMaterial = new THREE.MeshStandardMaterial({
|
||||
map: floorTexture,
|
||||
color: 0x6f7a80,
|
||||
roughness: 0.72,
|
||||
metalness: 0.48,
|
||||
});
|
||||
const floor = new THREE.Mesh(
|
||||
new THREE.PlaneGeometry(ARENA_HALF_SIZE * 2, ARENA_HALF_SIZE * 2),
|
||||
floorMaterial,
|
||||
);
|
||||
floor.rotation.x = -Math.PI / 2;
|
||||
floor.receiveShadow = true;
|
||||
scene.add(floor);
|
||||
|
||||
const grid = new THREE.GridHelper(
|
||||
ARENA_HALF_SIZE * 2,
|
||||
36,
|
||||
0x2d819a,
|
||||
0x18313b,
|
||||
);
|
||||
grid.position.y = 0.015;
|
||||
for (const material of Array.isArray(grid.material) ? grid.material : [grid.material]) {
|
||||
material.transparent = true;
|
||||
material.opacity = 0.3;
|
||||
}
|
||||
scene.add(grid);
|
||||
|
||||
const panelMaterial = new THREE.MeshStandardMaterial({
|
||||
map: texture,
|
||||
color: 0x97a4aa,
|
||||
roughness: 0.58,
|
||||
metalness: 0.54,
|
||||
});
|
||||
const coverMaterial = new THREE.MeshStandardMaterial({
|
||||
color: 0x35424b,
|
||||
roughness: 0.47,
|
||||
metalness: 0.72,
|
||||
});
|
||||
const reactorMaterial = new THREE.MeshStandardMaterial({
|
||||
color: 0x112933,
|
||||
emissive: 0x007c93,
|
||||
emissiveIntensity: 1.9,
|
||||
roughness: 0.3,
|
||||
metalness: 0.8,
|
||||
});
|
||||
|
||||
for (const block of ARENA_BLOCKS) {
|
||||
const material =
|
||||
block.style === "reactor"
|
||||
? reactorMaterial
|
||||
: block.style === "wall"
|
||||
? panelMaterial
|
||||
: coverMaterial;
|
||||
const mesh = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(block.width, block.height, block.depth),
|
||||
material,
|
||||
);
|
||||
mesh.position.set(block.x, block.height / 2, block.z);
|
||||
mesh.castShadow = true;
|
||||
mesh.receiveShadow = true;
|
||||
scene.add(mesh);
|
||||
|
||||
if (block.style === "reactor") {
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
const ring = new THREE.Mesh(
|
||||
new THREE.TorusGeometry(2.45 + index * 0.2, 0.035, 8, 36),
|
||||
new THREE.MeshBasicMaterial({
|
||||
color: index % 2 ? 0xffa240 : 0x31ddff,
|
||||
transparent: true,
|
||||
opacity: 0.78,
|
||||
}),
|
||||
);
|
||||
ring.position.set(0, 1.05 + index * 1.05, 0);
|
||||
ring.rotation.x = Math.PI / 2;
|
||||
ring.userData.spin = (index % 2 ? -1 : 1) * (0.00018 + index * 0.00004);
|
||||
scene.add(ring);
|
||||
animated.push(ring);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const boundaryGeometry = [
|
||||
[0, -ARENA_HALF_SIZE - 0.3, ARENA_HALF_SIZE * 2 + 1.2, 0.65],
|
||||
[0, ARENA_HALF_SIZE + 0.3, ARENA_HALF_SIZE * 2 + 1.2, 0.65],
|
||||
[-ARENA_HALF_SIZE - 0.3, 0, 0.65, ARENA_HALF_SIZE * 2 + 1.2],
|
||||
[ARENA_HALF_SIZE + 0.3, 0, 0.65, ARENA_HALF_SIZE * 2 + 1.2],
|
||||
] as const;
|
||||
for (const [x, z, width, depth] of boundaryGeometry) {
|
||||
const wall = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(width, 3.6, depth),
|
||||
panelMaterial,
|
||||
);
|
||||
wall.position.set(x, 1.8, z);
|
||||
wall.castShadow = true;
|
||||
wall.receiveShadow = true;
|
||||
scene.add(wall);
|
||||
}
|
||||
|
||||
const stripMaterial = new THREE.MeshStandardMaterial({
|
||||
color: 0xa1efff,
|
||||
emissive: 0x00a8cd,
|
||||
emissiveIntensity: 4,
|
||||
roughness: 0.2,
|
||||
});
|
||||
for (let index = -14; index <= 14; index += 7) {
|
||||
for (const z of [-17.75, 17.75]) {
|
||||
const strip = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(3.2, 0.05, 0.13),
|
||||
stripMaterial,
|
||||
);
|
||||
strip.position.set(index, 0.035, z);
|
||||
scene.add(strip);
|
||||
}
|
||||
}
|
||||
return animated;
|
||||
}
|
||||
|
||||
function buildWeapon(): {
|
||||
group: THREE.Group;
|
||||
muzzle: THREE.PointLight;
|
||||
muzzleCore: THREE.Mesh;
|
||||
accent: THREE.MeshStandardMaterial;
|
||||
} {
|
||||
const group = new THREE.Group();
|
||||
group.position.set(0.36, -0.3, -0.56);
|
||||
const dark = new THREE.MeshStandardMaterial({
|
||||
color: 0x121920,
|
||||
roughness: 0.3,
|
||||
metalness: 0.9,
|
||||
});
|
||||
const accent = new THREE.MeshStandardMaterial({
|
||||
color: 0x5de4c7,
|
||||
emissive: 0x0aa68d,
|
||||
emissiveIntensity: 3.8,
|
||||
roughness: 0.2,
|
||||
});
|
||||
const body = new THREE.Mesh(new THREE.BoxGeometry(0.22, 0.2, 0.62), dark);
|
||||
body.position.z = -0.16;
|
||||
group.add(body);
|
||||
const housing = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(0.31, 0.12, 0.3),
|
||||
dark,
|
||||
);
|
||||
housing.position.set(0, -0.05, -0.33);
|
||||
group.add(housing);
|
||||
const barrel = new THREE.Mesh(
|
||||
new THREE.CylinderGeometry(0.036, 0.047, 0.5, 10),
|
||||
dark,
|
||||
);
|
||||
barrel.rotation.x = Math.PI / 2;
|
||||
barrel.position.set(0, 0.025, -0.57);
|
||||
group.add(barrel);
|
||||
const rail = new THREE.Mesh(new THREE.BoxGeometry(0.105, 0.045, 0.38), accent);
|
||||
rail.position.set(0, 0.116, -0.18);
|
||||
group.add(rail);
|
||||
const muzzleCore = new THREE.Mesh(
|
||||
new THREE.SphereGeometry(0.075, 10, 8),
|
||||
new THREE.MeshBasicMaterial({ color: 0xc8ffff }),
|
||||
);
|
||||
muzzleCore.position.set(0, 0.025, -0.83);
|
||||
muzzleCore.visible = false;
|
||||
group.add(muzzleCore);
|
||||
const muzzle = new THREE.PointLight(0x8deeff, 0, 3.5);
|
||||
muzzle.position.copy(muzzleCore.position);
|
||||
group.add(muzzle);
|
||||
return { group, muzzle, muzzleCore, accent };
|
||||
}
|
||||
|
||||
function updateRuntime(
|
||||
runtime: SceneRuntime,
|
||||
world: ShooterWorldState,
|
||||
playerId: number | null,
|
||||
lastEventId: MutableRefObject<number>,
|
||||
time: number,
|
||||
): void {
|
||||
const local = playerId === null ? undefined : world.players.get(playerId);
|
||||
if (local) {
|
||||
runtime.camera.position.set(local.x, PLAYER_EYE_HEIGHT, local.z);
|
||||
runtime.camera.rotation.set(local.pitch, -local.yaw, 0);
|
||||
runtime.weapon.visible = local.alive;
|
||||
const moving = Math.hypot(local.velocityX, local.velocityZ);
|
||||
runtime.weapon.position.y =
|
||||
-0.3 + Math.sin(time * 0.013) * Math.min(0.021, moving * 0.003);
|
||||
runtime.weapon.position.x =
|
||||
0.36 + Math.cos(time * 0.0065) * Math.min(0.008, moving * 0.0012);
|
||||
const accentColor = new THREE.Color(WEAPONS[local.weapon].accent);
|
||||
runtime.weaponAccent.color.copy(accentColor);
|
||||
runtime.weaponAccent.emissive.copy(accentColor).multiplyScalar(0.52);
|
||||
|
||||
}
|
||||
runtime.muzzle.intensity = time < runtime.muzzleUntil ? 14 : 0;
|
||||
runtime.muzzleCore.visible = time < runtime.muzzleUntil;
|
||||
|
||||
const seen = new Set<number>();
|
||||
for (const player of world.players.values()) {
|
||||
if (player.id === playerId || !player.alive) continue;
|
||||
seen.add(player.id);
|
||||
let model = runtime.players.get(player.id);
|
||||
if (!model) {
|
||||
model = createPlayerModel(player);
|
||||
runtime.players.set(player.id, model);
|
||||
runtime.scene.add(model);
|
||||
}
|
||||
model.visible = true;
|
||||
model.position.x += (player.x - model.position.x) * 0.28;
|
||||
model.position.z += (player.z - model.position.z) * 0.28;
|
||||
model.rotation.y = -player.yaw;
|
||||
updatePlayerHealthBar(model, player.health);
|
||||
}
|
||||
for (const [id, model] of runtime.players) {
|
||||
if (!seen.has(id)) model.visible = false;
|
||||
}
|
||||
|
||||
updatePickupModels(runtime, world.pickups, time);
|
||||
|
||||
for (const entry of world.events) {
|
||||
if (entry.event.id <= lastEventId.current) continue;
|
||||
handlePerception(runtime, entry.event, playerId, time);
|
||||
lastEventId.current = Math.max(lastEventId.current, entry.event.id);
|
||||
}
|
||||
|
||||
for (const object of runtime.animated) {
|
||||
object.rotation.z = time * Number(object.userData.spin ?? 0);
|
||||
}
|
||||
for (let index = runtime.effects.length - 1; index >= 0; index -= 1) {
|
||||
const effect = runtime.effects[index]!;
|
||||
if (effect.createdAt !== undefined && effect.floatDistance !== undefined) {
|
||||
const progress = clamp01(
|
||||
(time - effect.createdAt) / (effect.expiresAt - effect.createdAt),
|
||||
);
|
||||
effect.object.position.y =
|
||||
Number(effect.object.userData.baseY ?? effect.object.position.y) +
|
||||
progress * effect.floatDistance;
|
||||
const material = effect.object instanceof THREE.Sprite
|
||||
? effect.object.material
|
||||
: null;
|
||||
if (material) material.opacity = 1 - progress;
|
||||
}
|
||||
if (time < effect.expiresAt) continue;
|
||||
runtime.scene.remove(effect.object);
|
||||
effect.object.traverse((child) => {
|
||||
if (!(child instanceof THREE.Mesh || child instanceof THREE.Line)) return;
|
||||
child.geometry.dispose();
|
||||
const materials = Array.isArray(child.material) ? child.material : [child.material];
|
||||
for (const material of materials) {
|
||||
if ("map" in material && material.map instanceof THREE.Texture) {
|
||||
material.map.dispose();
|
||||
}
|
||||
material.dispose();
|
||||
}
|
||||
});
|
||||
if (effect.object instanceof THREE.Sprite) {
|
||||
effect.object.material.map?.dispose();
|
||||
effect.object.material.dispose();
|
||||
}
|
||||
runtime.effects.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
function updatePickupModels(
|
||||
runtime: SceneRuntime,
|
||||
pickups: ReadonlyMap<number, PickupState>,
|
||||
time: number,
|
||||
): void {
|
||||
for (const pickup of pickups.values()) {
|
||||
let model = runtime.pickups.get(pickup.id);
|
||||
if (!model) {
|
||||
model = createPickupModel(pickup);
|
||||
runtime.pickups.set(pickup.id, model);
|
||||
runtime.scene.add(model);
|
||||
}
|
||||
model.visible = pickup.active;
|
||||
model.rotation.y = time * 0.0012 + pickup.id;
|
||||
model.position.y = 0.55 + Math.sin(time * 0.003 + pickup.id) * 0.1;
|
||||
}
|
||||
}
|
||||
|
||||
function handlePerception(
|
||||
runtime: SceneRuntime,
|
||||
event: ShooterPerception,
|
||||
playerId: number | null,
|
||||
time: number,
|
||||
): void {
|
||||
runtime.audio.play(event);
|
||||
if (event.type === "hit") {
|
||||
const impact = runtime.lastLocalImpact;
|
||||
if (impact && time - impact.at < 500) {
|
||||
const popup = createDamagePopup(event.amount, event.critical, event.eliminated);
|
||||
popup.position.set(impact.x, Math.max(0.7, impact.y + 0.36), impact.z);
|
||||
popup.userData.baseY = popup.position.y;
|
||||
runtime.scene.add(popup);
|
||||
runtime.effects.push({
|
||||
object: popup,
|
||||
createdAt: time,
|
||||
expiresAt: time + 850,
|
||||
floatDistance: 0.72,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.type !== "shot") return;
|
||||
|
||||
const color = new THREE.Color(WEAPONS[event.weapon].accent);
|
||||
const geometry = new THREE.BufferGeometry().setFromPoints([
|
||||
new THREE.Vector3(event.originX, event.originY, event.originZ),
|
||||
new THREE.Vector3(event.endX, event.endY, event.endZ),
|
||||
]);
|
||||
const tracer = new THREE.Line(
|
||||
geometry,
|
||||
new THREE.LineBasicMaterial({
|
||||
color,
|
||||
transparent: true,
|
||||
opacity: event.weapon === Weapon.RailRifle ? 0.95 : 0.72,
|
||||
}),
|
||||
);
|
||||
runtime.scene.add(tracer);
|
||||
runtime.effects.push({ object: tracer, expiresAt: time + (event.weapon === Weapon.RailRifle ? 150 : 75) });
|
||||
|
||||
if (event.impact !== "miss") {
|
||||
const impact = new THREE.Mesh(
|
||||
new THREE.SphereGeometry(event.weapon === Weapon.Scattergun ? 0.09 : 0.065, 8, 6),
|
||||
new THREE.MeshBasicMaterial({ color }),
|
||||
);
|
||||
impact.position.set(event.endX, event.endY, event.endZ);
|
||||
runtime.scene.add(impact);
|
||||
runtime.effects.push({ object: impact, expiresAt: time + 140 });
|
||||
}
|
||||
|
||||
if (event.sourceId === playerId) {
|
||||
runtime.muzzleUntil = time + 52;
|
||||
runtime.muzzle.color.copy(color);
|
||||
runtime.lastLocalImpact = {
|
||||
x: event.endX,
|
||||
y: event.endY,
|
||||
z: event.endZ,
|
||||
at: time,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function createPlayerModel(player: PlayerState): THREE.Group {
|
||||
const group = new THREE.Group();
|
||||
group.position.set(player.x, 0, player.z);
|
||||
const primary = new THREE.MeshStandardMaterial({
|
||||
color: player.kind === "bot" ? 0xcb6229 : 0x2f9dbb,
|
||||
roughness: 0.45,
|
||||
metalness: 0.58,
|
||||
});
|
||||
const dark = new THREE.MeshStandardMaterial({
|
||||
color: 0x10171d,
|
||||
roughness: 0.36,
|
||||
metalness: 0.76,
|
||||
});
|
||||
const visor = new THREE.MeshStandardMaterial({
|
||||
color: player.kind === "bot" ? 0xffc078 : 0x9ef3ff,
|
||||
emissive: player.kind === "bot" ? 0xb52d05 : 0x00a8d4,
|
||||
emissiveIntensity: 3.4,
|
||||
});
|
||||
|
||||
const torso = new THREE.Mesh(
|
||||
new THREE.CylinderGeometry(0.35, 0.43, 0.88, 8),
|
||||
primary,
|
||||
);
|
||||
torso.position.y = 0.91;
|
||||
torso.castShadow = true;
|
||||
group.add(torso);
|
||||
const chest = new THREE.Mesh(new THREE.BoxGeometry(0.42, 0.17, 0.16), dark);
|
||||
chest.position.set(0, 1.03, -0.35);
|
||||
group.add(chest);
|
||||
const head = new THREE.Mesh(new THREE.SphereGeometry(0.27, 12, 8), dark);
|
||||
head.position.y = 1.55;
|
||||
head.castShadow = true;
|
||||
group.add(head);
|
||||
const face = new THREE.Mesh(new THREE.BoxGeometry(0.34, 0.1, 0.06), visor);
|
||||
face.position.set(0, 1.57, -0.245);
|
||||
group.add(face);
|
||||
const weapon = new THREE.Mesh(new THREE.BoxGeometry(0.11, 0.13, 0.72), dark);
|
||||
weapon.position.set(0.34, 1.08, -0.38);
|
||||
group.add(weapon);
|
||||
|
||||
const healthBackground = new THREE.Sprite(
|
||||
new THREE.SpriteMaterial({
|
||||
color: 0x081015,
|
||||
opacity: 0.86,
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
}),
|
||||
);
|
||||
healthBackground.position.set(0, 2.04, 0);
|
||||
healthBackground.scale.set(0.9, 0.12, 1);
|
||||
healthBackground.renderOrder = 4;
|
||||
group.add(healthBackground);
|
||||
|
||||
const healthMaterial = new THREE.SpriteMaterial({
|
||||
color: 0x55ef9a,
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
});
|
||||
const healthFill = new THREE.Sprite(healthMaterial);
|
||||
healthFill.position.set(0, 2.04, 0.002);
|
||||
healthFill.scale.set(0.8, 0.065, 1);
|
||||
healthFill.renderOrder = 5;
|
||||
group.add(healthFill);
|
||||
group.userData.healthFill = healthFill;
|
||||
group.userData.healthMaterial = healthMaterial;
|
||||
return group;
|
||||
}
|
||||
|
||||
function updatePlayerHealthBar(model: THREE.Group, health: number): void {
|
||||
const fill = model.userData.healthFill as THREE.Sprite | undefined;
|
||||
const material = model.userData.healthMaterial as
|
||||
| THREE.SpriteMaterial
|
||||
| undefined;
|
||||
if (!fill || !material) return;
|
||||
const ratio = clamp01(health / 100);
|
||||
fill.scale.x = Math.max(0.012, 0.8 * ratio);
|
||||
material.color.setHex(
|
||||
ratio > 0.55 ? 0x55ef9a : ratio > 0.25 ? 0xffb04d : 0xff5c4f,
|
||||
);
|
||||
}
|
||||
|
||||
function createDamagePopup(
|
||||
amount: number,
|
||||
critical: boolean,
|
||||
eliminated: boolean,
|
||||
): THREE.Sprite {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 320;
|
||||
canvas.height = 112;
|
||||
const context = canvas.getContext("2d");
|
||||
if (context) {
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
context.textAlign = "center";
|
||||
context.textBaseline = "middle";
|
||||
context.font = critical
|
||||
? "800 52px IBM Plex Mono, monospace"
|
||||
: "700 48px IBM Plex Mono, monospace";
|
||||
context.lineWidth = 10;
|
||||
context.strokeStyle = "rgba(2, 7, 10, 0.92)";
|
||||
const label = eliminated
|
||||
? `${amount} ELIM`
|
||||
: critical
|
||||
? `${amount} CRIT`
|
||||
: `${amount}`;
|
||||
context.strokeText(label, canvas.width / 2, canvas.height / 2);
|
||||
context.fillStyle = eliminated
|
||||
? "#ff774f"
|
||||
: critical
|
||||
? "#ffc05d"
|
||||
: "#dffaff";
|
||||
context.fillText(label, canvas.width / 2, canvas.height / 2);
|
||||
}
|
||||
const texture = new THREE.CanvasTexture(canvas);
|
||||
texture.colorSpace = THREE.SRGBColorSpace;
|
||||
const sprite = new THREE.Sprite(
|
||||
new THREE.SpriteMaterial({
|
||||
map: texture,
|
||||
transparent: true,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
}),
|
||||
);
|
||||
sprite.scale.set(1.75, 0.61, 1);
|
||||
sprite.renderOrder = 20;
|
||||
return sprite;
|
||||
}
|
||||
|
||||
function clamp01(value: number): number {
|
||||
return Math.max(0, Math.min(1, value));
|
||||
}
|
||||
|
||||
function createPickupModel(pickup: PickupState): THREE.Group {
|
||||
const group = new THREE.Group();
|
||||
group.position.set(pickup.x, 0.55, pickup.z);
|
||||
const color =
|
||||
pickup.kind === "health"
|
||||
? 0x54ef9b
|
||||
: pickup.kind === "ammo"
|
||||
? 0xffb04d
|
||||
: new THREE.Color(WEAPONS[pickup.weapon].accent).getHex();
|
||||
const material = new THREE.MeshStandardMaterial({
|
||||
color,
|
||||
emissive: color,
|
||||
emissiveIntensity: 1.4,
|
||||
roughness: 0.28,
|
||||
metalness: 0.68,
|
||||
});
|
||||
const core = new THREE.Mesh(
|
||||
pickup.kind === "health"
|
||||
? new THREE.OctahedronGeometry(0.3)
|
||||
: pickup.kind === "ammo"
|
||||
? new THREE.BoxGeometry(0.5, 0.28, 0.32)
|
||||
: new THREE.TorusKnotGeometry(0.2, 0.065, 40, 7),
|
||||
material,
|
||||
);
|
||||
core.castShadow = true;
|
||||
group.add(core);
|
||||
const light = new THREE.PointLight(color, 3.5, 3);
|
||||
group.add(light);
|
||||
return group;
|
||||
}
|
||||
|
||||
function resize(runtime: SceneRuntime, host: HTMLDivElement): void {
|
||||
const width = Math.max(1, host.clientWidth);
|
||||
const height = Math.max(1, host.clientHeight);
|
||||
runtime.renderer.setSize(width, height, false);
|
||||
runtime.camera.aspect = width / height;
|
||||
runtime.camera.updateProjectionMatrix();
|
||||
}
|
||||
111
apps/web/src/FluxGame.tsx
Normal file
111
apps/web/src/FluxGame.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import type { FluxPlayerView, FluxTeam } from "@syncer/shared";
|
||||
import { useFluxClient } from "./useFluxClient.js";
|
||||
|
||||
export function FluxGame() {
|
||||
const client = useFluxClient();
|
||||
const local = client.world.players.find((player) => player.id === client.playerId);
|
||||
const recent = [...client.world.events].reverse()[0];
|
||||
const energy = local?.energy ?? 0;
|
||||
const corePercent = (client.world.core + 1) * 50;
|
||||
|
||||
return (
|
||||
<main className={`flux-game flux-game--${local?.team ?? "cyan"}`}>
|
||||
<div className="flux-grid" aria-hidden="true" />
|
||||
<header className="flux-header">
|
||||
<div>
|
||||
<small>SYNCER // SECOND GAME</small>
|
||||
<strong>FLUX RELAY</strong>
|
||||
</div>
|
||||
<section className="flux-score" aria-label="Team score">
|
||||
<b>{client.world.cyanScore.toString().padStart(2, "0")}</b>
|
||||
<span>ROUND {client.world.round.toString().padStart(2, "0")}</span>
|
||||
<b>{client.world.orangeScore.toString().padStart(2, "0")}</b>
|
||||
</section>
|
||||
<div className={`flux-connection flux-connection--${client.connection}`}>
|
||||
<i />
|
||||
<span>{client.connection}</span>
|
||||
<small>{client.network.roundTripTime.toFixed(0)} ms</small>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="flux-arena" aria-label="Flux Relay arena">
|
||||
<TeamRoster team="cyan" players={client.world.players} localId={client.playerId} />
|
||||
<div className="flux-field">
|
||||
<div className="flux-gate flux-gate--cyan"><span>CYAN GATE</span></div>
|
||||
<div className="flux-lane">
|
||||
<div className="flux-midline" />
|
||||
<div
|
||||
className="flux-core"
|
||||
style={{ left: `${corePercent}%` }}
|
||||
aria-label={`Core position ${client.world.core.toFixed(2)}`}
|
||||
>
|
||||
<i /><i /><i />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flux-gate flux-gate--orange"><span>ORANGE GATE</span></div>
|
||||
</div>
|
||||
<TeamRoster team="orange" players={client.world.players} localId={client.playerId} />
|
||||
</section>
|
||||
|
||||
{recent && client.tick - recent.receivedTick < 80 ? (
|
||||
<div className={`flux-event flux-event--${recent.event.team}`}>
|
||||
{recent.event.type === "round-won"
|
||||
? `${recent.event.team.toUpperCase()} CAPTURED THE CORE`
|
||||
: recent.event.scope === "self"
|
||||
? "REACTOR OVERHEATED · RELEASE TO RECOVER"
|
||||
: `${recent.event.team.toUpperCase()} THRUSTER FELL SILENT`}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<footer className="flux-controls">
|
||||
<div className="flux-identity">
|
||||
<small>YOUR LINK</small>
|
||||
<strong>{local ? `${local.team.toUpperCase()} // P${local.id}` : "CONNECTING"}</strong>
|
||||
<span>Other players' exact energy is never replicated.</span>
|
||||
</div>
|
||||
<button
|
||||
className={`flux-thrust${local?.thrust ? " flux-thrust--active" : ""}`}
|
||||
onPointerDown={() => client.setThrust(true)}
|
||||
onPointerUp={() => client.setThrust(false)}
|
||||
onPointerCancel={() => client.setThrust(false)}
|
||||
onPointerLeave={() => client.setThrust(false)}
|
||||
type="button"
|
||||
>
|
||||
<small>HOLD SPACE OR PRESS</small>
|
||||
<strong>{local?.thrust ? "THRUSTING" : "ENGAGE THRUSTER"}</strong>
|
||||
</button>
|
||||
<div className="flux-energy">
|
||||
<div><small>PRIVATE ENERGY</small><strong>{Math.round(energy)}%</strong></div>
|
||||
<span><i style={{ width: `${energy}%` }} /></span>
|
||||
<em>Tick {client.tick} · lead {client.inputLeadTicks}t · {client.validation}</em>
|
||||
</div>
|
||||
</footer>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function TeamRoster({
|
||||
team,
|
||||
players,
|
||||
localId,
|
||||
}: {
|
||||
team: FluxTeam;
|
||||
players: FluxPlayerView[];
|
||||
localId: number | null;
|
||||
}) {
|
||||
return (
|
||||
<div className={`flux-roster flux-roster--${team}`}>
|
||||
<small>{team} links</small>
|
||||
{players.filter((player) => player.team === team).map((player) => (
|
||||
<div
|
||||
className={`${player.thrust ? "flux-player--active" : ""}${player.id === localId ? " flux-player--local" : ""}`}
|
||||
key={player.id}
|
||||
>
|
||||
<i />
|
||||
<span>{player.bot ? "BOT" : `P${player.id}`}</span>
|
||||
<b>{player.energy === null ? "PRIVATE" : `${Math.round(player.energy)}%`}</b>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
540
apps/web/src/Royale3D.tsx
Normal file
540
apps/web/src/Royale3D.tsx
Normal file
@@ -0,0 +1,540 @@
|
||||
import { useEffect, useRef, useState, type MutableRefObject } from "react";
|
||||
import * as THREE from "three";
|
||||
import {
|
||||
ROYALE_CHUNK_SIZE,
|
||||
ROYALE_WORLD_RADIUS,
|
||||
generateRoyaleChunk,
|
||||
royaleChunkCoordinate,
|
||||
type RoyaleClientState,
|
||||
type RoyaleLootView,
|
||||
type RoyalePlayerView,
|
||||
} from "@syncer/shared";
|
||||
|
||||
interface Royale3DProps {
|
||||
world: RoyaleClientState;
|
||||
playerId: number | null;
|
||||
}
|
||||
|
||||
interface TemporaryEffect {
|
||||
object: THREE.Object3D;
|
||||
expiresAt: number;
|
||||
startedAt?: number;
|
||||
start?: THREE.Vector3;
|
||||
end?: THREE.Vector3;
|
||||
}
|
||||
|
||||
interface RoyaleRuntime {
|
||||
renderer: THREE.WebGLRenderer;
|
||||
scene: THREE.Scene;
|
||||
camera: THREE.PerspectiveCamera;
|
||||
viewWeapon: THREE.Group;
|
||||
viewMuzzle: THREE.PointLight;
|
||||
viewMuzzleCore: THREE.Mesh;
|
||||
muzzleUntil: number;
|
||||
chunks: Map<string, THREE.Group>;
|
||||
players: Map<number, THREE.Group>;
|
||||
loot: Map<number, THREE.Group>;
|
||||
storm: THREE.Mesh<THREE.RingGeometry, THREE.MeshBasicMaterial>;
|
||||
effects: TemporaryEffect[];
|
||||
resizeObserver: ResizeObserver;
|
||||
animationFrame: number;
|
||||
}
|
||||
|
||||
export function Royale3D({ world, playerId }: Royale3DProps) {
|
||||
const hostRef = useRef<HTMLDivElement>(null);
|
||||
const worldRef = useRef(world);
|
||||
const playerIdRef = useRef(playerId);
|
||||
const runtimeRef = useRef<RoyaleRuntime | null>(null);
|
||||
const lastEventIdRef = useRef(0);
|
||||
const [locked, setLocked] = useState(false);
|
||||
worldRef.current = world;
|
||||
playerIdRef.current = playerId;
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
if (!host) return;
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: "high-performance" });
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5));
|
||||
renderer.shadowMap.enabled = true;
|
||||
renderer.shadowMap.type = THREE.PCFShadowMap;
|
||||
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||||
renderer.toneMappingExposure = 1.18;
|
||||
host.append(renderer.domElement);
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0x8bb4c2);
|
||||
scene.fog = new THREE.FogExp2(0x9cb9bc, 0.0022);
|
||||
const camera = new THREE.PerspectiveCamera(78, 1, 0.045, 1_100);
|
||||
camera.rotation.order = "YXZ";
|
||||
scene.add(camera);
|
||||
|
||||
const {
|
||||
group: viewWeapon,
|
||||
muzzle: viewMuzzle,
|
||||
muzzleCore: viewMuzzleCore,
|
||||
} = buildViewWeapon();
|
||||
camera.add(viewWeapon);
|
||||
|
||||
scene.add(new THREE.HemisphereLight(0xcbe8ed, 0x253426, 2.2));
|
||||
const sun = new THREE.DirectionalLight(0xfff0cf, 3.25);
|
||||
sun.position.set(-180, 280, 90);
|
||||
sun.castShadow = true;
|
||||
sun.shadow.mapSize.set(1_024, 1_024);
|
||||
sun.shadow.camera.left = -100;
|
||||
sun.shadow.camera.right = 100;
|
||||
sun.shadow.camera.top = 100;
|
||||
sun.shadow.camera.bottom = -100;
|
||||
scene.add(sun);
|
||||
|
||||
const water = new THREE.Mesh(
|
||||
new THREE.PlaneGeometry(2_500, 2_500),
|
||||
new THREE.MeshStandardMaterial({ color: 0x1a6276, roughness: 0.32, metalness: 0.22 }),
|
||||
);
|
||||
water.rotation.x = -Math.PI / 2;
|
||||
water.position.y = -1.2;
|
||||
scene.add(water);
|
||||
|
||||
const storm = new THREE.Mesh(
|
||||
new THREE.RingGeometry(0.994, 1, 160),
|
||||
new THREE.MeshBasicMaterial({ color: 0x68d8ff, transparent: true, opacity: 0.84, side: THREE.DoubleSide }),
|
||||
);
|
||||
storm.rotation.x = -Math.PI / 2;
|
||||
storm.position.y = 0.24;
|
||||
scene.add(storm);
|
||||
|
||||
const runtime: RoyaleRuntime = {
|
||||
renderer,
|
||||
scene,
|
||||
camera,
|
||||
viewWeapon,
|
||||
viewMuzzle,
|
||||
viewMuzzleCore,
|
||||
muzzleUntil: 0,
|
||||
chunks: new Map(),
|
||||
players: new Map(),
|
||||
loot: new Map(),
|
||||
storm,
|
||||
effects: [],
|
||||
resizeObserver: new ResizeObserver(() => resize(runtime, host)),
|
||||
animationFrame: 0,
|
||||
};
|
||||
runtimeRef.current = runtime;
|
||||
runtime.resizeObserver.observe(host);
|
||||
resize(runtime, host);
|
||||
|
||||
const animate = (time: number) => {
|
||||
updateRuntime(runtime, worldRef.current, playerIdRef.current, lastEventIdRef, time);
|
||||
renderer.render(scene, camera);
|
||||
runtime.animationFrame = window.requestAnimationFrame(animate);
|
||||
};
|
||||
runtime.animationFrame = window.requestAnimationFrame(animate);
|
||||
|
||||
const pointerLockChanged = () => setLocked(document.pointerLockElement === renderer.domElement);
|
||||
document.addEventListener("pointerlockchange", pointerLockChanged);
|
||||
return () => {
|
||||
document.removeEventListener("pointerlockchange", pointerLockChanged);
|
||||
runtime.resizeObserver.disconnect();
|
||||
window.cancelAnimationFrame(runtime.animationFrame);
|
||||
scene.traverse((object) => disposeObject(object));
|
||||
renderer.dispose();
|
||||
renderer.domElement.remove();
|
||||
runtimeRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const deploy = () => {
|
||||
const canvas = runtimeRef.current?.renderer.domElement;
|
||||
if (!canvas) return;
|
||||
void canvas.requestPointerLock().catch(() => setLocked(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="royale-viewport" ref={hostRef}>
|
||||
{!locked && (
|
||||
<button className="royale-deploy" type="button" onClick={deploy}>
|
||||
<small>PROCEDURAL ISLAND // LIVE AUTHORITY</small>
|
||||
<strong>DROP IN</strong>
|
||||
<span>WASD · MOUSE AIM · FIRE · SHIFT SPRINT · R RELOAD</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function updateRuntime(
|
||||
runtime: RoyaleRuntime,
|
||||
world: RoyaleClientState,
|
||||
playerId: number | null,
|
||||
lastEventIdRef: MutableRefObject<number>,
|
||||
time: number,
|
||||
): void {
|
||||
const local = world.players.find((player) => player.id === playerId);
|
||||
if (local) {
|
||||
updateChunks(runtime, local.x, local.z);
|
||||
runtime.camera.position.set(local.x, local.altitude + 1.55, local.z);
|
||||
runtime.camera.rotation.set(local.pitch, Math.PI + local.yaw, 0);
|
||||
runtime.viewWeapon.visible = local.alive;
|
||||
const moving = Math.hypot(local.velocityX, local.velocityZ);
|
||||
runtime.viewWeapon.position.x =
|
||||
0.36 + Math.cos(time * 0.0065) * Math.min(0.009, moving * 0.0012);
|
||||
runtime.viewWeapon.position.y =
|
||||
-0.31 + Math.sin(time * 0.013) * Math.min(0.024, moving * 0.003);
|
||||
} else runtime.viewWeapon.visible = false;
|
||||
runtime.viewMuzzle.intensity = time < runtime.muzzleUntil ? 14 : 0;
|
||||
runtime.viewMuzzleCore.visible = time < runtime.muzzleUntil;
|
||||
|
||||
runtime.storm.position.x = world.stormX;
|
||||
runtime.storm.position.z = world.stormZ;
|
||||
runtime.storm.scale.set(world.stormRadius, world.stormRadius, 1);
|
||||
runtime.storm.material.opacity = 0.62 + Math.sin(time * 0.003) * 0.18;
|
||||
|
||||
updatePlayers(runtime, world.players, playerId, time);
|
||||
updateLoot(runtime, world.loot, time);
|
||||
updateEffects(runtime, world, playerId, lastEventIdRef, time);
|
||||
}
|
||||
|
||||
function updateChunks(runtime: RoyaleRuntime, x: number, z: number): void {
|
||||
const centerX = royaleChunkCoordinate(x);
|
||||
const centerZ = royaleChunkCoordinate(z);
|
||||
const wanted = new Set<string>();
|
||||
const radius = 3;
|
||||
for (let offsetX = -radius; offsetX <= radius; offsetX += 1) {
|
||||
for (let offsetZ = -radius; offsetZ <= radius; offsetZ += 1) {
|
||||
const chunkX = centerX + offsetX;
|
||||
const chunkZ = centerZ + offsetZ;
|
||||
const chunkCenterX = (chunkX + 0.5) * ROYALE_CHUNK_SIZE;
|
||||
const chunkCenterZ = (chunkZ + 0.5) * ROYALE_CHUNK_SIZE;
|
||||
if (Math.hypot(chunkCenterX, chunkCenterZ) > ROYALE_WORLD_RADIUS + 90) continue;
|
||||
const key = `${chunkX}:${chunkZ}`;
|
||||
wanted.add(key);
|
||||
if (!runtime.chunks.has(key)) {
|
||||
const group = buildChunk(chunkX, chunkZ);
|
||||
runtime.chunks.set(key, group);
|
||||
runtime.scene.add(group);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, group] of runtime.chunks) {
|
||||
if (wanted.has(key)) continue;
|
||||
runtime.chunks.delete(key);
|
||||
runtime.scene.remove(group);
|
||||
group.traverse((object) => disposeObject(object));
|
||||
}
|
||||
}
|
||||
|
||||
function buildChunk(chunkX: number, chunkZ: number): THREE.Group {
|
||||
const chunk = generateRoyaleChunk(chunkX, chunkZ);
|
||||
const group = new THREE.Group();
|
||||
const ground = new THREE.Mesh(
|
||||
new THREE.PlaneGeometry(ROYALE_CHUNK_SIZE, ROYALE_CHUNK_SIZE),
|
||||
new THREE.MeshStandardMaterial({ color: chunk.tint, roughness: 0.93, metalness: 0.02 }),
|
||||
);
|
||||
ground.rotation.x = -Math.PI / 2;
|
||||
ground.position.set(
|
||||
chunkX * ROYALE_CHUNK_SIZE + ROYALE_CHUNK_SIZE / 2,
|
||||
0,
|
||||
chunkZ * ROYALE_CHUNK_SIZE + ROYALE_CHUNK_SIZE / 2,
|
||||
);
|
||||
ground.receiveShadow = true;
|
||||
group.add(ground);
|
||||
|
||||
const roadMaterial = new THREE.MeshStandardMaterial({ color: 0x4b514c, roughness: 0.88 });
|
||||
if (chunk.roadX) {
|
||||
const road = new THREE.Mesh(new THREE.PlaneGeometry(ROYALE_CHUNK_SIZE, 18), roadMaterial);
|
||||
road.rotation.x = -Math.PI / 2;
|
||||
road.position.set(ground.position.x, 0.035, ground.position.z);
|
||||
group.add(road);
|
||||
}
|
||||
if (chunk.roadZ) {
|
||||
const road = new THREE.Mesh(new THREE.PlaneGeometry(18, ROYALE_CHUNK_SIZE), roadMaterial.clone());
|
||||
road.rotation.x = -Math.PI / 2;
|
||||
road.position.set(ground.position.x, 0.04, ground.position.z);
|
||||
group.add(road);
|
||||
}
|
||||
|
||||
for (const obstacle of chunk.obstacles) {
|
||||
if (obstacle.kind === "building") {
|
||||
const building = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(obstacle.width, obstacle.height, obstacle.depth),
|
||||
new THREE.MeshStandardMaterial({ color: 0x707b72, roughness: 0.78, metalness: 0.12 }),
|
||||
);
|
||||
building.position.set(obstacle.x, obstacle.height / 2, obstacle.z);
|
||||
building.castShadow = true;
|
||||
building.receiveShadow = true;
|
||||
group.add(building);
|
||||
const roof = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(obstacle.width + 0.6, 0.35, obstacle.depth + 0.6),
|
||||
new THREE.MeshStandardMaterial({ color: 0x30383a, roughness: 0.7 }),
|
||||
);
|
||||
roof.position.set(obstacle.x, obstacle.height + 0.18, obstacle.z);
|
||||
group.add(roof);
|
||||
} else if (obstacle.kind === "tree") {
|
||||
const trunk = new THREE.Mesh(
|
||||
new THREE.CylinderGeometry(0.65, 0.9, obstacle.height * 0.48, 7),
|
||||
new THREE.MeshStandardMaterial({ color: 0x58442d, roughness: 1 }),
|
||||
);
|
||||
trunk.position.set(obstacle.x, obstacle.height * 0.24, obstacle.z);
|
||||
trunk.castShadow = true;
|
||||
group.add(trunk);
|
||||
const crown = new THREE.Mesh(
|
||||
new THREE.ConeGeometry(obstacle.width * 1.25, obstacle.height * 0.72, 8),
|
||||
new THREE.MeshStandardMaterial({ color: 0x244f34, roughness: 0.92 }),
|
||||
);
|
||||
crown.position.set(obstacle.x, obstacle.height * 0.7, obstacle.z);
|
||||
crown.castShadow = true;
|
||||
group.add(crown);
|
||||
} else {
|
||||
const rock = new THREE.Mesh(
|
||||
new THREE.DodecahedronGeometry(obstacle.width * 0.48, 0),
|
||||
new THREE.MeshStandardMaterial({ color: 0x646a65, roughness: 0.96 }),
|
||||
);
|
||||
rock.scale.y = obstacle.height / obstacle.width;
|
||||
rock.position.set(obstacle.x, obstacle.height * 0.34, obstacle.z);
|
||||
rock.rotation.set(0.2, obstacle.x * 0.1, -0.12);
|
||||
rock.castShadow = true;
|
||||
group.add(rock);
|
||||
}
|
||||
}
|
||||
return group;
|
||||
}
|
||||
|
||||
function updatePlayers(
|
||||
runtime: RoyaleRuntime,
|
||||
players: RoyalePlayerView[],
|
||||
playerId: number | null,
|
||||
time: number,
|
||||
): void {
|
||||
const visible = new Set(players.map((player) => player.id));
|
||||
for (const player of players) {
|
||||
let group = runtime.players.get(player.id);
|
||||
if (!group) {
|
||||
group = buildPlayer(player.id === playerId, player.bot);
|
||||
runtime.players.set(player.id, group);
|
||||
runtime.scene.add(group);
|
||||
}
|
||||
group.position.set(player.x, player.altitude, player.z);
|
||||
group.rotation.y = player.yaw;
|
||||
group.visible = player.alive && player.id !== playerId;
|
||||
const canopy = group.getObjectByName("canopy");
|
||||
if (canopy) canopy.visible = player.altitude > 0;
|
||||
const pulse = group.getObjectByName("pulse") as THREE.PointLight | undefined;
|
||||
if (pulse) pulse.intensity = player.firing ? 15 : 2 + Math.sin(time * 0.005 + player.id) * 0.8;
|
||||
}
|
||||
for (const [id, group] of runtime.players) {
|
||||
if (visible.has(id)) continue;
|
||||
runtime.players.delete(id);
|
||||
runtime.scene.remove(group);
|
||||
group.traverse((object) => disposeObject(object));
|
||||
}
|
||||
}
|
||||
|
||||
function buildPlayer(local: boolean, bot: boolean): THREE.Group {
|
||||
const group = new THREE.Group();
|
||||
const color = local ? 0x69efff : bot ? 0xff8257 : 0xf7e9bd;
|
||||
const body = new THREE.Mesh(
|
||||
new THREE.CapsuleGeometry(0.72, 1.35, 4, 8),
|
||||
new THREE.MeshStandardMaterial({ color, roughness: 0.45, metalness: 0.28 }),
|
||||
);
|
||||
body.position.y = 1.35;
|
||||
body.castShadow = true;
|
||||
group.add(body);
|
||||
const weapon = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(0.18, 0.18, 1.55),
|
||||
new THREE.MeshStandardMaterial({ color: 0x202a2d, metalness: 0.65, roughness: 0.35 }),
|
||||
);
|
||||
weapon.position.set(0.42, 1.45, 0.55);
|
||||
group.add(weapon);
|
||||
const pulse = new THREE.PointLight(color, 3, 12, 2);
|
||||
pulse.name = "pulse";
|
||||
pulse.position.y = 1.8;
|
||||
group.add(pulse);
|
||||
const canopy = new THREE.Group();
|
||||
canopy.name = "canopy";
|
||||
const wing = new THREE.Mesh(
|
||||
new THREE.SphereGeometry(3.4, 16, 8, 0, Math.PI * 2, 0, Math.PI * 0.48),
|
||||
new THREE.MeshStandardMaterial({ color: local ? 0x4ddff8 : 0x3c4747, side: THREE.DoubleSide, roughness: 0.66 }),
|
||||
);
|
||||
wing.scale.z = 0.42;
|
||||
wing.position.y = 5.2;
|
||||
canopy.add(wing);
|
||||
group.add(canopy);
|
||||
return group;
|
||||
}
|
||||
|
||||
function buildViewWeapon(): {
|
||||
group: THREE.Group;
|
||||
muzzle: THREE.PointLight;
|
||||
muzzleCore: THREE.Mesh;
|
||||
} {
|
||||
const group = new THREE.Group();
|
||||
group.position.set(0.36, -0.31, -0.62);
|
||||
group.visible = false;
|
||||
const body = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(0.18, 0.2, 0.9),
|
||||
new THREE.MeshStandardMaterial({
|
||||
color: 0x263438,
|
||||
metalness: 0.72,
|
||||
roughness: 0.28,
|
||||
}),
|
||||
);
|
||||
body.position.z = -0.18;
|
||||
group.add(body);
|
||||
const rail = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(0.1, 0.06, 0.58),
|
||||
new THREE.MeshStandardMaterial({
|
||||
color: 0x76e9ef,
|
||||
emissive: 0x1b8792,
|
||||
emissiveIntensity: 0.72,
|
||||
metalness: 0.48,
|
||||
roughness: 0.3,
|
||||
}),
|
||||
);
|
||||
rail.position.set(0, 0.12, -0.25);
|
||||
group.add(rail);
|
||||
const muzzleCore = new THREE.Mesh(
|
||||
new THREE.SphereGeometry(0.07, 10, 8),
|
||||
new THREE.MeshBasicMaterial({ color: 0xffe0a0 }),
|
||||
);
|
||||
muzzleCore.position.set(0, 0, -0.72);
|
||||
muzzleCore.visible = false;
|
||||
group.add(muzzleCore);
|
||||
const muzzle = new THREE.PointLight(0xffc66d, 0, 3.5, 2);
|
||||
muzzle.position.copy(muzzleCore.position);
|
||||
group.add(muzzle);
|
||||
return { group, muzzle, muzzleCore };
|
||||
}
|
||||
|
||||
function updateLoot(runtime: RoyaleRuntime, loot: RoyaleLootView[], time: number): void {
|
||||
const visible = new Set(loot.map((item) => item.id));
|
||||
for (const item of loot) {
|
||||
let group = runtime.loot.get(item.id);
|
||||
if (!group) {
|
||||
group = buildLoot(item);
|
||||
runtime.loot.set(item.id, group);
|
||||
runtime.scene.add(group);
|
||||
}
|
||||
group.position.set(item.x, 0.7 + Math.sin(time * 0.002 + item.id) * 0.18, item.z);
|
||||
group.rotation.y = time * 0.0006 + item.id;
|
||||
}
|
||||
for (const [id, group] of runtime.loot) {
|
||||
if (visible.has(id)) continue;
|
||||
runtime.loot.delete(id);
|
||||
runtime.scene.remove(group);
|
||||
group.traverse((object) => disposeObject(object));
|
||||
}
|
||||
}
|
||||
|
||||
function buildLoot(item: RoyaleLootView): THREE.Group {
|
||||
const group = new THREE.Group();
|
||||
const color = item.kind === "weapon" ? 0xffc75f : item.kind === "ammo" ? 0x8ceaff : item.kind === "armor" ? 0x9b8cff : 0x71ef9c;
|
||||
const mesh = new THREE.Mesh(
|
||||
item.kind === "weapon" ? new THREE.BoxGeometry(1.3, 0.28, 0.45) : new THREE.OctahedronGeometry(0.62),
|
||||
new THREE.MeshStandardMaterial({ color, emissive: color, emissiveIntensity: 0.7, metalness: 0.45, roughness: 0.3 }),
|
||||
);
|
||||
mesh.castShadow = true;
|
||||
group.add(mesh);
|
||||
group.add(new THREE.PointLight(color, 6, 9, 2));
|
||||
return group;
|
||||
}
|
||||
|
||||
function updateEffects(
|
||||
runtime: RoyaleRuntime,
|
||||
world: RoyaleClientState,
|
||||
playerId: number | null,
|
||||
lastEventIdRef: MutableRefObject<number>,
|
||||
time: number,
|
||||
): void {
|
||||
for (const entry of world.events) {
|
||||
if (entry.event.id <= lastEventIdRef.current || entry.event.type !== "shot") continue;
|
||||
const event = entry.event;
|
||||
if (event.sourceId === playerId) runtime.muzzleUntil = time + 85;
|
||||
const horizontal = Math.cos(event.pitch);
|
||||
const direction = new THREE.Vector3(
|
||||
Math.sin(event.yaw) * horizontal,
|
||||
Math.sin(event.pitch),
|
||||
Math.cos(event.yaw) * horizontal,
|
||||
).normalize();
|
||||
const start = new THREE.Vector3(event.x, event.y, event.z)
|
||||
.addScaledVector(direction, 1.35);
|
||||
const end = new THREE.Vector3(
|
||||
event.x + direction.x * 86,
|
||||
event.y + direction.y * 86,
|
||||
event.z + direction.z * 86,
|
||||
);
|
||||
const length = start.distanceTo(end);
|
||||
const trail = new THREE.Mesh(
|
||||
new THREE.CylinderGeometry(0.028, 0.055, length, 6),
|
||||
new THREE.MeshBasicMaterial({
|
||||
color: 0xffd27a,
|
||||
transparent: true,
|
||||
opacity: 0.72,
|
||||
depthWrite: false,
|
||||
}),
|
||||
);
|
||||
trail.position.copy(start).add(end).multiplyScalar(0.5);
|
||||
trail.quaternion.setFromUnitVectors(
|
||||
new THREE.Vector3(0, 1, 0),
|
||||
direction,
|
||||
);
|
||||
runtime.scene.add(trail);
|
||||
runtime.effects.push({ object: trail, expiresAt: time + 165 });
|
||||
|
||||
const bullet = new THREE.Group();
|
||||
const core = new THREE.Mesh(
|
||||
new THREE.SphereGeometry(0.13, 10, 8),
|
||||
new THREE.MeshBasicMaterial({ color: 0xfff1bd }),
|
||||
);
|
||||
bullet.add(core);
|
||||
bullet.add(new THREE.PointLight(0xffbd5c, 10, 5, 2));
|
||||
bullet.position.copy(start);
|
||||
runtime.scene.add(bullet);
|
||||
runtime.effects.push({
|
||||
object: bullet,
|
||||
startedAt: time,
|
||||
expiresAt: time + 230,
|
||||
start,
|
||||
end,
|
||||
});
|
||||
}
|
||||
lastEventIdRef.current = world.events.reduce(
|
||||
(latest, entry) => Math.max(latest, entry.event.id),
|
||||
lastEventIdRef.current,
|
||||
);
|
||||
runtime.effects = runtime.effects.filter((effect) => {
|
||||
if (
|
||||
effect.startedAt !== undefined &&
|
||||
effect.start &&
|
||||
effect.end
|
||||
) {
|
||||
const progress = clamp01(
|
||||
(time - effect.startedAt) / (effect.expiresAt - effect.startedAt),
|
||||
);
|
||||
effect.object.position.lerpVectors(effect.start, effect.end, progress);
|
||||
}
|
||||
if (effect.expiresAt > time) return true;
|
||||
runtime.scene.remove(effect.object);
|
||||
effect.object.traverse((object) => disposeObject(object));
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
function clamp01(value: number): number {
|
||||
return Math.max(0, Math.min(1, value));
|
||||
}
|
||||
|
||||
function resize(runtime: RoyaleRuntime, host: HTMLDivElement): void {
|
||||
const width = Math.max(1, host.clientWidth);
|
||||
const height = Math.max(1, host.clientHeight);
|
||||
runtime.renderer.setSize(width, height, false);
|
||||
runtime.camera.aspect = width / height;
|
||||
runtime.camera.updateProjectionMatrix();
|
||||
}
|
||||
|
||||
function disposeObject(object: THREE.Object3D): void {
|
||||
if (!(object instanceof THREE.Mesh || object instanceof THREE.Line)) return;
|
||||
object.geometry.dispose();
|
||||
const materials = Array.isArray(object.material) ? object.material : [object.material];
|
||||
for (const material of materials) material.dispose();
|
||||
}
|
||||
179
apps/web/src/RoyaleGame.tsx
Normal file
179
apps/web/src/RoyaleGame.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
import {
|
||||
ROYALE_WEAPONS,
|
||||
type RoyaleClientState,
|
||||
type RoyalePlayerView,
|
||||
} from "@syncer/shared";
|
||||
import { Royale3D } from "./Royale3D.js";
|
||||
import { useRoyaleClient } from "./useRoyaleClient.js";
|
||||
|
||||
export function RoyaleGame() {
|
||||
const client = useRoyaleClient();
|
||||
const local = client.world.players.find((player) => player.id === client.playerId);
|
||||
const recent = [...client.world.events].reverse();
|
||||
const damage = recent.find((entry) => entry.event.type === "damage");
|
||||
const hit = recent.find((entry) => entry.event.type === "hit");
|
||||
const pickup = recent.find((entry) => entry.event.type === "pickup");
|
||||
const eliminations = recent
|
||||
.filter((entry) => entry.event.type === "elimination")
|
||||
.slice(0, 4);
|
||||
const weapon = ROYALE_WEAPONS[local?.weapon ?? "pistol"];
|
||||
const stormDistance = local
|
||||
? Math.hypot(local.x - client.world.stormX, local.z - client.world.stormZ) -
|
||||
client.world.stormRadius
|
||||
: 0;
|
||||
const recentDamage = Boolean(
|
||||
damage && client.tick - damage.receivedTick < 18,
|
||||
);
|
||||
const recentHit = Boolean(hit && client.tick - hit.receivedTick < 12);
|
||||
|
||||
return (
|
||||
<main className="royale-game">
|
||||
<Royale3D world={client.world} playerId={client.playerId} />
|
||||
<div className="royale-grade" aria-hidden="true" />
|
||||
{recentDamage ? <div className="royale-damage" aria-hidden="true" /> : null}
|
||||
|
||||
<header className="royale-header">
|
||||
<div className="royale-brand">
|
||||
<small>SYNCER // LARGE WORLD PROOF</small>
|
||||
<strong>SYNCER ROYALE</strong>
|
||||
</div>
|
||||
<div className="royale-status">
|
||||
<span><b>{client.world.aliveCount}</b> ALIVE</span>
|
||||
<span>ROUND <b>{client.world.round}</b></span>
|
||||
<span className={stormDistance > 0 ? "is-danger" : ""}>
|
||||
{stormDistance > 0 ? `${Math.ceil(stormDistance)}M OUTSIDE` : `${Math.round(client.world.stormRadius)}M CIRCLE`}
|
||||
</span>
|
||||
</div>
|
||||
<div className={`royale-live royale-live--${client.connection}`}>
|
||||
<i />
|
||||
<span>{client.connection}</span>
|
||||
<small>{client.network.roundTripTime.toFixed(0)} ms · {client.validation}</small>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="royale-stream" aria-label="Spatial stream telemetry">
|
||||
<small>SPATIAL STREAM</small>
|
||||
<div><span>CHUNK</span><b>{client.world.stream.chunkX}:{client.world.stream.chunkZ}</b></div>
|
||||
<div><span>VISIBLE</span><b>{client.world.players.length - 1} / {client.world.aliveCount - 1}</b></div>
|
||||
<div><span>ENTITIES</span><b>{client.world.stream.candidateCount - client.world.stream.droppedCount}</b></div>
|
||||
<div><span>BUDGET</span><b>{formatBytes(client.world.stream.usedBytes)} / {formatBytes(client.world.stream.budgetBytes)}</b></div>
|
||||
<div><span>TICK</span><b>{client.tick}</b></div>
|
||||
</section>
|
||||
|
||||
<Radar world={client.world} local={local} />
|
||||
|
||||
<section className="royale-feed" aria-live="polite">
|
||||
{eliminations.map((entry) => {
|
||||
if (entry.event.type !== "elimination") return null;
|
||||
return (
|
||||
<div key={entry.event.id}>
|
||||
<span>{combatant(entry.event.killerId)}</span>
|
||||
<b>ELIMINATED</b>
|
||||
<span>{combatant(entry.event.victimId)}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
|
||||
<div className={`royale-crosshair${recentHit ? " royale-crosshair--hit" : ""}`}>
|
||||
<i /><i /><i /><i />
|
||||
</div>
|
||||
|
||||
{recentHit && hit?.event.type === "hit" ? (
|
||||
<div className="royale-hit">{hit.event.eliminated ? "ELIMINATION" : `${hit.event.amount} DAMAGE`}</div>
|
||||
) : null}
|
||||
{pickup &&
|
||||
pickup.event.type === "pickup" &&
|
||||
client.tick - pickup.receivedTick < 45 ? (
|
||||
<div className="royale-pickup">
|
||||
ACQUIRED // {pickup.event.kind === "weapon" ? ROYALE_WEAPONS[pickup.event.weapon].name : `${pickup.event.kind} +${pickup.event.amount}`}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{local?.altitude ? (
|
||||
<section className="royale-drop">
|
||||
<small>PARACHUTE INSERTION</small>
|
||||
<strong>{Math.ceil(local.altitude)} M</strong>
|
||||
<span><i style={{ height: `${Math.min(100, local.altitude / 1.1)}%` }} /></span>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{local && !local.alive ? (
|
||||
<section className="royale-eliminated">
|
||||
<small>OPERATOR ELIMINATED</small>
|
||||
<strong>{client.world.resetTicks > 0 ? `NEW ISLAND IN ${Math.ceil(client.world.resetTicks / 30)}` : "SPECTATING AUTHORITY"}</strong>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{client.world.winnerId !== null ? (
|
||||
<section className="royale-winner">
|
||||
<small>LAST OPERATOR STANDING</small>
|
||||
<strong>{combatant(client.world.winnerId)} WINS ROUND {client.world.round}</strong>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<footer className="royale-hud">
|
||||
<div className="royale-vitals">
|
||||
<small>HEALTH</small>
|
||||
<strong>{local?.health ?? 0}</strong>
|
||||
<span><i style={{ width: `${local?.health ?? 0}%` }} /></span>
|
||||
<em>ARMOR {local?.armor ?? 0}</em>
|
||||
</div>
|
||||
<div className="royale-mission">
|
||||
<strong>2 KM PROCEDURAL ISLAND</strong>
|
||||
<small>PUBLIC TERRAIN SEED · PRIVATE LOOT · 31 AUTHORITY BOTS</small>
|
||||
</div>
|
||||
<div className="royale-weapon">
|
||||
<small>{weapon.name}</small>
|
||||
<strong>{local?.magazine ?? 0}<i>/ {local?.reserve ?? 0}</i></strong>
|
||||
<span>{local?.reloadTicks ? "RELOADING" : local?.weapon.toUpperCase() ?? "PISTOL"}</span>
|
||||
</div>
|
||||
</footer>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function Radar({ world, local }: { world: RoyaleClientState; local: RoyalePlayerView | undefined }) {
|
||||
const scale = 150;
|
||||
return (
|
||||
<section className="royale-radar" aria-label="Local spatial radar">
|
||||
<div className="royale-radar__ring" />
|
||||
<i className="royale-radar__self" />
|
||||
{local
|
||||
? world.players
|
||||
.filter((player) => player.id !== local.id)
|
||||
.map((player) => (
|
||||
<i
|
||||
className="royale-radar__enemy"
|
||||
key={player.id}
|
||||
style={{
|
||||
left: `${50 + ((player.x - local.x) / scale) * 50}%`,
|
||||
top: `${50 + ((player.z - local.z) / scale) * 50}%`,
|
||||
}}
|
||||
/>
|
||||
))
|
||||
: null}
|
||||
{local
|
||||
? world.loot.map((item) => (
|
||||
<i
|
||||
className="royale-radar__loot"
|
||||
key={item.id}
|
||||
style={{
|
||||
left: `${50 + ((item.x - local.x) / scale) * 50}%`,
|
||||
top: `${50 + ((item.z - local.z) / scale) * 50}%`,
|
||||
}}
|
||||
/>
|
||||
))
|
||||
: null}
|
||||
<small>150 M LOCAL CELL</small>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function combatant(id: number): string {
|
||||
return id === 0 ? "THE STORM" : id >= 30_000 ? `BOT ${id - 29_999}` : `PLAYER ${id}`;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
return bytes >= 1_000 ? `${(bytes / 1_000).toFixed(1)}K` : `${bytes}B`;
|
||||
}
|
||||
142
apps/web/src/audio.ts
Normal file
142
apps/web/src/audio.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { Weapon, type ShooterPerception, type WeaponId } from "@syncer/shared";
|
||||
|
||||
export class ArenaAudio {
|
||||
private context: AudioContext | null = null;
|
||||
private master: GainNode | null = null;
|
||||
|
||||
unlock(): void {
|
||||
if (!this.context) {
|
||||
this.context = new AudioContext();
|
||||
this.master = this.context.createGain();
|
||||
this.master.gain.value = 0.26;
|
||||
this.master.connect(this.context.destination);
|
||||
}
|
||||
void this.context.resume();
|
||||
}
|
||||
|
||||
play(event: ShooterPerception): void {
|
||||
if (!this.context || !this.master || this.context.state !== "running") return;
|
||||
switch (event.type) {
|
||||
case "shot":
|
||||
this.shot(event.weapon, 0, 0.48);
|
||||
if (event.impact === "player") this.tone(760, 520, 0.07, "square");
|
||||
break;
|
||||
case "sound":
|
||||
if (event.sound === "footstep") this.footstep(event.bearingRadians, event.intensity);
|
||||
else this.shot(event.weapon, event.bearingRadians, event.intensity);
|
||||
break;
|
||||
case "hit":
|
||||
this.tone(
|
||||
event.eliminated ? 520 : event.critical ? 920 : 760,
|
||||
event.eliminated ? 180 : event.critical ? 620 : 520,
|
||||
0.08,
|
||||
event.critical ? "square" : "triangle",
|
||||
);
|
||||
break;
|
||||
case "damage":
|
||||
this.damage(event.bearingRadians);
|
||||
break;
|
||||
case "elimination":
|
||||
this.tone(510, 180, 0.12, "triangle");
|
||||
break;
|
||||
case "pickup":
|
||||
this.tone(440, 860, 0.15, "sine");
|
||||
break;
|
||||
case "respawn":
|
||||
this.tone(220, 660, 0.28, "sine");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
void this.context?.close();
|
||||
this.context = null;
|
||||
this.master = null;
|
||||
}
|
||||
|
||||
private footstep(bearing: number, intensity: number): void {
|
||||
const context = this.context!;
|
||||
const now = context.currentTime;
|
||||
const oscillator = context.createOscillator();
|
||||
const gain = context.createGain();
|
||||
oscillator.type = "sine";
|
||||
oscillator.frequency.setValueAtTime(105, now);
|
||||
oscillator.frequency.exponentialRampToValueAtTime(48, now + 0.09);
|
||||
gain.gain.setValueAtTime(Math.max(0.012, intensity * 0.2), now);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.13);
|
||||
oscillator.connect(gain).connect(this.panner(bearing));
|
||||
oscillator.start(now);
|
||||
oscillator.stop(now + 0.14);
|
||||
}
|
||||
|
||||
private shot(weapon: WeaponId, bearing: number, intensity: number): void {
|
||||
const context = this.context!;
|
||||
const now = context.currentTime;
|
||||
const panner = this.panner(bearing);
|
||||
const duration = weapon === Weapon.RailRifle ? 0.23 : 0.16;
|
||||
const noise = context.createBufferSource();
|
||||
const buffer = context.createBuffer(1, Math.floor(context.sampleRate * duration), context.sampleRate);
|
||||
const samples = buffer.getChannelData(0);
|
||||
for (let index = 0; index < samples.length; index += 1) {
|
||||
samples[index] = (Math.random() * 2 - 1) * Math.exp(-index / (samples.length * 0.18));
|
||||
}
|
||||
noise.buffer = buffer;
|
||||
const filter = context.createBiquadFilter();
|
||||
filter.type = weapon === Weapon.PulseRifle ? "bandpass" : "lowpass";
|
||||
filter.frequency.value = weapon === Weapon.PulseRifle ? 1_500 : weapon === Weapon.Scattergun ? 760 : 2_200;
|
||||
filter.Q.value = weapon === Weapon.PulseRifle ? 0.8 : 1.25;
|
||||
const gain = context.createGain();
|
||||
gain.gain.setValueAtTime(Math.max(0.02, intensity * (weapon === Weapon.Scattergun ? 0.62 : 0.46)), now);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, now + duration);
|
||||
noise.connect(filter).connect(gain).connect(panner);
|
||||
noise.start(now);
|
||||
|
||||
const thump = context.createOscillator();
|
||||
const thumpGain = context.createGain();
|
||||
const base = weapon === Weapon.Scattergun ? 88 : weapon === Weapon.RailRifle ? 210 : 145;
|
||||
thump.frequency.setValueAtTime(base, now);
|
||||
thump.frequency.exponentialRampToValueAtTime(45, now + 0.11);
|
||||
thumpGain.gain.setValueAtTime(intensity * 0.24, now);
|
||||
thumpGain.gain.exponentialRampToValueAtTime(0.001, now + 0.12);
|
||||
thump.connect(thumpGain).connect(panner);
|
||||
thump.start(now);
|
||||
thump.stop(now + 0.13);
|
||||
}
|
||||
|
||||
private damage(bearing: number): void {
|
||||
const context = this.context!;
|
||||
const now = context.currentTime;
|
||||
const oscillator = context.createOscillator();
|
||||
const gain = context.createGain();
|
||||
oscillator.type = "sawtooth";
|
||||
oscillator.frequency.setValueAtTime(72, now);
|
||||
oscillator.frequency.exponentialRampToValueAtTime(34, now + 0.2);
|
||||
gain.gain.setValueAtTime(0.22, now);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.22);
|
||||
oscillator.connect(gain).connect(this.panner(bearing));
|
||||
oscillator.start(now);
|
||||
oscillator.stop(now + 0.23);
|
||||
}
|
||||
|
||||
private tone(from: number, to: number, duration: number, type: OscillatorType): void {
|
||||
const context = this.context!;
|
||||
const now = context.currentTime;
|
||||
const oscillator = context.createOscillator();
|
||||
const gain = context.createGain();
|
||||
oscillator.type = type;
|
||||
oscillator.frequency.setValueAtTime(from, now);
|
||||
oscillator.frequency.exponentialRampToValueAtTime(to, now + duration);
|
||||
gain.gain.setValueAtTime(0.09, now);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, now + duration);
|
||||
oscillator.connect(gain).connect(this.master!);
|
||||
oscillator.start(now);
|
||||
oscillator.stop(now + duration + 0.01);
|
||||
}
|
||||
|
||||
private panner(bearing: number): StereoPannerNode {
|
||||
const panner = this.context!.createStereoPanner();
|
||||
panner.pan.value = Math.max(-1, Math.min(1, Math.sin(bearing)));
|
||||
panner.connect(this.master!);
|
||||
return panner;
|
||||
}
|
||||
}
|
||||
17
apps/web/src/main.tsx
Normal file
17
apps/web/src/main.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./App.js";
|
||||
import "./styles.css";
|
||||
|
||||
const root = document.querySelector<HTMLDivElement>("#root");
|
||||
|
||||
if (!root) {
|
||||
throw new Error("Root element was not found");
|
||||
}
|
||||
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
602
apps/web/src/styles.css
Normal file
602
apps/web/src/styles.css
Normal file
@@ -0,0 +1,602 @@
|
||||
@import url("https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@500;600;700;800&family=IBM+Plex+Mono:wght@400;500;600&display=swap");
|
||||
|
||||
:root {
|
||||
font-family: "Barlow Condensed", system-ui, sans-serif;
|
||||
color: #eaf7fb;
|
||||
background: #05080c;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body, #root { width: 100%; height: 100%; margin: 0; overflow: hidden; }
|
||||
button { font: inherit; }
|
||||
|
||||
.game-switcher {
|
||||
position: fixed;
|
||||
z-index: 30;
|
||||
top: 78px;
|
||||
left: 50%;
|
||||
display: flex;
|
||||
padding: 3px;
|
||||
border: 1px solid rgb(99 225 255 / 22%);
|
||||
background: rgb(3 11 16 / 78%);
|
||||
backdrop-filter: blur(10px);
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
.game-switcher button {
|
||||
padding: 5px 10px;
|
||||
border: 0;
|
||||
color: #5f7f8a;
|
||||
background: transparent;
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
font-size: .48rem;
|
||||
letter-spacing: .12em;
|
||||
cursor: pointer;
|
||||
}
|
||||
.game-switcher button:hover { color: #caeff7; }
|
||||
.game-switcher .is-active { color: #b9f4ff; background: rgb(75 213 243 / 14%); }
|
||||
|
||||
.game {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 320px;
|
||||
overflow: hidden;
|
||||
background: #05080c;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.viewport, .viewport canvas { position: absolute; inset: 0; width: 100%; height: 100%; }
|
||||
.viewport canvas { display: block; }
|
||||
|
||||
.vignette {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background:
|
||||
linear-gradient(180deg, rgb(3 7 11 / 52%), transparent 20%, transparent 78%, rgb(2 5 8 / 72%)),
|
||||
radial-gradient(circle, transparent 48%, rgb(0 0 0 / 44%) 100%);
|
||||
}
|
||||
|
||||
.topbar {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
display: grid;
|
||||
width: 100%;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: start;
|
||||
padding: 24px 28px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.brand { display: flex; align-items: center; gap: 11px; }
|
||||
.brand-mark {
|
||||
display: grid;
|
||||
width: 46px;
|
||||
height: 40px;
|
||||
place-items: center;
|
||||
border: 1px solid rgb(104 225 255 / 55%);
|
||||
color: #7ce8ff;
|
||||
background: rgb(5 19 27 / 70%);
|
||||
box-shadow: inset 0 0 20px rgb(0 185 230 / 12%);
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
clip-path: polygon(0 0, 88% 0, 100% 24%, 100% 100%, 12% 100%, 0 76%);
|
||||
}
|
||||
.brand strong { display: block; font-size: 1rem; letter-spacing: .14em; }
|
||||
.brand small { color: #72909b; font-family: "IBM Plex Mono", monospace; font-size: .56rem; letter-spacing: .12em; }
|
||||
|
||||
.match-clock { min-width: 124px; padding: 6px 22px 9px; text-align: center; background: linear-gradient(90deg, transparent, rgb(7 20 28 / 78%) 18%, rgb(7 20 28 / 78%) 82%, transparent); }
|
||||
.match-clock small { display: block; color: #7698a4; font-family: "IBM Plex Mono", monospace; font-size: .53rem; letter-spacing: .15em; }
|
||||
.match-clock strong { color: #e8faff; font-family: "IBM Plex Mono", monospace; font-size: 1.32rem; letter-spacing: .12em; }
|
||||
|
||||
.connection { justify-self: end; display: grid; grid-template-columns: auto auto; align-items: center; column-gap: 8px; padding: 8px 12px; border-right: 2px solid #62e0ff; background: linear-gradient(90deg, transparent, rgb(4 19 27 / 75%)); text-align: right; }
|
||||
.connection i { grid-row: 1 / span 2; width: 7px; height: 7px; border-radius: 50%; background: #66e4ff; box-shadow: 0 0 12px #4bdcff; }
|
||||
.connection span { color: #8eeaff; font-size: .66rem; font-weight: 800; letter-spacing: .14em; text-transform: uppercase; }
|
||||
.connection b { color: #7a939c; font-family: "IBM Plex Mono", monospace; font-size: .53rem; font-weight: 500; }
|
||||
.connection--connecting, .connection--reconnecting { border-color: #ffa84e; }
|
||||
.connection--connecting i, .connection--reconnecting i { background: #ffa84e; box-shadow: 0 0 12px #ffa84e; }
|
||||
.connection--connecting span, .connection--reconnecting span { color: #ffc17d; }
|
||||
|
||||
.telemetry { position: absolute; top: 104px; left: 28px; display: flex; flex-direction: column; gap: 1px; pointer-events: none; }
|
||||
.telemetry > div { display: grid; min-width: 112px; grid-template-columns: 45px 1fr; gap: 8px; padding: 5px 9px; border-left: 1px solid rgb(92 213 239 / 40%); background: linear-gradient(90deg, rgb(3 15 21 / 74%), transparent); }
|
||||
.telemetry small { color: #63818c; font-family: "IBM Plex Mono", monospace; font-size: .48rem; letter-spacing: .1em; }
|
||||
.telemetry strong { color: #a9c2ca; font-family: "IBM Plex Mono", monospace; font-size: .54rem; font-weight: 500; }
|
||||
|
||||
.kill-feed { position: absolute; top: 105px; right: 28px; display: flex; align-items: flex-end; flex-direction: column; gap: 5px; pointer-events: none; }
|
||||
.kill-feed div { display: flex; align-items: center; gap: 8px; padding: 5px 9px; border-right: 1px solid rgb(255 153 63 / 55%); background: linear-gradient(90deg, transparent, rgb(20 12 7 / 75%)); color: #afc1c7; font-family: "IBM Plex Mono", monospace; font-size: .58rem; }
|
||||
.kill-feed b { color: #ff9c45; font-size: .48rem; }
|
||||
|
||||
.leaderboard {
|
||||
position: absolute;
|
||||
top: 210px;
|
||||
right: 28px;
|
||||
width: 178px;
|
||||
padding: 9px 0 5px;
|
||||
border-top: 1px solid rgb(94 220 247 / 35%);
|
||||
background: linear-gradient(90deg, transparent, rgb(3 14 20 / 58%));
|
||||
pointer-events: none;
|
||||
}
|
||||
.leaderboard > small {
|
||||
display: block;
|
||||
margin: 0 8px 6px;
|
||||
color: #6b8a95;
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
font-size: .48rem;
|
||||
letter-spacing: .14em;
|
||||
text-align: right;
|
||||
}
|
||||
.leaderboard > div {
|
||||
display: grid;
|
||||
grid-template-columns: 17px 1fr 18px;
|
||||
gap: 6px;
|
||||
padding: 3px 8px;
|
||||
color: #8ca4ad;
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
font-size: .52rem;
|
||||
}
|
||||
.leaderboard > div b { color: #506b75; font-weight: 500; }
|
||||
.leaderboard > div strong { color: #d7eef5; text-align: right; }
|
||||
.leaderboard .leaderboard__local {
|
||||
color: #9aeeff;
|
||||
background: linear-gradient(90deg, transparent, rgb(32 168 201 / 15%));
|
||||
}
|
||||
|
||||
.crosshair { position: absolute; top: 50%; left: 50%; width: 40px; height: 40px; transform: translate(-50%, -50%); pointer-events: none; }
|
||||
.crosshair::after { position: absolute; top: 18px; left: 18px; width: 4px; height: 4px; border: 1px solid rgb(225 248 255 / 90%); border-radius: 50%; content: ""; box-shadow: 0 0 8px rgb(80 223 255 / 80%); }
|
||||
.crosshair i { position: absolute; display: block; background: rgb(224 249 255 / 88%); box-shadow: 0 0 4px rgb(46 214 255 / 60%); }
|
||||
.crosshair i:nth-child(1), .crosshair i:nth-child(2) { top: 19px; width: 9px; height: 1px; }
|
||||
.crosshair i:nth-child(1) { left: 2px; }
|
||||
.crosshair i:nth-child(2) { right: 2px; }
|
||||
.crosshair i:nth-child(3), .crosshair i:nth-child(4) { left: 19px; width: 1px; height: 9px; }
|
||||
.crosshair i:nth-child(3) { top: 2px; }
|
||||
.crosshair i:nth-child(4) { bottom: 2px; }
|
||||
.crosshair--damage { animation: crosshair-hit .18s ease-out; }
|
||||
@keyframes crosshair-hit { 50% { transform: translate(-50%, -50%) scale(1.5); filter: sepia(1) saturate(4); } }
|
||||
|
||||
.hit-confirm {
|
||||
position: absolute;
|
||||
top: calc(50% + 32px);
|
||||
left: 50%;
|
||||
color: #d9f9ff;
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
font-size: .48rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: .18em;
|
||||
text-shadow: 0 0 9px #66e5ff;
|
||||
transform: translateX(-50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.sound-indicator {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 210px;
|
||||
height: 210px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
}
|
||||
.sound-indicator i {
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
left: calc(50% - 10px);
|
||||
width: 20px;
|
||||
height: 2px;
|
||||
background: #ffb761;
|
||||
box-shadow: 0 0 10px #ff9e3e;
|
||||
}
|
||||
|
||||
.deploy {
|
||||
position: absolute;
|
||||
z-index: 8;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
display: flex;
|
||||
width: min(400px, calc(100% - 40px));
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 21px 28px;
|
||||
border: 1px solid rgb(99 225 255 / 50%);
|
||||
color: #dff9ff;
|
||||
background: rgb(3 14 20 / 86%);
|
||||
box-shadow: 0 0 80px rgb(0 178 221 / 12%), inset 0 0 28px rgb(0 178 221 / 6%);
|
||||
clip-path: polygon(0 0, calc(100% - 18px) 0, 100% 18px, 100% 100%, 18px 100%, 0 calc(100% - 18px));
|
||||
cursor: crosshair;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
.deploy span { font-size: 1.25rem; font-weight: 800; letter-spacing: .2em; }
|
||||
.deploy small { color: #6f939f; font-family: "IBM Plex Mono", monospace; font-size: .55rem; letter-spacing: .06em; }
|
||||
.deploy:hover { border-color: #abf1ff; background: rgb(6 27 37 / 92%); }
|
||||
|
||||
.respawn-panel { position: absolute; top: 50%; left: 50%; display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 16px 38px; border-block: 1px solid rgb(255 118 70 / 55%); background: linear-gradient(90deg, transparent, rgb(31 8 5 / 75%), transparent); transform: translate(-50%, -50%); pointer-events: none; }
|
||||
.respawn-panel small { color: #c06c53; font-family: "IBM Plex Mono", monospace; font-size: .58rem; letter-spacing: .18em; }
|
||||
.respawn-panel strong { color: #ffbea6; font-size: 1.4rem; letter-spacing: .15em; }
|
||||
|
||||
.killcam-panel {
|
||||
position: absolute;
|
||||
bottom: 126px;
|
||||
left: 50%;
|
||||
width: min(560px, calc(100% - 48px));
|
||||
padding: 12px 15px 10px;
|
||||
border-top: 1px solid rgb(255 164 79 / 62%);
|
||||
border-bottom: 1px solid rgb(100 222 247 / 28%);
|
||||
color: #e8f8fc;
|
||||
background: linear-gradient(90deg, transparent, rgb(7 17 23 / 90%) 11%, rgb(7 17 23 / 90%) 89%, transparent);
|
||||
transform: translateX(-50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.killcam-panel__title { display: flex; align-items: end; justify-content: space-between; gap: 18px; }
|
||||
.killcam-panel__title small { color: #8b6f5b; font-family: "IBM Plex Mono", monospace; font-size: .47rem; letter-spacing: .13em; }
|
||||
.killcam-panel__title strong { color: #ffc28c; font-size: .87rem; letter-spacing: .16em; text-align: right; text-shadow: 0 0 14px rgb(255 134 48 / 40%); }
|
||||
.killcam-panel__timeline { overflow: hidden; height: 3px; margin: 8px 0 6px; background: rgb(113 151 162 / 22%); transform: skewX(-22deg); }
|
||||
.killcam-panel__timeline i { display: block; height: 100%; background: linear-gradient(90deg, #ff8e48, #7be5ff); box-shadow: 0 0 12px rgb(99 223 255 / 55%); }
|
||||
.killcam-panel__meta { display: flex; justify-content: space-between; gap: 15px; color: #68838d; font-family: "IBM Plex Mono", monospace; font-size: .46rem; letter-spacing: .09em; }
|
||||
|
||||
.hud { position: absolute; right: 28px; bottom: 24px; left: 28px; display: grid; grid-template-columns: minmax(150px, .7fr) 1fr minmax(210px, .85fr) auto; align-items: end; gap: 30px; pointer-events: none; }
|
||||
.health-block small, .weapon-block small { color: #77919a; font-family: "IBM Plex Mono", monospace; font-size: .53rem; letter-spacing: .15em; }
|
||||
.health-block strong { display: block; margin-top: -2px; color: #d8f8ff; font-family: "IBM Plex Mono", monospace; font-size: 2.3rem; font-weight: 500; line-height: 1; }
|
||||
.health-track { overflow: hidden; width: 150px; height: 3px; margin-top: 7px; background: rgb(117 150 160 / 28%); transform: skewX(-24deg); }
|
||||
.health-track i { display: block; height: 100%; background: #65e2ff; box-shadow: 0 0 12px #42d8fb; transition: width .15s; }
|
||||
|
||||
.mission-note { align-self: end; padding-bottom: 1px; color: #92abb3; text-align: center; }
|
||||
.mission-note span { display: block; font-size: .72rem; font-weight: 700; letter-spacing: .16em; }
|
||||
.mission-note small { color: #5f7881; font-family: "IBM Plex Mono", monospace; font-size: .48rem; letter-spacing: .08em; }
|
||||
|
||||
.weapon-block { display: flex; align-items: end; justify-content: flex-end; gap: 18px; padding-right: 16px; border-right: 1px solid rgb(93 218 245 / 35%); text-align: right; }
|
||||
.weapon-block div > span { display: block; margin-top: 4px; color: #5f7c86; font-family: "IBM Plex Mono", monospace; font-size: .48rem; letter-spacing: .08em; }
|
||||
.weapon-block strong { color: #effcff; font-family: "IBM Plex Mono", monospace; font-size: 2.5rem; font-weight: 500; line-height: .9; }
|
||||
.weapon-block strong i { color: #75909a; font-size: .72rem; font-style: normal; }
|
||||
.score-block { display: flex; gap: 12px; }
|
||||
.score-block span { display: flex; flex-direction: column; color: #d5edf3; font-family: "IBM Plex Mono", monospace; font-size: 1.2rem; }
|
||||
.score-block small { color: #66838e; font-size: .5rem; }
|
||||
|
||||
.damage-flash { position: absolute; inset: 0; pointer-events: none; background: radial-gradient(circle, transparent 15%, rgb(158 22 7 / 28%)); animation: damage .3s ease-out forwards; }
|
||||
@keyframes damage { from { opacity: 1; } to { opacity: 0; } }
|
||||
|
||||
.flux-game {
|
||||
--team: #66e6ff;
|
||||
position: relative;
|
||||
display: grid;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 320px;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
overflow: hidden;
|
||||
color: #e8faff;
|
||||
background:
|
||||
radial-gradient(circle at 50% 45%, rgb(40 137 161 / 16%), transparent 26%),
|
||||
radial-gradient(circle at 8% 50%, rgb(35 198 234 / 10%), transparent 25%),
|
||||
radial-gradient(circle at 92% 50%, rgb(255 128 48 / 11%), transparent 25%),
|
||||
#04080c;
|
||||
user-select: none;
|
||||
}
|
||||
.flux-game--orange { --team: #ff9a4e; }
|
||||
.flux-grid {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: .18;
|
||||
background-image:
|
||||
linear-gradient(rgb(102 226 255 / 16%) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgb(102 226 255 / 16%) 1px, transparent 1px);
|
||||
background-size: 48px 48px;
|
||||
mask-image: radial-gradient(circle at center, black, transparent 75%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.flux-header {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: start;
|
||||
padding: 26px 34px;
|
||||
}
|
||||
.flux-header > div:first-child small,
|
||||
.flux-identity small,
|
||||
.flux-energy small {
|
||||
display: block;
|
||||
color: #66818a;
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
font-size: .48rem;
|
||||
letter-spacing: .14em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.flux-header > div:first-child strong { display: block; color: #dffbff; font-size: 1.32rem; letter-spacing: .2em; }
|
||||
.flux-score { display: grid; grid-template-columns: 54px auto 54px; align-items: center; gap: 15px; text-align: center; }
|
||||
.flux-score b { color: #72eaff; font-family: "IBM Plex Mono", monospace; font-size: 1.6rem; font-weight: 500; text-shadow: 0 0 18px rgb(90 225 255 / 50%); }
|
||||
.flux-score b:last-child { color: #ffa05d; text-shadow: 0 0 18px rgb(255 126 54 / 50%); }
|
||||
.flux-score span { padding: 6px 18px; border-inline: 1px solid rgb(119 210 229 / 24%); color: #7d99a2; font-family: "IBM Plex Mono", monospace; font-size: .53rem; letter-spacing: .13em; }
|
||||
.flux-connection { justify-self: end; display: grid; grid-template-columns: auto auto; align-items: center; column-gap: 8px; text-align: right; }
|
||||
.flux-connection i { grid-row: 1 / span 2; width: 7px; height: 7px; border-radius: 50%; background: #66e4ff; box-shadow: 0 0 12px #4bdcff; }
|
||||
.flux-connection span { color: #99eefd; font-size: .65rem; font-weight: 800; letter-spacing: .14em; text-transform: uppercase; }
|
||||
.flux-connection small { color: #627e87; font-family: "IBM Plex Mono", monospace; font-size: .48rem; }
|
||||
.flux-connection--connecting i, .flux-connection--reconnecting i { background: #ff9c50; box-shadow: 0 0 12px #ff9c50; }
|
||||
|
||||
.flux-arena {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
width: min(1200px, calc(100% - 64px));
|
||||
grid-template-columns: 145px 1fr 145px;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
margin: auto;
|
||||
}
|
||||
.flux-field { display: grid; height: 230px; grid-template-columns: 80px 1fr 80px; align-items: center; }
|
||||
.flux-lane {
|
||||
position: relative;
|
||||
height: 6px;
|
||||
background: linear-gradient(90deg, #4be3ff, rgb(114 184 199 / 28%) 48%, rgb(114 184 199 / 28%) 52%, #ff8c42);
|
||||
box-shadow: 0 0 24px rgb(75 224 255 / 10%);
|
||||
}
|
||||
.flux-lane::before,
|
||||
.flux-lane::after { position: absolute; top: -42px; width: 1px; height: 90px; background: rgb(107 191 210 / 18%); content: ""; }
|
||||
.flux-lane::before { left: 25%; }
|
||||
.flux-lane::after { right: 25%; }
|
||||
.flux-midline { position: absolute; top: -72px; left: 50%; width: 1px; height: 150px; background: linear-gradient(transparent, rgb(183 239 250 / 42%), transparent); }
|
||||
.flux-core {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 74px;
|
||||
height: 74px;
|
||||
border: 1px solid #d6f9ff;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, #e9fdff 0 8%, #6eeaff 10%, rgb(46 206 239 / 22%) 36%, transparent 38%);
|
||||
box-shadow: 0 0 18px #62e6ff, 0 0 65px rgb(60 219 251 / 50%);
|
||||
transform: translate(-50%, -50%);
|
||||
transition: left 60ms linear;
|
||||
}
|
||||
.flux-core i { position: absolute; inset: -10px; border: 1px solid rgb(135 236 255 / 35%); border-radius: 50%; animation: flux-spin 4s linear infinite; }
|
||||
.flux-core i:nth-child(2) { inset: -18px 8px; transform: rotate(64deg); animation-duration: 2.8s; }
|
||||
.flux-core i:nth-child(3) { inset: 8px -18px; transform: rotate(-64deg); animation-duration: 3.4s; }
|
||||
@keyframes flux-spin { to { transform: rotate(360deg); } }
|
||||
.flux-gate { position: relative; height: 170px; border: 1px solid; opacity: .8; }
|
||||
.flux-gate span { position: absolute; top: 50%; width: 110px; font-family: "IBM Plex Mono", monospace; font-size: .46rem; letter-spacing: .12em; text-align: center; transform: translateY(-50%) rotate(-90deg); }
|
||||
.flux-gate--cyan { border-color: #53def9; border-right: 0; box-shadow: inset 18px 0 30px rgb(57 208 239 / 9%); }
|
||||
.flux-gate--cyan span { left: -28px; color: #55dffb; }
|
||||
.flux-gate--orange { border-color: #ff8c47; border-left: 0; box-shadow: inset -18px 0 30px rgb(255 125 48 / 9%); }
|
||||
.flux-gate--orange span { right: -28px; color: #ff9554; }
|
||||
|
||||
.flux-roster { display: flex; flex-direction: column; gap: 7px; }
|
||||
.flux-roster > small { margin-bottom: 5px; color: #526e77; font-family: "IBM Plex Mono", monospace; font-size: .45rem; letter-spacing: .13em; text-transform: uppercase; }
|
||||
.flux-roster > div { display: grid; grid-template-columns: 9px 1fr auto; align-items: center; gap: 7px; padding: 7px 8px; border-left: 1px solid rgb(88 211 238 / 24%); color: #77949d; background: linear-gradient(90deg, rgb(34 135 158 / 9%), transparent); font-family: "IBM Plex Mono", monospace; font-size: .48rem; }
|
||||
.flux-roster--orange { text-align: right; }
|
||||
.flux-roster--orange > div { grid-template-columns: auto 1fr 9px; border-right: 1px solid rgb(255 142 75 / 24%); border-left: 0; background: linear-gradient(90deg, transparent, rgb(159 77 34 / 9%)); }
|
||||
.flux-roster--orange > div i { grid-column: 3; grid-row: 1; background: #70462f; }
|
||||
.flux-roster--orange > div span { grid-column: 2; grid-row: 1; }
|
||||
.flux-roster--orange > div b { grid-column: 1; grid-row: 1; }
|
||||
.flux-roster > div i { width: 6px; height: 6px; border-radius: 50%; background: #376977; }
|
||||
.flux-roster > div b { color: #465f68; font-size: .4rem; font-weight: 500; }
|
||||
.flux-roster > .flux-player--active { color: #baf7ff; }
|
||||
.flux-roster > .flux-player--active i { background: #6ceaff; box-shadow: 0 0 10px #5ee5ff; }
|
||||
.flux-roster--orange > .flux-player--active i { background: #ff9d5d; box-shadow: 0 0 10px #ff8c45; }
|
||||
.flux-roster > .flux-player--local { border-color: var(--team); background: linear-gradient(90deg, rgb(64 208 239 / 18%), transparent); }
|
||||
|
||||
.flux-event { position: absolute; z-index: 4; top: 26%; left: 50%; padding: 8px 24px; border-block: 1px solid; background: linear-gradient(90deg, transparent, rgb(4 17 23 / 88%) 18%, rgb(4 17 23 / 88%) 82%, transparent); font-family: "IBM Plex Mono", monospace; font-size: .58rem; letter-spacing: .13em; transform: translateX(-50%); pointer-events: none; }
|
||||
.flux-event--cyan { border-color: rgb(84 223 250 / 50%); color: #8bedff; }
|
||||
.flux-event--orange { border-color: rgb(255 144 79 / 50%); color: #ffad76; }
|
||||
|
||||
.flux-controls {
|
||||
position: relative;
|
||||
z-index: 3;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: center;
|
||||
gap: 36px;
|
||||
padding: 24px 34px 30px;
|
||||
border-top: 1px solid rgb(109 205 225 / 12%);
|
||||
background: linear-gradient(180deg, transparent, rgb(2 8 12 / 75%));
|
||||
}
|
||||
.flux-identity strong { display: block; margin: 3px 0; color: var(--team); font-size: .88rem; letter-spacing: .13em; }
|
||||
.flux-identity span { color: #536d76; font-family: "IBM Plex Mono", monospace; font-size: .46rem; }
|
||||
.flux-thrust { min-width: 250px; padding: 13px 28px; border: 1px solid color-mix(in srgb, var(--team) 48%, transparent); color: #dffaff; background: rgb(6 23 30 / 84%); cursor: pointer; clip-path: polygon(10px 0, calc(100% - 10px) 0, 100% 10px, 100% calc(100% - 10px), calc(100% - 10px) 100%, 10px 100%, 0 calc(100% - 10px), 0 10px); }
|
||||
.flux-thrust small { display: block; color: #5e7d87; font-family: "IBM Plex Mono", monospace; font-size: .43rem; letter-spacing: .11em; }
|
||||
.flux-thrust strong { display: block; margin-top: 3px; font-size: .92rem; letter-spacing: .16em; }
|
||||
.flux-thrust:hover { border-color: var(--team); }
|
||||
.flux-thrust--active { color: #fff; background: color-mix(in srgb, var(--team) 20%, rgb(5 21 28)); box-shadow: 0 0 28px color-mix(in srgb, var(--team) 25%, transparent), inset 0 0 24px color-mix(in srgb, var(--team) 14%, transparent); }
|
||||
.flux-energy { justify-self: end; width: 245px; }
|
||||
.flux-energy > div { display: flex; align-items: end; justify-content: space-between; }
|
||||
.flux-energy strong { color: var(--team); font-family: "IBM Plex Mono", monospace; font-size: 1.1rem; font-weight: 500; }
|
||||
.flux-energy > span { display: block; overflow: hidden; height: 4px; margin: 7px 0; background: rgb(119 155 165 / 20%); transform: skewX(-20deg); }
|
||||
.flux-energy > span i { display: block; height: 100%; background: var(--team); box-shadow: 0 0 13px var(--team); transition: width 80ms linear; }
|
||||
.flux-energy em { display: block; color: #4c6871; font-family: "IBM Plex Mono", monospace; font-size: .43rem; font-style: normal; letter-spacing: .08em; text-align: right; }
|
||||
|
||||
.royale-game {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
color: #f1f5e9;
|
||||
background: #17291f;
|
||||
user-select: none;
|
||||
}
|
||||
.royale-viewport,
|
||||
.royale-viewport canvas { position: absolute; inset: 0; width: 100%; height: 100%; }
|
||||
.royale-viewport canvas { display: block; }
|
||||
.royale-grade {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
linear-gradient(180deg, rgb(7 14 10 / 54%), transparent 24%, transparent 70%, rgb(4 9 7 / 70%)),
|
||||
radial-gradient(circle, transparent 52%, rgb(3 7 5 / 44%));
|
||||
pointer-events: none;
|
||||
}
|
||||
.royale-deploy {
|
||||
position: absolute;
|
||||
z-index: 12;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
display: flex;
|
||||
width: min(430px, calc(100% - 36px));
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
padding: 24px 34px;
|
||||
border: 1px solid rgb(198 231 161 / 55%);
|
||||
color: #efffdc;
|
||||
background: rgb(8 19 12 / 86%);
|
||||
box-shadow: 0 0 80px rgb(99 177 67 / 17%), inset 0 0 34px rgb(124 202 82 / 7%);
|
||||
clip-path: polygon(0 0, calc(100% - 18px) 0, 100% 18px, 100% 100%, 18px 100%, 0 calc(100% - 18px));
|
||||
cursor: crosshair;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
.royale-deploy small,
|
||||
.royale-deploy span { color: #84977b; font-family: "IBM Plex Mono", monospace; font-size: .5rem; letter-spacing: .1em; }
|
||||
.royale-deploy strong { font-size: 1.55rem; letter-spacing: .24em; }
|
||||
.royale-deploy:hover { border-color: #e4ffc6; background: rgb(12 30 18 / 92%); }
|
||||
|
||||
.royale-header {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
top: 0;
|
||||
left: 0;
|
||||
display: grid;
|
||||
width: 100%;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: start;
|
||||
padding: 24px 30px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.royale-brand small { display: block; color: #899987; font-family: "IBM Plex Mono", monospace; font-size: .48rem; letter-spacing: .13em; }
|
||||
.royale-brand strong { display: block; margin-top: 2px; color: #efffda; font-size: 1.13rem; letter-spacing: .2em; text-shadow: 0 0 20px rgb(151 214 98 / 24%); }
|
||||
.royale-status { display: flex; align-items: center; gap: 1px; background: rgb(8 19 12 / 65%); }
|
||||
.royale-status span { padding: 7px 14px; border-inline: 1px solid rgb(208 232 187 / 11%); color: #9dab96; font-family: "IBM Plex Mono", monospace; font-size: .48rem; letter-spacing: .1em; }
|
||||
.royale-status b { color: #efffd8; font-size: .74rem; font-weight: 500; }
|
||||
.royale-status .is-danger { color: #ff8a65; }
|
||||
.royale-live { justify-self: end; display: grid; grid-template-columns: auto auto; align-items: center; column-gap: 8px; text-align: right; }
|
||||
.royale-live i { grid-row: 1 / span 2; width: 7px; height: 7px; border-radius: 50%; background: #b7f27c; box-shadow: 0 0 12px #a7eb67; }
|
||||
.royale-live span { color: #c9f4a4; font-size: .64rem; font-weight: 800; letter-spacing: .14em; text-transform: uppercase; }
|
||||
.royale-live small { color: #758172; font-family: "IBM Plex Mono", monospace; font-size: .46rem; }
|
||||
.royale-live--connecting i,
|
||||
.royale-live--reconnecting i { background: #ffad68; box-shadow: 0 0 12px #ff9a4d; }
|
||||
|
||||
.royale-stream {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
top: 108px;
|
||||
left: 30px;
|
||||
width: 174px;
|
||||
padding: 9px 10px;
|
||||
border-left: 1px solid rgb(187 226 153 / 35%);
|
||||
background: linear-gradient(90deg, rgb(8 18 12 / 80%), transparent);
|
||||
pointer-events: none;
|
||||
}
|
||||
.royale-stream > small { display: block; margin-bottom: 6px; color: #81917d; font-family: "IBM Plex Mono", monospace; font-size: .45rem; letter-spacing: .14em; }
|
||||
.royale-stream div { display: grid; grid-template-columns: 58px 1fr; padding: 2px 0; font-family: "IBM Plex Mono", monospace; font-size: .45rem; }
|
||||
.royale-stream span { color: #60705e; }
|
||||
.royale-stream b { color: #b3c8a7; font-weight: 500; }
|
||||
|
||||
.royale-radar {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
top: 106px;
|
||||
right: 30px;
|
||||
overflow: hidden;
|
||||
width: 148px;
|
||||
height: 148px;
|
||||
border: 1px solid rgb(190 226 157 / 35%);
|
||||
border-radius: 50%;
|
||||
background:
|
||||
linear-gradient(rgb(176 215 144 / 8%) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgb(176 215 144 / 8%) 1px, transparent 1px),
|
||||
rgb(5 15 9 / 74%);
|
||||
background-size: 18px 18px;
|
||||
box-shadow: inset 0 0 30px rgb(83 141 61 / 12%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.royale-radar__ring { position: absolute; inset: 24%; border: 1px solid rgb(194 231 164 / 15%); border-radius: 50%; }
|
||||
.royale-radar i { position: absolute; display: block; transform: translate(-50%, -50%); }
|
||||
.royale-radar__self { top: 50%; left: 50%; width: 7px; height: 7px; border: 1px solid #ecffd6; transform: translate(-50%, -50%) rotate(45deg) !important; box-shadow: 0 0 8px #beef8f; }
|
||||
.royale-radar__enemy { width: 5px; height: 5px; border-radius: 50%; background: #ff7957; box-shadow: 0 0 7px #ff5d3d; }
|
||||
.royale-radar__loot { width: 3px; height: 3px; background: #78dcff; box-shadow: 0 0 5px #68d7ff; }
|
||||
.royale-radar small { position: absolute; right: 0; bottom: 11px; left: 0; color: #6f806c; font-family: "IBM Plex Mono", monospace; font-size: .39rem; letter-spacing: .11em; text-align: center; }
|
||||
|
||||
.royale-feed { position: absolute; z-index: 4; top: 274px; right: 30px; display: flex; align-items: flex-end; flex-direction: column; gap: 4px; pointer-events: none; }
|
||||
.royale-feed div { display: flex; gap: 7px; padding: 4px 8px; border-right: 1px solid rgb(255 137 92 / 35%); color: #b7c1b2; background: linear-gradient(90deg, transparent, rgb(12 17 12 / 68%)); font-family: "IBM Plex Mono", monospace; font-size: .43rem; }
|
||||
.royale-feed b { color: #ff9068; font-size: .38rem; }
|
||||
|
||||
.royale-crosshair { position: absolute; z-index: 3; top: 50%; left: 50%; width: 42px; height: 42px; transform: translate(-50%, -50%); pointer-events: none; }
|
||||
.royale-crosshair::after { position: absolute; top: 19px; left: 19px; width: 4px; height: 4px; border: 1px solid #f4ffe5; border-radius: 50%; content: ""; }
|
||||
.royale-crosshair i { position: absolute; background: rgb(241 255 224 / 88%); box-shadow: 0 0 5px rgb(177 239 113 / 65%); }
|
||||
.royale-crosshair i:nth-child(1), .royale-crosshair i:nth-child(2) { top: 20px; width: 9px; height: 1px; }
|
||||
.royale-crosshair i:nth-child(1) { left: 1px; }
|
||||
.royale-crosshair i:nth-child(2) { right: 1px; }
|
||||
.royale-crosshair i:nth-child(3), .royale-crosshair i:nth-child(4) { left: 20px; width: 1px; height: 9px; }
|
||||
.royale-crosshair i:nth-child(3) { top: 1px; }
|
||||
.royale-crosshair i:nth-child(4) { bottom: 1px; }
|
||||
.royale-crosshair--hit { animation: royale-hit .16s ease-out; }
|
||||
@keyframes royale-hit { 50% { transform: translate(-50%, -50%) scale(1.55); filter: sepia(1) saturate(5); } }
|
||||
.royale-hit,
|
||||
.royale-pickup { position: absolute; z-index: 4; left: 50%; color: #f0ffdf; font-family: "IBM Plex Mono", monospace; font-size: .48rem; letter-spacing: .15em; text-shadow: 0 0 9px #a9e877; transform: translateX(-50%); pointer-events: none; }
|
||||
.royale-hit { top: calc(50% + 33px); }
|
||||
.royale-pickup { bottom: 126px; color: #9fe7ff; text-shadow: 0 0 9px #55cfff; }
|
||||
|
||||
.royale-drop { position: absolute; z-index: 4; top: 50%; right: 33px; display: grid; grid-template-columns: auto 5px; gap: 4px 12px; color: #dff8ff; transform: translateY(-50%); pointer-events: none; }
|
||||
.royale-drop small { color: #7d989c; font-family: "IBM Plex Mono", monospace; font-size: .42rem; letter-spacing: .12em; }
|
||||
.royale-drop strong { grid-column: 1; font-family: "IBM Plex Mono", monospace; font-size: 1.4rem; font-weight: 500; text-align: right; }
|
||||
.royale-drop span { grid-column: 2; grid-row: 1 / span 2; position: relative; overflow: hidden; width: 4px; height: 60px; background: rgb(137 183 190 / 22%); }
|
||||
.royale-drop i { position: absolute; right: 0; bottom: 0; left: 0; background: #9cecff; box-shadow: 0 0 9px #6edfff; }
|
||||
|
||||
.royale-eliminated,
|
||||
.royale-winner { position: absolute; z-index: 5; top: 50%; left: 50%; display: flex; flex-direction: column; align-items: center; gap: 5px; width: min(600px, calc(100% - 40px)); padding: 18px; border-block: 1px solid rgb(255 116 76 / 55%); background: linear-gradient(90deg, transparent, rgb(30 11 7 / 83%), transparent); transform: translate(-50%, -50%); pointer-events: none; }
|
||||
.royale-eliminated small,
|
||||
.royale-winner small { color: #bd735e; font-family: "IBM Plex Mono", monospace; font-size: .5rem; letter-spacing: .17em; }
|
||||
.royale-eliminated strong,
|
||||
.royale-winner strong { color: #ffd0bc; font-size: 1.25rem; letter-spacing: .18em; }
|
||||
.royale-winner { border-color: rgb(184 239 129 / 58%); background: linear-gradient(90deg, transparent, rgb(12 29 9 / 86%), transparent); }
|
||||
.royale-winner small { color: #8cad72; }
|
||||
.royale-winner strong { color: #e8ffd0; }
|
||||
.royale-damage { position: absolute; inset: 0; z-index: 2; background: radial-gradient(circle, transparent 22%, rgb(145 22 10 / 32%)); animation: damage .32s ease-out forwards; pointer-events: none; }
|
||||
|
||||
.royale-hud { position: absolute; z-index: 4; right: 30px; bottom: 24px; left: 30px; display: grid; grid-template-columns: 230px 1fr 260px; align-items: end; gap: 28px; pointer-events: none; }
|
||||
.royale-vitals small,
|
||||
.royale-weapon small { color: #83907d; font-family: "IBM Plex Mono", monospace; font-size: .48rem; letter-spacing: .13em; }
|
||||
.royale-vitals strong { display: block; color: #efffde; font-family: "IBM Plex Mono", monospace; font-size: 2.15rem; font-weight: 500; line-height: 1; }
|
||||
.royale-vitals > span { display: block; overflow: hidden; width: 180px; height: 4px; margin: 6px 0; background: rgb(155 174 144 / 24%); transform: skewX(-22deg); }
|
||||
.royale-vitals > span i { display: block; height: 100%; background: #a8ed72; box-shadow: 0 0 12px #9ce362; }
|
||||
.royale-vitals em { color: #7fa0ad; font-family: "IBM Plex Mono", monospace; font-size: .45rem; font-style: normal; }
|
||||
.royale-mission { text-align: center; }
|
||||
.royale-mission strong { display: block; color: #d4dfca; font-size: .7rem; letter-spacing: .17em; }
|
||||
.royale-mission small { color: #71806d; font-family: "IBM Plex Mono", monospace; font-size: .43rem; letter-spacing: .08em; }
|
||||
.royale-weapon { justify-self: end; min-width: 240px; padding-right: 14px; border-right: 1px solid rgb(194 225 166 / 30%); text-align: right; }
|
||||
.royale-weapon strong { display: block; color: #f6ffec; font-family: "IBM Plex Mono", monospace; font-size: 2.35rem; font-weight: 500; line-height: .9; }
|
||||
.royale-weapon strong i { color: #81907d; font-size: .72rem; font-style: normal; }
|
||||
.royale-weapon span { color: #6e8069; font-family: "IBM Plex Mono", monospace; font-size: .45rem; letter-spacing: .1em; }
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.game-switcher { top: 70px; }
|
||||
.topbar { padding: 16px; grid-template-columns: 1fr auto; }
|
||||
.match-clock { display: none; }
|
||||
.brand small, .telemetry, .mission-note { display: none; }
|
||||
.kill-feed { top: 82px; right: 16px; }
|
||||
.leaderboard { display: none; }
|
||||
.hud { right: 16px; bottom: 16px; left: 16px; grid-template-columns: 1fr 1fr; gap: 18px; }
|
||||
.weapon-block { grid-column: 2; grid-row: 1; }
|
||||
.score-block { display: none; }
|
||||
.killcam-panel { bottom: 104px; }
|
||||
.killcam-panel__title small, .killcam-panel__meta span:last-child { display: none; }
|
||||
.flux-header { grid-template-columns: 1fr auto; padding: 18px; }
|
||||
.flux-score { grid-column: 1 / -1; grid-row: 2; margin-top: 38px; }
|
||||
.flux-arena { width: calc(100% - 24px); grid-template-columns: 1fr; }
|
||||
.flux-roster { display: none; }
|
||||
.flux-field { height: 180px; grid-template-columns: 45px 1fr 45px; }
|
||||
.flux-gate { height: 125px; }
|
||||
.flux-controls { grid-template-columns: 1fr; gap: 13px; padding: 16px 20px 20px; }
|
||||
.flux-identity { display: none; }
|
||||
.flux-energy { justify-self: stretch; width: auto; }
|
||||
.flux-thrust { grid-row: 1; min-width: 0; }
|
||||
.royale-header { padding: 16px; grid-template-columns: 1fr auto; }
|
||||
.royale-status { grid-column: 1 / -1; grid-row: 2; justify-self: center; margin-top: 42px; }
|
||||
.royale-stream { top: 112px; left: 16px; }
|
||||
.royale-radar { top: 112px; right: 16px; width: 112px; height: 112px; }
|
||||
.royale-feed { display: none; }
|
||||
.royale-hud { right: 16px; bottom: 14px; left: 16px; grid-template-columns: 1fr 1fr; gap: 18px; }
|
||||
.royale-mission { display: none; }
|
||||
.royale-weapon { min-width: 0; }
|
||||
.royale-drop { right: 16px; }
|
||||
}
|
||||
208
apps/web/src/useFluxClient.ts
Normal file
208
apps/web/src/useFluxClient.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { FixedStepClock, type NetworkStats } from "@syncer/engine";
|
||||
import {
|
||||
FLUX_SOCKET_PATH,
|
||||
fluxGame,
|
||||
type FluxClientState,
|
||||
type FluxInput,
|
||||
} from "@syncer/shared";
|
||||
import type { ConnectionStatus, ValidationStatus } from "./useGameClient.js";
|
||||
|
||||
export interface FluxClientView {
|
||||
connection: ConnectionStatus;
|
||||
validation: ValidationStatus;
|
||||
playerId: number | null;
|
||||
tick: number;
|
||||
inputLeadTicks: number;
|
||||
world: FluxClientState;
|
||||
network: NetworkStats;
|
||||
setThrust(active: boolean): void;
|
||||
}
|
||||
|
||||
const emptyNetworkStats: NetworkStats = {
|
||||
roundTripTime: 0,
|
||||
jitter: 0,
|
||||
clockOffset: 0,
|
||||
samples: 0,
|
||||
};
|
||||
|
||||
function socketUrl(): string {
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
if (window.location.protocol === "http:" && window.location.port === "5173") {
|
||||
return `ws://${window.location.hostname}:3001${FLUX_SOCKET_PATH}`;
|
||||
}
|
||||
return `${protocol}//${window.location.host}${FLUX_SOCKET_PATH}`;
|
||||
}
|
||||
|
||||
export function useFluxClient(): FluxClientView {
|
||||
const engine = useMemo(() => fluxGame.createClient(), []);
|
||||
const protocol = fluxGame.protocol;
|
||||
const clock = useMemo(
|
||||
() => new FixedStepClock({ rateHz: fluxGame.tickRateHz, maxCatchUpSteps: 5 }),
|
||||
[],
|
||||
);
|
||||
const setThrustRef = useRef<(active: boolean) => void>(() => undefined);
|
||||
const setThrust = useCallback((active: boolean) => setThrustRef.current(active), []);
|
||||
const [view, setView] = useState<Omit<FluxClientView, "setThrust">>({
|
||||
connection: "connecting",
|
||||
validation: "waiting",
|
||||
playerId: null,
|
||||
tick: 0,
|
||||
inputLeadTicks: 1,
|
||||
world: fluxGame.client.createInitialState(),
|
||||
network: emptyNetworkStats,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
let socket: WebSocket | undefined;
|
||||
let retryTimer: number | undefined;
|
||||
let animationFrame: number | undefined;
|
||||
let connection: ConnectionStatus = "connecting";
|
||||
let validation: ValidationStatus = "waiting";
|
||||
let input: FluxInput = { thrust: false };
|
||||
|
||||
const publish = () => {
|
||||
if (!active) return;
|
||||
setView({
|
||||
connection,
|
||||
validation,
|
||||
playerId: engine.localPlayerId,
|
||||
tick: engine.tick,
|
||||
inputLeadTicks: engine.networkClock.recommendedInputLeadTicks(fluxGame.tickRateHz),
|
||||
world: fluxGame.client.cloneState(engine.currentState as FluxClientState),
|
||||
network: engine.networkClock.stats,
|
||||
});
|
||||
};
|
||||
|
||||
const send = (frame: ArrayBuffer) => {
|
||||
if (socket?.readyState === WebSocket.OPEN) socket.send(frame);
|
||||
};
|
||||
|
||||
const sendInput = () => {
|
||||
if (!engine.initialized) return;
|
||||
send(protocol.encodeClient({ kind: "input", packet: engine.createInput(input) }));
|
||||
};
|
||||
|
||||
const updateThrust = (thrust: boolean) => {
|
||||
if (input.thrust === thrust) return;
|
||||
input = { thrust };
|
||||
sendInput();
|
||||
publish();
|
||||
};
|
||||
setThrustRef.current = updateThrust;
|
||||
|
||||
const connect = () => {
|
||||
connection = engine.initialized ? "reconnecting" : "connecting";
|
||||
publish();
|
||||
socket = new WebSocket(socketUrl());
|
||||
socket.binaryType = "arraybuffer";
|
||||
socket.addEventListener("open", () => {
|
||||
if (!active) return;
|
||||
connection = "live";
|
||||
publish();
|
||||
});
|
||||
socket.addEventListener("message", (event: MessageEvent<ArrayBuffer>) => {
|
||||
if (!active || !(event.data instanceof ArrayBuffer)) return;
|
||||
try {
|
||||
const message = protocol.decodeServer(event.data);
|
||||
switch (message.kind) {
|
||||
case "welcome":
|
||||
engine.initialize(message.playerId, message.snapshot);
|
||||
clock.reset(performance.now());
|
||||
input = { thrust: false };
|
||||
validation = "waiting";
|
||||
sendInput();
|
||||
break;
|
||||
case "snapshot":
|
||||
engine.reconcile(message.snapshot);
|
||||
break;
|
||||
case "acknowledge":
|
||||
engine.acknowledge(message.sequence);
|
||||
break;
|
||||
case "pong":
|
||||
engine.networkClock.receivePong(message.pong, performance.now());
|
||||
break;
|
||||
case "validation":
|
||||
validation = message.valid ? "valid" : "invalid";
|
||||
break;
|
||||
case "reject-input":
|
||||
engine.reject(message.sequence);
|
||||
validation = "invalid";
|
||||
break;
|
||||
case "event":
|
||||
engine.receiveEvent(message.event, message.tick);
|
||||
break;
|
||||
case "replay-start":
|
||||
case "replay-frame":
|
||||
case "replay-end":
|
||||
break;
|
||||
}
|
||||
publish();
|
||||
} catch {
|
||||
socket?.close(1003, "Invalid game frame");
|
||||
}
|
||||
});
|
||||
socket.addEventListener("close", () => {
|
||||
if (!active) return;
|
||||
input = { thrust: false };
|
||||
connection = "reconnecting";
|
||||
publish();
|
||||
retryTimer = window.setTimeout(connect, 1_000);
|
||||
});
|
||||
socket.addEventListener("error", () => socket?.close());
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.code !== "Space") return;
|
||||
event.preventDefault();
|
||||
updateThrust(true);
|
||||
};
|
||||
const handleKeyUp = (event: KeyboardEvent) => {
|
||||
if (event.code !== "Space") return;
|
||||
event.preventDefault();
|
||||
updateThrust(false);
|
||||
};
|
||||
const release = () => updateThrust(false);
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
window.addEventListener("keyup", handleKeyUp);
|
||||
window.addEventListener("blur", release);
|
||||
|
||||
const pingTimer = window.setInterval(() => {
|
||||
send(protocol.encodeClient({
|
||||
kind: "ping",
|
||||
ping: engine.networkClock.createPing(performance.now()),
|
||||
}));
|
||||
}, 1_000);
|
||||
const validationTimer = window.setInterval(() => {
|
||||
if (engine.initialized) {
|
||||
send(protocol.encodeClient({
|
||||
kind: "state-report",
|
||||
report: engine.createStateReport(),
|
||||
}));
|
||||
}
|
||||
}, 2_000);
|
||||
const animate = (now: number) => {
|
||||
clock.advance(now, () => engine.step());
|
||||
publish();
|
||||
animationFrame = window.requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
connect();
|
||||
animationFrame = window.requestAnimationFrame(animate);
|
||||
return () => {
|
||||
active = false;
|
||||
setThrustRef.current = () => undefined;
|
||||
window.clearTimeout(retryTimer);
|
||||
window.clearInterval(pingTimer);
|
||||
window.clearInterval(validationTimer);
|
||||
if (animationFrame !== undefined) window.cancelAnimationFrame(animationFrame);
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
window.removeEventListener("keyup", handleKeyUp);
|
||||
window.removeEventListener("blur", release);
|
||||
socket?.close();
|
||||
};
|
||||
}, [clock, engine, protocol]);
|
||||
|
||||
return { ...view, setThrust };
|
||||
}
|
||||
487
apps/web/src/useGameClient.ts
Normal file
487
apps/web/src/useGameClient.ts
Normal file
@@ -0,0 +1,487 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
FixedStepClock,
|
||||
type NetworkStats,
|
||||
type ProjectedReplayFrame,
|
||||
} from "@syncer/engine";
|
||||
import {
|
||||
GAME_SOCKET_PATH,
|
||||
WEAPONS,
|
||||
Weapon,
|
||||
shooterGame,
|
||||
type ShooterInput,
|
||||
type ShooterPerception,
|
||||
type ShooterWorldState,
|
||||
} from "@syncer/shared";
|
||||
|
||||
export type ConnectionStatus = "connecting" | "live" | "reconnecting";
|
||||
export type ValidationStatus = "waiting" | "valid" | "invalid";
|
||||
|
||||
export interface ReplayPlaybackView {
|
||||
ticketId: number;
|
||||
perspectiveId: number;
|
||||
fromTick: number;
|
||||
toTick: number;
|
||||
currentTick: number;
|
||||
progress: number;
|
||||
playbackRate: number;
|
||||
}
|
||||
|
||||
export interface GameClientView {
|
||||
connection: ConnectionStatus;
|
||||
validation: ValidationStatus;
|
||||
playerId: number | null;
|
||||
cameraPlayerId: number | null;
|
||||
presentationKey: string;
|
||||
tick: number;
|
||||
inputLeadTicks: number;
|
||||
world: ShooterWorldState;
|
||||
network: NetworkStats;
|
||||
replay: ReplayPlaybackView | null;
|
||||
}
|
||||
|
||||
interface IncomingReplay {
|
||||
ticketId: number;
|
||||
perspectiveId: number;
|
||||
fromTick: number;
|
||||
toTick: number;
|
||||
frameCount: number;
|
||||
playbackRate: number;
|
||||
frames: Array<ProjectedReplayFrame<ShooterWorldState, ShooterPerception>>;
|
||||
}
|
||||
|
||||
interface ActiveReplay extends IncomingReplay {
|
||||
startedAt: number;
|
||||
durationMilliseconds: number;
|
||||
}
|
||||
|
||||
const emptyNetworkStats: NetworkStats = {
|
||||
roundTripTime: 0,
|
||||
jitter: 0,
|
||||
clockOffset: 0,
|
||||
samples: 0,
|
||||
};
|
||||
|
||||
const neutralInput: ShooterInput = {
|
||||
strafe: 0,
|
||||
forward: 0,
|
||||
yaw: 0,
|
||||
pitch: 0,
|
||||
fire: false,
|
||||
sprint: false,
|
||||
reload: false,
|
||||
weapon: Weapon.PulseRifle,
|
||||
};
|
||||
|
||||
function getSocketUrl(): string {
|
||||
const socketProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
if (window.location.protocol === "http:" && window.location.port === "5173") {
|
||||
return `ws://${window.location.hostname}:3001${GAME_SOCKET_PATH}`;
|
||||
}
|
||||
return `${socketProtocol}//${window.location.host}${GAME_SOCKET_PATH}`;
|
||||
}
|
||||
|
||||
export function useGameClient(): GameClientView {
|
||||
const engine = useMemo(() => shooterGame.createClient(), []);
|
||||
const protocol = shooterGame.protocol;
|
||||
const simulationClock = useMemo(
|
||||
() => new FixedStepClock({ rateHz: shooterGame.tickRateHz, maxCatchUpSteps: 5 }),
|
||||
[],
|
||||
);
|
||||
const [view, setView] = useState<GameClientView>({
|
||||
connection: "connecting",
|
||||
validation: "waiting",
|
||||
playerId: null,
|
||||
cameraPlayerId: null,
|
||||
presentationKey: "live",
|
||||
tick: 0,
|
||||
inputLeadTicks: 1,
|
||||
world: shooterGame.client.createInitialState(),
|
||||
network: emptyNetworkStats,
|
||||
replay: null,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
let socket: WebSocket | undefined;
|
||||
let retryTimer: number | undefined;
|
||||
let animationFrame: number | undefined;
|
||||
let connection: ConnectionStatus = "connecting";
|
||||
let validation: ValidationStatus = "waiting";
|
||||
const pressedKeys = new Set<string>();
|
||||
let input: ShooterInput = { ...neutralInput };
|
||||
let lastSentInput: ShooterInput | null = null;
|
||||
let inputDirty = true;
|
||||
const incomingReplays = new Map<number, IncomingReplay>();
|
||||
let activeReplay: ActiveReplay | null = null;
|
||||
|
||||
const publishView = () => {
|
||||
if (!active) return;
|
||||
const now = performance.now();
|
||||
let world = shooterGame.client.cloneState(
|
||||
engine.currentState as ShooterWorldState,
|
||||
);
|
||||
let tick = engine.tick;
|
||||
let cameraPlayerId = engine.localPlayerId;
|
||||
let replayView: ReplayPlaybackView | null = null;
|
||||
|
||||
if (activeReplay) {
|
||||
const progress = clamp(
|
||||
(now - activeReplay.startedAt) / activeReplay.durationMilliseconds,
|
||||
0,
|
||||
1,
|
||||
);
|
||||
if (progress >= 1) {
|
||||
activeReplay = null;
|
||||
} else {
|
||||
const targetTick =
|
||||
activeReplay.fromTick +
|
||||
(activeReplay.toTick - activeReplay.fromTick) * progress;
|
||||
const frame = frameAtOrBefore(activeReplay.frames, targetTick);
|
||||
if (frame) {
|
||||
world = shooterGame.client.cloneState(frame.state);
|
||||
world.events = frame.events.map((event) => ({
|
||||
receivedTick: frame.tick,
|
||||
event: { ...event },
|
||||
}));
|
||||
tick = frame.tick;
|
||||
cameraPlayerId = activeReplay.perspectiveId;
|
||||
replayView = {
|
||||
ticketId: activeReplay.ticketId,
|
||||
perspectiveId: activeReplay.perspectiveId,
|
||||
fromTick: activeReplay.fromTick,
|
||||
toTick: activeReplay.toTick,
|
||||
currentTick: frame.tick,
|
||||
progress,
|
||||
playbackRate: activeReplay.playbackRate,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setView({
|
||||
connection,
|
||||
validation,
|
||||
playerId: engine.localPlayerId,
|
||||
cameraPlayerId,
|
||||
presentationKey: replayView ? `replay-${replayView.ticketId}` : "live",
|
||||
tick,
|
||||
inputLeadTicks: engine.networkClock.recommendedInputLeadTicks(shooterGame.tickRateHz),
|
||||
world,
|
||||
network: engine.networkClock.stats,
|
||||
replay: replayView,
|
||||
});
|
||||
};
|
||||
|
||||
const send = (message: ArrayBuffer) => {
|
||||
if (socket?.readyState === WebSocket.OPEN) socket.send(message);
|
||||
};
|
||||
|
||||
const sendInput = (force = false) => {
|
||||
if (!engine.initialized || (!force && !inputDirty)) return;
|
||||
if (!force && lastSentInput && inputsEqual(input, lastSentInput)) {
|
||||
inputDirty = false;
|
||||
return;
|
||||
}
|
||||
const packet = engine.createInput(input);
|
||||
send(protocol.encodeClient({ kind: "input", packet }));
|
||||
lastSentInput = { ...input };
|
||||
inputDirty = false;
|
||||
};
|
||||
|
||||
const updateMovement = () => {
|
||||
input = {
|
||||
...input,
|
||||
strafe: Number(pressedKeys.has("d")) - Number(pressedKeys.has("a")),
|
||||
forward: Number(pressedKeys.has("w")) - Number(pressedKeys.has("s")),
|
||||
sprint: pressedKeys.has("shift"),
|
||||
};
|
||||
inputDirty = true;
|
||||
sendInput();
|
||||
};
|
||||
|
||||
const connect = () => {
|
||||
connection = engine.initialized ? "reconnecting" : "connecting";
|
||||
publishView();
|
||||
socket = new WebSocket(getSocketUrl());
|
||||
socket.binaryType = "arraybuffer";
|
||||
|
||||
socket.addEventListener("open", () => {
|
||||
if (!active) return;
|
||||
connection = "live";
|
||||
publishView();
|
||||
});
|
||||
|
||||
socket.addEventListener("message", (event: MessageEvent<ArrayBuffer>) => {
|
||||
if (!active || !(event.data instanceof ArrayBuffer)) return;
|
||||
try {
|
||||
const message = protocol.decodeServer(event.data);
|
||||
switch (message.kind) {
|
||||
case "welcome": {
|
||||
activeReplay = null;
|
||||
incomingReplays.clear();
|
||||
engine.initialize(message.playerId, message.snapshot);
|
||||
simulationClock.reset(performance.now());
|
||||
const local = message.snapshot.state.players.get(message.playerId);
|
||||
input = {
|
||||
...neutralInput,
|
||||
yaw: local?.yaw ?? 0,
|
||||
pitch: local?.pitch ?? 0,
|
||||
weapon: local?.weapon ?? Weapon.PulseRifle,
|
||||
};
|
||||
lastSentInput = null;
|
||||
inputDirty = true;
|
||||
validation = "waiting";
|
||||
sendInput(true);
|
||||
break;
|
||||
}
|
||||
case "snapshot":
|
||||
engine.reconcile(message.snapshot);
|
||||
break;
|
||||
case "acknowledge":
|
||||
engine.acknowledge(message.sequence);
|
||||
break;
|
||||
case "pong":
|
||||
engine.networkClock.receivePong(message.pong, performance.now());
|
||||
break;
|
||||
case "validation":
|
||||
validation = message.valid ? "valid" : "invalid";
|
||||
break;
|
||||
case "reject-input":
|
||||
engine.reject(message.sequence);
|
||||
validation = "invalid";
|
||||
break;
|
||||
case "event":
|
||||
engine.receiveEvent(message.event, message.tick);
|
||||
break;
|
||||
case "replay-start": {
|
||||
if (message.frameCount === 0 || message.frameCount > 4_096) break;
|
||||
incomingReplays.set(message.ticketId, {
|
||||
ticketId: message.ticketId,
|
||||
perspectiveId: message.perspectiveId,
|
||||
fromTick: message.fromTick,
|
||||
toTick: message.toTick,
|
||||
frameCount: message.frameCount,
|
||||
playbackRate: message.playbackRate,
|
||||
frames: [],
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "replay-frame": {
|
||||
const replay = incomingReplays.get(message.ticketId);
|
||||
if (
|
||||
!replay ||
|
||||
replay.frames.length >= replay.frameCount ||
|
||||
message.tick < replay.fromTick ||
|
||||
message.tick > replay.toTick
|
||||
) {
|
||||
break;
|
||||
}
|
||||
replay.frames.push({
|
||||
tick: message.tick,
|
||||
state: message.state,
|
||||
events: message.events,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "replay-end": {
|
||||
const replay = incomingReplays.get(message.ticketId);
|
||||
incomingReplays.delete(message.ticketId);
|
||||
if (!replay || replay.frames.length !== replay.frameCount) break;
|
||||
replay.frames.sort((left, right) => left.tick - right.tick);
|
||||
const replayTicks = Math.max(1, replay.toTick - replay.fromTick);
|
||||
activeReplay = {
|
||||
...replay,
|
||||
startedAt: performance.now(),
|
||||
durationMilliseconds: Math.max(
|
||||
1_200,
|
||||
(replayTicks / shooterGame.tickRateHz / replay.playbackRate) * 1_000,
|
||||
),
|
||||
};
|
||||
releaseControls();
|
||||
break;
|
||||
}
|
||||
}
|
||||
publishView();
|
||||
} catch {
|
||||
socket?.close(1003, "Invalid game frame");
|
||||
}
|
||||
});
|
||||
|
||||
socket.addEventListener("close", () => {
|
||||
if (!active) return;
|
||||
activeReplay = null;
|
||||
incomingReplays.clear();
|
||||
connection = "reconnecting";
|
||||
publishView();
|
||||
retryTimer = window.setTimeout(connect, 1_000);
|
||||
});
|
||||
socket.addEventListener("error", () => socket?.close());
|
||||
};
|
||||
|
||||
const animate = (now: number) => {
|
||||
simulationClock.advance(now, () => engine.step());
|
||||
publishView();
|
||||
animationFrame = window.requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
const key = event.key.toLowerCase();
|
||||
if (["w", "a", "s", "d", "shift", "r", "1", "2", "3"].includes(key)) event.preventDefault();
|
||||
if (activeReplay) return;
|
||||
if (["w", "a", "s", "d", "shift"].includes(key)) {
|
||||
pressedKeys.add(key);
|
||||
updateMovement();
|
||||
} else if (key === "r") {
|
||||
input = { ...input, reload: true };
|
||||
inputDirty = true;
|
||||
sendInput();
|
||||
} else if (key === "1" || key === "2" || key === "3") {
|
||||
input = {
|
||||
...input,
|
||||
weapon:
|
||||
key === "1"
|
||||
? Weapon.PulseRifle
|
||||
: key === "2"
|
||||
? Weapon.Scattergun
|
||||
: Weapon.RailRifle,
|
||||
};
|
||||
inputDirty = true;
|
||||
sendInput();
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyUp = (event: KeyboardEvent) => {
|
||||
const key = event.key.toLowerCase();
|
||||
if (activeReplay) return;
|
||||
if (["w", "a", "s", "d", "shift"].includes(key)) {
|
||||
pressedKeys.delete(key);
|
||||
updateMovement();
|
||||
} else if (key === "r") {
|
||||
input = { ...input, reload: false };
|
||||
inputDirty = true;
|
||||
sendInput();
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseMove = (event: MouseEvent) => {
|
||||
if (activeReplay || !document.pointerLockElement) return;
|
||||
input = {
|
||||
...input,
|
||||
yaw: normalizeAngle(input.yaw + event.movementX * 0.00225),
|
||||
pitch: clamp(input.pitch - event.movementY * 0.0019, -1.25, 1.25),
|
||||
};
|
||||
inputDirty = true;
|
||||
};
|
||||
|
||||
const handleMouseDown = (event: MouseEvent) => {
|
||||
if (activeReplay || event.button !== 0 || !document.pointerLockElement) return;
|
||||
input = { ...input, fire: true };
|
||||
inputDirty = true;
|
||||
sendInput();
|
||||
};
|
||||
|
||||
const handleMouseUp = (event: MouseEvent) => {
|
||||
if (event.button !== 0) return;
|
||||
if (activeReplay) return;
|
||||
input = { ...input, fire: false };
|
||||
inputDirty = true;
|
||||
sendInput();
|
||||
};
|
||||
|
||||
const releaseControls = () => {
|
||||
pressedKeys.clear();
|
||||
input = { ...input, strafe: 0, forward: 0, fire: false, sprint: false, reload: false };
|
||||
inputDirty = true;
|
||||
sendInput(true);
|
||||
};
|
||||
|
||||
const handlePointerLockChange = () => {
|
||||
if (!document.pointerLockElement) releaseControls();
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
window.addEventListener("keyup", handleKeyUp);
|
||||
window.addEventListener("mousemove", handleMouseMove);
|
||||
window.addEventListener("mousedown", handleMouseDown);
|
||||
window.addEventListener("mouseup", handleMouseUp);
|
||||
window.addEventListener("blur", releaseControls);
|
||||
document.addEventListener("pointerlockchange", handlePointerLockChange);
|
||||
|
||||
const inputTimer = window.setInterval(
|
||||
() => sendInput(input.fire && WEAPONS[input.weapon].automatic),
|
||||
1_000 / 30,
|
||||
);
|
||||
const pingTimer = window.setInterval(() => {
|
||||
send(protocol.encodeClient({ kind: "ping", ping: engine.networkClock.createPing(performance.now()) }));
|
||||
}, 1_000);
|
||||
const validationTimer = window.setInterval(() => {
|
||||
if (engine.initialized) {
|
||||
send(protocol.encodeClient({ kind: "state-report", report: engine.createStateReport() }));
|
||||
}
|
||||
}, 2_000);
|
||||
|
||||
connect();
|
||||
animationFrame = window.requestAnimationFrame(animate);
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
window.clearTimeout(retryTimer);
|
||||
window.clearInterval(inputTimer);
|
||||
window.clearInterval(pingTimer);
|
||||
window.clearInterval(validationTimer);
|
||||
if (animationFrame !== undefined) window.cancelAnimationFrame(animationFrame);
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
window.removeEventListener("keyup", handleKeyUp);
|
||||
window.removeEventListener("mousemove", handleMouseMove);
|
||||
window.removeEventListener("mousedown", handleMouseDown);
|
||||
window.removeEventListener("mouseup", handleMouseUp);
|
||||
window.removeEventListener("blur", releaseControls);
|
||||
document.removeEventListener("pointerlockchange", handlePointerLockChange);
|
||||
socket?.close();
|
||||
};
|
||||
}, [engine, protocol, simulationClock]);
|
||||
|
||||
return view;
|
||||
}
|
||||
|
||||
function inputsEqual(left: ShooterInput, right: ShooterInput): boolean {
|
||||
return (
|
||||
left.strafe === right.strafe &&
|
||||
left.forward === right.forward &&
|
||||
left.yaw === right.yaw &&
|
||||
left.pitch === right.pitch &&
|
||||
left.fire === right.fire &&
|
||||
left.sprint === right.sprint &&
|
||||
left.reload === right.reload &&
|
||||
left.weapon === right.weapon
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeAngle(value: number): number {
|
||||
return Math.atan2(Math.sin(value), Math.cos(value));
|
||||
}
|
||||
|
||||
function clamp(value: number, minimum: number, maximum: number): number {
|
||||
return Math.max(minimum, Math.min(maximum, value));
|
||||
}
|
||||
|
||||
function frameAtOrBefore<State, Event>(
|
||||
frames: ReadonlyArray<ProjectedReplayFrame<State, Event>>,
|
||||
targetTick: number,
|
||||
): ProjectedReplayFrame<State, Event> | undefined {
|
||||
let low = 0;
|
||||
let high = frames.length - 1;
|
||||
let selected: ProjectedReplayFrame<State, Event> | undefined;
|
||||
while (low <= high) {
|
||||
const middle = Math.floor((low + high) / 2);
|
||||
const frame = frames[middle]!;
|
||||
if (frame.tick <= targetTick) {
|
||||
selected = frame;
|
||||
low = middle + 1;
|
||||
} else {
|
||||
high = middle - 1;
|
||||
}
|
||||
}
|
||||
return selected ?? frames[0];
|
||||
}
|
||||
323
apps/web/src/useRoyaleClient.ts
Normal file
323
apps/web/src/useRoyaleClient.ts
Normal file
@@ -0,0 +1,323 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { FixedStepClock, type NetworkStats } from "@syncer/engine";
|
||||
import {
|
||||
ROYALE_SOCKET_PATH,
|
||||
royaleGame,
|
||||
type RoyaleClientState,
|
||||
type RoyaleInput,
|
||||
} from "@syncer/shared";
|
||||
import type { ConnectionStatus, ValidationStatus } from "./useGameClient.js";
|
||||
|
||||
export interface RoyaleClientView {
|
||||
connection: ConnectionStatus;
|
||||
validation: ValidationStatus;
|
||||
playerId: number | null;
|
||||
tick: number;
|
||||
inputLeadTicks: number;
|
||||
world: RoyaleClientState;
|
||||
network: NetworkStats;
|
||||
}
|
||||
|
||||
const emptyNetworkStats: NetworkStats = {
|
||||
roundTripTime: 0,
|
||||
jitter: 0,
|
||||
clockOffset: 0,
|
||||
samples: 0,
|
||||
};
|
||||
|
||||
const neutralInput: RoyaleInput = {
|
||||
forward: 0,
|
||||
strafe: 0,
|
||||
yaw: 0,
|
||||
pitch: 0,
|
||||
fire: false,
|
||||
sprint: false,
|
||||
reload: false,
|
||||
};
|
||||
|
||||
function socketUrls(): string[] {
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const proxied = `${protocol}//${window.location.host}${ROYALE_SOCKET_PATH}`;
|
||||
if (window.location.protocol !== "http:" || window.location.port !== "5173") {
|
||||
return [proxied];
|
||||
}
|
||||
return [
|
||||
`ws://${window.location.hostname}:3001${ROYALE_SOCKET_PATH}`,
|
||||
proxied,
|
||||
];
|
||||
}
|
||||
|
||||
export function useRoyaleClient(): RoyaleClientView {
|
||||
const engine = useMemo(() => royaleGame.createClient(), []);
|
||||
const protocol = royaleGame.protocol;
|
||||
const clock = useMemo(
|
||||
() => new FixedStepClock({ rateHz: royaleGame.tickRateHz, maxCatchUpSteps: 5 }),
|
||||
[],
|
||||
);
|
||||
const [view, setView] = useState<RoyaleClientView>({
|
||||
connection: "connecting",
|
||||
validation: "waiting",
|
||||
playerId: null,
|
||||
tick: 0,
|
||||
inputLeadTicks: 1,
|
||||
world: royaleGame.client.createInitialState(),
|
||||
network: emptyNetworkStats,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
let socket: WebSocket | undefined;
|
||||
let retryTimer: number | undefined;
|
||||
let animationFrame: number | undefined;
|
||||
let connection: ConnectionStatus = "connecting";
|
||||
let validation: ValidationStatus = "waiting";
|
||||
let input: RoyaleInput = { ...neutralInput };
|
||||
let lastSent: RoyaleInput | null = null;
|
||||
let inputDirty = false;
|
||||
let socketUrlIndex = 0;
|
||||
const connectionUrls = socketUrls();
|
||||
const pressed = new Set<string>();
|
||||
|
||||
const publish = () => {
|
||||
if (!active) return;
|
||||
setView({
|
||||
connection,
|
||||
validation,
|
||||
playerId: engine.localPlayerId,
|
||||
tick: engine.tick,
|
||||
inputLeadTicks: engine.networkClock.recommendedInputLeadTicks(royaleGame.tickRateHz),
|
||||
world: royaleGame.client.cloneState(engine.currentState as RoyaleClientState),
|
||||
network: engine.networkClock.stats,
|
||||
});
|
||||
};
|
||||
|
||||
const send = (frame: ArrayBuffer) => {
|
||||
if (socket?.readyState === WebSocket.OPEN) socket.send(frame);
|
||||
};
|
||||
const sendInput = (force = false) => {
|
||||
if (!engine.initialized) return;
|
||||
if (!force && !inputDirty) return;
|
||||
if (!force && lastSent && inputsEqual(lastSent, input)) {
|
||||
inputDirty = false;
|
||||
return;
|
||||
}
|
||||
const packet = engine.createInput(input);
|
||||
send(protocol.encodeClient({ kind: "input", packet }));
|
||||
lastSent = { ...input };
|
||||
inputDirty = false;
|
||||
};
|
||||
const updateMovement = () => {
|
||||
input = {
|
||||
...input,
|
||||
forward: Number(pressed.has("w")) - Number(pressed.has("s")),
|
||||
strafe: Number(pressed.has("d")) - Number(pressed.has("a")),
|
||||
sprint: pressed.has("shift"),
|
||||
};
|
||||
inputDirty = true;
|
||||
sendInput();
|
||||
};
|
||||
|
||||
const connect = () => {
|
||||
let opened = false;
|
||||
connection = engine.initialized ? "reconnecting" : "connecting";
|
||||
publish();
|
||||
socket = new WebSocket(connectionUrls[socketUrlIndex]!);
|
||||
socket.binaryType = "arraybuffer";
|
||||
socket.addEventListener("open", () => {
|
||||
if (!active) return;
|
||||
opened = true;
|
||||
connection = "live";
|
||||
publish();
|
||||
});
|
||||
socket.addEventListener("message", (event: MessageEvent<ArrayBuffer>) => {
|
||||
if (!active || !(event.data instanceof ArrayBuffer)) return;
|
||||
try {
|
||||
const message = protocol.decodeServer(event.data);
|
||||
switch (message.kind) {
|
||||
case "welcome": {
|
||||
engine.initialize(message.playerId, message.snapshot);
|
||||
clock.reset(performance.now());
|
||||
const local = message.snapshot.state.players.find(
|
||||
(player) => player.id === message.playerId,
|
||||
);
|
||||
input = {
|
||||
...neutralInput,
|
||||
yaw: local?.yaw ?? 0,
|
||||
pitch: local?.pitch ?? 0,
|
||||
};
|
||||
lastSent = null;
|
||||
inputDirty = true;
|
||||
validation = "waiting";
|
||||
sendInput(true);
|
||||
break;
|
||||
}
|
||||
case "snapshot":
|
||||
engine.reconcile(message.snapshot);
|
||||
break;
|
||||
case "acknowledge":
|
||||
engine.acknowledge(message.sequence);
|
||||
break;
|
||||
case "pong":
|
||||
engine.networkClock.receivePong(message.pong, performance.now());
|
||||
break;
|
||||
case "validation":
|
||||
validation = message.valid ? "valid" : "invalid";
|
||||
break;
|
||||
case "reject-input":
|
||||
engine.reject(message.sequence);
|
||||
validation = "invalid";
|
||||
break;
|
||||
case "event":
|
||||
engine.receiveEvent(message.event, message.tick);
|
||||
break;
|
||||
case "replay-start":
|
||||
case "replay-frame":
|
||||
case "replay-end":
|
||||
break;
|
||||
}
|
||||
publish();
|
||||
} catch {
|
||||
socket?.close(1003, "Invalid game frame");
|
||||
}
|
||||
});
|
||||
socket.addEventListener("close", () => {
|
||||
if (!active) return;
|
||||
if (!opened && connectionUrls.length > 1) {
|
||||
socketUrlIndex = (socketUrlIndex + 1) % connectionUrls.length;
|
||||
} else if (opened) {
|
||||
socketUrlIndex = 0;
|
||||
}
|
||||
connection = "reconnecting";
|
||||
publish();
|
||||
retryTimer = window.setTimeout(connect, 1_000);
|
||||
});
|
||||
socket.addEventListener("error", () => socket?.close());
|
||||
};
|
||||
|
||||
const keyDown = (event: KeyboardEvent) => {
|
||||
const key = event.key.toLowerCase();
|
||||
if (["w", "a", "s", "d", "shift", "r"].includes(key)) event.preventDefault();
|
||||
if (["w", "a", "s", "d", "shift"].includes(key)) {
|
||||
pressed.add(key);
|
||||
updateMovement();
|
||||
} else if (key === "r") {
|
||||
input = { ...input, reload: true };
|
||||
inputDirty = true;
|
||||
sendInput(true);
|
||||
}
|
||||
};
|
||||
const keyUp = (event: KeyboardEvent) => {
|
||||
const key = event.key.toLowerCase();
|
||||
if (["w", "a", "s", "d", "shift"].includes(key)) {
|
||||
pressed.delete(key);
|
||||
updateMovement();
|
||||
} else if (key === "r") {
|
||||
input = { ...input, reload: false };
|
||||
inputDirty = true;
|
||||
sendInput(true);
|
||||
}
|
||||
};
|
||||
const mouseMove = (event: MouseEvent) => {
|
||||
if (!document.pointerLockElement) return;
|
||||
input = {
|
||||
...input,
|
||||
yaw: normalizeAngle(input.yaw - event.movementX * 0.0027),
|
||||
pitch: clamp(input.pitch - event.movementY * 0.0022, -1.25, 1.25),
|
||||
};
|
||||
inputDirty = true;
|
||||
};
|
||||
const mouseDown = (event: MouseEvent) => {
|
||||
if (event.button !== 0 || !document.pointerLockElement) return;
|
||||
input = { ...input, fire: true };
|
||||
inputDirty = true;
|
||||
sendInput(true);
|
||||
};
|
||||
const mouseUp = (event: MouseEvent) => {
|
||||
if (event.button !== 0) return;
|
||||
input = { ...input, fire: false };
|
||||
inputDirty = true;
|
||||
sendInput(true);
|
||||
};
|
||||
const release = () => {
|
||||
pressed.clear();
|
||||
input = { ...input, forward: 0, strafe: 0, sprint: false, fire: false, reload: false };
|
||||
inputDirty = true;
|
||||
sendInput(true);
|
||||
};
|
||||
const pointerLockChange = () => {
|
||||
if (!document.pointerLockElement) release();
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", keyDown);
|
||||
window.addEventListener("keyup", keyUp);
|
||||
window.addEventListener("mousemove", mouseMove);
|
||||
window.addEventListener("mousedown", mouseDown);
|
||||
window.addEventListener("mouseup", mouseUp);
|
||||
window.addEventListener("blur", release);
|
||||
document.addEventListener("pointerlockchange", pointerLockChange);
|
||||
|
||||
const inputTimer = window.setInterval(() => {
|
||||
sendInput(input.fire);
|
||||
}, 1_000 / royaleGame.tickRateHz);
|
||||
const pingTimer = window.setInterval(() => {
|
||||
send(protocol.encodeClient({
|
||||
kind: "ping",
|
||||
ping: engine.networkClock.createPing(performance.now()),
|
||||
}));
|
||||
}, 1_000);
|
||||
const validationTimer = window.setInterval(() => {
|
||||
if (engine.initialized) {
|
||||
send(protocol.encodeClient({
|
||||
kind: "state-report",
|
||||
report: engine.createStateReport(),
|
||||
}));
|
||||
}
|
||||
}, 2_000);
|
||||
const animate = (now: number) => {
|
||||
clock.advance(now, () => engine.step());
|
||||
publish();
|
||||
animationFrame = window.requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
connect();
|
||||
animationFrame = window.requestAnimationFrame(animate);
|
||||
return () => {
|
||||
active = false;
|
||||
window.clearTimeout(retryTimer);
|
||||
window.clearInterval(inputTimer);
|
||||
window.clearInterval(pingTimer);
|
||||
window.clearInterval(validationTimer);
|
||||
if (animationFrame !== undefined) window.cancelAnimationFrame(animationFrame);
|
||||
window.removeEventListener("keydown", keyDown);
|
||||
window.removeEventListener("keyup", keyUp);
|
||||
window.removeEventListener("mousemove", mouseMove);
|
||||
window.removeEventListener("mousedown", mouseDown);
|
||||
window.removeEventListener("mouseup", mouseUp);
|
||||
window.removeEventListener("blur", release);
|
||||
document.removeEventListener("pointerlockchange", pointerLockChange);
|
||||
socket?.close();
|
||||
};
|
||||
}, [clock, engine, protocol]);
|
||||
|
||||
return view;
|
||||
}
|
||||
|
||||
function inputsEqual(left: RoyaleInput, right: RoyaleInput): boolean {
|
||||
return (
|
||||
left.forward === right.forward &&
|
||||
left.strafe === right.strafe &&
|
||||
left.yaw === right.yaw &&
|
||||
left.pitch === right.pitch &&
|
||||
left.fire === right.fire &&
|
||||
left.sprint === right.sprint &&
|
||||
left.reload === right.reload
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeAngle(value: number): number {
|
||||
return Math.atan2(Math.sin(value), Math.cos(value));
|
||||
}
|
||||
|
||||
function clamp(value: number, minimum: number, maximum: number): number {
|
||||
return Math.max(minimum, Math.min(maximum, value));
|
||||
}
|
||||
13
apps/web/tsconfig.json
Normal file
13
apps/web/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"jsx": "react-jsx",
|
||||
"noEmit": true,
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
|
||||
15
apps/web/vite.config.ts
Normal file
15
apps/web/vite.config.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": "http://localhost:3001",
|
||||
"/ws": {
|
||||
target: "ws://localhost:3001",
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
89
deploy/kubernetes.yaml
Normal file
89
deploy/kubernetes.yaml
Normal file
@@ -0,0 +1,89 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: syncer
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: syncer
|
||||
namespace: syncer
|
||||
labels:
|
||||
app.kubernetes.io/name: syncer
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: syncer
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: syncer
|
||||
spec:
|
||||
containers:
|
||||
- name: syncer
|
||||
image: registry.a2acloud.io/syncer/syncer:latest
|
||||
imagePullPolicy: Always
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 3001
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /api/message
|
||||
port: http
|
||||
initialDelaySeconds: 2
|
||||
periodSeconds: 5
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /api/message
|
||||
port: http
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 15
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 128Mi
|
||||
limits:
|
||||
cpu: "2"
|
||||
memory: 1Gi
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: syncer
|
||||
namespace: syncer
|
||||
spec:
|
||||
selector:
|
||||
app.kubernetes.io/name: syncer
|
||||
ports:
|
||||
- name: http
|
||||
port: 80
|
||||
targetPort: http
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: syncer
|
||||
namespace: syncer
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||
traefik.ingress.kubernetes.io/router.entrypoints: web,websecure
|
||||
spec:
|
||||
ingressClassName: traefik
|
||||
tls:
|
||||
- hosts:
|
||||
- syncer.a2acloud.io
|
||||
secretName: syncer-a2acloud-io-tls
|
||||
rules:
|
||||
- host: syncer.a2acloud.io
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: syncer
|
||||
port:
|
||||
name: http
|
||||
BIN
output/screenshots/sync-arena-duel.jpg
Normal file
BIN
output/screenshots/sync-arena-duel.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 39 KiB |
BIN
output/screenshots/sync-arena-firefight.jpg
Normal file
BIN
output/screenshots/sync-arena-firefight.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 41 KiB |
2205
package-lock.json
generated
Normal file
2205
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
package.json
Normal file
23
package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "syncer",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
"packages/*"
|
||||
],
|
||||
"scripts": {
|
||||
"predev": "npm run build --workspace @syncer/engine && npm run build --workspace @syncer/shared",
|
||||
"dev": "concurrently --kill-others-on-fail --names engine,shared,server,web --prefix-colors blue,magenta,cyan,green \"npm run dev --workspace @syncer/engine\" \"npm run dev --workspace @syncer/shared\" \"npm run dev --workspace @syncer/server\" \"npm run dev --workspace @syncer/web\"",
|
||||
"build": "npm run build --workspace @syncer/engine && npm run build --workspace @syncer/shared && npm run build --workspace @syncer/server && npm run build --workspace @syncer/web",
|
||||
"pretypecheck": "npm run build --workspace @syncer/engine && npm run build --workspace @syncer/shared",
|
||||
"typecheck": "npm run typecheck --workspace @syncer/engine && npm run typecheck --workspace @syncer/shared && npm run typecheck --workspace @syncer/server && npm run typecheck --workspace @syncer/web",
|
||||
"test": "npm run build --workspace @syncer/engine && npm run build --workspace @syncer/shared && node --test packages/engine/test/*.test.mjs packages/shared/test/*.test.mjs",
|
||||
"start": "npm run start --workspace @syncer/server"
|
||||
},
|
||||
"devDependencies": {
|
||||
"concurrently": "^10.0.5",
|
||||
"typescript": "^7.0.2"
|
||||
}
|
||||
}
|
||||
25
packages/engine/package.json
Normal file
25
packages/engine/package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "@syncer/engine",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "tsc --watch --preserveWatchOutput",
|
||||
"build": "tsc",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^7.0.2"
|
||||
}
|
||||
}
|
||||
185
packages/engine/src/client.ts
Normal file
185
packages/engine/src/client.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import { NetworkClock } from "./network-clock.js";
|
||||
import type {
|
||||
ClientStateReport,
|
||||
GameDefinition,
|
||||
InputContext,
|
||||
InputPacket,
|
||||
PlayerId,
|
||||
StateSnapshot,
|
||||
} from "./types.js";
|
||||
|
||||
interface PredictedInput<Input> {
|
||||
packet: InputPacket<Input>;
|
||||
applied: boolean;
|
||||
}
|
||||
|
||||
export class PredictedEngine<State, Input> {
|
||||
readonly game: GameDefinition<State, Input>;
|
||||
readonly networkClock = new NetworkClock();
|
||||
private state: State;
|
||||
private playerId: PlayerId | null = null;
|
||||
private currentTick = 0;
|
||||
private nextInputSequence = 1;
|
||||
private acknowledgedSequence = 0;
|
||||
private pendingInputs: PredictedInput<Input>[] = [];
|
||||
|
||||
constructor(game: GameDefinition<State, Input>) {
|
||||
this.game = game;
|
||||
this.state = game.createInitialState();
|
||||
}
|
||||
|
||||
get tick(): number {
|
||||
return this.currentTick;
|
||||
}
|
||||
|
||||
get localPlayerId(): PlayerId | null {
|
||||
return this.playerId;
|
||||
}
|
||||
|
||||
get currentState(): Readonly<State> {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
get initialized(): boolean {
|
||||
return this.playerId !== null;
|
||||
}
|
||||
|
||||
initialize(playerId: PlayerId, snapshot: StateSnapshot<State>): void {
|
||||
this.playerId = playerId;
|
||||
this.currentTick = snapshot.tick;
|
||||
this.state = this.game.cloneState(snapshot.state);
|
||||
this.pendingInputs = [];
|
||||
this.acknowledgedSequence = 0;
|
||||
}
|
||||
|
||||
createInput(input: Input, leadTicks?: number): InputPacket<Input> {
|
||||
if (this.playerId === null) {
|
||||
throw new Error("Client engine has not received a welcome snapshot");
|
||||
}
|
||||
|
||||
const targetTick =
|
||||
this.currentTick +
|
||||
(leadTicks ??
|
||||
this.networkClock.recommendedInputLeadTicks(this.game.tickRateHz));
|
||||
const packet: InputPacket<Input> = {
|
||||
sequence: this.nextInputSequence,
|
||||
targetTick,
|
||||
observedTick: this.currentTick,
|
||||
input,
|
||||
};
|
||||
const context = this.inputContext(packet);
|
||||
|
||||
if (!this.game.validateInput(input, context)) {
|
||||
throw new Error("Refusing to send invalid local input");
|
||||
}
|
||||
|
||||
this.nextInputSequence = (this.nextInputSequence + 1) >>> 0;
|
||||
this.pendingInputs.push({ packet, applied: false });
|
||||
return packet;
|
||||
}
|
||||
|
||||
acknowledge(sequence: number): void {
|
||||
this.acknowledgedSequence = Math.max(this.acknowledgedSequence, sequence);
|
||||
this.pendingInputs = this.pendingInputs.filter(
|
||||
(pending) => pending.packet.sequence > this.acknowledgedSequence,
|
||||
);
|
||||
}
|
||||
|
||||
reject(sequence: number): void {
|
||||
this.pendingInputs = this.pendingInputs.filter(
|
||||
(pending) => pending.packet.sequence !== sequence,
|
||||
);
|
||||
}
|
||||
|
||||
step(): void {
|
||||
if (this.playerId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.currentTick += 1;
|
||||
this.applyDueInputs();
|
||||
this.game.step(this.state, {
|
||||
tick: this.currentTick,
|
||||
deltaSeconds: 1 / this.game.tickRateHz,
|
||||
});
|
||||
this.assertValidState();
|
||||
}
|
||||
|
||||
reconcile(snapshot: StateSnapshot<State>): void {
|
||||
if (
|
||||
this.playerId === null ||
|
||||
snapshot.tick < this.currentTick - this.game.tickRateHz * 2
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const predictedTick = Math.max(this.currentTick, snapshot.tick);
|
||||
this.state = this.game.cloneState(snapshot.state);
|
||||
this.currentTick = snapshot.tick;
|
||||
|
||||
for (const pending of this.pendingInputs) {
|
||||
pending.applied = false;
|
||||
}
|
||||
|
||||
this.applyDueInputs();
|
||||
while (this.currentTick < predictedTick) {
|
||||
this.currentTick += 1;
|
||||
this.applyDueInputs();
|
||||
this.game.step(this.state, {
|
||||
tick: this.currentTick,
|
||||
deltaSeconds: 1 / this.game.tickRateHz,
|
||||
});
|
||||
}
|
||||
|
||||
this.assertValidState();
|
||||
}
|
||||
|
||||
createStateReport(): ClientStateReport<State> {
|
||||
return {
|
||||
tick: this.currentTick,
|
||||
state: this.game.cloneState(this.state),
|
||||
};
|
||||
}
|
||||
|
||||
private applyDueInputs(): void {
|
||||
for (const pending of this.pendingInputs) {
|
||||
if (!pending.applied && pending.packet.targetTick <= this.currentTick) {
|
||||
this.game.applyInput(
|
||||
this.state,
|
||||
pending.packet.input,
|
||||
this.inputContext(pending.packet),
|
||||
);
|
||||
pending.applied = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private inputContext(packet: InputPacket<Input>): InputContext {
|
||||
if (this.playerId === null) {
|
||||
throw new Error("Client engine has not been initialized");
|
||||
}
|
||||
|
||||
return {
|
||||
playerId: this.playerId,
|
||||
sequence: packet.sequence,
|
||||
targetTick: packet.targetTick,
|
||||
observedTick: packet.observedTick ?? packet.targetTick,
|
||||
tick: this.currentTick,
|
||||
deltaSeconds: 1 / this.game.tickRateHz,
|
||||
};
|
||||
}
|
||||
|
||||
private assertValidState(): void {
|
||||
if (
|
||||
this.game.validateState &&
|
||||
!this.game.validateState(this.state, {
|
||||
tick: this.currentTick,
|
||||
deltaSeconds: 1 / this.game.tickRateHz,
|
||||
})
|
||||
) {
|
||||
throw new Error(
|
||||
`Client prediction produced invalid state at tick ${this.currentTick}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
55
packages/engine/src/clock.ts
Normal file
55
packages/engine/src/clock.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
export interface FixedStepClockOptions {
|
||||
rateHz: number;
|
||||
maxCatchUpSteps?: number;
|
||||
}
|
||||
|
||||
export class FixedStepClock {
|
||||
readonly stepMilliseconds: number;
|
||||
private readonly maxCatchUpSteps: number;
|
||||
private previousTime: number | null = null;
|
||||
private accumulator = 0;
|
||||
|
||||
constructor(options: FixedStepClockOptions) {
|
||||
if (!Number.isFinite(options.rateHz) || options.rateHz <= 0) {
|
||||
throw new RangeError("Clock rate must be a positive finite number");
|
||||
}
|
||||
|
||||
this.stepMilliseconds = 1_000 / options.rateHz;
|
||||
this.maxCatchUpSteps = options.maxCatchUpSteps ?? 5;
|
||||
}
|
||||
|
||||
advance(now: number, step: () => void): number {
|
||||
if (this.previousTime === null) {
|
||||
this.previousTime = now;
|
||||
return 0;
|
||||
}
|
||||
|
||||
const elapsed = Math.max(0, now - this.previousTime);
|
||||
this.previousTime = now;
|
||||
this.accumulator += Math.min(
|
||||
elapsed,
|
||||
this.stepMilliseconds * this.maxCatchUpSteps,
|
||||
);
|
||||
|
||||
let steps = 0;
|
||||
while (
|
||||
this.accumulator >= this.stepMilliseconds &&
|
||||
steps < this.maxCatchUpSteps
|
||||
) {
|
||||
step();
|
||||
this.accumulator -= this.stepMilliseconds;
|
||||
steps += 1;
|
||||
}
|
||||
|
||||
return steps;
|
||||
}
|
||||
|
||||
get interpolationAlpha(): number {
|
||||
return this.accumulator / this.stepMilliseconds;
|
||||
}
|
||||
|
||||
reset(now?: number): void {
|
||||
this.previousTime = now ?? null;
|
||||
this.accumulator = 0;
|
||||
}
|
||||
}
|
||||
38
packages/engine/src/define-game.ts
Normal file
38
packages/engine/src/define-game.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { PredictedEngine } from "./client.js";
|
||||
import { AuthoritativeEngine, type ServerEngineOptions } from "./server.js";
|
||||
import type { GameDefinition } from "./types.js";
|
||||
|
||||
export interface DefinedGame<State, Input>
|
||||
extends GameDefinition<State, Input> {
|
||||
createServer(options?: ServerEngineOptions): AuthoritativeEngine<State, Input>;
|
||||
createClient(): PredictedEngine<State, Input>;
|
||||
}
|
||||
|
||||
export function defineGame<State, Input>(
|
||||
definition: GameDefinition<State, Input>,
|
||||
): DefinedGame<State, Input> {
|
||||
if (!Number.isInteger(definition.tickRateHz) || definition.tickRateHz <= 0) {
|
||||
throw new RangeError("tickRateHz must be a positive integer");
|
||||
}
|
||||
|
||||
if (
|
||||
!Number.isInteger(definition.snapshotRateHz) ||
|
||||
definition.snapshotRateHz <= 0 ||
|
||||
definition.snapshotRateHz > definition.tickRateHz ||
|
||||
definition.tickRateHz % definition.snapshotRateHz !== 0
|
||||
) {
|
||||
throw new RangeError(
|
||||
"snapshotRateHz must be a positive divisor of tickRateHz",
|
||||
);
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
...definition,
|
||||
createServer(options?: ServerEngineOptions) {
|
||||
return new AuthoritativeEngine(definition, options);
|
||||
},
|
||||
createClient() {
|
||||
return new PredictedEngine(definition);
|
||||
},
|
||||
});
|
||||
}
|
||||
90
packages/engine/src/define-multiplayer-game.ts
Normal file
90
packages/engine/src/define-multiplayer-game.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import {
|
||||
defineNetworkedGame,
|
||||
type DefinedNetworkedGame,
|
||||
} from "./define-networked-game.js";
|
||||
import type {
|
||||
ClientSimulationRules,
|
||||
ReplicationRules,
|
||||
ServerSimulationRules,
|
||||
} from "./networked-types.js";
|
||||
import type { BinaryCodec, InputContext } from "./types.js";
|
||||
|
||||
/**
|
||||
* Names the five types that cross a multiplayer game's trust boundaries.
|
||||
* Define this once, then every callback in defineMultiplayerGame is inferred.
|
||||
*/
|
||||
export interface MultiplayerGameContract {
|
||||
authority: unknown;
|
||||
client: unknown;
|
||||
input: unknown;
|
||||
authorityEvent: unknown;
|
||||
perceptionEvent: unknown;
|
||||
}
|
||||
|
||||
export interface MultiplayerGameConfiguration<
|
||||
Contract extends MultiplayerGameContract,
|
||||
> {
|
||||
clock: {
|
||||
ticksPerSecond: number;
|
||||
snapshotsPerSecond: number;
|
||||
};
|
||||
/** Private rules that run only on trusted authority. */
|
||||
authority: ServerSimulationRules<
|
||||
Contract["authority"],
|
||||
Contract["input"],
|
||||
Contract["authorityEvent"]
|
||||
>;
|
||||
/** Rules the client may run speculatively between snapshots. */
|
||||
prediction: ClientSimulationRules<
|
||||
Contract["client"],
|
||||
Contract["input"],
|
||||
Contract["perceptionEvent"]
|
||||
>;
|
||||
/** The only authority data and events a particular player may receive. */
|
||||
visibility: ReplicationRules<
|
||||
Contract["authority"],
|
||||
Contract["client"],
|
||||
Contract["authorityEvent"],
|
||||
Contract["perceptionEvent"]
|
||||
>;
|
||||
input: {
|
||||
validate(input: Contract["input"], context: InputContext): boolean;
|
||||
};
|
||||
encoding: {
|
||||
input: BinaryCodec<Contract["input"]>;
|
||||
clientState: BinaryCodec<Contract["client"]>;
|
||||
perception?: BinaryCodec<Contract["perceptionEvent"]>;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Developer-facing game-definition API. It compiles the readable game sections
|
||||
* into the same low-level definition consumed by every engine HOF.
|
||||
*/
|
||||
export function defineMultiplayerGame<
|
||||
Contract extends MultiplayerGameContract,
|
||||
>(
|
||||
configuration: MultiplayerGameConfiguration<Contract>,
|
||||
): DefinedNetworkedGame<
|
||||
Contract["authority"],
|
||||
Contract["client"],
|
||||
Contract["input"],
|
||||
Contract["authorityEvent"],
|
||||
Contract["perceptionEvent"]
|
||||
> {
|
||||
return defineNetworkedGame({
|
||||
tickRateHz: configuration.clock.ticksPerSecond,
|
||||
snapshotRateHz: configuration.clock.snapshotsPerSecond,
|
||||
server: configuration.authority,
|
||||
client: configuration.prediction,
|
||||
replication: configuration.visibility,
|
||||
validateInput: configuration.input.validate,
|
||||
codecs: {
|
||||
input: configuration.encoding.input,
|
||||
state: configuration.encoding.clientState,
|
||||
...(configuration.encoding.perception
|
||||
? { event: configuration.encoding.perception }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
93
packages/engine/src/define-networked-game.ts
Normal file
93
packages/engine/src/define-networked-game.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { NetworkedPredictedEngine } from "./networked-client.js";
|
||||
import { NetworkedAuthoritativeEngine } from "./networked-server.js";
|
||||
import type { NetworkedGameDefinition } from "./networked-types.js";
|
||||
import { createBinaryProtocol, type BinaryProtocol } from "./protocol.js";
|
||||
import type { ServerEngineOptions } from "./server.js";
|
||||
|
||||
export interface DefinedNetworkedGame<
|
||||
AuthorityState,
|
||||
ClientState,
|
||||
Input,
|
||||
AuthorityEvent = never,
|
||||
PerceptionEvent = never,
|
||||
> extends NetworkedGameDefinition<
|
||||
AuthorityState,
|
||||
ClientState,
|
||||
Input,
|
||||
AuthorityEvent,
|
||||
PerceptionEvent
|
||||
> {
|
||||
protocol: BinaryProtocol<Input, ClientState, PerceptionEvent>;
|
||||
createServer(
|
||||
options?: ServerEngineOptions,
|
||||
): NetworkedAuthoritativeEngine<
|
||||
AuthorityState,
|
||||
ClientState,
|
||||
Input,
|
||||
AuthorityEvent,
|
||||
PerceptionEvent
|
||||
>;
|
||||
createClient(): NetworkedPredictedEngine<
|
||||
AuthorityState,
|
||||
ClientState,
|
||||
Input,
|
||||
AuthorityEvent,
|
||||
PerceptionEvent
|
||||
>;
|
||||
}
|
||||
|
||||
export function defineNetworkedGame<
|
||||
AuthorityState,
|
||||
ClientState,
|
||||
Input,
|
||||
AuthorityEvent = never,
|
||||
PerceptionEvent = never,
|
||||
>(
|
||||
definition: NetworkedGameDefinition<
|
||||
AuthorityState,
|
||||
ClientState,
|
||||
Input,
|
||||
AuthorityEvent,
|
||||
PerceptionEvent
|
||||
>,
|
||||
): DefinedNetworkedGame<
|
||||
AuthorityState,
|
||||
ClientState,
|
||||
Input,
|
||||
AuthorityEvent,
|
||||
PerceptionEvent
|
||||
> {
|
||||
validateRates(definition.tickRateHz, definition.snapshotRateHz);
|
||||
|
||||
const protocol = createBinaryProtocol<Input, ClientState, PerceptionEvent>(
|
||||
definition.codecs,
|
||||
);
|
||||
|
||||
return Object.freeze({
|
||||
...definition,
|
||||
protocol,
|
||||
createServer(options?: ServerEngineOptions) {
|
||||
return new NetworkedAuthoritativeEngine(definition, options);
|
||||
},
|
||||
createClient() {
|
||||
return new NetworkedPredictedEngine(definition);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function validateRates(tickRateHz: number, snapshotRateHz: number): void {
|
||||
if (!Number.isInteger(tickRateHz) || tickRateHz <= 0) {
|
||||
throw new RangeError("tickRateHz must be a positive integer");
|
||||
}
|
||||
|
||||
if (
|
||||
!Number.isInteger(snapshotRateHz) ||
|
||||
snapshotRateHz <= 0 ||
|
||||
snapshotRateHz > tickRateHz ||
|
||||
tickRateHz % snapshotRateHz !== 0
|
||||
) {
|
||||
throw new RangeError(
|
||||
"snapshotRateHz must be a positive divisor of tickRateHz",
|
||||
);
|
||||
}
|
||||
}
|
||||
113
packages/engine/src/deterministic-hash.ts
Normal file
113
packages/engine/src/deterministic-hash.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
const fnvOffset = 0xcbf29ce484222325n;
|
||||
const fnvPrime = 0x100000001b3n;
|
||||
const uint64Mask = 0xffffffffffffffffn;
|
||||
|
||||
/**
|
||||
* Produces a stable 64-bit hash for deterministic simulation state.
|
||||
* Maps, sets, and object keys are sorted so insertion order does not affect it.
|
||||
* Cyclic structures and executable values are rejected deliberately.
|
||||
*/
|
||||
export function deterministicHash(value: unknown): string {
|
||||
const canonical = canonicalize(value, new WeakSet<object>());
|
||||
let hash = fnvOffset;
|
||||
|
||||
for (let index = 0; index < canonical.length; index += 1) {
|
||||
const code = canonical.charCodeAt(index);
|
||||
hash ^= BigInt(code & 0xff);
|
||||
hash = (hash * fnvPrime) & uint64Mask;
|
||||
hash ^= BigInt(code >>> 8);
|
||||
hash = (hash * fnvPrime) & uint64Mask;
|
||||
}
|
||||
|
||||
return hash.toString(16).padStart(16, "0");
|
||||
}
|
||||
|
||||
function canonicalize(value: unknown, ancestors: WeakSet<object>): string {
|
||||
if (value === null) return "null";
|
||||
|
||||
switch (typeof value) {
|
||||
case "undefined":
|
||||
return "undefined";
|
||||
case "boolean":
|
||||
return value ? "boolean:true" : "boolean:false";
|
||||
case "number":
|
||||
if (Number.isNaN(value)) return "number:NaN";
|
||||
if (value === Infinity) return "number:+Infinity";
|
||||
if (value === -Infinity) return "number:-Infinity";
|
||||
if (Object.is(value, -0)) return "number:-0";
|
||||
return `number:${value}`;
|
||||
case "bigint":
|
||||
return `bigint:${value}`;
|
||||
case "string":
|
||||
return `string:${JSON.stringify(value)}`;
|
||||
case "symbol":
|
||||
case "function":
|
||||
throw new TypeError(`Cannot hash simulation value of type ${typeof value}`);
|
||||
case "object":
|
||||
return canonicalizeObject(value, ancestors);
|
||||
}
|
||||
|
||||
throw new TypeError("Unsupported simulation value");
|
||||
}
|
||||
|
||||
function canonicalizeObject(value: object, ancestors: WeakSet<object>): string {
|
||||
if (ancestors.has(value)) {
|
||||
throw new TypeError("Cannot hash cyclic simulation state");
|
||||
}
|
||||
ancestors.add(value);
|
||||
|
||||
try {
|
||||
if (Array.isArray(value)) {
|
||||
return `array:[${value.map((entry) => canonicalize(entry, ancestors)).join(",")}]`;
|
||||
}
|
||||
|
||||
if (value instanceof Date) {
|
||||
return `date:${value.toISOString()}`;
|
||||
}
|
||||
|
||||
if (value instanceof Map) {
|
||||
const entries = [...value].map(([key, entryValue]) => [
|
||||
canonicalize(key, ancestors),
|
||||
canonicalize(entryValue, ancestors),
|
||||
] as const);
|
||||
entries.sort(([left], [right]) => compareText(left, right));
|
||||
return `map:{${entries
|
||||
.map(([key, entryValue]) => `${key}=>${entryValue}`)
|
||||
.join(",")}}`;
|
||||
}
|
||||
|
||||
if (value instanceof Set) {
|
||||
const entries = [...value].map((entry) => canonicalize(entry, ancestors));
|
||||
entries.sort(compareText);
|
||||
return `set:{${entries.join(",")}}`;
|
||||
}
|
||||
|
||||
if (value instanceof ArrayBuffer) {
|
||||
return `bytes:${bytesToHex(new Uint8Array(value))}`;
|
||||
}
|
||||
|
||||
if (ArrayBuffer.isView(value)) {
|
||||
return `${value.constructor.name}:${bytesToHex(
|
||||
new Uint8Array(value.buffer, value.byteOffset, value.byteLength),
|
||||
)}`;
|
||||
}
|
||||
|
||||
const record = value as Record<string, unknown>;
|
||||
const keys = Object.keys(record).sort(compareText);
|
||||
return `object:{${keys
|
||||
.map((key) => `${JSON.stringify(key)}:${canonicalize(record[key], ancestors)}`)
|
||||
.join(",")}}`;
|
||||
} finally {
|
||||
ancestors.delete(value);
|
||||
}
|
||||
}
|
||||
|
||||
function bytesToHex(bytes: Uint8Array): string {
|
||||
let result = "";
|
||||
for (const byte of bytes) result += byte.toString(16).padStart(2, "0");
|
||||
return result;
|
||||
}
|
||||
|
||||
function compareText(left: string, right: string): number {
|
||||
return left < right ? -1 : left > right ? 1 : 0;
|
||||
}
|
||||
113
packages/engine/src/index.ts
Normal file
113
packages/engine/src/index.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
export { FixedStepClock, type FixedStepClockOptions } from "./clock.js";
|
||||
export { deterministicHash } from "./deterministic-hash.js";
|
||||
export { PredictedEngine } from "./client.js";
|
||||
export {
|
||||
defineGame,
|
||||
type DefinedGame,
|
||||
} from "./define-game.js";
|
||||
export {
|
||||
defineNetworkedGame,
|
||||
type DefinedNetworkedGame,
|
||||
} from "./define-networked-game.js";
|
||||
export {
|
||||
defineMultiplayerGame,
|
||||
type MultiplayerGameConfiguration,
|
||||
type MultiplayerGameContract,
|
||||
} from "./define-multiplayer-game.js";
|
||||
export {
|
||||
createJsonCodec,
|
||||
type JsonCodecOptions,
|
||||
} from "./json-codec.js";
|
||||
export {
|
||||
withLagCompensation,
|
||||
type LagCompensatedAction,
|
||||
type LagCompensationActionPolicy,
|
||||
type LagCompensationDefinition,
|
||||
type LagCompensationMode,
|
||||
type LagCompensationPolicies,
|
||||
type LagCompensationResolutionContext,
|
||||
} from "./lag-compensation.js";
|
||||
export { NetworkedPredictedEngine } from "./networked-client.js";
|
||||
export {
|
||||
NetworkedAuthoritativeEngine,
|
||||
type NetworkedServerStepResult,
|
||||
type NetworkedSimulationCheckpoint,
|
||||
} from "./networked-server.js";
|
||||
export type {
|
||||
ClientEventContext,
|
||||
ClientSimulationRules,
|
||||
EmittingInputContext,
|
||||
EmittingPlayerContext,
|
||||
EmittingTickContext,
|
||||
NetworkedGameDefinition,
|
||||
ReplicationRules,
|
||||
ServerSimulationRules,
|
||||
SnapshotBatch,
|
||||
} from "./networked-types.js";
|
||||
export { NetworkClock, type NetworkStats } from "./network-clock.js";
|
||||
export {
|
||||
createBinaryProtocol,
|
||||
type BinaryProtocol,
|
||||
type ClientWireMessage,
|
||||
type ServerWireMessage,
|
||||
} from "./protocol.js";
|
||||
export {
|
||||
ReplayTransportAuthoritativeEngine,
|
||||
withReplayTransport,
|
||||
type ProjectedReplayFrame,
|
||||
type ReplayAuthorizationContext,
|
||||
type ReplayTicket,
|
||||
type ReplayTicketPlan,
|
||||
type ReplayTransportDefinition,
|
||||
type ReplayTransportNetworkedGame,
|
||||
type ReplayTriggerContext,
|
||||
} from "./replay-transport.js";
|
||||
export {
|
||||
AuthoritativeEngine,
|
||||
type Acknowledgement,
|
||||
type InputDecision,
|
||||
type ServerEngineOptions,
|
||||
type ServerStepResult,
|
||||
} from "./server.js";
|
||||
export {
|
||||
SpatialGridIndex,
|
||||
withSpatialReplication,
|
||||
type SpatialPoint,
|
||||
type SpatialReplicationController,
|
||||
type SpatialReplicationDefinition,
|
||||
type SpatialReplicationEntity,
|
||||
type SpatialReplicationSelection,
|
||||
type SpatialReplicationSource,
|
||||
type SpatialReplicationViewerContext,
|
||||
type SpatiallyReplicatedNetworkedGame,
|
||||
} from "./spatial-replication.js";
|
||||
export {
|
||||
ReplayDivergenceError,
|
||||
ReplaySession,
|
||||
TimeTravelAuthoritativeEngine,
|
||||
withTimeTravel,
|
||||
type ReplayCheckpoint,
|
||||
type ReplayCommand,
|
||||
type ReplayFrame,
|
||||
type ReplayRecording,
|
||||
type ReplayStateHash,
|
||||
type ReplayVerification,
|
||||
type TimeTravelDefinition,
|
||||
type TimeTravelNetworkedGame,
|
||||
type TimeTravelServerOptions,
|
||||
} from "./time-travel.js";
|
||||
export type {
|
||||
BinaryCodec,
|
||||
ClientStateContext,
|
||||
ClientStateReport,
|
||||
GameDefinition,
|
||||
InputContext,
|
||||
InputPacket,
|
||||
PingPacket,
|
||||
PlayerContext,
|
||||
PlayerId,
|
||||
PongPacket,
|
||||
StateSnapshot,
|
||||
TickContext,
|
||||
ValidationResult,
|
||||
} from "./types.js";
|
||||
45
packages/engine/src/json-codec.ts
Normal file
45
packages/engine/src/json-codec.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { BinaryCodec } from "./types.js";
|
||||
|
||||
export interface JsonCodecOptions<Value> {
|
||||
serialize?(value: Value): unknown;
|
||||
deserialize?(value: unknown): Value;
|
||||
}
|
||||
|
||||
interface Utf8Encoder {
|
||||
encode(value: string): Uint8Array;
|
||||
}
|
||||
|
||||
interface Utf8Decoder {
|
||||
decode(value: Uint8Array): string;
|
||||
}
|
||||
|
||||
type Utf8Globals = typeof globalThis & {
|
||||
TextEncoder: new () => Utf8Encoder;
|
||||
TextDecoder: new (
|
||||
label?: string,
|
||||
options?: { fatal?: boolean },
|
||||
) => Utf8Decoder;
|
||||
};
|
||||
|
||||
/** Creates a UTF-8 JSON codec for prototypes and low-volume game messages. */
|
||||
export function createJsonCodec<Value>(
|
||||
options: JsonCodecOptions<Value> = {},
|
||||
): BinaryCodec<Value> {
|
||||
const utf8 = globalThis as Utf8Globals;
|
||||
const encoder = new utf8.TextEncoder();
|
||||
const decoder = new utf8.TextDecoder("utf-8", { fatal: true });
|
||||
|
||||
return {
|
||||
encode(value) {
|
||||
const json = JSON.stringify(options.serialize?.(value) ?? value);
|
||||
if (json === undefined) {
|
||||
throw new TypeError("The JSON codec cannot encode undefined");
|
||||
}
|
||||
return encoder.encode(json);
|
||||
},
|
||||
decode(payload) {
|
||||
const value: unknown = JSON.parse(decoder.decode(payload));
|
||||
return options.deserialize ? options.deserialize(value) : (value as Value);
|
||||
},
|
||||
};
|
||||
}
|
||||
425
packages/engine/src/lag-compensation.ts
Normal file
425
packages/engine/src/lag-compensation.ts
Normal file
@@ -0,0 +1,425 @@
|
||||
import {
|
||||
defineNetworkedGame,
|
||||
type DefinedNetworkedGame,
|
||||
} from "./define-networked-game.js";
|
||||
import type { EmittingInputContext } from "./networked-types.js";
|
||||
import type { PlayerId, TickContext } from "./types.js";
|
||||
|
||||
export type LagCompensationMode = "current" | "rewind" | "fast-forward";
|
||||
|
||||
export interface LagCompensatedAction {
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface LagCompensationResolutionContext<
|
||||
AuthorityState,
|
||||
HistoricalState,
|
||||
Action,
|
||||
AuthorityEvent,
|
||||
> {
|
||||
currentState: AuthorityState;
|
||||
historicalState: Readonly<HistoricalState>;
|
||||
action: Readonly<Action>;
|
||||
playerId: PlayerId;
|
||||
sequence: number;
|
||||
requestedTick: number;
|
||||
resolvedTick: number;
|
||||
currentTick: number;
|
||||
rewindTicks: number;
|
||||
catchUpTicks: number;
|
||||
clamped: boolean;
|
||||
emit(event: AuthorityEvent): void;
|
||||
}
|
||||
|
||||
export interface LagCompensationActionPolicy<
|
||||
AuthorityState,
|
||||
HistoricalState,
|
||||
Action,
|
||||
AuthorityEvent,
|
||||
> {
|
||||
mode: LagCompensationMode;
|
||||
maximumRewindMs?: number;
|
||||
maximumFutureMs?: number;
|
||||
outOfWindow?: "clamp" | "reject";
|
||||
validate?(
|
||||
context: LagCompensationResolutionContext<
|
||||
AuthorityState,
|
||||
HistoricalState,
|
||||
Action,
|
||||
AuthorityEvent
|
||||
>,
|
||||
): boolean;
|
||||
resolve(
|
||||
context: LagCompensationResolutionContext<
|
||||
AuthorityState,
|
||||
HistoricalState,
|
||||
Action,
|
||||
AuthorityEvent
|
||||
>,
|
||||
): void;
|
||||
}
|
||||
|
||||
export type LagCompensationPolicies<
|
||||
AuthorityState,
|
||||
HistoricalState,
|
||||
Action extends LagCompensatedAction,
|
||||
AuthorityEvent,
|
||||
> = {
|
||||
[Kind in Action["type"]]: LagCompensationActionPolicy<
|
||||
AuthorityState,
|
||||
HistoricalState,
|
||||
Extract<Action, { type: Kind }>,
|
||||
AuthorityEvent
|
||||
>;
|
||||
};
|
||||
|
||||
export interface LagCompensationDefinition<
|
||||
AuthorityState,
|
||||
Input,
|
||||
AuthorityEvent,
|
||||
HistoricalState,
|
||||
Action extends LagCompensatedAction,
|
||||
> {
|
||||
historySeconds: number;
|
||||
captureState(
|
||||
state: Readonly<AuthorityState>,
|
||||
context: TickContext,
|
||||
): HistoricalState;
|
||||
cloneHistoricalState(state: Readonly<HistoricalState>): HistoricalState;
|
||||
classifyAction(
|
||||
input: Readonly<Input>,
|
||||
context: EmittingInputContext<AuthorityEvent>,
|
||||
): Action | null;
|
||||
cloneAction(action: Readonly<Action>): Action;
|
||||
actions: LagCompensationPolicies<
|
||||
AuthorityState,
|
||||
HistoricalState,
|
||||
Action,
|
||||
AuthorityEvent
|
||||
>;
|
||||
}
|
||||
|
||||
interface HistoricalFrame<HistoricalState> {
|
||||
tick: number;
|
||||
state: HistoricalState;
|
||||
}
|
||||
|
||||
interface QueuedAction<Action> {
|
||||
action: Action;
|
||||
playerId: PlayerId;
|
||||
sequence: number;
|
||||
requestedTick: number;
|
||||
}
|
||||
|
||||
interface LagRuntime<HistoricalState, Action> {
|
||||
frames: Array<HistoricalFrame<HistoricalState>>;
|
||||
queuedActions: Array<QueuedAction<Action>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds one private authority-history ring to a game and resolves only classified
|
||||
* actions against it. The live world is never rewound or exposed to clients.
|
||||
*/
|
||||
export function withLagCompensation<
|
||||
AuthorityState extends object,
|
||||
ClientState,
|
||||
Input,
|
||||
AuthorityEvent,
|
||||
PerceptionEvent,
|
||||
HistoricalState,
|
||||
Action extends LagCompensatedAction,
|
||||
>(
|
||||
game: DefinedNetworkedGame<
|
||||
AuthorityState,
|
||||
ClientState,
|
||||
Input,
|
||||
AuthorityEvent,
|
||||
PerceptionEvent
|
||||
>,
|
||||
definition: LagCompensationDefinition<
|
||||
AuthorityState,
|
||||
Input,
|
||||
AuthorityEvent,
|
||||
HistoricalState,
|
||||
Action
|
||||
>,
|
||||
): DefinedNetworkedGame<
|
||||
AuthorityState,
|
||||
ClientState,
|
||||
Input,
|
||||
AuthorityEvent,
|
||||
PerceptionEvent
|
||||
> {
|
||||
const historyTicks = normalizeHistoryTicks(
|
||||
game.tickRateHz,
|
||||
definition.historySeconds,
|
||||
);
|
||||
validatePolicies(definition.actions);
|
||||
const runtimes = new WeakMap<
|
||||
AuthorityState,
|
||||
LagRuntime<HistoricalState, Action>
|
||||
>();
|
||||
|
||||
const ensureRuntime = (
|
||||
state: AuthorityState,
|
||||
): LagRuntime<HistoricalState, Action> => {
|
||||
const existing = runtimes.get(state);
|
||||
if (existing) return existing;
|
||||
const created = { frames: [], queuedActions: [] };
|
||||
runtimes.set(state, created);
|
||||
return created;
|
||||
};
|
||||
|
||||
const capture = (state: AuthorityState, context: TickContext): void => {
|
||||
const runtime = ensureRuntime(state);
|
||||
const frame = {
|
||||
tick: context.tick,
|
||||
state: definition.cloneHistoricalState(
|
||||
definition.captureState(state, context),
|
||||
),
|
||||
};
|
||||
if (runtime.frames.at(-1)?.tick === context.tick) {
|
||||
runtime.frames[runtime.frames.length - 1] = frame;
|
||||
} else {
|
||||
runtime.frames.push(frame);
|
||||
}
|
||||
const earliestTick = context.tick - historyTicks;
|
||||
while (runtime.frames[0] && runtime.frames[0].tick < earliestTick) {
|
||||
runtime.frames.shift();
|
||||
}
|
||||
};
|
||||
|
||||
return defineNetworkedGame({
|
||||
...game,
|
||||
server: {
|
||||
...game.server,
|
||||
createInitialState() {
|
||||
const state = game.server.createInitialState();
|
||||
ensureRuntime(state);
|
||||
return state;
|
||||
},
|
||||
initializeState(state, context) {
|
||||
game.server.initializeState?.(state, context);
|
||||
capture(state, context);
|
||||
},
|
||||
cloneState(state) {
|
||||
const cloned = game.server.cloneState(state);
|
||||
const source = ensureRuntime(state);
|
||||
runtimes.set(cloned, {
|
||||
frames: source.frames.map((frame) => ({
|
||||
tick: frame.tick,
|
||||
state: definition.cloneHistoricalState(frame.state),
|
||||
})),
|
||||
queuedActions: source.queuedActions.map((queued) => ({
|
||||
...queued,
|
||||
action: definition.cloneAction(queued.action),
|
||||
})),
|
||||
});
|
||||
return cloned;
|
||||
},
|
||||
cloneStateForHistory(state) {
|
||||
return game.server.cloneStateForHistory?.(state) ??
|
||||
game.server.cloneState(state);
|
||||
},
|
||||
addPlayer(state, context) {
|
||||
game.server.addPlayer?.(state, context);
|
||||
capture(state, {
|
||||
tick: context.tick,
|
||||
deltaSeconds: 1 / game.tickRateHz,
|
||||
});
|
||||
},
|
||||
removePlayer(state, context) {
|
||||
game.server.removePlayer?.(state, context);
|
||||
capture(state, {
|
||||
tick: context.tick,
|
||||
deltaSeconds: 1 / game.tickRateHz,
|
||||
});
|
||||
},
|
||||
applyInput(state, input, context) {
|
||||
game.server.applyInput(state, input, context);
|
||||
const action = definition.classifyAction(input, context);
|
||||
if (!action) return;
|
||||
const policy = definition.actions[action.type as Action["type"]] as
|
||||
| LagCompensationActionPolicy<
|
||||
AuthorityState,
|
||||
HistoricalState,
|
||||
Action,
|
||||
AuthorityEvent
|
||||
>
|
||||
| undefined;
|
||||
if (!policy) return;
|
||||
ensureRuntime(state).queuedActions.push({
|
||||
action: definition.cloneAction(action),
|
||||
playerId: context.playerId,
|
||||
sequence: context.sequence,
|
||||
requestedTick: context.observedTick,
|
||||
});
|
||||
},
|
||||
step(state, context) {
|
||||
game.server.step(state, context);
|
||||
const runtime = ensureRuntime(state);
|
||||
const queuedActions = runtime.queuedActions;
|
||||
runtime.queuedActions = [];
|
||||
|
||||
for (const queued of queuedActions) {
|
||||
resolveQueuedAction(
|
||||
state,
|
||||
context,
|
||||
runtime.frames,
|
||||
queued,
|
||||
game.tickRateHz,
|
||||
historyTicks,
|
||||
definition,
|
||||
);
|
||||
}
|
||||
capture(state, context);
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function resolveQueuedAction<
|
||||
AuthorityState,
|
||||
Input,
|
||||
AuthorityEvent,
|
||||
HistoricalState,
|
||||
Action extends LagCompensatedAction,
|
||||
>(
|
||||
state: AuthorityState,
|
||||
tickContext: TickContext & { emit(event: AuthorityEvent): void },
|
||||
frames: readonly HistoricalFrame<HistoricalState>[],
|
||||
queued: QueuedAction<Action>,
|
||||
tickRateHz: number,
|
||||
historyTicks: number,
|
||||
definition: LagCompensationDefinition<
|
||||
AuthorityState,
|
||||
Input,
|
||||
AuthorityEvent,
|
||||
HistoricalState,
|
||||
Action
|
||||
>,
|
||||
): void {
|
||||
const policy = definition.actions[queued.action.type as Action["type"]] as
|
||||
| LagCompensationActionPolicy<
|
||||
AuthorityState,
|
||||
HistoricalState,
|
||||
Action,
|
||||
AuthorityEvent
|
||||
>
|
||||
| undefined;
|
||||
if (!policy) return;
|
||||
|
||||
const maximumRewindTicks = Math.min(
|
||||
historyTicks,
|
||||
millisecondsToTicks(
|
||||
policy.maximumRewindMs ?? (historyTicks / tickRateHz) * 1_000,
|
||||
tickRateHz,
|
||||
),
|
||||
);
|
||||
const maximumFutureTicks = millisecondsToTicks(
|
||||
policy.maximumFutureMs ?? 0,
|
||||
tickRateHz,
|
||||
);
|
||||
const minimumTick = Math.max(0, tickContext.tick - maximumRewindTicks);
|
||||
const maximumTick = tickContext.tick + maximumFutureTicks;
|
||||
const outsideWindow =
|
||||
queued.requestedTick < minimumTick || queued.requestedTick > maximumTick;
|
||||
if (outsideWindow && policy.outOfWindow === "reject") return;
|
||||
|
||||
const boundedRequestedTick = clamp(
|
||||
queued.requestedTick,
|
||||
minimumTick,
|
||||
tickContext.tick,
|
||||
);
|
||||
let resolvedTick = tickContext.tick;
|
||||
let historicalState: HistoricalState;
|
||||
|
||||
if (policy.mode === "current") {
|
||||
historicalState = definition.cloneHistoricalState(
|
||||
definition.captureState(state, tickContext),
|
||||
);
|
||||
} else {
|
||||
const frame = frameAtOrBefore(frames, boundedRequestedTick);
|
||||
if (!frame) return;
|
||||
resolvedTick = frame.tick;
|
||||
historicalState = definition.cloneHistoricalState(frame.state);
|
||||
}
|
||||
|
||||
const resolutionContext: LagCompensationResolutionContext<
|
||||
AuthorityState,
|
||||
HistoricalState,
|
||||
Action,
|
||||
AuthorityEvent
|
||||
> = {
|
||||
currentState: state,
|
||||
historicalState,
|
||||
action: queued.action,
|
||||
playerId: queued.playerId,
|
||||
sequence: queued.sequence,
|
||||
requestedTick: queued.requestedTick,
|
||||
resolvedTick,
|
||||
currentTick: tickContext.tick,
|
||||
rewindTicks: Math.max(0, tickContext.tick - resolvedTick),
|
||||
catchUpTicks:
|
||||
policy.mode === "fast-forward"
|
||||
? Math.max(0, tickContext.tick - resolvedTick)
|
||||
: 0,
|
||||
clamped: resolvedTick !== queued.requestedTick,
|
||||
emit: tickContext.emit,
|
||||
};
|
||||
if (policy.validate && !policy.validate(resolutionContext)) return;
|
||||
policy.resolve(resolutionContext);
|
||||
}
|
||||
|
||||
function frameAtOrBefore<HistoricalState>(
|
||||
frames: readonly HistoricalFrame<HistoricalState>[],
|
||||
tick: number,
|
||||
): HistoricalFrame<HistoricalState> | undefined {
|
||||
let low = 0;
|
||||
let high = frames.length - 1;
|
||||
let selected: HistoricalFrame<HistoricalState> | undefined;
|
||||
while (low <= high) {
|
||||
const middle = Math.floor((low + high) / 2);
|
||||
const frame = frames[middle]!;
|
||||
if (frame.tick <= tick) {
|
||||
selected = frame;
|
||||
low = middle + 1;
|
||||
} else {
|
||||
high = middle - 1;
|
||||
}
|
||||
}
|
||||
return selected ?? frames[0];
|
||||
}
|
||||
|
||||
function normalizeHistoryTicks(tickRateHz: number, seconds: number): number {
|
||||
if (!Number.isFinite(seconds) || seconds <= 0) {
|
||||
throw new RangeError("historySeconds must be positive");
|
||||
}
|
||||
return Math.max(1, Math.ceil(seconds * tickRateHz));
|
||||
}
|
||||
|
||||
function validatePolicies(
|
||||
policies: Record<string, { mode: LagCompensationMode; maximumRewindMs?: number; maximumFutureMs?: number }>,
|
||||
): void {
|
||||
for (const [kind, policy] of Object.entries(policies)) {
|
||||
if (!["current", "rewind", "fast-forward"].includes(policy.mode)) {
|
||||
throw new RangeError(`Unknown lag compensation mode for ${kind}`);
|
||||
}
|
||||
for (const [name, value] of [
|
||||
["maximumRewindMs", policy.maximumRewindMs],
|
||||
["maximumFutureMs", policy.maximumFutureMs],
|
||||
] as const) {
|
||||
if (value !== undefined && (!Number.isFinite(value) || value < 0)) {
|
||||
throw new RangeError(`${name} for ${kind} must be non-negative`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function millisecondsToTicks(milliseconds: number, tickRateHz: number): number {
|
||||
return Math.max(0, Math.floor((milliseconds / 1_000) * tickRateHz));
|
||||
}
|
||||
|
||||
function clamp(value: number, minimum: number, maximum: number): number {
|
||||
return Math.max(minimum, Math.min(maximum, value));
|
||||
}
|
||||
92
packages/engine/src/network-clock.ts
Normal file
92
packages/engine/src/network-clock.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import type { PingPacket, PongPacket } from "./types.js";
|
||||
|
||||
export interface NetworkStats {
|
||||
roundTripTime: number;
|
||||
jitter: number;
|
||||
clockOffset: number;
|
||||
samples: number;
|
||||
}
|
||||
|
||||
interface ClockSample {
|
||||
roundTripTime: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
export class NetworkClock {
|
||||
private nextPingId = 1;
|
||||
private readonly recentSamples: ClockSample[] = [];
|
||||
private smoothedRtt = 0;
|
||||
private smoothedJitter = 0;
|
||||
private offset = 0;
|
||||
|
||||
createPing(clientNow: number): PingPacket {
|
||||
const ping = {
|
||||
id: this.nextPingId,
|
||||
clientSentAt: clientNow,
|
||||
};
|
||||
|
||||
this.nextPingId = (this.nextPingId + 1) >>> 0;
|
||||
return ping;
|
||||
}
|
||||
|
||||
receivePong(pong: PongPacket, clientReceivedAt: number): NetworkStats {
|
||||
const serverProcessing = Math.max(
|
||||
0,
|
||||
pong.serverSentAt - pong.serverReceivedAt,
|
||||
);
|
||||
const roundTripTime = Math.max(
|
||||
0,
|
||||
clientReceivedAt - pong.clientSentAt - serverProcessing,
|
||||
);
|
||||
const offset =
|
||||
((pong.serverReceivedAt - pong.clientSentAt) +
|
||||
(pong.serverSentAt - clientReceivedAt)) /
|
||||
2;
|
||||
|
||||
const previousRtt = this.smoothedRtt;
|
||||
this.smoothedRtt =
|
||||
this.recentSamples.length === 0
|
||||
? roundTripTime
|
||||
: previousRtt * 0.8 + roundTripTime * 0.2;
|
||||
this.smoothedJitter =
|
||||
this.recentSamples.length === 0
|
||||
? 0
|
||||
: this.smoothedJitter * 0.8 +
|
||||
Math.abs(roundTripTime - previousRtt) * 0.2;
|
||||
|
||||
this.recentSamples.push({ roundTripTime, offset });
|
||||
if (this.recentSamples.length > 10) {
|
||||
this.recentSamples.shift();
|
||||
}
|
||||
|
||||
const bestSample = this.recentSamples.reduce((best, sample) =>
|
||||
sample.roundTripTime < best.roundTripTime ? sample : best,
|
||||
);
|
||||
this.offset = bestSample.offset;
|
||||
|
||||
return this.stats;
|
||||
}
|
||||
|
||||
toServerTime(clientNow: number): number {
|
||||
return clientNow + this.offset;
|
||||
}
|
||||
|
||||
recommendedInputLeadTicks(tickRateHz: number, maximum = 8): number {
|
||||
const tickMilliseconds = 1_000 / tickRateHz;
|
||||
const latencyBudget = this.smoothedRtt / 2 + this.smoothedJitter * 2;
|
||||
|
||||
return Math.max(
|
||||
1,
|
||||
Math.min(maximum, Math.ceil(latencyBudget / tickMilliseconds) + 1),
|
||||
);
|
||||
}
|
||||
|
||||
get stats(): NetworkStats {
|
||||
return {
|
||||
roundTripTime: this.smoothedRtt,
|
||||
jitter: this.smoothedJitter,
|
||||
clockOffset: this.offset,
|
||||
samples: this.recentSamples.length,
|
||||
};
|
||||
}
|
||||
}
|
||||
214
packages/engine/src/networked-client.ts
Normal file
214
packages/engine/src/networked-client.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
import { NetworkClock } from "./network-clock.js";
|
||||
import type {
|
||||
ClientStateReport,
|
||||
InputContext,
|
||||
InputPacket,
|
||||
PlayerId,
|
||||
StateSnapshot,
|
||||
} from "./types.js";
|
||||
import type { NetworkedGameDefinition } from "./networked-types.js";
|
||||
|
||||
interface PredictedInput<Input> {
|
||||
packet: InputPacket<Input>;
|
||||
applied: boolean;
|
||||
}
|
||||
|
||||
export class NetworkedPredictedEngine<
|
||||
AuthorityState,
|
||||
ClientState,
|
||||
Input,
|
||||
AuthorityEvent,
|
||||
PerceptionEvent,
|
||||
> {
|
||||
readonly game: NetworkedGameDefinition<
|
||||
AuthorityState,
|
||||
ClientState,
|
||||
Input,
|
||||
AuthorityEvent,
|
||||
PerceptionEvent
|
||||
>;
|
||||
readonly networkClock = new NetworkClock();
|
||||
private state: ClientState;
|
||||
private playerId: PlayerId | null = null;
|
||||
private currentTick = 0;
|
||||
private nextInputSequence = 1;
|
||||
private acknowledgedSequence = 0;
|
||||
private pendingInputs: PredictedInput<Input>[] = [];
|
||||
|
||||
constructor(
|
||||
game: NetworkedGameDefinition<
|
||||
AuthorityState,
|
||||
ClientState,
|
||||
Input,
|
||||
AuthorityEvent,
|
||||
PerceptionEvent
|
||||
>,
|
||||
) {
|
||||
this.game = game;
|
||||
this.state = game.client.createInitialState();
|
||||
}
|
||||
|
||||
get tick(): number {
|
||||
return this.currentTick;
|
||||
}
|
||||
|
||||
get localPlayerId(): PlayerId | null {
|
||||
return this.playerId;
|
||||
}
|
||||
|
||||
get currentState(): Readonly<ClientState> {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
get initialized(): boolean {
|
||||
return this.playerId !== null;
|
||||
}
|
||||
|
||||
initialize(playerId: PlayerId, snapshot: StateSnapshot<ClientState>): void {
|
||||
this.playerId = playerId;
|
||||
this.currentTick = snapshot.tick;
|
||||
this.state = this.game.client.cloneState(snapshot.state);
|
||||
this.pendingInputs = [];
|
||||
this.acknowledgedSequence = 0;
|
||||
}
|
||||
|
||||
createInput(input: Input, leadTicks?: number): InputPacket<Input> {
|
||||
if (this.playerId === null) {
|
||||
throw new Error("Client engine has not received a welcome snapshot");
|
||||
}
|
||||
|
||||
const targetTick =
|
||||
this.currentTick +
|
||||
(leadTicks ??
|
||||
this.networkClock.recommendedInputLeadTicks(this.game.tickRateHz));
|
||||
const packet = {
|
||||
sequence: this.nextInputSequence,
|
||||
targetTick,
|
||||
observedTick: this.currentTick,
|
||||
input,
|
||||
};
|
||||
|
||||
if (!this.game.validateInput(input, this.inputContext(packet))) {
|
||||
throw new Error("Refusing to send invalid local input");
|
||||
}
|
||||
|
||||
this.nextInputSequence = (this.nextInputSequence + 1) >>> 0;
|
||||
this.pendingInputs.push({ packet, applied: false });
|
||||
return packet;
|
||||
}
|
||||
|
||||
acknowledge(sequence: number): void {
|
||||
this.acknowledgedSequence = Math.max(this.acknowledgedSequence, sequence);
|
||||
this.pendingInputs = this.pendingInputs.filter(
|
||||
(pending) => pending.packet.sequence > this.acknowledgedSequence,
|
||||
);
|
||||
}
|
||||
|
||||
reject(sequence: number): void {
|
||||
this.pendingInputs = this.pendingInputs.filter(
|
||||
(pending) => pending.packet.sequence !== sequence,
|
||||
);
|
||||
}
|
||||
|
||||
step(): void {
|
||||
if (this.playerId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.currentTick += 1;
|
||||
this.applyDueInputs();
|
||||
this.game.client.step(this.state, {
|
||||
tick: this.currentTick,
|
||||
deltaSeconds: 1 / this.game.tickRateHz,
|
||||
});
|
||||
this.assertValidState();
|
||||
}
|
||||
|
||||
reconcile(snapshot: StateSnapshot<ClientState>): void {
|
||||
if (
|
||||
this.playerId === null ||
|
||||
snapshot.tick < this.currentTick - this.game.tickRateHz * 2
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const predictedTick = Math.max(this.currentTick, snapshot.tick);
|
||||
const context = {
|
||||
tick: snapshot.tick,
|
||||
deltaSeconds: 1 / this.game.tickRateHz,
|
||||
};
|
||||
this.state = this.game.client.mergeSnapshot
|
||||
? this.game.client.mergeSnapshot(this.state, snapshot.state, context)
|
||||
: this.game.client.cloneState(snapshot.state);
|
||||
this.currentTick = snapshot.tick;
|
||||
|
||||
for (const pending of this.pendingInputs) {
|
||||
pending.applied = false;
|
||||
}
|
||||
|
||||
this.applyDueInputs();
|
||||
while (this.currentTick < predictedTick) {
|
||||
this.currentTick += 1;
|
||||
this.applyDueInputs();
|
||||
this.game.client.step(this.state, {
|
||||
tick: this.currentTick,
|
||||
deltaSeconds: 1 / this.game.tickRateHz,
|
||||
});
|
||||
}
|
||||
this.assertValidState();
|
||||
}
|
||||
|
||||
receiveEvent(event: PerceptionEvent, tick: number): void {
|
||||
this.game.client.applyEvent?.(this.state, event, { tick });
|
||||
this.assertValidState();
|
||||
}
|
||||
|
||||
createStateReport(): ClientStateReport<ClientState> {
|
||||
return {
|
||||
tick: this.currentTick,
|
||||
state: this.game.client.cloneState(this.state),
|
||||
};
|
||||
}
|
||||
|
||||
private applyDueInputs(): void {
|
||||
for (const pending of this.pendingInputs) {
|
||||
if (!pending.applied && pending.packet.targetTick <= this.currentTick) {
|
||||
this.game.client.applyInput(
|
||||
this.state,
|
||||
pending.packet.input,
|
||||
this.inputContext(pending.packet),
|
||||
);
|
||||
pending.applied = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private inputContext(packet: InputPacket<Input>): InputContext {
|
||||
if (this.playerId === null) {
|
||||
throw new Error("Client engine has not been initialized");
|
||||
}
|
||||
|
||||
return {
|
||||
playerId: this.playerId,
|
||||
sequence: packet.sequence,
|
||||
targetTick: packet.targetTick,
|
||||
observedTick: packet.observedTick ?? packet.targetTick,
|
||||
tick: this.currentTick,
|
||||
deltaSeconds: 1 / this.game.tickRateHz,
|
||||
};
|
||||
}
|
||||
|
||||
private assertValidState(): void {
|
||||
if (
|
||||
this.game.client.validateState &&
|
||||
!this.game.client.validateState(this.state, {
|
||||
tick: this.currentTick,
|
||||
deltaSeconds: 1 / this.game.tickRateHz,
|
||||
})
|
||||
) {
|
||||
throw new Error(
|
||||
`Client prediction produced invalid state at tick ${this.currentTick}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
472
packages/engine/src/networked-server.ts
Normal file
472
packages/engine/src/networked-server.ts
Normal file
@@ -0,0 +1,472 @@
|
||||
import type {
|
||||
ClientStateReport,
|
||||
InputContext,
|
||||
InputPacket,
|
||||
PlayerId,
|
||||
StateSnapshot,
|
||||
ValidationResult,
|
||||
} from "./types.js";
|
||||
import type {
|
||||
NetworkedGameDefinition,
|
||||
SnapshotBatch,
|
||||
} from "./networked-types.js";
|
||||
import type {
|
||||
Acknowledgement,
|
||||
InputDecision,
|
||||
ServerEngineOptions,
|
||||
} from "./server.js";
|
||||
|
||||
export interface NetworkedServerStepResult<AuthorityEvent> {
|
||||
tick: number;
|
||||
acknowledgements: Acknowledgement[];
|
||||
validations: ValidationResult[];
|
||||
events: AuthorityEvent[];
|
||||
snapshotDue: boolean;
|
||||
}
|
||||
|
||||
export interface NetworkedSimulationCheckpoint<
|
||||
AuthorityState,
|
||||
Input,
|
||||
AuthorityEvent,
|
||||
> {
|
||||
tick: number;
|
||||
state: AuthorityState;
|
||||
playerIds: PlayerId[];
|
||||
lastReceivedSequences: Array<readonly [PlayerId, number]>;
|
||||
acknowledgedSequences: Array<readonly [PlayerId, number]>;
|
||||
queuedInputs: Array<{
|
||||
playerId: PlayerId;
|
||||
packet: InputPacket<Input>;
|
||||
}>;
|
||||
pendingEvents: AuthorityEvent[];
|
||||
}
|
||||
|
||||
interface QueuedInput<Input> {
|
||||
playerId: PlayerId;
|
||||
packet: InputPacket<Input>;
|
||||
}
|
||||
|
||||
interface QueuedReport<ClientState> {
|
||||
playerId: PlayerId;
|
||||
report: ClientStateReport<ClientState>;
|
||||
}
|
||||
|
||||
export class NetworkedAuthoritativeEngine<
|
||||
AuthorityState,
|
||||
ClientState,
|
||||
Input,
|
||||
AuthorityEvent,
|
||||
PerceptionEvent,
|
||||
> {
|
||||
readonly game: NetworkedGameDefinition<
|
||||
AuthorityState,
|
||||
ClientState,
|
||||
Input,
|
||||
AuthorityEvent,
|
||||
PerceptionEvent
|
||||
>;
|
||||
private readonly historyTicks: number;
|
||||
private readonly maxPastTicks: number;
|
||||
private readonly maxFutureTicks: number;
|
||||
private readonly snapshotEveryTicks: number;
|
||||
private readonly players = new Set<PlayerId>();
|
||||
private readonly lastReceivedSequence = new Map<PlayerId, number>();
|
||||
private readonly acknowledgedSequence = new Map<PlayerId, number>();
|
||||
private readonly history = new Map<number, AuthorityState>();
|
||||
private queuedInputs: QueuedInput<Input>[] = [];
|
||||
private queuedReports: QueuedReport<ClientState>[] = [];
|
||||
private pendingEvents: AuthorityEvent[] = [];
|
||||
private state: AuthorityState;
|
||||
private currentTick = 0;
|
||||
|
||||
constructor(
|
||||
game: NetworkedGameDefinition<
|
||||
AuthorityState,
|
||||
ClientState,
|
||||
Input,
|
||||
AuthorityEvent,
|
||||
PerceptionEvent
|
||||
>,
|
||||
options: ServerEngineOptions = {},
|
||||
) {
|
||||
this.game = game;
|
||||
this.historyTicks = options.historyTicks ?? game.tickRateHz * 2;
|
||||
this.maxPastTicks = options.maxPastTicks ?? 2;
|
||||
this.maxFutureTicks = options.maxFutureTicks ?? game.tickRateHz;
|
||||
this.snapshotEveryTicks = game.tickRateHz / game.snapshotRateHz;
|
||||
this.state = game.server.createInitialState();
|
||||
game.server.initializeState?.(this.state, {
|
||||
tick: 0,
|
||||
deltaSeconds: 1 / game.tickRateHz,
|
||||
});
|
||||
this.assertValidState();
|
||||
this.storeHistory();
|
||||
}
|
||||
|
||||
get tick(): number {
|
||||
return this.currentTick;
|
||||
}
|
||||
|
||||
get currentState(): Readonly<AuthorityState> {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
get playerIds(): PlayerId[] {
|
||||
return [...this.players];
|
||||
}
|
||||
|
||||
addPlayer(playerId: PlayerId): void {
|
||||
if (this.players.has(playerId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.players.add(playerId);
|
||||
this.game.server.addPlayer?.(this.state, {
|
||||
playerId,
|
||||
tick: this.currentTick,
|
||||
emit: (event) => this.pendingEvents.push(event),
|
||||
});
|
||||
this.assertValidState();
|
||||
this.storeHistory();
|
||||
}
|
||||
|
||||
removePlayer(playerId: PlayerId): void {
|
||||
if (!this.players.delete(playerId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.game.server.removePlayer?.(this.state, {
|
||||
playerId,
|
||||
tick: this.currentTick,
|
||||
emit: (event) => this.pendingEvents.push(event),
|
||||
});
|
||||
this.lastReceivedSequence.delete(playerId);
|
||||
this.acknowledgedSequence.delete(playerId);
|
||||
this.queuedInputs = this.queuedInputs.filter(
|
||||
(queued) => queued.playerId !== playerId,
|
||||
);
|
||||
this.queuedReports = this.queuedReports.filter(
|
||||
(queued) => queued.playerId !== playerId,
|
||||
);
|
||||
this.assertValidState();
|
||||
this.storeHistory();
|
||||
}
|
||||
|
||||
submitInput(playerId: PlayerId, packet: InputPacket<Input>): InputDecision {
|
||||
if (!this.players.has(playerId)) {
|
||||
return { accepted: false, reason: "unknown-player" };
|
||||
}
|
||||
|
||||
const previousSequence = this.lastReceivedSequence.get(playerId) ?? 0;
|
||||
if (packet.sequence <= previousSequence) {
|
||||
return { accepted: false, reason: "duplicate" };
|
||||
}
|
||||
if (packet.targetTick < this.currentTick - this.maxPastTicks) {
|
||||
return { accepted: false, reason: "past" };
|
||||
}
|
||||
if (packet.targetTick > this.currentTick + this.maxFutureTicks) {
|
||||
return { accepted: false, reason: "future" };
|
||||
}
|
||||
|
||||
const context = this.inputContext(playerId, packet);
|
||||
if (!this.game.validateInput(packet.input, context)) {
|
||||
return { accepted: false, reason: "invalid" };
|
||||
}
|
||||
|
||||
this.lastReceivedSequence.set(playerId, packet.sequence);
|
||||
this.queuedInputs.push({ playerId, packet });
|
||||
return { accepted: true };
|
||||
}
|
||||
|
||||
submitStateReport(
|
||||
playerId: PlayerId,
|
||||
report: ClientStateReport<ClientState>,
|
||||
): ValidationResult | null {
|
||||
if (report.tick > this.currentTick + this.maxFutureTicks) {
|
||||
return {
|
||||
playerId,
|
||||
tick: report.tick,
|
||||
valid: false,
|
||||
reason: "too-far-ahead",
|
||||
};
|
||||
}
|
||||
|
||||
if (report.tick > this.currentTick) {
|
||||
this.queuedReports = this.queuedReports.filter(
|
||||
(queued) => queued.playerId !== playerId,
|
||||
);
|
||||
this.queuedReports.push({ playerId, report });
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.validateReport(playerId, report);
|
||||
}
|
||||
|
||||
step(): NetworkedServerStepResult<AuthorityEvent> {
|
||||
this.currentTick += 1;
|
||||
const deltaSeconds = 1 / this.game.tickRateHz;
|
||||
const events = this.pendingEvents;
|
||||
this.pendingEvents = [];
|
||||
const emit = (event: AuthorityEvent) => events.push(event);
|
||||
const acknowledgementByPlayer = new Map<PlayerId, number>();
|
||||
const dueInputs: QueuedInput<Input>[] = [];
|
||||
const futureInputs: QueuedInput<Input>[] = [];
|
||||
|
||||
for (const queued of this.queuedInputs) {
|
||||
(queued.packet.targetTick <= this.currentTick
|
||||
? dueInputs
|
||||
: futureInputs
|
||||
).push(queued);
|
||||
}
|
||||
this.queuedInputs = futureInputs;
|
||||
|
||||
dueInputs.sort(
|
||||
(left, right) =>
|
||||
left.packet.targetTick - right.packet.targetTick ||
|
||||
left.playerId - right.playerId ||
|
||||
left.packet.sequence - right.packet.sequence,
|
||||
);
|
||||
|
||||
for (const queued of dueInputs) {
|
||||
const { playerId, packet } = queued;
|
||||
this.game.server.applyInput(this.state, packet.input, {
|
||||
...this.inputContext(playerId, packet),
|
||||
emit,
|
||||
});
|
||||
|
||||
const previousAck = this.acknowledgedSequence.get(playerId) ?? 0;
|
||||
if (packet.sequence > previousAck) {
|
||||
this.acknowledgedSequence.set(playerId, packet.sequence);
|
||||
acknowledgementByPlayer.set(playerId, packet.sequence);
|
||||
}
|
||||
}
|
||||
|
||||
this.game.server.step(this.state, {
|
||||
tick: this.currentTick,
|
||||
deltaSeconds,
|
||||
emit,
|
||||
});
|
||||
this.assertValidState();
|
||||
this.storeHistory();
|
||||
|
||||
const validations: ValidationResult[] = [];
|
||||
const futureReports: QueuedReport<ClientState>[] = [];
|
||||
for (const queued of this.queuedReports) {
|
||||
if (queued.report.tick <= this.currentTick) {
|
||||
validations.push(
|
||||
this.validateReport(queued.playerId, queued.report),
|
||||
);
|
||||
} else {
|
||||
futureReports.push(queued);
|
||||
}
|
||||
}
|
||||
this.queuedReports = futureReports;
|
||||
|
||||
return {
|
||||
tick: this.currentTick,
|
||||
acknowledgements: [...acknowledgementByPlayer].map(
|
||||
([playerId, sequence]) => ({ playerId, sequence }),
|
||||
),
|
||||
validations,
|
||||
events,
|
||||
snapshotDue: this.currentTick % this.snapshotEveryTicks === 0,
|
||||
};
|
||||
}
|
||||
|
||||
createSnapshot(
|
||||
playerId: PlayerId,
|
||||
serverTime: number,
|
||||
): StateSnapshot<ClientState> {
|
||||
return {
|
||||
tick: this.currentTick,
|
||||
serverTime,
|
||||
state: this.game.replication.createSnapshot(this.state, {
|
||||
playerId,
|
||||
tick: this.currentTick,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
createSnapshotBatches(serverTime: number): SnapshotBatch<ClientState>[] {
|
||||
const groups = new Map<string | number, PlayerId[]>();
|
||||
for (const playerId of this.players) {
|
||||
const context = { playerId, tick: this.currentTick };
|
||||
const key =
|
||||
this.game.replication.groupKey?.(this.state, context) ?? playerId;
|
||||
const group = groups.get(key);
|
||||
if (group) {
|
||||
group.push(playerId);
|
||||
} else {
|
||||
groups.set(key, [playerId]);
|
||||
}
|
||||
}
|
||||
|
||||
return [...groups.values()].map((playerIds) => ({
|
||||
playerIds,
|
||||
state: this.createSnapshot(playerIds[0]!, serverTime).state,
|
||||
}));
|
||||
}
|
||||
|
||||
createPerceptions(
|
||||
playerId: PlayerId,
|
||||
events: readonly AuthorityEvent[],
|
||||
): PerceptionEvent[] {
|
||||
if (!this.game.replication.perceive) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const perceptions: PerceptionEvent[] = [];
|
||||
for (const event of events) {
|
||||
const perception = this.game.replication.perceive(
|
||||
this.state,
|
||||
event,
|
||||
{ playerId, tick: this.currentTick },
|
||||
);
|
||||
if (perception !== null) {
|
||||
perceptions.push(perception);
|
||||
}
|
||||
}
|
||||
return perceptions;
|
||||
}
|
||||
|
||||
createSimulationCheckpoint(): NetworkedSimulationCheckpoint<
|
||||
AuthorityState,
|
||||
Input,
|
||||
AuthorityEvent
|
||||
> {
|
||||
return {
|
||||
tick: this.currentTick,
|
||||
state: this.game.server.cloneState(this.state),
|
||||
playerIds: [...this.players],
|
||||
lastReceivedSequences: [...this.lastReceivedSequence],
|
||||
acknowledgedSequences: [...this.acknowledgedSequence],
|
||||
queuedInputs: this.queuedInputs.map(({ playerId, packet }) => ({
|
||||
playerId,
|
||||
packet: this.cloneInputPacket(packet),
|
||||
})),
|
||||
pendingEvents: [...this.pendingEvents],
|
||||
};
|
||||
}
|
||||
|
||||
restoreSimulationCheckpoint(
|
||||
checkpoint: NetworkedSimulationCheckpoint<
|
||||
AuthorityState,
|
||||
Input,
|
||||
AuthorityEvent
|
||||
>,
|
||||
): void {
|
||||
if (!Number.isInteger(checkpoint.tick) || checkpoint.tick < 0) {
|
||||
throw new RangeError("checkpoint tick must be a non-negative integer");
|
||||
}
|
||||
|
||||
this.currentTick = checkpoint.tick;
|
||||
this.state = this.game.server.cloneState(checkpoint.state);
|
||||
|
||||
this.players.clear();
|
||||
for (const playerId of checkpoint.playerIds) this.players.add(playerId);
|
||||
|
||||
this.lastReceivedSequence.clear();
|
||||
for (const [playerId, sequence] of checkpoint.lastReceivedSequences) {
|
||||
this.lastReceivedSequence.set(playerId, sequence);
|
||||
}
|
||||
|
||||
this.acknowledgedSequence.clear();
|
||||
for (const [playerId, sequence] of checkpoint.acknowledgedSequences) {
|
||||
this.acknowledgedSequence.set(playerId, sequence);
|
||||
}
|
||||
|
||||
this.queuedInputs = checkpoint.queuedInputs.map(({ playerId, packet }) => ({
|
||||
playerId,
|
||||
packet: this.cloneInputPacket(packet),
|
||||
}));
|
||||
this.queuedReports = [];
|
||||
this.pendingEvents = [...checkpoint.pendingEvents];
|
||||
this.history.clear();
|
||||
this.assertValidState();
|
||||
this.storeHistory();
|
||||
}
|
||||
|
||||
private inputContext(
|
||||
playerId: PlayerId,
|
||||
packet: InputPacket<Input>,
|
||||
): InputContext {
|
||||
return {
|
||||
playerId,
|
||||
sequence: packet.sequence,
|
||||
targetTick: packet.targetTick,
|
||||
observedTick: packet.observedTick ?? packet.targetTick,
|
||||
tick: this.currentTick,
|
||||
deltaSeconds: 1 / this.game.tickRateHz,
|
||||
};
|
||||
}
|
||||
|
||||
private cloneInputPacket(packet: InputPacket<Input>): InputPacket<Input> {
|
||||
const encoded = this.game.codecs.input.encode(packet.input);
|
||||
return {
|
||||
sequence: packet.sequence,
|
||||
targetTick: packet.targetTick,
|
||||
observedTick: packet.observedTick ?? packet.targetTick,
|
||||
input: this.game.codecs.input.decode(encoded),
|
||||
};
|
||||
}
|
||||
|
||||
private validateReport(
|
||||
playerId: PlayerId,
|
||||
report: ClientStateReport<ClientState>,
|
||||
): ValidationResult {
|
||||
const authoritative = this.history.get(report.tick);
|
||||
if (!authoritative) {
|
||||
return {
|
||||
playerId,
|
||||
tick: report.tick,
|
||||
valid: false,
|
||||
reason: "outside-history",
|
||||
};
|
||||
}
|
||||
|
||||
const valid = this.game.replication.validateClientState
|
||||
? this.game.replication.validateClientState(
|
||||
authoritative,
|
||||
report.state,
|
||||
{ playerId, tick: report.tick },
|
||||
)
|
||||
: (this.game.client.validateState?.(report.state, {
|
||||
tick: report.tick,
|
||||
deltaSeconds: 1 / this.game.tickRateHz,
|
||||
}) ?? true);
|
||||
|
||||
return {
|
||||
playerId,
|
||||
tick: report.tick,
|
||||
valid,
|
||||
...(valid ? {} : { reason: "mismatch" as const }),
|
||||
};
|
||||
}
|
||||
|
||||
private assertValidState(): void {
|
||||
if (
|
||||
this.game.server.validateState &&
|
||||
!this.game.server.validateState(this.state, {
|
||||
tick: this.currentTick,
|
||||
deltaSeconds: 1 / this.game.tickRateHz,
|
||||
})
|
||||
) {
|
||||
throw new Error(`Game produced invalid state at tick ${this.currentTick}`);
|
||||
}
|
||||
}
|
||||
|
||||
private storeHistory(): void {
|
||||
this.history.set(
|
||||
this.currentTick,
|
||||
this.game.server.cloneStateForHistory?.(this.state) ??
|
||||
this.game.server.cloneState(this.state),
|
||||
);
|
||||
|
||||
const oldestTick = this.currentTick - this.historyTicks;
|
||||
for (const tick of this.history.keys()) {
|
||||
if (tick < oldestTick) {
|
||||
this.history.delete(tick);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
125
packages/engine/src/networked-types.ts
Normal file
125
packages/engine/src/networked-types.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import type {
|
||||
BinaryCodec,
|
||||
ClientStateContext,
|
||||
InputContext,
|
||||
PlayerContext,
|
||||
PlayerId,
|
||||
TickContext,
|
||||
} from "./types.js";
|
||||
|
||||
export interface EmittingTickContext<Event> extends TickContext {
|
||||
emit(event: Event): void;
|
||||
}
|
||||
|
||||
export interface EmittingInputContext<Event> extends InputContext {
|
||||
emit(event: Event): void;
|
||||
}
|
||||
|
||||
export interface EmittingPlayerContext<Event> extends PlayerContext {
|
||||
emit(event: Event): void;
|
||||
}
|
||||
|
||||
export interface ClientEventContext {
|
||||
tick: number;
|
||||
}
|
||||
|
||||
export interface ServerSimulationRules<AuthorityState, Input, AuthorityEvent> {
|
||||
createInitialState(): AuthorityState;
|
||||
/** Runs after all higher-order initializers have finished creating state. */
|
||||
initializeState?(state: AuthorityState, context: TickContext): void;
|
||||
cloneState(state: AuthorityState): AuthorityState;
|
||||
/** Optional cheaper clone used only for client-state validation history. */
|
||||
cloneStateForHistory?(state: AuthorityState): AuthorityState;
|
||||
step(
|
||||
state: AuthorityState,
|
||||
context: EmittingTickContext<AuthorityEvent>,
|
||||
): void;
|
||||
applyInput(
|
||||
state: AuthorityState,
|
||||
input: Input,
|
||||
context: EmittingInputContext<AuthorityEvent>,
|
||||
): void;
|
||||
validateState?(state: AuthorityState, context: TickContext): boolean;
|
||||
addPlayer?(
|
||||
state: AuthorityState,
|
||||
context: EmittingPlayerContext<AuthorityEvent>,
|
||||
): void;
|
||||
removePlayer?(
|
||||
state: AuthorityState,
|
||||
context: EmittingPlayerContext<AuthorityEvent>,
|
||||
): void;
|
||||
}
|
||||
|
||||
export interface ClientSimulationRules<ClientState, Input, PerceptionEvent> {
|
||||
createInitialState(): ClientState;
|
||||
cloneState(state: ClientState): ClientState;
|
||||
step(state: ClientState, context: TickContext): void;
|
||||
applyInput(state: ClientState, input: Input, context: InputContext): void;
|
||||
validateState?(state: ClientState, context: TickContext): boolean;
|
||||
mergeSnapshot?(
|
||||
predicted: ClientState,
|
||||
snapshot: ClientState,
|
||||
context: TickContext,
|
||||
): ClientState;
|
||||
applyEvent?(
|
||||
state: ClientState,
|
||||
event: PerceptionEvent,
|
||||
context: ClientEventContext,
|
||||
): void;
|
||||
}
|
||||
|
||||
export interface ReplicationRules<
|
||||
AuthorityState,
|
||||
ClientState,
|
||||
AuthorityEvent,
|
||||
PerceptionEvent,
|
||||
> {
|
||||
createSnapshot(
|
||||
authoritative: AuthorityState,
|
||||
context: PlayerContext,
|
||||
): ClientState;
|
||||
validateClientState?(
|
||||
authoritative: AuthorityState,
|
||||
candidate: ClientState,
|
||||
context: ClientStateContext,
|
||||
): boolean;
|
||||
perceive?(
|
||||
authoritative: AuthorityState,
|
||||
event: AuthorityEvent,
|
||||
context: PlayerContext,
|
||||
): PerceptionEvent | null;
|
||||
groupKey?(
|
||||
authoritative: AuthorityState,
|
||||
context: PlayerContext,
|
||||
): string | number;
|
||||
}
|
||||
|
||||
export interface NetworkedGameDefinition<
|
||||
AuthorityState,
|
||||
ClientState,
|
||||
Input,
|
||||
AuthorityEvent = never,
|
||||
PerceptionEvent = never,
|
||||
> {
|
||||
tickRateHz: number;
|
||||
snapshotRateHz: number;
|
||||
server: ServerSimulationRules<AuthorityState, Input, AuthorityEvent>;
|
||||
client: ClientSimulationRules<ClientState, Input, PerceptionEvent>;
|
||||
replication: ReplicationRules<
|
||||
AuthorityState,
|
||||
ClientState,
|
||||
AuthorityEvent,
|
||||
PerceptionEvent
|
||||
>;
|
||||
validateInput(input: Input, context: InputContext): boolean;
|
||||
codecs: {
|
||||
input: BinaryCodec<Input>;
|
||||
state: BinaryCodec<ClientState>;
|
||||
event?: BinaryCodec<PerceptionEvent>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SnapshotBatch<ClientState> {
|
||||
playerIds: PlayerId[];
|
||||
state: ClientState;
|
||||
}
|
||||
401
packages/engine/src/protocol.ts
Normal file
401
packages/engine/src/protocol.ts
Normal file
@@ -0,0 +1,401 @@
|
||||
import type {
|
||||
BinaryCodec,
|
||||
ClientStateReport,
|
||||
InputPacket,
|
||||
PingPacket,
|
||||
PlayerId,
|
||||
PongPacket,
|
||||
StateSnapshot,
|
||||
} from "./types.js";
|
||||
|
||||
const enum ServerOpcode {
|
||||
Welcome = 1,
|
||||
Snapshot = 2,
|
||||
Acknowledge = 3,
|
||||
Pong = 4,
|
||||
Validation = 5,
|
||||
RejectInput = 6,
|
||||
Event = 7,
|
||||
ReplayStart = 8,
|
||||
ReplayFrame = 9,
|
||||
ReplayEnd = 10,
|
||||
}
|
||||
|
||||
const enum ClientOpcode {
|
||||
Input = 16,
|
||||
Ping = 17,
|
||||
StateReport = 18,
|
||||
}
|
||||
|
||||
export type ClientWireMessage<Input, State> =
|
||||
| { kind: "input"; packet: InputPacket<Input> }
|
||||
| { kind: "ping"; ping: PingPacket }
|
||||
| { kind: "state-report"; report: ClientStateReport<State> };
|
||||
|
||||
export type ServerWireMessage<State, Event = never> =
|
||||
| { kind: "welcome"; playerId: PlayerId; snapshot: StateSnapshot<State> }
|
||||
| { kind: "snapshot"; snapshot: StateSnapshot<State> }
|
||||
| { kind: "acknowledge"; sequence: number }
|
||||
| { kind: "pong"; pong: PongPacket }
|
||||
| { kind: "validation"; tick: number; valid: boolean }
|
||||
| { kind: "reject-input"; sequence: number }
|
||||
| { kind: "event"; tick: number; event: Event }
|
||||
| {
|
||||
kind: "replay-start";
|
||||
ticketId: number;
|
||||
perspectiveId: PlayerId;
|
||||
fromTick: number;
|
||||
toTick: number;
|
||||
frameCount: number;
|
||||
playbackRate: number;
|
||||
}
|
||||
| {
|
||||
kind: "replay-frame";
|
||||
ticketId: number;
|
||||
tick: number;
|
||||
state: State;
|
||||
events: Event[];
|
||||
}
|
||||
| { kind: "replay-end"; ticketId: number };
|
||||
|
||||
export interface BinaryProtocol<Input, State, Event = never> {
|
||||
encodeClient(message: ClientWireMessage<Input, State>): ArrayBuffer;
|
||||
decodeClient(payload: ArrayBuffer): ClientWireMessage<Input, State>;
|
||||
encodeServer(message: ServerWireMessage<State, Event>): ArrayBuffer;
|
||||
decodeServer(payload: ArrayBuffer): ServerWireMessage<State, Event>;
|
||||
}
|
||||
|
||||
export function createBinaryProtocol<Input, State, Event = never>(codecs: {
|
||||
input: BinaryCodec<Input>;
|
||||
state: BinaryCodec<State>;
|
||||
event?: BinaryCodec<Event>;
|
||||
}): BinaryProtocol<Input, State, Event> {
|
||||
return {
|
||||
encodeClient(message) {
|
||||
switch (message.kind) {
|
||||
case "input": {
|
||||
const input = codecs.input.encode(message.packet.input);
|
||||
const payload = createFrame(ClientOpcode.Input, 13, input);
|
||||
const view = new DataView(payload);
|
||||
view.setUint32(1, message.packet.sequence, true);
|
||||
view.setUint32(5, message.packet.targetTick, true);
|
||||
view.setUint32(
|
||||
9,
|
||||
message.packet.observedTick ?? message.packet.targetTick,
|
||||
true,
|
||||
);
|
||||
return payload;
|
||||
}
|
||||
case "ping": {
|
||||
const payload = createFrame(ClientOpcode.Ping, 13);
|
||||
const view = new DataView(payload);
|
||||
view.setUint32(1, message.ping.id, true);
|
||||
view.setFloat64(5, message.ping.clientSentAt, true);
|
||||
return payload;
|
||||
}
|
||||
case "state-report": {
|
||||
const state = codecs.state.encode(message.report.state);
|
||||
const payload = createFrame(ClientOpcode.StateReport, 5, state);
|
||||
new DataView(payload).setUint32(1, message.report.tick, true);
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
},
|
||||
decodeClient(payload) {
|
||||
const view = new DataView(payload);
|
||||
switch (view.getUint8(0)) {
|
||||
case ClientOpcode.Input:
|
||||
assertLength(payload, 13);
|
||||
return {
|
||||
kind: "input",
|
||||
packet: {
|
||||
sequence: view.getUint32(1, true),
|
||||
targetTick: view.getUint32(5, true),
|
||||
observedTick: view.getUint32(9, true),
|
||||
input: codecs.input.decode(new Uint8Array(payload, 13)),
|
||||
},
|
||||
};
|
||||
case ClientOpcode.Ping:
|
||||
assertExactLength(payload, 13);
|
||||
return {
|
||||
kind: "ping",
|
||||
ping: {
|
||||
id: view.getUint32(1, true),
|
||||
clientSentAt: view.getFloat64(5, true),
|
||||
},
|
||||
};
|
||||
case ClientOpcode.StateReport:
|
||||
assertLength(payload, 5);
|
||||
return {
|
||||
kind: "state-report",
|
||||
report: {
|
||||
tick: view.getUint32(1, true),
|
||||
state: codecs.state.decode(new Uint8Array(payload, 5)),
|
||||
},
|
||||
};
|
||||
default:
|
||||
throw new RangeError("Unknown client message opcode");
|
||||
}
|
||||
},
|
||||
encodeServer(message) {
|
||||
switch (message.kind) {
|
||||
case "welcome": {
|
||||
const state = codecs.state.encode(message.snapshot.state);
|
||||
const payload = createFrame(ServerOpcode.Welcome, 17, state);
|
||||
const view = new DataView(payload);
|
||||
view.setUint32(1, message.playerId, true);
|
||||
view.setUint32(5, message.snapshot.tick, true);
|
||||
view.setFloat64(9, message.snapshot.serverTime, true);
|
||||
return payload;
|
||||
}
|
||||
case "snapshot": {
|
||||
const state = codecs.state.encode(message.snapshot.state);
|
||||
const payload = createFrame(ServerOpcode.Snapshot, 13, state);
|
||||
const view = new DataView(payload);
|
||||
view.setUint32(1, message.snapshot.tick, true);
|
||||
view.setFloat64(5, message.snapshot.serverTime, true);
|
||||
return payload;
|
||||
}
|
||||
case "acknowledge": {
|
||||
const payload = createFrame(ServerOpcode.Acknowledge, 5);
|
||||
new DataView(payload).setUint32(1, message.sequence, true);
|
||||
return payload;
|
||||
}
|
||||
case "pong": {
|
||||
const payload = createFrame(ServerOpcode.Pong, 29);
|
||||
const view = new DataView(payload);
|
||||
view.setUint32(1, message.pong.id, true);
|
||||
view.setFloat64(5, message.pong.clientSentAt, true);
|
||||
view.setFloat64(13, message.pong.serverReceivedAt, true);
|
||||
view.setFloat64(21, message.pong.serverSentAt, true);
|
||||
return payload;
|
||||
}
|
||||
case "validation": {
|
||||
const payload = createFrame(ServerOpcode.Validation, 6);
|
||||
const view = new DataView(payload);
|
||||
view.setUint32(1, message.tick, true);
|
||||
view.setUint8(5, message.valid ? 1 : 0);
|
||||
return payload;
|
||||
}
|
||||
case "reject-input": {
|
||||
const payload = createFrame(ServerOpcode.RejectInput, 5);
|
||||
new DataView(payload).setUint32(1, message.sequence, true);
|
||||
return payload;
|
||||
}
|
||||
case "event": {
|
||||
if (!codecs.event) {
|
||||
throw new Error("This protocol has no event codec");
|
||||
}
|
||||
const event = codecs.event.encode(message.event);
|
||||
const payload = createFrame(ServerOpcode.Event, 5, event);
|
||||
new DataView(payload).setUint32(1, message.tick, true);
|
||||
return payload;
|
||||
}
|
||||
case "replay-start": {
|
||||
if (message.frameCount > 65_535) {
|
||||
throw new RangeError("Replay frame count exceeds protocol limit");
|
||||
}
|
||||
const payload = createFrame(ServerOpcode.ReplayStart, 23);
|
||||
const view = new DataView(payload);
|
||||
view.setUint32(1, message.ticketId, true);
|
||||
view.setUint32(5, message.perspectiveId, true);
|
||||
view.setUint32(9, message.fromTick, true);
|
||||
view.setUint32(13, message.toTick, true);
|
||||
view.setUint16(17, message.frameCount, true);
|
||||
view.setFloat32(19, message.playbackRate, true);
|
||||
return payload;
|
||||
}
|
||||
case "replay-frame": {
|
||||
if (message.events.length > 65_535) {
|
||||
throw new RangeError("Replay event count exceeds protocol limit");
|
||||
}
|
||||
if (message.events.length > 0 && !codecs.event) {
|
||||
throw new Error("This protocol has no event codec");
|
||||
}
|
||||
const state = codecs.state.encode(message.state);
|
||||
const events = message.events.map((event) => codecs.event!.encode(event));
|
||||
const bodyLength =
|
||||
state.byteLength +
|
||||
events.reduce((total, event) => total + 4 + event.byteLength, 0);
|
||||
const payload = createFrame(ServerOpcode.ReplayFrame, 15 + bodyLength);
|
||||
const view = new DataView(payload);
|
||||
view.setUint32(1, message.ticketId, true);
|
||||
view.setUint32(5, message.tick, true);
|
||||
view.setUint16(9, events.length, true);
|
||||
view.setUint32(11, state.byteLength, true);
|
||||
const bytes = new Uint8Array(payload);
|
||||
let offset = 15;
|
||||
bytes.set(state, offset);
|
||||
offset += state.byteLength;
|
||||
for (const event of events) {
|
||||
view.setUint32(offset, event.byteLength, true);
|
||||
offset += 4;
|
||||
bytes.set(event, offset);
|
||||
offset += event.byteLength;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
case "replay-end": {
|
||||
const payload = createFrame(ServerOpcode.ReplayEnd, 5);
|
||||
new DataView(payload).setUint32(1, message.ticketId, true);
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
},
|
||||
decodeServer(payload) {
|
||||
const view = new DataView(payload);
|
||||
switch (view.getUint8(0)) {
|
||||
case ServerOpcode.Welcome:
|
||||
assertLength(payload, 17);
|
||||
return {
|
||||
kind: "welcome",
|
||||
playerId: view.getUint32(1, true),
|
||||
snapshot: {
|
||||
tick: view.getUint32(5, true),
|
||||
serverTime: view.getFloat64(9, true),
|
||||
state: codecs.state.decode(new Uint8Array(payload, 17)),
|
||||
},
|
||||
};
|
||||
case ServerOpcode.Snapshot:
|
||||
assertLength(payload, 13);
|
||||
return {
|
||||
kind: "snapshot",
|
||||
snapshot: {
|
||||
tick: view.getUint32(1, true),
|
||||
serverTime: view.getFloat64(5, true),
|
||||
state: codecs.state.decode(new Uint8Array(payload, 13)),
|
||||
},
|
||||
};
|
||||
case ServerOpcode.Acknowledge:
|
||||
assertExactLength(payload, 5);
|
||||
return {
|
||||
kind: "acknowledge",
|
||||
sequence: view.getUint32(1, true),
|
||||
};
|
||||
case ServerOpcode.Pong:
|
||||
assertExactLength(payload, 29);
|
||||
return {
|
||||
kind: "pong",
|
||||
pong: {
|
||||
id: view.getUint32(1, true),
|
||||
clientSentAt: view.getFloat64(5, true),
|
||||
serverReceivedAt: view.getFloat64(13, true),
|
||||
serverSentAt: view.getFloat64(21, true),
|
||||
},
|
||||
};
|
||||
case ServerOpcode.Validation:
|
||||
assertExactLength(payload, 6);
|
||||
return {
|
||||
kind: "validation",
|
||||
tick: view.getUint32(1, true),
|
||||
valid: view.getUint8(5) === 1,
|
||||
};
|
||||
case ServerOpcode.RejectInput:
|
||||
assertExactLength(payload, 5);
|
||||
return {
|
||||
kind: "reject-input",
|
||||
sequence: view.getUint32(1, true),
|
||||
};
|
||||
case ServerOpcode.Event:
|
||||
assertLength(payload, 5);
|
||||
if (!codecs.event) {
|
||||
throw new Error("This protocol has no event codec");
|
||||
}
|
||||
return {
|
||||
kind: "event",
|
||||
tick: view.getUint32(1, true),
|
||||
event: codecs.event.decode(new Uint8Array(payload, 5)),
|
||||
};
|
||||
case ServerOpcode.ReplayStart:
|
||||
assertExactLength(payload, 23);
|
||||
return {
|
||||
kind: "replay-start",
|
||||
ticketId: view.getUint32(1, true),
|
||||
perspectiveId: view.getUint32(5, true),
|
||||
fromTick: view.getUint32(9, true),
|
||||
toTick: view.getUint32(13, true),
|
||||
frameCount: view.getUint16(17, true),
|
||||
playbackRate: view.getFloat32(19, true),
|
||||
};
|
||||
case ServerOpcode.ReplayFrame: {
|
||||
assertLength(payload, 15);
|
||||
const ticketId = view.getUint32(1, true);
|
||||
const tick = view.getUint32(5, true);
|
||||
const eventCount = view.getUint16(9, true);
|
||||
const stateLength = view.getUint32(11, true);
|
||||
assertAvailable(payload, 15, stateLength);
|
||||
const state = codecs.state.decode(
|
||||
new Uint8Array(payload, 15, stateLength),
|
||||
);
|
||||
let offset = 15 + stateLength;
|
||||
const events: Event[] = [];
|
||||
for (let index = 0; index < eventCount; index += 1) {
|
||||
if (!codecs.event) throw new Error("This protocol has no event codec");
|
||||
assertAvailable(payload, offset, 4);
|
||||
const eventLength = view.getUint32(offset, true);
|
||||
offset += 4;
|
||||
assertAvailable(payload, offset, eventLength);
|
||||
events.push(
|
||||
codecs.event.decode(new Uint8Array(payload, offset, eventLength)),
|
||||
);
|
||||
offset += eventLength;
|
||||
}
|
||||
if (offset !== payload.byteLength) {
|
||||
throw new RangeError("Replay frame has trailing bytes");
|
||||
}
|
||||
return { kind: "replay-frame", ticketId, tick, state, events };
|
||||
}
|
||||
case ServerOpcode.ReplayEnd:
|
||||
assertExactLength(payload, 5);
|
||||
return {
|
||||
kind: "replay-end",
|
||||
ticketId: view.getUint32(1, true),
|
||||
};
|
||||
default:
|
||||
throw new RangeError("Unknown server message opcode");
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createFrame(
|
||||
opcode: number,
|
||||
headerLength: number,
|
||||
body?: Uint8Array,
|
||||
): ArrayBuffer {
|
||||
const payload = new ArrayBuffer(headerLength + (body?.byteLength ?? 0));
|
||||
const bytes = new Uint8Array(payload);
|
||||
bytes[0] = opcode;
|
||||
if (body) {
|
||||
bytes.set(body, headerLength);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
function assertLength(payload: ArrayBuffer, minimum: number): void {
|
||||
if (payload.byteLength < minimum) {
|
||||
throw new RangeError(`Message must be at least ${minimum} bytes`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertExactLength(payload: ArrayBuffer, expected: number): void {
|
||||
if (payload.byteLength !== expected) {
|
||||
throw new RangeError(`Message must be exactly ${expected} bytes`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertAvailable(
|
||||
payload: ArrayBuffer,
|
||||
offset: number,
|
||||
length: number,
|
||||
): void {
|
||||
if (
|
||||
!Number.isInteger(offset) ||
|
||||
!Number.isInteger(length) ||
|
||||
offset < 0 ||
|
||||
length < 0 ||
|
||||
offset > payload.byteLength - length
|
||||
) {
|
||||
throw new RangeError("Message body is truncated");
|
||||
}
|
||||
}
|
||||
482
packages/engine/src/replay-transport.ts
Normal file
482
packages/engine/src/replay-transport.ts
Normal file
@@ -0,0 +1,482 @@
|
||||
import type { DefinedNetworkedGame } from "./define-networked-game.js";
|
||||
import type { NetworkedServerStepResult } from "./networked-server.js";
|
||||
import type { SnapshotBatch } from "./networked-types.js";
|
||||
import type { InputDecision } from "./server.js";
|
||||
import {
|
||||
type ReplayRecording,
|
||||
type ReplayStateHash,
|
||||
type TimeTravelAuthoritativeEngine,
|
||||
type TimeTravelNetworkedGame,
|
||||
type TimeTravelServerOptions,
|
||||
} from "./time-travel.js";
|
||||
import type {
|
||||
ClientStateReport,
|
||||
InputPacket,
|
||||
PlayerId,
|
||||
StateSnapshot,
|
||||
ValidationResult,
|
||||
} from "./types.js";
|
||||
|
||||
export interface ProjectedReplayFrame<ClientState, PerceptionEvent> {
|
||||
tick: number;
|
||||
state: ClientState;
|
||||
events: PerceptionEvent[];
|
||||
}
|
||||
|
||||
export interface ReplayTicket<ClientState, PerceptionEvent> {
|
||||
ticketId: number;
|
||||
requesterId: PlayerId;
|
||||
perspectiveId: PlayerId;
|
||||
issuedAtTick: number;
|
||||
fromTick: number;
|
||||
toTick: number;
|
||||
playbackRate: number;
|
||||
frames: Array<ProjectedReplayFrame<ClientState, PerceptionEvent>>;
|
||||
}
|
||||
|
||||
export interface ReplayTicketPlan {
|
||||
requesterId: PlayerId;
|
||||
perspectiveId: PlayerId;
|
||||
fromTick: number;
|
||||
toTick: number;
|
||||
playbackRate?: number;
|
||||
}
|
||||
|
||||
export interface ReplayTriggerContext<AuthorityState> {
|
||||
tick: number;
|
||||
authorityState: Readonly<AuthorityState>;
|
||||
connectedPlayerIds: readonly PlayerId[];
|
||||
}
|
||||
|
||||
export interface ReplayAuthorizationContext<AuthorityState>
|
||||
extends ReplayTicketPlan {
|
||||
currentTick: number;
|
||||
authorityState: Readonly<AuthorityState>;
|
||||
connectedPlayerIds: readonly PlayerId[];
|
||||
}
|
||||
|
||||
export interface ReplayTransportDefinition<AuthorityState, AuthorityEvent> {
|
||||
/** Maximum age of the projected frame ring and private deterministic log. */
|
||||
historySeconds: number;
|
||||
/** Baseline frame rate. Event ticks are captured even between baseline frames. */
|
||||
captureRateHz?: number;
|
||||
/** Perspectives worth retaining. Defaults to connected network players. */
|
||||
listPerspectives?(
|
||||
authorityState: Readonly<AuthorityState>,
|
||||
connectedPlayerIds: readonly PlayerId[],
|
||||
): Iterable<PlayerId>;
|
||||
/** Trusted server hook that can create automatic tickets from authority events. */
|
||||
createTickets?(
|
||||
event: Readonly<AuthorityEvent>,
|
||||
context: ReplayTriggerContext<AuthorityState>,
|
||||
): ReplayTicketPlan | readonly ReplayTicketPlan[] | null;
|
||||
/** Every automatic or manual ticket passes through this policy. */
|
||||
authorizeReplay?(
|
||||
context: ReplayAuthorizationContext<AuthorityState>,
|
||||
): boolean;
|
||||
maxPendingTicketsPerPlayer?: number;
|
||||
}
|
||||
|
||||
interface NormalizedReplayTransportDefinition<AuthorityState, AuthorityEvent>
|
||||
extends ReplayTransportDefinition<AuthorityState, AuthorityEvent> {
|
||||
historyTicks: number;
|
||||
captureEveryTicks: number;
|
||||
maxPendingTicketsPerPlayer: number;
|
||||
}
|
||||
|
||||
export type ReplayTransportNetworkedGame<
|
||||
AuthorityState,
|
||||
ClientState,
|
||||
Input,
|
||||
AuthorityEvent,
|
||||
PerceptionEvent,
|
||||
Seed,
|
||||
StateHash extends ReplayStateHash,
|
||||
> = Omit<
|
||||
TimeTravelNetworkedGame<
|
||||
AuthorityState,
|
||||
ClientState,
|
||||
Input,
|
||||
AuthorityEvent,
|
||||
PerceptionEvent,
|
||||
Seed,
|
||||
StateHash
|
||||
>,
|
||||
"createServer"
|
||||
> & {
|
||||
createServer(
|
||||
options?: TimeTravelServerOptions<Seed>,
|
||||
): ReplayTransportAuthoritativeEngine<
|
||||
AuthorityState,
|
||||
ClientState,
|
||||
Input,
|
||||
AuthorityEvent,
|
||||
PerceptionEvent,
|
||||
Seed,
|
||||
StateHash
|
||||
>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Keeps client-safe historical projections around a private authoritative
|
||||
* engine. Raw authority checkpoints never cross this boundary.
|
||||
*/
|
||||
export class ReplayTransportAuthoritativeEngine<
|
||||
AuthorityState,
|
||||
ClientState,
|
||||
Input,
|
||||
AuthorityEvent,
|
||||
PerceptionEvent,
|
||||
Seed,
|
||||
StateHash extends ReplayStateHash,
|
||||
> {
|
||||
readonly game: DefinedNetworkedGame<
|
||||
AuthorityState,
|
||||
ClientState,
|
||||
Input,
|
||||
AuthorityEvent,
|
||||
PerceptionEvent
|
||||
>;
|
||||
private readonly engine: TimeTravelAuthoritativeEngine<
|
||||
AuthorityState,
|
||||
ClientState,
|
||||
Input,
|
||||
AuthorityEvent,
|
||||
PerceptionEvent,
|
||||
Seed,
|
||||
StateHash
|
||||
>;
|
||||
private readonly replay: NormalizedReplayTransportDefinition<
|
||||
AuthorityState,
|
||||
AuthorityEvent
|
||||
>;
|
||||
private readonly history = new Map<
|
||||
PlayerId,
|
||||
Array<ProjectedReplayFrame<ClientState, PerceptionEvent>>
|
||||
>();
|
||||
private readonly pendingTickets = new Map<
|
||||
PlayerId,
|
||||
Array<ReplayTicket<ClientState, PerceptionEvent>>
|
||||
>();
|
||||
private nextTicketId = 1;
|
||||
|
||||
constructor(
|
||||
game: DefinedNetworkedGame<
|
||||
AuthorityState,
|
||||
ClientState,
|
||||
Input,
|
||||
AuthorityEvent,
|
||||
PerceptionEvent
|
||||
>,
|
||||
engine: TimeTravelAuthoritativeEngine<
|
||||
AuthorityState,
|
||||
ClientState,
|
||||
Input,
|
||||
AuthorityEvent,
|
||||
PerceptionEvent,
|
||||
Seed,
|
||||
StateHash
|
||||
>,
|
||||
replay: NormalizedReplayTransportDefinition<
|
||||
AuthorityState,
|
||||
AuthorityEvent
|
||||
>,
|
||||
) {
|
||||
this.game = game;
|
||||
this.engine = engine;
|
||||
this.replay = replay;
|
||||
}
|
||||
|
||||
get tick(): number {
|
||||
return this.engine.tick;
|
||||
}
|
||||
|
||||
get currentState(): Readonly<AuthorityState> {
|
||||
return this.engine.currentState;
|
||||
}
|
||||
|
||||
get playerIds(): PlayerId[] {
|
||||
return this.engine.playerIds;
|
||||
}
|
||||
|
||||
addPlayer(playerId: PlayerId): void {
|
||||
this.engine.addPlayer(playerId);
|
||||
this.capture([], true);
|
||||
}
|
||||
|
||||
removePlayer(playerId: PlayerId): void {
|
||||
this.engine.removePlayer(playerId);
|
||||
this.pendingTickets.delete(playerId);
|
||||
this.capture([], true);
|
||||
}
|
||||
|
||||
submitInput(playerId: PlayerId, packet: InputPacket<Input>): InputDecision {
|
||||
return this.engine.submitInput(playerId, packet);
|
||||
}
|
||||
|
||||
submitStateReport(
|
||||
playerId: PlayerId,
|
||||
report: ClientStateReport<ClientState>,
|
||||
): ValidationResult | null {
|
||||
return this.engine.submitStateReport(playerId, report);
|
||||
}
|
||||
|
||||
step(): NetworkedServerStepResult<AuthorityEvent> {
|
||||
const result = this.engine.step();
|
||||
const shouldCapture =
|
||||
result.tick % this.replay.captureEveryTicks === 0 ||
|
||||
result.events.length > 0;
|
||||
if (shouldCapture) this.capture(result.events, true);
|
||||
this.pruneHistory();
|
||||
|
||||
if (this.replay.createTickets) {
|
||||
const context: ReplayTriggerContext<AuthorityState> = {
|
||||
tick: result.tick,
|
||||
authorityState: this.currentState,
|
||||
connectedPlayerIds: this.playerIds,
|
||||
};
|
||||
for (const event of result.events) {
|
||||
const plans = this.replay.createTickets(event, context);
|
||||
if (!plans) continue;
|
||||
for (const plan of Array.isArray(plans) ? plans : [plans]) {
|
||||
this.issueReplay(plan);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
createSnapshot(
|
||||
playerId: PlayerId,
|
||||
serverTime: number,
|
||||
): StateSnapshot<ClientState> {
|
||||
return this.engine.createSnapshot(playerId, serverTime);
|
||||
}
|
||||
|
||||
createSnapshotBatches(serverTime: number): SnapshotBatch<ClientState>[] {
|
||||
return this.engine.createSnapshotBatches(serverTime);
|
||||
}
|
||||
|
||||
createPerceptions(
|
||||
playerId: PlayerId,
|
||||
events: readonly AuthorityEvent[],
|
||||
): PerceptionEvent[] {
|
||||
return this.engine.createPerceptions(playerId, events);
|
||||
}
|
||||
|
||||
exportRecording(): ReplayRecording<
|
||||
AuthorityState,
|
||||
Input,
|
||||
AuthorityEvent,
|
||||
Seed,
|
||||
StateHash
|
||||
> {
|
||||
return this.engine.exportRecording();
|
||||
}
|
||||
|
||||
/** Trusted-server API. The configured authorization policy still applies. */
|
||||
issueReplay(
|
||||
plan: ReplayTicketPlan,
|
||||
): ReplayTicket<ClientState, PerceptionEvent> | null {
|
||||
if (!this.playerIds.includes(plan.requesterId)) return null;
|
||||
if (!validTick(plan.fromTick) || !validTick(plan.toTick)) return null;
|
||||
if (plan.toTick < plan.fromTick || plan.toTick > this.tick) return null;
|
||||
|
||||
const authorization: ReplayAuthorizationContext<AuthorityState> = {
|
||||
...plan,
|
||||
currentTick: this.tick,
|
||||
authorityState: this.currentState,
|
||||
connectedPlayerIds: this.playerIds,
|
||||
};
|
||||
const authorized = this.replay.authorizeReplay
|
||||
? this.replay.authorizeReplay(authorization)
|
||||
: plan.requesterId === plan.perspectiveId;
|
||||
if (!authorized) return null;
|
||||
|
||||
const perspectiveHistory = this.history.get(plan.perspectiveId);
|
||||
if (!perspectiveHistory) return null;
|
||||
const selected = perspectiveHistory.filter(
|
||||
({ tick }) => tick >= plan.fromTick && tick <= plan.toTick,
|
||||
);
|
||||
if (selected.length === 0) return null;
|
||||
|
||||
const first = selected[0]!;
|
||||
const last = selected[selected.length - 1]!;
|
||||
const ticket: ReplayTicket<ClientState, PerceptionEvent> = {
|
||||
ticketId: this.allocateTicketId(),
|
||||
requesterId: plan.requesterId,
|
||||
perspectiveId: plan.perspectiveId,
|
||||
issuedAtTick: this.tick,
|
||||
fromTick: first.tick,
|
||||
toTick: last.tick,
|
||||
playbackRate: clamp(plan.playbackRate ?? 1, 0.1, 4),
|
||||
frames: selected.map((frame) => this.cloneFrame(frame)),
|
||||
};
|
||||
|
||||
const queue = this.pendingTickets.get(plan.requesterId) ?? [];
|
||||
queue.push(ticket);
|
||||
while (queue.length > this.replay.maxPendingTicketsPerPlayer) queue.shift();
|
||||
this.pendingTickets.set(plan.requesterId, queue);
|
||||
return ticket;
|
||||
}
|
||||
|
||||
drainReplayTickets(
|
||||
playerId: PlayerId,
|
||||
): Array<ReplayTicket<ClientState, PerceptionEvent>> {
|
||||
const tickets = this.pendingTickets.get(playerId) ?? [];
|
||||
this.pendingTickets.delete(playerId);
|
||||
return tickets;
|
||||
}
|
||||
|
||||
private capture(events: readonly AuthorityEvent[], replaceSameTick: boolean): void {
|
||||
const connected = this.playerIds;
|
||||
const perspectives = this.replay.listPerspectives
|
||||
? this.replay.listPerspectives(this.currentState, connected)
|
||||
: connected;
|
||||
const uniquePerspectives = new Set(perspectives);
|
||||
|
||||
for (const perspectiveId of uniquePerspectives) {
|
||||
if (!validPlayerId(perspectiveId)) continue;
|
||||
const snapshot = this.engine.createSnapshot(perspectiveId, 0);
|
||||
const perceptions = this.engine.createPerceptions(perspectiveId, events);
|
||||
const frame: ProjectedReplayFrame<ClientState, PerceptionEvent> = {
|
||||
tick: this.tick,
|
||||
state: this.cloneState(snapshot.state),
|
||||
events: perceptions.map((event) => this.cloneEvent(event)),
|
||||
};
|
||||
const frames = this.history.get(perspectiveId) ?? [];
|
||||
if (replaceSameTick && frames.at(-1)?.tick === this.tick) {
|
||||
frames[frames.length - 1] = frame;
|
||||
} else {
|
||||
frames.push(frame);
|
||||
}
|
||||
this.history.set(perspectiveId, frames);
|
||||
}
|
||||
}
|
||||
|
||||
private pruneHistory(): void {
|
||||
const earliestTick = Math.max(0, this.tick - this.replay.historyTicks);
|
||||
for (const [perspectiveId, frames] of this.history) {
|
||||
const firstRetained = frames.findIndex(({ tick }) => tick >= earliestTick);
|
||||
if (firstRetained === -1) {
|
||||
this.history.delete(perspectiveId);
|
||||
continue;
|
||||
}
|
||||
if (firstRetained > 0) frames.splice(0, firstRetained);
|
||||
}
|
||||
}
|
||||
|
||||
private cloneFrame(
|
||||
frame: ProjectedReplayFrame<ClientState, PerceptionEvent>,
|
||||
): ProjectedReplayFrame<ClientState, PerceptionEvent> {
|
||||
return {
|
||||
tick: frame.tick,
|
||||
state: this.cloneState(frame.state),
|
||||
events: frame.events.map((event) => this.cloneEvent(event)),
|
||||
};
|
||||
}
|
||||
|
||||
private cloneState(state: ClientState): ClientState {
|
||||
return this.game.codecs.state.decode(this.game.codecs.state.encode(state));
|
||||
}
|
||||
|
||||
private cloneEvent(event: PerceptionEvent): PerceptionEvent {
|
||||
const codec = this.game.codecs.event;
|
||||
if (!codec) {
|
||||
throw new Error("Replay perceptions require an event codec");
|
||||
}
|
||||
return codec.decode(codec.encode(event));
|
||||
}
|
||||
|
||||
private allocateTicketId(): number {
|
||||
const ticketId = this.nextTicketId;
|
||||
this.nextTicketId = this.nextTicketId === 0xffff_ffff
|
||||
? 1
|
||||
: this.nextTicketId + 1;
|
||||
return ticketId;
|
||||
}
|
||||
}
|
||||
|
||||
export function withReplayTransport<
|
||||
AuthorityState,
|
||||
ClientState,
|
||||
Input,
|
||||
AuthorityEvent,
|
||||
PerceptionEvent,
|
||||
Seed,
|
||||
StateHash extends ReplayStateHash,
|
||||
>(
|
||||
game: TimeTravelNetworkedGame<
|
||||
AuthorityState,
|
||||
ClientState,
|
||||
Input,
|
||||
AuthorityEvent,
|
||||
PerceptionEvent,
|
||||
Seed,
|
||||
StateHash
|
||||
>,
|
||||
definition: ReplayTransportDefinition<AuthorityState, AuthorityEvent>,
|
||||
): ReplayTransportNetworkedGame<
|
||||
AuthorityState,
|
||||
ClientState,
|
||||
Input,
|
||||
AuthorityEvent,
|
||||
PerceptionEvent,
|
||||
Seed,
|
||||
StateHash
|
||||
> {
|
||||
const replay = normalizeReplayDefinition(game, definition);
|
||||
|
||||
return Object.freeze({
|
||||
...game,
|
||||
createServer(options: TimeTravelServerOptions<Seed> = {}) {
|
||||
const engine = game.createServer({
|
||||
...options,
|
||||
recordingHistoryTicks:
|
||||
options.recordingHistoryTicks ?? replay.historyTicks,
|
||||
});
|
||||
return new ReplayTransportAuthoritativeEngine(engine.game, engine, replay);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeReplayDefinition<AuthorityState, AuthorityEvent>(
|
||||
game: { tickRateHz: number; snapshotRateHz: number },
|
||||
definition: ReplayTransportDefinition<AuthorityState, AuthorityEvent>,
|
||||
): NormalizedReplayTransportDefinition<AuthorityState, AuthorityEvent> {
|
||||
if (!Number.isFinite(definition.historySeconds) || definition.historySeconds <= 0) {
|
||||
throw new RangeError("historySeconds must be positive");
|
||||
}
|
||||
const captureRateHz = definition.captureRateHz ?? game.snapshotRateHz;
|
||||
if (
|
||||
!Number.isInteger(captureRateHz) ||
|
||||
captureRateHz <= 0 ||
|
||||
game.tickRateHz % captureRateHz !== 0
|
||||
) {
|
||||
throw new RangeError("captureRateHz must divide tickRateHz evenly");
|
||||
}
|
||||
const maxPendingTicketsPerPlayer =
|
||||
definition.maxPendingTicketsPerPlayer ?? 2;
|
||||
if (!Number.isInteger(maxPendingTicketsPerPlayer) || maxPendingTicketsPerPlayer <= 0) {
|
||||
throw new RangeError("maxPendingTicketsPerPlayer must be a positive integer");
|
||||
}
|
||||
return {
|
||||
...definition,
|
||||
historyTicks: Math.ceil(definition.historySeconds * game.tickRateHz),
|
||||
captureEveryTicks: game.tickRateHz / captureRateHz,
|
||||
maxPendingTicketsPerPlayer,
|
||||
};
|
||||
}
|
||||
|
||||
function validPlayerId(value: number): boolean {
|
||||
return Number.isInteger(value) && value >= 0 && value <= 0xffff_ffff;
|
||||
}
|
||||
|
||||
function validTick(value: number): boolean {
|
||||
return Number.isInteger(value) && value >= 0 && value <= 0xffff_ffff;
|
||||
}
|
||||
|
||||
function clamp(value: number, minimum: number, maximum: number): number {
|
||||
return Math.max(minimum, Math.min(maximum, value));
|
||||
}
|
||||
306
packages/engine/src/server.ts
Normal file
306
packages/engine/src/server.ts
Normal file
@@ -0,0 +1,306 @@
|
||||
import type {
|
||||
ClientStateContext,
|
||||
ClientStateReport,
|
||||
GameDefinition,
|
||||
InputContext,
|
||||
InputPacket,
|
||||
PlayerId,
|
||||
StateSnapshot,
|
||||
ValidationResult,
|
||||
} from "./types.js";
|
||||
|
||||
export interface ServerEngineOptions {
|
||||
historyTicks?: number;
|
||||
maxPastTicks?: number;
|
||||
maxFutureTicks?: number;
|
||||
}
|
||||
|
||||
export interface InputDecision {
|
||||
accepted: boolean;
|
||||
reason?: "unknown-player" | "duplicate" | "invalid" | "past" | "future";
|
||||
}
|
||||
|
||||
export interface Acknowledgement {
|
||||
playerId: PlayerId;
|
||||
sequence: number;
|
||||
}
|
||||
|
||||
export interface ServerStepResult {
|
||||
tick: number;
|
||||
acknowledgements: Acknowledgement[];
|
||||
validations: ValidationResult[];
|
||||
snapshotDue: boolean;
|
||||
}
|
||||
|
||||
interface QueuedInput<Input> {
|
||||
playerId: PlayerId;
|
||||
packet: InputPacket<Input>;
|
||||
}
|
||||
|
||||
interface QueuedReport<State> {
|
||||
playerId: PlayerId;
|
||||
report: ClientStateReport<State>;
|
||||
}
|
||||
|
||||
export class AuthoritativeEngine<State, Input> {
|
||||
readonly game: GameDefinition<State, Input>;
|
||||
private readonly historyTicks: number;
|
||||
private readonly maxPastTicks: number;
|
||||
private readonly maxFutureTicks: number;
|
||||
private readonly snapshotEveryTicks: number;
|
||||
private readonly players = new Set<PlayerId>();
|
||||
private readonly lastReceivedSequence = new Map<PlayerId, number>();
|
||||
private readonly acknowledgedSequence = new Map<PlayerId, number>();
|
||||
private readonly history = new Map<number, State>();
|
||||
private queuedInputs: QueuedInput<Input>[] = [];
|
||||
private queuedReports: QueuedReport<State>[] = [];
|
||||
private state: State;
|
||||
private currentTick = 0;
|
||||
|
||||
constructor(
|
||||
game: GameDefinition<State, Input>,
|
||||
options: ServerEngineOptions = {},
|
||||
) {
|
||||
this.game = game;
|
||||
this.historyTicks = options.historyTicks ?? game.tickRateHz * 2;
|
||||
this.maxPastTicks = options.maxPastTicks ?? 2;
|
||||
this.maxFutureTicks = options.maxFutureTicks ?? game.tickRateHz;
|
||||
this.snapshotEveryTicks = game.tickRateHz / game.snapshotRateHz;
|
||||
this.state = game.createInitialState();
|
||||
this.storeHistory();
|
||||
}
|
||||
|
||||
get tick(): number {
|
||||
return this.currentTick;
|
||||
}
|
||||
|
||||
get currentState(): Readonly<State> {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
addPlayer(playerId: PlayerId): void {
|
||||
if (this.players.has(playerId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.players.add(playerId);
|
||||
this.game.addPlayer?.(this.state, { playerId, tick: this.currentTick });
|
||||
this.assertValidState();
|
||||
this.storeHistory();
|
||||
}
|
||||
|
||||
removePlayer(playerId: PlayerId): void {
|
||||
if (!this.players.delete(playerId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.game.removePlayer?.(this.state, {
|
||||
playerId,
|
||||
tick: this.currentTick,
|
||||
});
|
||||
this.lastReceivedSequence.delete(playerId);
|
||||
this.acknowledgedSequence.delete(playerId);
|
||||
this.queuedInputs = this.queuedInputs.filter(
|
||||
(queued) => queued.playerId !== playerId,
|
||||
);
|
||||
this.queuedReports = this.queuedReports.filter(
|
||||
(queued) => queued.playerId !== playerId,
|
||||
);
|
||||
this.assertValidState();
|
||||
this.storeHistory();
|
||||
}
|
||||
|
||||
submitInput(playerId: PlayerId, packet: InputPacket<Input>): InputDecision {
|
||||
if (!this.players.has(playerId)) {
|
||||
return { accepted: false, reason: "unknown-player" };
|
||||
}
|
||||
|
||||
const previousSequence = this.lastReceivedSequence.get(playerId) ?? 0;
|
||||
if (packet.sequence <= previousSequence) {
|
||||
return { accepted: false, reason: "duplicate" };
|
||||
}
|
||||
|
||||
if (packet.targetTick < this.currentTick - this.maxPastTicks) {
|
||||
return { accepted: false, reason: "past" };
|
||||
}
|
||||
|
||||
if (packet.targetTick > this.currentTick + this.maxFutureTicks) {
|
||||
return { accepted: false, reason: "future" };
|
||||
}
|
||||
|
||||
const context = this.inputContext(playerId, packet);
|
||||
if (!this.game.validateInput(packet.input, context)) {
|
||||
return { accepted: false, reason: "invalid" };
|
||||
}
|
||||
|
||||
this.lastReceivedSequence.set(playerId, packet.sequence);
|
||||
this.queuedInputs.push({ playerId, packet });
|
||||
return { accepted: true };
|
||||
}
|
||||
|
||||
submitStateReport(
|
||||
playerId: PlayerId,
|
||||
report: ClientStateReport<State>,
|
||||
): ValidationResult | null {
|
||||
if (report.tick > this.currentTick + this.maxFutureTicks) {
|
||||
return {
|
||||
playerId,
|
||||
tick: report.tick,
|
||||
valid: false,
|
||||
reason: "too-far-ahead",
|
||||
};
|
||||
}
|
||||
|
||||
if (report.tick > this.currentTick) {
|
||||
this.queuedReports = this.queuedReports.filter(
|
||||
(queued) => queued.playerId !== playerId,
|
||||
);
|
||||
this.queuedReports.push({ playerId, report });
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.validateReport(playerId, report);
|
||||
}
|
||||
|
||||
step(): ServerStepResult {
|
||||
this.currentTick += 1;
|
||||
const deltaSeconds = 1 / this.game.tickRateHz;
|
||||
const acknowledgementByPlayer = new Map<PlayerId, number>();
|
||||
const dueInputs: QueuedInput<Input>[] = [];
|
||||
const futureInputs: QueuedInput<Input>[] = [];
|
||||
|
||||
for (const queued of this.queuedInputs) {
|
||||
(queued.packet.targetTick <= this.currentTick
|
||||
? dueInputs
|
||||
: futureInputs
|
||||
).push(queued);
|
||||
}
|
||||
this.queuedInputs = futureInputs;
|
||||
|
||||
dueInputs.sort(
|
||||
(left, right) =>
|
||||
left.packet.targetTick - right.packet.targetTick ||
|
||||
left.playerId - right.playerId ||
|
||||
left.packet.sequence - right.packet.sequence,
|
||||
);
|
||||
|
||||
for (const queued of dueInputs) {
|
||||
const { playerId, packet } = queued;
|
||||
this.game.applyInput(
|
||||
this.state,
|
||||
packet.input,
|
||||
this.inputContext(playerId, packet),
|
||||
);
|
||||
|
||||
const previousAck = this.acknowledgedSequence.get(playerId) ?? 0;
|
||||
if (packet.sequence > previousAck) {
|
||||
this.acknowledgedSequence.set(playerId, packet.sequence);
|
||||
acknowledgementByPlayer.set(playerId, packet.sequence);
|
||||
}
|
||||
}
|
||||
|
||||
this.game.step(this.state, {
|
||||
tick: this.currentTick,
|
||||
deltaSeconds,
|
||||
});
|
||||
this.assertValidState();
|
||||
this.storeHistory();
|
||||
|
||||
const validations: ValidationResult[] = [];
|
||||
const futureReports: QueuedReport<State>[] = [];
|
||||
for (const queued of this.queuedReports) {
|
||||
if (queued.report.tick <= this.currentTick) {
|
||||
validations.push(
|
||||
this.validateReport(queued.playerId, queued.report),
|
||||
);
|
||||
} else {
|
||||
futureReports.push(queued);
|
||||
}
|
||||
}
|
||||
this.queuedReports = futureReports;
|
||||
|
||||
return {
|
||||
tick: this.currentTick,
|
||||
acknowledgements: [...acknowledgementByPlayer].map(
|
||||
([playerId, sequence]) => ({ playerId, sequence }),
|
||||
),
|
||||
validations,
|
||||
snapshotDue: this.currentTick % this.snapshotEveryTicks === 0,
|
||||
};
|
||||
}
|
||||
|
||||
createSnapshot(serverTime: number): StateSnapshot<State> {
|
||||
return {
|
||||
tick: this.currentTick,
|
||||
serverTime,
|
||||
state: this.game.cloneState(this.state),
|
||||
};
|
||||
}
|
||||
|
||||
private inputContext(
|
||||
playerId: PlayerId,
|
||||
packet: InputPacket<Input>,
|
||||
): InputContext {
|
||||
return {
|
||||
playerId,
|
||||
sequence: packet.sequence,
|
||||
targetTick: packet.targetTick,
|
||||
observedTick: packet.observedTick ?? packet.targetTick,
|
||||
tick: this.currentTick,
|
||||
deltaSeconds: 1 / this.game.tickRateHz,
|
||||
};
|
||||
}
|
||||
|
||||
private validateReport(
|
||||
playerId: PlayerId,
|
||||
report: ClientStateReport<State>,
|
||||
): ValidationResult {
|
||||
const authoritative = this.history.get(report.tick);
|
||||
if (!authoritative) {
|
||||
return {
|
||||
playerId,
|
||||
tick: report.tick,
|
||||
valid: false,
|
||||
reason: "outside-history",
|
||||
};
|
||||
}
|
||||
|
||||
const context: ClientStateContext = { playerId, tick: report.tick };
|
||||
const valid = this.game.validateClientState
|
||||
? this.game.validateClientState(authoritative, report.state, context)
|
||||
: (this.game.validateState?.(report.state, {
|
||||
tick: report.tick,
|
||||
deltaSeconds: 1 / this.game.tickRateHz,
|
||||
}) ?? true);
|
||||
|
||||
return {
|
||||
playerId,
|
||||
tick: report.tick,
|
||||
valid,
|
||||
...(valid ? {} : { reason: "mismatch" as const }),
|
||||
};
|
||||
}
|
||||
|
||||
private assertValidState(): void {
|
||||
if (
|
||||
this.game.validateState &&
|
||||
!this.game.validateState(this.state, {
|
||||
tick: this.currentTick,
|
||||
deltaSeconds: 1 / this.game.tickRateHz,
|
||||
})
|
||||
) {
|
||||
throw new Error(`Game produced invalid state at tick ${this.currentTick}`);
|
||||
}
|
||||
}
|
||||
|
||||
private storeHistory(): void {
|
||||
this.history.set(this.currentTick, this.game.cloneState(this.state));
|
||||
|
||||
const oldestTick = this.currentTick - this.historyTicks;
|
||||
for (const tick of this.history.keys()) {
|
||||
if (tick < oldestTick) {
|
||||
this.history.delete(tick);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
474
packages/engine/src/spatial-replication.ts
Normal file
474
packages/engine/src/spatial-replication.ts
Normal file
@@ -0,0 +1,474 @@
|
||||
import {
|
||||
defineNetworkedGame,
|
||||
type DefinedNetworkedGame,
|
||||
} from "./define-networked-game.js";
|
||||
import type { ReplicationRules } from "./networked-types.js";
|
||||
import type { PlayerContext, PlayerId } from "./types.js";
|
||||
|
||||
export interface SpatialPoint {
|
||||
x: number;
|
||||
z: number;
|
||||
}
|
||||
|
||||
export interface SpatialReplicationEntity extends SpatialPoint {
|
||||
id: string | number;
|
||||
}
|
||||
|
||||
export interface SpatialReplicationViewerContext extends PlayerContext {
|
||||
position: SpatialPoint;
|
||||
}
|
||||
|
||||
export interface SpatialReplicationSource<AuthorityState> {
|
||||
category: string;
|
||||
maximumDistance: number;
|
||||
entities(
|
||||
authority: Readonly<AuthorityState>,
|
||||
): Iterable<SpatialReplicationEntity>;
|
||||
estimatedBytes:
|
||||
| number
|
||||
| ((
|
||||
entity: SpatialReplicationEntity,
|
||||
context: SpatialReplicationViewerContext,
|
||||
) => number);
|
||||
priority?:
|
||||
| number
|
||||
| ((
|
||||
entity: SpatialReplicationEntity,
|
||||
distance: number,
|
||||
context: SpatialReplicationViewerContext,
|
||||
) => number);
|
||||
required?(entity: SpatialReplicationEntity, playerId: PlayerId): boolean;
|
||||
}
|
||||
|
||||
export interface SpatialReplicationSelection {
|
||||
readonly playerId: PlayerId;
|
||||
readonly tick: number;
|
||||
readonly position: SpatialPoint;
|
||||
readonly budgetBytes: number;
|
||||
readonly usedBytes: number;
|
||||
readonly candidateCount: number;
|
||||
readonly droppedCount: number;
|
||||
has(category: string, id: string | number): boolean;
|
||||
ids(category: string): ReadonlySet<string | number>;
|
||||
}
|
||||
|
||||
export interface SpatialReplicationDefinition<
|
||||
AuthorityState,
|
||||
ClientState,
|
||||
AuthorityEvent,
|
||||
PerceptionEvent,
|
||||
> {
|
||||
cellSize: number;
|
||||
bandwidthBudgetBytesPerSecond: number;
|
||||
/** Bytes reserved for non-entity match state in every snapshot. */
|
||||
reservedBytesPerSnapshot?: number;
|
||||
viewerPosition(
|
||||
authority: Readonly<AuthorityState>,
|
||||
playerId: PlayerId,
|
||||
): SpatialPoint | null;
|
||||
sources: readonly SpatialReplicationSource<AuthorityState>[];
|
||||
projectSnapshot(
|
||||
snapshot: ClientState,
|
||||
selection: SpatialReplicationSelection,
|
||||
context: PlayerContext,
|
||||
): ClientState;
|
||||
perceive?(
|
||||
authority: Readonly<AuthorityState>,
|
||||
event: Readonly<AuthorityEvent>,
|
||||
perception: PerceptionEvent,
|
||||
selection: SpatialReplicationSelection,
|
||||
context: PlayerContext,
|
||||
): PerceptionEvent | null;
|
||||
}
|
||||
|
||||
export interface SpatialReplicationController<AuthorityState> {
|
||||
select(
|
||||
authority: Readonly<AuthorityState>,
|
||||
context: PlayerContext,
|
||||
): SpatialReplicationSelection;
|
||||
}
|
||||
|
||||
export type SpatiallyReplicatedNetworkedGame<
|
||||
AuthorityState,
|
||||
ClientState,
|
||||
Input,
|
||||
AuthorityEvent,
|
||||
PerceptionEvent,
|
||||
> = DefinedNetworkedGame<
|
||||
AuthorityState,
|
||||
ClientState,
|
||||
Input,
|
||||
AuthorityEvent,
|
||||
PerceptionEvent
|
||||
> & {
|
||||
spatialReplication: SpatialReplicationController<AuthorityState>;
|
||||
};
|
||||
|
||||
interface IndexedEntity extends SpatialPoint {
|
||||
entity: SpatialReplicationEntity;
|
||||
source: SpatialReplicationSource<unknown>;
|
||||
}
|
||||
|
||||
interface Candidate extends IndexedEntity {
|
||||
distance: number;
|
||||
estimatedBytes: number;
|
||||
priority: number;
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
/** A deterministic two-dimensional uniform grid used by spatial replication. */
|
||||
export class SpatialGridIndex<Value extends SpatialPoint> {
|
||||
private readonly cells = new Map<string, Value[]>();
|
||||
|
||||
constructor(readonly cellSize: number) {
|
||||
if (!Number.isFinite(cellSize) || cellSize <= 0) {
|
||||
throw new RangeError("cellSize must be a positive finite number");
|
||||
}
|
||||
}
|
||||
|
||||
insert(value: Value): void {
|
||||
if (!Number.isFinite(value.x) || !Number.isFinite(value.z)) {
|
||||
throw new RangeError("spatial coordinates must be finite");
|
||||
}
|
||||
const key = this.key(this.coordinate(value.x), this.coordinate(value.z));
|
||||
const cell = this.cells.get(key);
|
||||
if (cell) cell.push(value);
|
||||
else this.cells.set(key, [value]);
|
||||
}
|
||||
|
||||
query(position: SpatialPoint, radius: number): Value[] {
|
||||
if (!Number.isFinite(radius) || radius < 0) {
|
||||
throw new RangeError("query radius must be a non-negative finite number");
|
||||
}
|
||||
const minimumX = this.coordinate(position.x - radius);
|
||||
const maximumX = this.coordinate(position.x + radius);
|
||||
const minimumZ = this.coordinate(position.z - radius);
|
||||
const maximumZ = this.coordinate(position.z + radius);
|
||||
const radiusSquared = radius * radius;
|
||||
const values: Value[] = [];
|
||||
|
||||
for (let cellX = minimumX; cellX <= maximumX; cellX += 1) {
|
||||
for (let cellZ = minimumZ; cellZ <= maximumZ; cellZ += 1) {
|
||||
for (const value of this.cells.get(this.key(cellX, cellZ)) ?? []) {
|
||||
const dx = value.x - position.x;
|
||||
const dz = value.z - position.z;
|
||||
if (dx * dx + dz * dz <= radiusSquared) values.push(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
private coordinate(value: number): number {
|
||||
return Math.floor(value / this.cellSize);
|
||||
}
|
||||
|
||||
private key(x: number, z: number): string {
|
||||
return `${x}:${z}`;
|
||||
}
|
||||
}
|
||||
|
||||
/** Adds deterministic interest management and bandwidth budgeting to a game. */
|
||||
export function withSpatialReplication<
|
||||
AuthorityState,
|
||||
ClientState,
|
||||
Input,
|
||||
AuthorityEvent,
|
||||
PerceptionEvent,
|
||||
>(
|
||||
game: DefinedNetworkedGame<
|
||||
AuthorityState,
|
||||
ClientState,
|
||||
Input,
|
||||
AuthorityEvent,
|
||||
PerceptionEvent
|
||||
>,
|
||||
definition: SpatialReplicationDefinition<
|
||||
AuthorityState,
|
||||
ClientState,
|
||||
AuthorityEvent,
|
||||
PerceptionEvent
|
||||
>,
|
||||
): SpatiallyReplicatedNetworkedGame<
|
||||
AuthorityState,
|
||||
ClientState,
|
||||
Input,
|
||||
AuthorityEvent,
|
||||
PerceptionEvent
|
||||
> {
|
||||
validateDefinition(game.snapshotRateHz, definition);
|
||||
|
||||
let cachedAuthority: Readonly<AuthorityState> | null = null;
|
||||
let cachedTick = -1;
|
||||
let cachedIndex = new SpatialGridIndex<IndexedEntity>(definition.cellSize);
|
||||
const maximumDistance = Math.max(
|
||||
0,
|
||||
...definition.sources.map((source) => source.maximumDistance),
|
||||
);
|
||||
const budgetBytes = Math.max(
|
||||
0,
|
||||
Math.floor(
|
||||
definition.bandwidthBudgetBytesPerSecond / game.snapshotRateHz -
|
||||
(definition.reservedBytesPerSnapshot ?? 0),
|
||||
),
|
||||
);
|
||||
|
||||
const controller: SpatialReplicationController<AuthorityState> = {
|
||||
select(authority, context) {
|
||||
const position = definition.viewerPosition(authority, context.playerId);
|
||||
if (!position) {
|
||||
return createSelection(
|
||||
context,
|
||||
{ x: 0, z: 0 },
|
||||
budgetBytes,
|
||||
[],
|
||||
new Map(),
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
if (authority !== cachedAuthority || context.tick !== cachedTick) {
|
||||
cachedAuthority = authority;
|
||||
cachedTick = context.tick;
|
||||
cachedIndex = buildIndex(authority, definition);
|
||||
}
|
||||
|
||||
const viewerContext: SpatialReplicationViewerContext = {
|
||||
...context,
|
||||
position,
|
||||
};
|
||||
const candidates = cachedIndex
|
||||
.query(position, maximumDistance)
|
||||
.map((indexed): Candidate | null => {
|
||||
const source = indexed.source as SpatialReplicationSource<AuthorityState>;
|
||||
const distance = Math.hypot(
|
||||
indexed.entity.x - position.x,
|
||||
indexed.entity.z - position.z,
|
||||
);
|
||||
if (distance > source.maximumDistance) return null;
|
||||
const estimatedBytes = normalizeNonNegativeInteger(
|
||||
typeof source.estimatedBytes === "function"
|
||||
? source.estimatedBytes(indexed.entity, viewerContext)
|
||||
: source.estimatedBytes,
|
||||
`${source.category}.estimatedBytes`,
|
||||
);
|
||||
const priorityValue =
|
||||
typeof source.priority === "function"
|
||||
? source.priority(indexed.entity, distance, viewerContext)
|
||||
: (source.priority ?? 0);
|
||||
return {
|
||||
...indexed,
|
||||
distance,
|
||||
estimatedBytes,
|
||||
priority: Number.isFinite(priorityValue) ? priorityValue : 0,
|
||||
required: source.required?.(indexed.entity, context.playerId) ?? false,
|
||||
};
|
||||
})
|
||||
.filter((candidate): candidate is Candidate => candidate !== null)
|
||||
.sort(compareCandidates);
|
||||
|
||||
const selected = new Map<string, Set<string | number>>();
|
||||
let usedBytes = 0;
|
||||
let droppedCount = 0;
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate.required && usedBytes + candidate.estimatedBytes > budgetBytes) {
|
||||
droppedCount += 1;
|
||||
continue;
|
||||
}
|
||||
let ids = selected.get(candidate.source.category);
|
||||
if (!ids) {
|
||||
ids = new Set();
|
||||
selected.set(candidate.source.category, ids);
|
||||
}
|
||||
if (ids.has(candidate.entity.id)) continue;
|
||||
ids.add(candidate.entity.id);
|
||||
usedBytes += candidate.estimatedBytes;
|
||||
}
|
||||
|
||||
return createSelection(
|
||||
context,
|
||||
position,
|
||||
budgetBytes,
|
||||
candidates,
|
||||
selected,
|
||||
droppedCount,
|
||||
usedBytes,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
const replication: ReplicationRules<
|
||||
AuthorityState,
|
||||
ClientState,
|
||||
AuthorityEvent,
|
||||
PerceptionEvent
|
||||
> = {
|
||||
...game.replication,
|
||||
createSnapshot(authority, context) {
|
||||
const snapshot = game.replication.createSnapshot(authority, context);
|
||||
return definition.projectSnapshot(
|
||||
snapshot,
|
||||
controller.select(authority, context),
|
||||
context,
|
||||
);
|
||||
},
|
||||
...(game.replication.perceive
|
||||
? {
|
||||
perceive(authority, event, context) {
|
||||
const perception = game.replication.perceive!(
|
||||
authority,
|
||||
event,
|
||||
context,
|
||||
);
|
||||
if (perception === null) return null;
|
||||
return definition.perceive
|
||||
? definition.perceive(
|
||||
authority,
|
||||
event,
|
||||
perception,
|
||||
controller.select(authority, context),
|
||||
context,
|
||||
)
|
||||
: perception;
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
// Spatial selections are viewer-specific even when a base game groups views.
|
||||
groupKey: (_authority, { playerId }) => playerId,
|
||||
};
|
||||
|
||||
const wrapped = defineNetworkedGame<
|
||||
AuthorityState,
|
||||
ClientState,
|
||||
Input,
|
||||
AuthorityEvent,
|
||||
PerceptionEvent
|
||||
>({
|
||||
tickRateHz: game.tickRateHz,
|
||||
snapshotRateHz: game.snapshotRateHz,
|
||||
server: game.server,
|
||||
client: game.client,
|
||||
validateInput: game.validateInput,
|
||||
codecs: game.codecs,
|
||||
replication,
|
||||
});
|
||||
|
||||
return Object.freeze({ ...wrapped, spatialReplication: controller });
|
||||
}
|
||||
|
||||
function buildIndex<AuthorityState, ClientState, AuthorityEvent, PerceptionEvent>(
|
||||
authority: Readonly<AuthorityState>,
|
||||
definition: SpatialReplicationDefinition<
|
||||
AuthorityState,
|
||||
ClientState,
|
||||
AuthorityEvent,
|
||||
PerceptionEvent
|
||||
>,
|
||||
): SpatialGridIndex<IndexedEntity> {
|
||||
const index = new SpatialGridIndex<IndexedEntity>(definition.cellSize);
|
||||
for (const source of definition.sources) {
|
||||
for (const entity of source.entities(authority)) {
|
||||
index.insert({
|
||||
x: entity.x,
|
||||
z: entity.z,
|
||||
entity,
|
||||
source: source as SpatialReplicationSource<unknown>,
|
||||
});
|
||||
}
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
function createSelection(
|
||||
context: PlayerContext,
|
||||
position: SpatialPoint,
|
||||
budgetBytes: number,
|
||||
candidates: readonly Candidate[],
|
||||
selected: ReadonlyMap<string, ReadonlySet<string | number>>,
|
||||
droppedCount: number,
|
||||
usedBytes = 0,
|
||||
): SpatialReplicationSelection {
|
||||
const empty = new Set<string | number>();
|
||||
return Object.freeze({
|
||||
playerId: context.playerId,
|
||||
tick: context.tick,
|
||||
position: { ...position },
|
||||
budgetBytes,
|
||||
usedBytes,
|
||||
candidateCount: candidates.length,
|
||||
droppedCount,
|
||||
has(category: string, id: string | number) {
|
||||
return selected.get(category)?.has(id) ?? false;
|
||||
},
|
||||
ids(category: string) {
|
||||
return selected.get(category) ?? empty;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function compareCandidates(left: Candidate, right: Candidate): number {
|
||||
return (
|
||||
Number(right.required) - Number(left.required) ||
|
||||
right.priority - left.priority ||
|
||||
left.distance - right.distance ||
|
||||
left.source.category.localeCompare(right.source.category) ||
|
||||
stableId(left.entity.id).localeCompare(stableId(right.entity.id))
|
||||
);
|
||||
}
|
||||
|
||||
function stableId(id: string | number): string {
|
||||
return `${typeof id}:${String(id)}`;
|
||||
}
|
||||
|
||||
function validateDefinition<AuthorityState, ClientState, AuthorityEvent, PerceptionEvent>(
|
||||
snapshotRateHz: number,
|
||||
definition: SpatialReplicationDefinition<
|
||||
AuthorityState,
|
||||
ClientState,
|
||||
AuthorityEvent,
|
||||
PerceptionEvent
|
||||
>,
|
||||
): void {
|
||||
if (!Number.isFinite(definition.cellSize) || definition.cellSize <= 0) {
|
||||
throw new RangeError("cellSize must be a positive finite number");
|
||||
}
|
||||
if (
|
||||
!Number.isFinite(definition.bandwidthBudgetBytesPerSecond) ||
|
||||
definition.bandwidthBudgetBytesPerSecond <= 0
|
||||
) {
|
||||
throw new RangeError("bandwidthBudgetBytesPerSecond must be positive");
|
||||
}
|
||||
if (
|
||||
definition.reservedBytesPerSnapshot !== undefined &&
|
||||
(!Number.isFinite(definition.reservedBytesPerSnapshot) ||
|
||||
definition.reservedBytesPerSnapshot < 0)
|
||||
) {
|
||||
throw new RangeError("reservedBytesPerSnapshot must be non-negative");
|
||||
}
|
||||
const categories = new Set<string>();
|
||||
for (const source of definition.sources) {
|
||||
if (!source.category || categories.has(source.category)) {
|
||||
throw new Error(`spatial source categories must be unique: ${source.category}`);
|
||||
}
|
||||
categories.add(source.category);
|
||||
if (!Number.isFinite(source.maximumDistance) || source.maximumDistance < 0) {
|
||||
throw new RangeError(`${source.category}.maximumDistance must be non-negative`);
|
||||
}
|
||||
if (typeof source.estimatedBytes === "number") {
|
||||
normalizeNonNegativeInteger(
|
||||
source.estimatedBytes,
|
||||
`${source.category}.estimatedBytes`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!Number.isInteger(snapshotRateHz) || snapshotRateHz <= 0) {
|
||||
throw new RangeError("snapshotRateHz must be positive");
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeNonNegativeInteger(value: number, name: string): number {
|
||||
if (!Number.isFinite(value) || value < 0) {
|
||||
throw new RangeError(`${name} must be non-negative and finite`);
|
||||
}
|
||||
return Math.ceil(value);
|
||||
}
|
||||
1124
packages/engine/src/time-travel.ts
Normal file
1124
packages/engine/src/time-travel.ts
Normal file
File diff suppressed because it is too large
Load Diff
80
packages/engine/src/types.ts
Normal file
80
packages/engine/src/types.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
export type PlayerId = number;
|
||||
|
||||
export interface TickContext {
|
||||
tick: number;
|
||||
deltaSeconds: number;
|
||||
}
|
||||
|
||||
export interface InputContext extends TickContext {
|
||||
playerId: PlayerId;
|
||||
sequence: number;
|
||||
targetTick: number;
|
||||
/** Client simulation tick whose world presentation produced this input. */
|
||||
observedTick: number;
|
||||
}
|
||||
|
||||
export interface PlayerContext {
|
||||
playerId: PlayerId;
|
||||
tick: number;
|
||||
}
|
||||
|
||||
export interface ClientStateContext extends PlayerContext {}
|
||||
|
||||
export interface GameDefinition<State, Input> {
|
||||
tickRateHz: number;
|
||||
snapshotRateHz: number;
|
||||
createInitialState(): State;
|
||||
cloneState(state: State): State;
|
||||
step(state: State, context: TickContext): void;
|
||||
applyInput(state: State, input: Input, context: InputContext): void;
|
||||
validateInput(input: Input, context: InputContext): boolean;
|
||||
validateState?(state: State, context: TickContext): boolean;
|
||||
validateClientState?(
|
||||
authoritative: State,
|
||||
candidate: State,
|
||||
context: ClientStateContext,
|
||||
): boolean;
|
||||
addPlayer?(state: State, context: PlayerContext): void;
|
||||
removePlayer?(state: State, context: PlayerContext): void;
|
||||
}
|
||||
|
||||
export interface InputPacket<Input> {
|
||||
sequence: number;
|
||||
targetTick: number;
|
||||
/** Optional for backwards compatibility; servers fall back to targetTick. */
|
||||
observedTick?: number;
|
||||
input: Input;
|
||||
}
|
||||
|
||||
export interface StateSnapshot<State> {
|
||||
tick: number;
|
||||
serverTime: number;
|
||||
state: State;
|
||||
}
|
||||
|
||||
export interface ClientStateReport<State> {
|
||||
tick: number;
|
||||
state: State;
|
||||
}
|
||||
|
||||
export interface PingPacket {
|
||||
id: number;
|
||||
clientSentAt: number;
|
||||
}
|
||||
|
||||
export interface PongPacket extends PingPacket {
|
||||
serverReceivedAt: number;
|
||||
serverSentAt: number;
|
||||
}
|
||||
|
||||
export interface BinaryCodec<Value> {
|
||||
encode(value: Value): Uint8Array;
|
||||
decode(payload: Uint8Array): Value;
|
||||
}
|
||||
|
||||
export interface ValidationResult {
|
||||
playerId: PlayerId;
|
||||
tick: number;
|
||||
valid: boolean;
|
||||
reason?: "mismatch" | "outside-history" | "too-far-ahead";
|
||||
}
|
||||
874
packages/engine/test/engine.test.mjs
Normal file
874
packages/engine/test/engine.test.mjs
Normal file
@@ -0,0 +1,874 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
FixedStepClock,
|
||||
NetworkClock,
|
||||
ReplayDivergenceError,
|
||||
SpatialGridIndex,
|
||||
createBinaryProtocol,
|
||||
createJsonCodec,
|
||||
deterministicHash,
|
||||
defineGame,
|
||||
defineMultiplayerGame,
|
||||
defineNetworkedGame,
|
||||
withLagCompensation,
|
||||
withReplayTransport,
|
||||
withSpatialReplication,
|
||||
withTimeTravel,
|
||||
} from "../dist/index.js";
|
||||
|
||||
test("spatial replication indexes, prioritizes, budgets, and projects per viewer", () => {
|
||||
const grid = new SpatialGridIndex(10);
|
||||
grid.insert({ id: "negative", x: -4, z: -7 });
|
||||
grid.insert({ id: "near", x: 6, z: 2 });
|
||||
grid.insert({ id: "far", x: 40, z: 40 });
|
||||
assert.deepEqual(
|
||||
grid.query({ x: 0, z: 0 }, 10).map(({ id }) => id).sort(),
|
||||
["near", "negative"],
|
||||
);
|
||||
|
||||
const codec = createJsonCodec();
|
||||
const base = defineMultiplayerGame({
|
||||
clock: { ticksPerSecond: 10, snapshotsPerSecond: 10 },
|
||||
authority: {
|
||||
createInitialState: () => ({
|
||||
entities: [
|
||||
{ id: 1, x: 0, z: 0 },
|
||||
{ id: 2, x: 10, z: 0 },
|
||||
{ id: 3, x: 20, z: 0 },
|
||||
{ id: 4, x: 200, z: 0 },
|
||||
],
|
||||
}),
|
||||
cloneState: (state) => ({ entities: state.entities.map((entity) => ({ ...entity })) }),
|
||||
applyInput() {},
|
||||
step() {},
|
||||
},
|
||||
prediction: {
|
||||
createInitialState: () => ({ entities: [], events: [] }),
|
||||
cloneState: (state) => ({ entities: state.entities.map((entity) => ({ ...entity })), events: [...state.events] }),
|
||||
applyInput() {},
|
||||
step() {},
|
||||
applyEvent(state, event) {
|
||||
state.events.push(event);
|
||||
},
|
||||
},
|
||||
visibility: {
|
||||
createSnapshot: (authority) => ({
|
||||
entities: authority.entities.map((entity) => ({ ...entity })),
|
||||
events: [],
|
||||
}),
|
||||
perceive: (_authority, event) => event,
|
||||
},
|
||||
input: { validate: () => true },
|
||||
encoding: { input: codec, clientState: codec, perception: codec },
|
||||
});
|
||||
const game = withSpatialReplication(base, {
|
||||
cellSize: 16,
|
||||
bandwidthBudgetBytesPerSecond: 1_000,
|
||||
viewerPosition: (authority, playerId) => authority.entities.find(({ id }) => id === playerId) ?? null,
|
||||
sources: [
|
||||
{
|
||||
category: "entities",
|
||||
maximumDistance: 100,
|
||||
entities: (authority) => authority.entities,
|
||||
estimatedBytes: 40,
|
||||
required: (entity, playerId) => entity.id === playerId,
|
||||
},
|
||||
],
|
||||
projectSnapshot(snapshot, selection) {
|
||||
return {
|
||||
...snapshot,
|
||||
entities: snapshot.entities.filter((entity) => selection.has("entities", entity.id)),
|
||||
};
|
||||
},
|
||||
perceive(_authority, event, perception, selection) {
|
||||
return selection.has("entities", event.sourceId) ? perception : null;
|
||||
},
|
||||
});
|
||||
|
||||
const server = game.createServer();
|
||||
server.addPlayer(1);
|
||||
assert.deepEqual(
|
||||
server.createSnapshot(1, 0).state.entities.map(({ id }) => id),
|
||||
[1, 2],
|
||||
);
|
||||
const selection = game.spatialReplication.select(server.currentState, {
|
||||
playerId: 1,
|
||||
tick: server.tick,
|
||||
});
|
||||
assert.equal(selection.budgetBytes, 100);
|
||||
assert.equal(selection.usedBytes, 80);
|
||||
assert.equal(selection.candidateCount, 3);
|
||||
assert.equal(selection.droppedCount, 1);
|
||||
assert.deepEqual(server.createPerceptions(1, [{ sourceId: 2 }]), [{ sourceId: 2 }]);
|
||||
assert.deepEqual(server.createPerceptions(1, [{ sourceId: 3 }]), []);
|
||||
});
|
||||
|
||||
test("the developer-facing API compiles named sections into a networked game", () => {
|
||||
const codec = createJsonCodec();
|
||||
const game = defineMultiplayerGame({
|
||||
clock: { ticksPerSecond: 12, snapshotsPerSecond: 4 },
|
||||
authority: {
|
||||
createInitialState: () => ({ value: 0 }),
|
||||
cloneState: (state) => ({ ...state }),
|
||||
applyInput(state, input) {
|
||||
state.value += input.amount;
|
||||
},
|
||||
step() {},
|
||||
},
|
||||
prediction: {
|
||||
createInitialState: () => ({ value: 0 }),
|
||||
cloneState: (state) => ({ ...state }),
|
||||
applyInput(state, input) {
|
||||
state.value += input.amount;
|
||||
},
|
||||
step() {},
|
||||
},
|
||||
visibility: {
|
||||
createSnapshot: (authority) => ({ value: authority.value }),
|
||||
},
|
||||
input: {
|
||||
validate: (input) => Number.isFinite(input.amount),
|
||||
},
|
||||
encoding: {
|
||||
input: codec,
|
||||
clientState: codec,
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(game.tickRateHz, 12);
|
||||
assert.equal(game.snapshotRateHz, 4);
|
||||
const server = game.createServer();
|
||||
server.addPlayer(1);
|
||||
assert.equal(
|
||||
server.submitInput(1, {
|
||||
sequence: 1,
|
||||
targetTick: 1,
|
||||
observedTick: 0,
|
||||
input: { amount: 3 },
|
||||
}).accepted,
|
||||
true,
|
||||
);
|
||||
server.step();
|
||||
assert.equal(server.currentState.value, 3);
|
||||
assert.deepEqual(codec.decode(codec.encode({ unicode: "flux ⚡" })), {
|
||||
unicode: "flux ⚡",
|
||||
});
|
||||
});
|
||||
|
||||
const testGame = defineGame({
|
||||
tickRateHz: 10,
|
||||
snapshotRateHz: 5,
|
||||
createInitialState: () => ({ position: 0, velocity: 0 }),
|
||||
cloneState: (state) => ({ ...state }),
|
||||
validateInput: (input) =>
|
||||
Number.isFinite(input.axis) && Math.abs(input.axis) <= 1,
|
||||
applyInput: (state, input) => {
|
||||
state.velocity = input.axis;
|
||||
},
|
||||
step: (state, { deltaSeconds }) => {
|
||||
state.position += state.velocity * deltaSeconds;
|
||||
},
|
||||
validateState: (state) =>
|
||||
Number.isFinite(state.position) && Number.isFinite(state.velocity),
|
||||
validateClientState: (authoritative, candidate) =>
|
||||
Math.abs(authoritative.position - candidate.position) < 0.000_001,
|
||||
});
|
||||
|
||||
test("defineGame creates matching authoritative and predicted runtimes", () => {
|
||||
const server = testGame.createServer();
|
||||
const client = testGame.createClient();
|
||||
|
||||
server.addPlayer(7);
|
||||
client.initialize(7, server.createSnapshot(0));
|
||||
|
||||
const packet = client.createInput({ axis: 1 }, 1);
|
||||
assert.deepEqual(server.submitInput(7, packet), { accepted: true });
|
||||
|
||||
client.step();
|
||||
const result = server.step();
|
||||
|
||||
assert.equal(result.snapshotDue, false);
|
||||
assert.deepEqual(result.acknowledgements, [
|
||||
{ playerId: 7, sequence: packet.sequence },
|
||||
]);
|
||||
|
||||
client.acknowledge(packet.sequence);
|
||||
client.reconcile(server.createSnapshot(100));
|
||||
assert.equal(client.currentState.position, server.currentState.position);
|
||||
|
||||
const valid = server.submitStateReport(7, client.createStateReport());
|
||||
assert.equal(valid?.valid, true);
|
||||
|
||||
const invalid = server.submitStateReport(7, {
|
||||
tick: server.tick,
|
||||
state: { position: 99, velocity: 0 },
|
||||
});
|
||||
assert.equal(invalid?.valid, false);
|
||||
|
||||
assert.deepEqual(
|
||||
server.submitInput(7, {
|
||||
sequence: packet.sequence + 1,
|
||||
targetTick: server.tick + 1,
|
||||
input: { axis: 2 },
|
||||
}),
|
||||
{ accepted: false, reason: "invalid" },
|
||||
);
|
||||
});
|
||||
|
||||
test("fixed-step clocks cap catch-up work", () => {
|
||||
const clock = new FixedStepClock({ rateHz: 10, maxCatchUpSteps: 3 });
|
||||
let steps = 0;
|
||||
|
||||
assert.equal(clock.advance(0, () => steps++), 0);
|
||||
assert.equal(clock.advance(100, () => steps++), 1);
|
||||
assert.equal(clock.advance(1_000, () => steps++), 3);
|
||||
assert.equal(steps, 4);
|
||||
});
|
||||
|
||||
test("network clock estimates RTT, offset, and input lead", () => {
|
||||
const clock = new NetworkClock();
|
||||
const ping = clock.createPing(100);
|
||||
const stats = clock.receivePong(
|
||||
{
|
||||
...ping,
|
||||
serverReceivedAt: 160,
|
||||
serverSentAt: 162,
|
||||
},
|
||||
202,
|
||||
);
|
||||
|
||||
assert.equal(stats.roundTripTime, 100);
|
||||
assert.equal(stats.clockOffset, 10);
|
||||
assert.equal(clock.toServerTime(300), 310);
|
||||
assert.equal(clock.recommendedInputLeadTicks(60), 4);
|
||||
});
|
||||
|
||||
test("binary protocol round-trips generic inputs, state, and control frames", () => {
|
||||
const numberCodec = {
|
||||
encode(value) {
|
||||
const payload = new Uint8Array(8);
|
||||
new DataView(payload.buffer).setFloat64(0, value, true);
|
||||
return payload;
|
||||
},
|
||||
decode(payload) {
|
||||
return new DataView(
|
||||
payload.buffer,
|
||||
payload.byteOffset,
|
||||
payload.byteLength,
|
||||
).getFloat64(0, true);
|
||||
},
|
||||
};
|
||||
const protocol = createBinaryProtocol({
|
||||
input: numberCodec,
|
||||
state: numberCodec,
|
||||
event: numberCodec,
|
||||
});
|
||||
|
||||
const input = {
|
||||
kind: "input",
|
||||
packet: {
|
||||
sequence: 4,
|
||||
targetTick: 20,
|
||||
observedTick: 16,
|
||||
input: 0.75,
|
||||
},
|
||||
};
|
||||
assert.deepEqual(protocol.decodeClient(protocol.encodeClient(input)), input);
|
||||
|
||||
const welcome = {
|
||||
kind: "welcome",
|
||||
playerId: 9,
|
||||
snapshot: { tick: 18, serverTime: 42.5, state: 3.25 },
|
||||
};
|
||||
assert.deepEqual(protocol.decodeServer(protocol.encodeServer(welcome)), welcome);
|
||||
|
||||
const rejection = { kind: "reject-input", sequence: 44 };
|
||||
assert.deepEqual(
|
||||
protocol.decodeServer(protocol.encodeServer(rejection)),
|
||||
rejection,
|
||||
);
|
||||
|
||||
const replayStart = {
|
||||
kind: "replay-start",
|
||||
ticketId: 12,
|
||||
perspectiveId: 9,
|
||||
fromTick: 100,
|
||||
toTick: 160,
|
||||
frameCount: 2,
|
||||
playbackRate: 0.75,
|
||||
};
|
||||
assert.deepEqual(
|
||||
protocol.decodeServer(protocol.encodeServer(replayStart)),
|
||||
replayStart,
|
||||
);
|
||||
|
||||
const replayFrame = {
|
||||
kind: "replay-frame",
|
||||
ticketId: 12,
|
||||
tick: 120,
|
||||
state: 3.5,
|
||||
events: [4.5, 5.5],
|
||||
};
|
||||
assert.deepEqual(
|
||||
protocol.decodeServer(protocol.encodeServer(replayFrame)),
|
||||
replayFrame,
|
||||
);
|
||||
const truncatedReplayFrame = protocol.encodeServer(replayFrame).slice(0, -1);
|
||||
assert.throws(() => protocol.decodeServer(truncatedReplayFrame), RangeError);
|
||||
|
||||
const replayEnd = { kind: "replay-end", ticketId: 12 };
|
||||
assert.deepEqual(
|
||||
protocol.decodeServer(protocol.encodeServer(replayEnd)),
|
||||
replayEnd,
|
||||
);
|
||||
});
|
||||
|
||||
test("networked games keep authority private and derive viewer perceptions", () => {
|
||||
const jsonCodec = {
|
||||
encode: (value) => new TextEncoder().encode(JSON.stringify(value)),
|
||||
decode: (payload) => JSON.parse(new TextDecoder().decode(payload)),
|
||||
};
|
||||
const privateGame = defineNetworkedGame({
|
||||
tickRateHz: 10,
|
||||
snapshotRateHz: 5,
|
||||
server: {
|
||||
createInitialState: () => ({
|
||||
publicValues: new Map(),
|
||||
secrets: new Map(),
|
||||
}),
|
||||
cloneState: (state) => ({
|
||||
publicValues: new Map(state.publicValues),
|
||||
secrets: new Map(state.secrets),
|
||||
}),
|
||||
addPlayer(state, { playerId }) {
|
||||
state.publicValues.set(playerId, 0);
|
||||
state.secrets.set(playerId, `secret-${playerId}`);
|
||||
},
|
||||
applyInput(state, input, { playerId, emit }) {
|
||||
state.publicValues.set(playerId, input);
|
||||
emit({ source: playerId, privateValue: state.secrets.get(playerId) });
|
||||
},
|
||||
step() {},
|
||||
},
|
||||
client: {
|
||||
createInitialState: () => ({ ownValue: 0, cues: [] }),
|
||||
cloneState: (state) => ({ ...state, cues: [...state.cues] }),
|
||||
applyInput(state, input) {
|
||||
state.ownValue = input;
|
||||
},
|
||||
step() {},
|
||||
applyEvent(state, event) {
|
||||
state.cues.push(event);
|
||||
},
|
||||
},
|
||||
replication: {
|
||||
createSnapshot(authority, { playerId }) {
|
||||
return {
|
||||
ownValue: authority.publicValues.get(playerId) ?? 0,
|
||||
cues: [],
|
||||
};
|
||||
},
|
||||
validateClientState(authority, candidate, { playerId }) {
|
||||
return candidate.ownValue === authority.publicValues.get(playerId);
|
||||
},
|
||||
perceive(_authority, event, { playerId }) {
|
||||
return event.source === playerId ? null : "heard-an-event";
|
||||
},
|
||||
},
|
||||
validateInput: (input) => Number.isFinite(input),
|
||||
codecs: { input: jsonCodec, state: jsonCodec, event: jsonCodec },
|
||||
});
|
||||
|
||||
const server = privateGame.createServer();
|
||||
server.addPlayer(1);
|
||||
server.addPlayer(2);
|
||||
|
||||
const snapshot = server.createSnapshot(1, 0);
|
||||
assert.deepEqual(snapshot.state, { ownValue: 0, cues: [] });
|
||||
assert.equal("secrets" in snapshot.state, false);
|
||||
|
||||
assert.equal(
|
||||
server.submitInput(2, { sequence: 1, targetTick: 1, input: 5 }).accepted,
|
||||
true,
|
||||
);
|
||||
const result = server.step();
|
||||
assert.deepEqual(server.createPerceptions(1, result.events), [
|
||||
"heard-an-event",
|
||||
]);
|
||||
assert.deepEqual(server.createPerceptions(2, result.events), []);
|
||||
|
||||
const client = privateGame.createClient();
|
||||
client.initialize(1, snapshot);
|
||||
client.receiveEvent("heard-an-event", result.tick);
|
||||
assert.deepEqual(client.currentState.cues, ["heard-an-event"]);
|
||||
|
||||
const eventFrame = privateGame.protocol.encodeServer({
|
||||
kind: "event",
|
||||
tick: result.tick,
|
||||
event: "heard-an-event",
|
||||
});
|
||||
assert.deepEqual(privateGame.protocol.decodeServer(eventFrame), {
|
||||
kind: "event",
|
||||
tick: result.tick,
|
||||
event: "heard-an-event",
|
||||
});
|
||||
});
|
||||
|
||||
const lagJsonCodec = {
|
||||
encode: (value) => new TextEncoder().encode(JSON.stringify(value)),
|
||||
decode: (payload) => JSON.parse(new TextDecoder().decode(payload)),
|
||||
};
|
||||
|
||||
const lagBaseGame = defineNetworkedGame({
|
||||
tickRateHz: 10,
|
||||
snapshotRateHz: 5,
|
||||
server: {
|
||||
createInitialState: () => ({ positions: {}, resolutions: [] }),
|
||||
cloneState: (state) => ({
|
||||
positions: { ...state.positions },
|
||||
resolutions: state.resolutions.map((entry) => ({ ...entry })),
|
||||
}),
|
||||
addPlayer(state, { playerId }) {
|
||||
state.positions[playerId] = 0;
|
||||
},
|
||||
applyInput(state, input, { playerId }) {
|
||||
if (input.moveTo !== undefined) state.positions[playerId] = input.moveTo;
|
||||
},
|
||||
step() {},
|
||||
},
|
||||
client: {
|
||||
createInitialState: () => ({ positions: {}, resolutions: [] }),
|
||||
cloneState: (state) => structuredClone(state),
|
||||
applyInput() {},
|
||||
step() {},
|
||||
},
|
||||
replication: {
|
||||
createSnapshot: (state) => structuredClone(state),
|
||||
},
|
||||
validateInput: (input) =>
|
||||
(input.moveTo === undefined || Number.isFinite(input.moveTo)) &&
|
||||
(input.action === undefined ||
|
||||
["rewind", "current", "projectile", "strict"].includes(input.action)),
|
||||
codecs: { input: lagJsonCodec, state: lagJsonCodec, event: lagJsonCodec },
|
||||
});
|
||||
|
||||
const lagPolicyGame = withLagCompensation(lagBaseGame, {
|
||||
historySeconds: 2,
|
||||
captureState: (state) => ({ positions: { ...state.positions } }),
|
||||
cloneHistoricalState: (state) => ({ positions: { ...state.positions } }),
|
||||
classifyAction: (input) =>
|
||||
input.action ? { type: input.action, targetId: input.targetId ?? 2 } : null,
|
||||
cloneAction: (action) => ({ ...action }),
|
||||
actions: {
|
||||
rewind: {
|
||||
mode: "rewind",
|
||||
maximumRewindMs: 1_000,
|
||||
resolve: recordLagResolution,
|
||||
},
|
||||
current: {
|
||||
mode: "current",
|
||||
resolve: recordLagResolution,
|
||||
},
|
||||
projectile: {
|
||||
mode: "fast-forward",
|
||||
maximumRewindMs: 1_000,
|
||||
resolve: recordLagResolution,
|
||||
},
|
||||
strict: {
|
||||
mode: "rewind",
|
||||
maximumRewindMs: 100,
|
||||
outOfWindow: "reject",
|
||||
resolve: recordLagResolution,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const lagReplayGame = withTimeTravel(lagPolicyGame, {
|
||||
createSeed: () => 0,
|
||||
hashState: deterministicHash,
|
||||
checkpointIntervalTicks: 2,
|
||||
verificationIntervalTicks: 1,
|
||||
});
|
||||
|
||||
function recordLagResolution(context) {
|
||||
context.currentState.resolutions.push({
|
||||
type: context.action.type,
|
||||
requestedTick: context.requestedTick,
|
||||
resolvedTick: context.resolvedTick,
|
||||
currentTick: context.currentTick,
|
||||
rewindTicks: context.rewindTicks,
|
||||
catchUpTicks: context.catchUpTicks,
|
||||
historicalPosition:
|
||||
context.historicalState.positions[context.action.targetId],
|
||||
currentPosition: context.currentState.positions[context.action.targetId],
|
||||
});
|
||||
}
|
||||
|
||||
test("lag-compensation HOF applies an independent policy per action", () => {
|
||||
const server = lagPolicyGame.createServer();
|
||||
server.addPlayer(1);
|
||||
server.addPlayer(2);
|
||||
|
||||
server.submitInput(2, {
|
||||
sequence: 1,
|
||||
targetTick: 1,
|
||||
observedTick: 0,
|
||||
input: { moveTo: 5 },
|
||||
});
|
||||
server.step();
|
||||
server.step();
|
||||
|
||||
server.submitInput(1, {
|
||||
sequence: 1,
|
||||
targetTick: 3,
|
||||
observedTick: 0,
|
||||
input: { action: "rewind", targetId: 2 },
|
||||
});
|
||||
server.step();
|
||||
server.submitInput(1, {
|
||||
sequence: 2,
|
||||
targetTick: 4,
|
||||
observedTick: 0,
|
||||
input: { action: "current", targetId: 2 },
|
||||
});
|
||||
server.step();
|
||||
server.submitInput(1, {
|
||||
sequence: 3,
|
||||
targetTick: 5,
|
||||
observedTick: 2,
|
||||
input: { action: "projectile", targetId: 2 },
|
||||
});
|
||||
server.step();
|
||||
server.submitInput(1, {
|
||||
sequence: 4,
|
||||
targetTick: 6,
|
||||
observedTick: 0,
|
||||
input: { action: "strict", targetId: 2 },
|
||||
});
|
||||
server.step();
|
||||
|
||||
assert.equal(server.currentState.positions[2], 5);
|
||||
assert.deepEqual(server.currentState.resolutions, [
|
||||
{
|
||||
type: "rewind",
|
||||
requestedTick: 0,
|
||||
resolvedTick: 0,
|
||||
currentTick: 3,
|
||||
rewindTicks: 3,
|
||||
catchUpTicks: 0,
|
||||
historicalPosition: 0,
|
||||
currentPosition: 5,
|
||||
},
|
||||
{
|
||||
type: "current",
|
||||
requestedTick: 0,
|
||||
resolvedTick: 4,
|
||||
currentTick: 4,
|
||||
rewindTicks: 0,
|
||||
catchUpTicks: 0,
|
||||
historicalPosition: 5,
|
||||
currentPosition: 5,
|
||||
},
|
||||
{
|
||||
type: "projectile",
|
||||
requestedTick: 2,
|
||||
resolvedTick: 2,
|
||||
currentTick: 5,
|
||||
rewindTicks: 3,
|
||||
catchUpTicks: 3,
|
||||
historicalPosition: 5,
|
||||
currentPosition: 5,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("lag-compensated actions replay deterministically through checkpoints", () => {
|
||||
const server = lagReplayGame.createServer();
|
||||
server.addPlayer(1);
|
||||
server.addPlayer(2);
|
||||
server.submitInput(2, {
|
||||
sequence: 1,
|
||||
targetTick: 1,
|
||||
observedTick: 0,
|
||||
input: { moveTo: 5 },
|
||||
});
|
||||
server.step();
|
||||
server.step();
|
||||
server.submitInput(1, {
|
||||
sequence: 1,
|
||||
targetTick: 3,
|
||||
observedTick: 0,
|
||||
input: { action: "rewind", targetId: 2 },
|
||||
});
|
||||
for (let tick = 2; tick < 6; tick += 1) server.step();
|
||||
|
||||
const replay = lagReplayGame.createReplay(server.exportRecording());
|
||||
assert.deepEqual(replay.seek(6).state, server.currentState);
|
||||
assert.equal(replay.currentState.resolutions[0].historicalPosition, 0);
|
||||
});
|
||||
|
||||
const replayCodec = {
|
||||
encode: (value) => new TextEncoder().encode(JSON.stringify(value)),
|
||||
decode: (payload) => JSON.parse(new TextDecoder().decode(payload)),
|
||||
};
|
||||
|
||||
const replayDefinition = defineNetworkedGame({
|
||||
tickRateHz: 10,
|
||||
snapshotRateHz: 5,
|
||||
server: {
|
||||
createInitialState: () => ({
|
||||
seed: 0,
|
||||
secret: "uninitialized",
|
||||
positions: new Map(),
|
||||
velocities: new Map(),
|
||||
}),
|
||||
cloneState: (state) => ({
|
||||
...state,
|
||||
positions: new Map(state.positions),
|
||||
velocities: new Map(state.velocities),
|
||||
}),
|
||||
addPlayer(state, { playerId, emit }) {
|
||||
state.positions.set(playerId, state.seed);
|
||||
state.velocities.set(playerId, 0);
|
||||
emit({ type: "joined", playerId });
|
||||
},
|
||||
removePlayer(state, { playerId }) {
|
||||
state.positions.delete(playerId);
|
||||
state.velocities.delete(playerId);
|
||||
},
|
||||
applyInput(state, input, { playerId, emit }) {
|
||||
state.velocities.set(playerId, input.axis);
|
||||
emit({ type: "moved", playerId });
|
||||
},
|
||||
step(state, { deltaSeconds }) {
|
||||
for (const [playerId, velocity] of state.velocities) {
|
||||
state.positions.set(
|
||||
playerId,
|
||||
(state.positions.get(playerId) ?? 0) + velocity * deltaSeconds,
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
client: {
|
||||
createInitialState: () => ({ ownPosition: 0, cues: [] }),
|
||||
cloneState: (state) => ({ ...state, cues: [...state.cues] }),
|
||||
applyInput(state, input) {
|
||||
state.ownPosition += input.axis / 10;
|
||||
},
|
||||
step() {},
|
||||
applyEvent(state, event) {
|
||||
state.cues.push(event);
|
||||
},
|
||||
},
|
||||
replication: {
|
||||
createSnapshot(authority, { playerId }) {
|
||||
return {
|
||||
ownPosition: authority.positions.get(playerId) ?? 0,
|
||||
cues: [],
|
||||
};
|
||||
},
|
||||
perceive(_authority, event, { playerId }) {
|
||||
return event.playerId === playerId ? null : `heard-${event.type}`;
|
||||
},
|
||||
},
|
||||
validateInput: (input) =>
|
||||
Number.isFinite(input.axis) && Math.abs(input.axis) <= 1,
|
||||
codecs: {
|
||||
input: replayCodec,
|
||||
state: replayCodec,
|
||||
event: replayCodec,
|
||||
},
|
||||
});
|
||||
|
||||
const replayGame = withTimeTravel(replayDefinition, {
|
||||
createSeed: () => 1,
|
||||
initializeState(state, seed) {
|
||||
state.seed = seed;
|
||||
state.secret = `secret-${seed}`;
|
||||
},
|
||||
hashState: deterministicHash,
|
||||
checkpointIntervalTicks: 2,
|
||||
verificationIntervalTicks: 1,
|
||||
});
|
||||
|
||||
const replayTransportGame = withReplayTransport(replayGame, {
|
||||
historySeconds: 0.6,
|
||||
captureRateHz: 5,
|
||||
createTickets(event, { tick }) {
|
||||
return event.type === "joined" && event.playerId === 2
|
||||
? {
|
||||
requesterId: 1,
|
||||
perspectiveId: 2,
|
||||
fromTick: 0,
|
||||
toTick: tick,
|
||||
}
|
||||
: null;
|
||||
},
|
||||
authorizeReplay: ({ requesterId, perspectiveId }) =>
|
||||
requesterId === 1 && perspectiveId === 2,
|
||||
});
|
||||
|
||||
test("time-travel HOF records, seeks, verifies, filters, and branches", () => {
|
||||
const server = replayGame.createServer({ replaySeed: 7 });
|
||||
server.addPlayer(1);
|
||||
server.addPlayer(2);
|
||||
assert.equal(
|
||||
server.submitInput(1, {
|
||||
sequence: 1,
|
||||
targetTick: 1,
|
||||
input: { axis: 1 },
|
||||
}).accepted,
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
server.submitInput(2, {
|
||||
sequence: 1,
|
||||
targetTick: 1,
|
||||
input: { axis: -0.5 },
|
||||
}).accepted,
|
||||
true,
|
||||
);
|
||||
|
||||
for (let tick = 0; tick < 3; tick += 1) server.step();
|
||||
assert.equal(
|
||||
server.submitInput(1, {
|
||||
sequence: 2,
|
||||
targetTick: 5,
|
||||
input: { axis: 0.25 },
|
||||
}).accepted,
|
||||
true,
|
||||
);
|
||||
for (let tick = 3; tick < 6; tick += 1) server.step();
|
||||
|
||||
const recording = server.exportRecording();
|
||||
assert.equal(recording.seed, 7);
|
||||
assert.equal(recording.durationTicks, 6);
|
||||
assert.deepEqual(
|
||||
recording.checkpoints.map(({ tick }) => tick),
|
||||
[0, 2, 4, 6],
|
||||
);
|
||||
|
||||
const replay = replayGame.createReplay(recording);
|
||||
const finalFrame = replay.seek(6);
|
||||
assert.deepEqual(finalFrame.state, server.currentState);
|
||||
assert.equal(finalFrame.hash, deterministicHash(server.currentState));
|
||||
|
||||
const privateView = replay.viewAs(1, 123);
|
||||
assert.equal(privateView.tick, 6);
|
||||
assert.equal("secret" in privateView.state, false);
|
||||
assert.deepEqual(Object.keys(privateView.state).sort(), ["cues", "ownPosition"]);
|
||||
|
||||
replay.seek(1);
|
||||
assert.deepEqual(replay.perceptionsAs(1), ["heard-joined", "heard-moved"]);
|
||||
const rewindPosition = replay.currentState.positions.get(1);
|
||||
replay.seek(6);
|
||||
assert.notEqual(replay.currentState.positions.get(1), rewindPosition);
|
||||
|
||||
const branch = replay.branch(2);
|
||||
assert.equal(
|
||||
branch.submitInput(1, {
|
||||
sequence: 2,
|
||||
targetTick: 3,
|
||||
input: { axis: -1 },
|
||||
}).accepted,
|
||||
true,
|
||||
);
|
||||
while (branch.tick < 6) branch.step();
|
||||
assert.notEqual(
|
||||
deterministicHash(branch.currentState),
|
||||
deterministicHash(server.currentState),
|
||||
);
|
||||
|
||||
const branchReplay = replayGame.createReplay(branch.exportRecording());
|
||||
assert.deepEqual(branchReplay.seek(6).state, branch.currentState);
|
||||
assert.deepEqual(replay.seek(6).state, server.currentState);
|
||||
});
|
||||
|
||||
test("time-travel replay pinpoints deterministic divergence", () => {
|
||||
const server = replayGame.createServer({ replaySeed: 4 });
|
||||
server.addPlayer(1);
|
||||
server.submitInput(1, {
|
||||
sequence: 1,
|
||||
targetTick: 1,
|
||||
input: { axis: 1 },
|
||||
});
|
||||
for (let tick = 0; tick < 3; tick += 1) server.step();
|
||||
|
||||
const recording = server.exportRecording();
|
||||
const corrupted = {
|
||||
...recording,
|
||||
verifications: recording.verifications.map((entry) =>
|
||||
entry.tick === 3 ? { ...entry, hash: "corrupted" } : entry,
|
||||
),
|
||||
};
|
||||
const replay = replayGame.createReplay(corrupted);
|
||||
assert.throws(() => replay.seek(3), ReplayDivergenceError);
|
||||
});
|
||||
|
||||
test("rolling authority recordings stay bounded and remain seekable", () => {
|
||||
const server = replayGame.createServer({
|
||||
replaySeed: 8,
|
||||
recordingHistoryTicks: 4,
|
||||
});
|
||||
server.addPlayer(1);
|
||||
for (let tick = 0; tick < 10; tick += 1) server.step();
|
||||
|
||||
const recording = server.exportRecording();
|
||||
assert.equal(recording.startTick, 6);
|
||||
assert.equal(recording.durationTicks, 10);
|
||||
assert.ok(recording.verifications.every(({ tick }) => tick >= 6));
|
||||
assert.ok(recording.checkpoints.every(({ tick }) => tick >= 6));
|
||||
|
||||
const replay = replayGame.createReplay(recording);
|
||||
assert.equal(replay.startTick, 6);
|
||||
assert.deepEqual(replay.seek(10).state, server.currentState);
|
||||
assert.throws(() => replay.seek(5), RangeError);
|
||||
});
|
||||
|
||||
test("replay transport issues only authorized projected frames from a bounded ring", () => {
|
||||
const server = replayTransportGame.createServer({ replaySeed: 11 });
|
||||
server.addPlayer(1);
|
||||
server.addPlayer(2);
|
||||
for (let tick = 0; tick < 12; tick += 1) server.step();
|
||||
|
||||
const automaticTickets = server.drainReplayTickets(1);
|
||||
assert.equal(automaticTickets.length, 1);
|
||||
assert.equal(automaticTickets[0].perspectiveId, 2);
|
||||
|
||||
assert.equal(
|
||||
server.issueReplay({
|
||||
requesterId: 1,
|
||||
perspectiveId: 1,
|
||||
fromTick: 0,
|
||||
toTick: 12,
|
||||
}),
|
||||
null,
|
||||
);
|
||||
|
||||
const ticket = server.issueReplay({
|
||||
requesterId: 1,
|
||||
perspectiveId: 2,
|
||||
fromTick: 0,
|
||||
toTick: 12,
|
||||
playbackRate: 0.5,
|
||||
});
|
||||
assert.ok(ticket);
|
||||
assert.equal(ticket.requesterId, 1);
|
||||
assert.equal(ticket.perspectiveId, 2);
|
||||
assert.equal(ticket.fromTick, 6);
|
||||
assert.equal(ticket.toTick, 12);
|
||||
assert.equal(ticket.playbackRate, 0.5);
|
||||
assert.ok(ticket.frames.length > 1);
|
||||
assert.ok(
|
||||
ticket.frames.every(
|
||||
({ state }) =>
|
||||
!Object.hasOwn(state, "secret") &&
|
||||
Object.keys(state).sort().join(",") === "cues,ownPosition",
|
||||
),
|
||||
);
|
||||
|
||||
assert.deepEqual(server.drainReplayTickets(1), [ticket]);
|
||||
assert.deepEqual(server.drainReplayTickets(1), []);
|
||||
assert.equal(server.exportRecording().startTick, 6);
|
||||
});
|
||||
14
packages/engine/tsconfig.json
Normal file
14
packages/engine/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"lib": ["ES2022"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
28
packages/shared/package.json
Normal file
28
packages/shared/package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "@syncer/shared",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"dependencies": {
|
||||
"@syncer/engine": "0.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "tsc --watch --preserveWatchOutput",
|
||||
"build": "tsc",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^7.0.2"
|
||||
}
|
||||
}
|
||||
231
packages/shared/src/arena.ts
Normal file
231
packages/shared/src/arena.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
export interface ArenaBlock {
|
||||
id: string;
|
||||
x: number;
|
||||
z: number;
|
||||
width: number;
|
||||
depth: number;
|
||||
height: number;
|
||||
style: "reactor" | "wall" | "crate" | "column";
|
||||
}
|
||||
|
||||
export interface ArenaSpawn {
|
||||
x: number;
|
||||
z: number;
|
||||
yaw: number;
|
||||
}
|
||||
|
||||
export interface ArenaPoint {
|
||||
x: number;
|
||||
z: number;
|
||||
}
|
||||
|
||||
export const ARENA_HALF_SIZE = 18;
|
||||
export const PLAYER_RADIUS = 0.44;
|
||||
export const PLAYER_EYE_HEIGHT = 1.58;
|
||||
export const PLAYER_HEIGHT = 1.8;
|
||||
|
||||
export const ARENA_BLOCKS: readonly ArenaBlock[] = [
|
||||
{ id: "reactor", x: 0, z: 0, width: 4.2, depth: 4.2, height: 4.8, style: "reactor" },
|
||||
{ id: "north-spine", x: 0, z: -10.2, width: 8.2, depth: 1.2, height: 2.8, style: "wall" },
|
||||
{ id: "south-spine", x: 0, z: 10.2, width: 8.2, depth: 1.2, height: 2.8, style: "wall" },
|
||||
{ id: "west-spine", x: -10.2, z: 0, width: 1.2, depth: 8.2, height: 2.8, style: "wall" },
|
||||
{ id: "east-spine", x: 10.2, z: 0, width: 1.2, depth: 8.2, height: 2.8, style: "wall" },
|
||||
{ id: "nw-cover-a", x: -7, z: -7, width: 3.2, depth: 1.5, height: 1.35, style: "crate" },
|
||||
{ id: "nw-cover-b", x: -7, z: -4.7, width: 1.5, depth: 3, height: 2.1, style: "crate" },
|
||||
{ id: "se-cover-a", x: 7, z: 7, width: 3.2, depth: 1.5, height: 1.35, style: "crate" },
|
||||
{ id: "se-cover-b", x: 7, z: 4.7, width: 1.5, depth: 3, height: 2.1, style: "crate" },
|
||||
{ id: "ne-cover-a", x: 7, z: -7, width: 1.5, depth: 3.2, height: 1.55, style: "crate" },
|
||||
{ id: "sw-cover-a", x: -7, z: 7, width: 1.5, depth: 3.2, height: 1.55, style: "crate" },
|
||||
{ id: "northwest-column", x: -14, z: -13, width: 1.8, depth: 1.8, height: 4.2, style: "column" },
|
||||
{ id: "northeast-column", x: 14, z: -13, width: 1.8, depth: 1.8, height: 4.2, style: "column" },
|
||||
{ id: "southwest-column", x: -14, z: 13, width: 1.8, depth: 1.8, height: 4.2, style: "column" },
|
||||
{ id: "southeast-column", x: 14, z: 13, width: 1.8, depth: 1.8, height: 4.2, style: "column" },
|
||||
] as const;
|
||||
|
||||
export const ARENA_SPAWNS: readonly ArenaSpawn[] = [
|
||||
{ x: -14.5, z: -9.5, yaw: 2.35 },
|
||||
{ x: 14.5, z: 9.5, yaw: -0.78 },
|
||||
{ x: 14.5, z: -9.5, yaw: -2.35 },
|
||||
{ x: -14.5, z: 9.5, yaw: 0.78 },
|
||||
{ x: 0, z: -15, yaw: Math.PI },
|
||||
{ x: 0, z: 15, yaw: 0 },
|
||||
{ x: -15, z: 0, yaw: Math.PI / 2 },
|
||||
{ x: 15, z: 0, yaw: -Math.PI / 2 },
|
||||
] as const;
|
||||
|
||||
export const BOT_PATROL_POINTS: readonly ArenaPoint[] = [
|
||||
{ x: -13, z: -8 },
|
||||
{ x: -5.5, z: -12.5 },
|
||||
{ x: 5.5, z: -12.5 },
|
||||
{ x: 13, z: -8 },
|
||||
{ x: 12.5, z: 4 },
|
||||
{ x: 8, z: 12.5 },
|
||||
{ x: -3.5, z: 13.5 },
|
||||
{ x: -12.5, z: 8 },
|
||||
{ x: -13.5, z: -3.5 },
|
||||
{ x: -5.8, z: -4.8 },
|
||||
{ x: 5.8, z: 4.8 },
|
||||
{ x: 5.5, z: -5.5 },
|
||||
] as const;
|
||||
|
||||
export function hasLineOfSight(
|
||||
fromX: number,
|
||||
fromZ: number,
|
||||
toX: number,
|
||||
toZ: number,
|
||||
): boolean {
|
||||
for (const block of ARENA_BLOCKS) {
|
||||
if (segmentIntersectsExpandedBlock(fromX, fromZ, toX, toZ, block, 0.08)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function collidesWithArena(x: number, z: number, radius = PLAYER_RADIUS): boolean {
|
||||
if (
|
||||
x - radius < -ARENA_HALF_SIZE ||
|
||||
x + radius > ARENA_HALF_SIZE ||
|
||||
z - radius < -ARENA_HALF_SIZE ||
|
||||
z + radius > ARENA_HALF_SIZE
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return ARENA_BLOCKS.some((block) =>
|
||||
circleIntersectsBlock(x, z, radius, block),
|
||||
);
|
||||
}
|
||||
|
||||
export function rayBlockDistance(
|
||||
originX: number,
|
||||
originY: number,
|
||||
originZ: number,
|
||||
directionX: number,
|
||||
directionY: number,
|
||||
directionZ: number,
|
||||
maximumDistance: number,
|
||||
): number | null {
|
||||
let nearest: number | null = null;
|
||||
|
||||
for (const block of ARENA_BLOCKS) {
|
||||
const distance = rayAabbDistance(
|
||||
originX,
|
||||
originY,
|
||||
originZ,
|
||||
directionX,
|
||||
directionY,
|
||||
directionZ,
|
||||
block.x - block.width / 2,
|
||||
0,
|
||||
block.z - block.depth / 2,
|
||||
block.x + block.width / 2,
|
||||
block.height,
|
||||
block.z + block.depth / 2,
|
||||
);
|
||||
|
||||
if (
|
||||
distance !== null &&
|
||||
distance >= 0 &&
|
||||
distance <= maximumDistance &&
|
||||
(nearest === null || distance < nearest)
|
||||
) {
|
||||
nearest = distance;
|
||||
}
|
||||
}
|
||||
|
||||
return nearest;
|
||||
}
|
||||
|
||||
function circleIntersectsBlock(
|
||||
x: number,
|
||||
z: number,
|
||||
radius: number,
|
||||
block: ArenaBlock,
|
||||
): boolean {
|
||||
const halfWidth = block.width / 2;
|
||||
const halfDepth = block.depth / 2;
|
||||
const nearestX = Math.max(block.x - halfWidth, Math.min(x, block.x + halfWidth));
|
||||
const nearestZ = Math.max(block.z - halfDepth, Math.min(z, block.z + halfDepth));
|
||||
const dx = x - nearestX;
|
||||
const dz = z - nearestZ;
|
||||
return dx * dx + dz * dz < radius * radius;
|
||||
}
|
||||
|
||||
function segmentIntersectsExpandedBlock(
|
||||
fromX: number,
|
||||
fromZ: number,
|
||||
toX: number,
|
||||
toZ: number,
|
||||
block: ArenaBlock,
|
||||
padding: number,
|
||||
): boolean {
|
||||
const directionX = toX - fromX;
|
||||
const directionZ = toZ - fromZ;
|
||||
const distance = Math.hypot(directionX, directionZ);
|
||||
if (distance < 0.0001) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const hit = rayAabbDistance(
|
||||
fromX,
|
||||
1.2,
|
||||
fromZ,
|
||||
directionX / distance,
|
||||
0,
|
||||
directionZ / distance,
|
||||
block.x - block.width / 2 - padding,
|
||||
0,
|
||||
block.z - block.depth / 2 - padding,
|
||||
block.x + block.width / 2 + padding,
|
||||
block.height,
|
||||
block.z + block.depth / 2 + padding,
|
||||
);
|
||||
return hit !== null && hit > 0.05 && hit < distance - 0.05;
|
||||
}
|
||||
|
||||
function rayAabbDistance(
|
||||
originX: number,
|
||||
originY: number,
|
||||
originZ: number,
|
||||
directionX: number,
|
||||
directionY: number,
|
||||
directionZ: number,
|
||||
minimumX: number,
|
||||
minimumY: number,
|
||||
minimumZ: number,
|
||||
maximumX: number,
|
||||
maximumY: number,
|
||||
maximumZ: number,
|
||||
): number | null {
|
||||
let minimumDistance = -Infinity;
|
||||
let maximumDistance = Infinity;
|
||||
|
||||
for (const [origin, direction, minimum, maximum] of [
|
||||
[originX, directionX, minimumX, maximumX],
|
||||
[originY, directionY, minimumY, maximumY],
|
||||
[originZ, directionZ, minimumZ, maximumZ],
|
||||
] as const) {
|
||||
if (Math.abs(direction) < 0.000001) {
|
||||
if (origin < minimum || origin > maximum) {
|
||||
return null;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const inverse = 1 / direction;
|
||||
let near = (minimum - origin) * inverse;
|
||||
let far = (maximum - origin) * inverse;
|
||||
if (near > far) {
|
||||
[near, far] = [far, near];
|
||||
}
|
||||
minimumDistance = Math.max(minimumDistance, near);
|
||||
maximumDistance = Math.min(maximumDistance, far);
|
||||
if (maximumDistance < minimumDistance) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return maximumDistance >= 0 ? Math.max(0, minimumDistance) : null;
|
||||
}
|
||||
|
||||
364
packages/shared/src/flux-game.ts
Normal file
364
packages/shared/src/flux-game.ts
Normal file
@@ -0,0 +1,364 @@
|
||||
import {
|
||||
createJsonCodec,
|
||||
defineMultiplayerGame,
|
||||
} from "@syncer/engine";
|
||||
import type {
|
||||
FluxAuthorityEvent,
|
||||
FluxAuthorityPlayer,
|
||||
FluxAuthorityState,
|
||||
FluxClientState,
|
||||
FluxGameContract,
|
||||
FluxInput,
|
||||
FluxPerception,
|
||||
FluxPlayerView,
|
||||
FluxTeam,
|
||||
} from "./flux-types.js";
|
||||
|
||||
export const FLUX_SOCKET_PATH = "/ws/flux";
|
||||
export const FLUX_TICK_RATE = 30;
|
||||
export const FLUX_SNAPSHOT_RATE = 10;
|
||||
export const FLUX_ROUND_RESET_TICKS = 60;
|
||||
export const FLUX_BOT_IDS = [20_001, 20_002] as const;
|
||||
|
||||
const maximumEnergy = 100;
|
||||
const energyDrainPerSecond = 31;
|
||||
const energyRecoveryPerSecond = 20;
|
||||
const coreAcceleration = 0.92;
|
||||
const coreDragPerSecond = 0.86;
|
||||
const eventLifetimeTicks = FLUX_TICK_RATE * 4;
|
||||
|
||||
const inputCodec = createJsonCodec<FluxInput>();
|
||||
const stateCodec = createJsonCodec<FluxClientState>();
|
||||
const perceptionCodec = createJsonCodec<FluxPerception>();
|
||||
|
||||
/**
|
||||
* A complete second game built through the public authoring API. Flux Relay is
|
||||
* a team tug-of-war: hold thrust, manage private energy, and pull the shared
|
||||
* core through your team's gate.
|
||||
*/
|
||||
export const fluxGame = defineMultiplayerGame<FluxGameContract>({
|
||||
clock: {
|
||||
ticksPerSecond: FLUX_TICK_RATE,
|
||||
snapshotsPerSecond: FLUX_SNAPSHOT_RATE,
|
||||
},
|
||||
|
||||
authority: {
|
||||
createInitialState: createAuthorityState,
|
||||
cloneState: cloneAuthorityState,
|
||||
addPlayer(state, { playerId }) {
|
||||
state.players.push(createPlayer(playerId, leastPopulatedTeam(state), false));
|
||||
sortPlayers(state.players);
|
||||
},
|
||||
removePlayer(state, { playerId }) {
|
||||
state.players = state.players.filter((player) => player.id !== playerId);
|
||||
},
|
||||
applyInput(state, input, { playerId }) {
|
||||
const player = state.players.find((candidate) => candidate.id === playerId);
|
||||
if (!player || player.bot) return;
|
||||
player.thrust = input.thrust && !player.overheated;
|
||||
},
|
||||
step(state, { tick, deltaSeconds, emit }) {
|
||||
state.elapsedTicks += 1;
|
||||
|
||||
if (state.resetTicks > 0) {
|
||||
state.resetTicks -= 1;
|
||||
state.core *= 0.88;
|
||||
state.velocity = 0;
|
||||
if (state.resetTicks === 0) state.core = 0;
|
||||
updatePlayers(state, tick, deltaSeconds, emit, false);
|
||||
return;
|
||||
}
|
||||
|
||||
updatePlayers(state, tick, deltaSeconds, emit, true);
|
||||
const cyanForce = activePlayerCount(state, "cyan");
|
||||
const orangeForce = activePlayerCount(state, "orange");
|
||||
const signedForce = orangeForce - cyanForce;
|
||||
state.velocity += signedForce * coreAcceleration * deltaSeconds;
|
||||
state.velocity *= Math.pow(coreDragPerSecond, deltaSeconds);
|
||||
state.core += state.velocity * deltaSeconds;
|
||||
|
||||
if (Math.abs(state.core) >= 1) {
|
||||
const team: FluxTeam = state.core < 0 ? "cyan" : "orange";
|
||||
state.core = team === "cyan" ? -1 : 1;
|
||||
state.velocity = 0;
|
||||
state.resetTicks = FLUX_ROUND_RESET_TICKS;
|
||||
state.round += 1;
|
||||
if (team === "cyan") state.cyanScore += 1;
|
||||
else state.orangeScore += 1;
|
||||
emit({
|
||||
id: state.nextEventId++,
|
||||
type: "round-won",
|
||||
team,
|
||||
round: state.round,
|
||||
});
|
||||
}
|
||||
},
|
||||
validateState(state) {
|
||||
return (
|
||||
Number.isFinite(state.core) &&
|
||||
Number.isFinite(state.velocity) &&
|
||||
Math.abs(state.core) <= 1.000_001 &&
|
||||
state.players.every(
|
||||
(player) =>
|
||||
Number.isFinite(player.energy) &&
|
||||
player.energy >= 0 &&
|
||||
player.energy <= maximumEnergy,
|
||||
)
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
prediction: {
|
||||
createInitialState: createClientState,
|
||||
cloneState: cloneClientState,
|
||||
applyInput(state, input, { playerId }) {
|
||||
const player = state.players.find((candidate) => candidate.id === playerId);
|
||||
if (player && player.energy !== null) {
|
||||
player.thrust = input.thrust && player.energy > 0;
|
||||
}
|
||||
},
|
||||
step(state, { tick, deltaSeconds }) {
|
||||
state.elapsedTicks += 1;
|
||||
state.events = state.events.filter(
|
||||
(entry) => entry.receivedTick >= tick - eventLifetimeTicks,
|
||||
);
|
||||
|
||||
const ownPlayer = state.players.find((player) => player.energy !== null);
|
||||
if (ownPlayer) updateVisibleEnergy(ownPlayer, deltaSeconds);
|
||||
|
||||
if (state.resetTicks > 0) {
|
||||
state.resetTicks -= 1;
|
||||
state.core *= 0.88;
|
||||
state.velocity = 0;
|
||||
if (state.resetTicks === 0) state.core = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const cyanForce = visibleActiveCount(state, "cyan");
|
||||
const orangeForce = visibleActiveCount(state, "orange");
|
||||
state.velocity += (orangeForce - cyanForce) * coreAcceleration * deltaSeconds;
|
||||
state.velocity *= Math.pow(coreDragPerSecond, deltaSeconds);
|
||||
state.core = clamp(state.core + state.velocity * deltaSeconds, -1, 1);
|
||||
},
|
||||
mergeSnapshot(predicted, snapshot, { tick }) {
|
||||
const merged = cloneClientState(snapshot);
|
||||
merged.events = predicted.events
|
||||
.filter((entry) => entry.receivedTick >= tick - eventLifetimeTicks)
|
||||
.map((entry) => ({
|
||||
receivedTick: entry.receivedTick,
|
||||
event: { ...entry.event },
|
||||
}));
|
||||
return merged;
|
||||
},
|
||||
applyEvent(state, event, { tick }) {
|
||||
if (state.events.some((entry) => entry.event.id === event.id)) return;
|
||||
state.events.push({ receivedTick: tick, event: { ...event } });
|
||||
},
|
||||
validateState(state) {
|
||||
const ownEnergy = state.players.find((player) => player.energy !== null)?.energy;
|
||||
return (
|
||||
Number.isFinite(state.core) &&
|
||||
Math.abs(state.core) <= 1.000_001 &&
|
||||
(ownEnergy === undefined ||
|
||||
(ownEnergy !== null && ownEnergy >= 0 && ownEnergy <= maximumEnergy))
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
visibility: {
|
||||
createSnapshot(authority, { playerId }) {
|
||||
return {
|
||||
core: authority.core,
|
||||
velocity: authority.velocity,
|
||||
round: authority.round,
|
||||
cyanScore: authority.cyanScore,
|
||||
orangeScore: authority.orangeScore,
|
||||
resetTicks: authority.resetTicks,
|
||||
elapsedTicks: authority.elapsedTicks,
|
||||
players: authority.players.map((player) => ({
|
||||
id: player.id,
|
||||
team: player.team,
|
||||
bot: player.bot,
|
||||
thrust: player.thrust,
|
||||
energy: player.id === playerId ? player.energy : null,
|
||||
})),
|
||||
events: [],
|
||||
};
|
||||
},
|
||||
validateClientState(authority, candidate, { playerId }) {
|
||||
const expected = authority.players.find((player) => player.id === playerId);
|
||||
const reported = candidate.players.find((player) => player.id === playerId);
|
||||
return Boolean(
|
||||
expected &&
|
||||
reported &&
|
||||
reported.energy !== null &&
|
||||
Math.abs(reported.energy - expected.energy) <= 12 &&
|
||||
Math.abs(candidate.core - authority.core) <= 0.3,
|
||||
);
|
||||
},
|
||||
perceive(_authority, event, { playerId }) {
|
||||
if (event.type === "round-won") return { ...event };
|
||||
return {
|
||||
id: event.id,
|
||||
type: "overheated",
|
||||
team: event.team,
|
||||
scope: event.playerId === playerId ? "self" : "field",
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
input: {
|
||||
validate(input) {
|
||||
return typeof input === "object" && input !== null && typeof input.thrust === "boolean";
|
||||
},
|
||||
},
|
||||
|
||||
encoding: {
|
||||
input: inputCodec,
|
||||
clientState: stateCodec,
|
||||
perception: perceptionCodec,
|
||||
},
|
||||
});
|
||||
|
||||
function createAuthorityState(): FluxAuthorityState {
|
||||
return {
|
||||
core: 0,
|
||||
velocity: 0,
|
||||
round: 1,
|
||||
cyanScore: 0,
|
||||
orangeScore: 0,
|
||||
resetTicks: 0,
|
||||
elapsedTicks: 0,
|
||||
nextEventId: 1,
|
||||
players: [
|
||||
createPlayer(FLUX_BOT_IDS[0], "cyan", true),
|
||||
createPlayer(FLUX_BOT_IDS[1], "orange", true),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function createClientState(): FluxClientState {
|
||||
return {
|
||||
core: 0,
|
||||
velocity: 0,
|
||||
round: 1,
|
||||
cyanScore: 0,
|
||||
orangeScore: 0,
|
||||
resetTicks: 0,
|
||||
elapsedTicks: 0,
|
||||
players: [],
|
||||
events: [],
|
||||
};
|
||||
}
|
||||
|
||||
function createPlayer(
|
||||
id: number,
|
||||
team: FluxTeam,
|
||||
bot: boolean,
|
||||
): FluxAuthorityPlayer {
|
||||
return {
|
||||
id,
|
||||
team,
|
||||
bot,
|
||||
thrust: false,
|
||||
energy: maximumEnergy,
|
||||
overheated: false,
|
||||
};
|
||||
}
|
||||
|
||||
function updatePlayers(
|
||||
state: FluxAuthorityState,
|
||||
tick: number,
|
||||
deltaSeconds: number,
|
||||
emit: (event: FluxAuthorityEvent) => void,
|
||||
allowThrust: boolean,
|
||||
): void {
|
||||
for (const player of state.players) {
|
||||
if (player.bot) {
|
||||
// Both bots use the same deterministic cadence, so they animate the field
|
||||
// without biasing a solo player's match.
|
||||
player.thrust = allowThrust && tick % 150 < 92 && !player.overheated;
|
||||
}
|
||||
|
||||
if (player.thrust && allowThrust && !player.overheated) {
|
||||
player.energy = clamp(
|
||||
player.energy - energyDrainPerSecond * deltaSeconds,
|
||||
0,
|
||||
maximumEnergy,
|
||||
);
|
||||
if (player.energy === 0) {
|
||||
player.thrust = false;
|
||||
player.overheated = true;
|
||||
emit({
|
||||
id: state.nextEventId++,
|
||||
type: "overheated",
|
||||
playerId: player.id,
|
||||
team: player.team,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
player.energy = clamp(
|
||||
player.energy + energyRecoveryPerSecond * deltaSeconds,
|
||||
0,
|
||||
maximumEnergy,
|
||||
);
|
||||
if (player.overheated && player.energy >= maximumEnergy * 0.34) {
|
||||
player.overheated = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateVisibleEnergy(player: FluxPlayerView, deltaSeconds: number): void {
|
||||
if (player.energy === null) return;
|
||||
player.energy = clamp(
|
||||
player.energy +
|
||||
(player.thrust ? -energyDrainPerSecond : energyRecoveryPerSecond) * deltaSeconds,
|
||||
0,
|
||||
maximumEnergy,
|
||||
);
|
||||
if (player.energy === 0) player.thrust = false;
|
||||
}
|
||||
|
||||
function activePlayerCount(state: FluxAuthorityState, team: FluxTeam): number {
|
||||
return state.players.filter(
|
||||
(player) => player.team === team && player.thrust && !player.overheated,
|
||||
).length;
|
||||
}
|
||||
|
||||
function visibleActiveCount(state: FluxClientState, team: FluxTeam): number {
|
||||
return state.players.filter((player) => player.team === team && player.thrust).length;
|
||||
}
|
||||
|
||||
function leastPopulatedTeam(state: FluxAuthorityState): FluxTeam {
|
||||
const humans = state.players.filter((player) => !player.bot);
|
||||
const cyan = humans.filter((player) => player.team === "cyan").length;
|
||||
const orange = humans.length - cyan;
|
||||
return cyan <= orange ? "cyan" : "orange";
|
||||
}
|
||||
|
||||
function cloneAuthorityState(state: FluxAuthorityState): FluxAuthorityState {
|
||||
return {
|
||||
...state,
|
||||
players: state.players.map((player) => ({ ...player })),
|
||||
};
|
||||
}
|
||||
|
||||
function cloneClientState(state: FluxClientState): FluxClientState {
|
||||
return {
|
||||
...state,
|
||||
players: state.players.map((player) => ({ ...player })),
|
||||
events: state.events.map((entry) => ({
|
||||
receivedTick: entry.receivedTick,
|
||||
event: { ...entry.event },
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function sortPlayers(players: FluxAuthorityPlayer[]): void {
|
||||
players.sort((left, right) => left.id - right.id);
|
||||
}
|
||||
|
||||
function clamp(value: number, minimum: number, maximum: number): number {
|
||||
return Math.max(minimum, Math.min(maximum, value));
|
||||
}
|
||||
88
packages/shared/src/flux-types.ts
Normal file
88
packages/shared/src/flux-types.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
export type FluxTeam = "cyan" | "orange";
|
||||
|
||||
export interface FluxInput {
|
||||
thrust: boolean;
|
||||
}
|
||||
|
||||
export interface FluxAuthorityPlayer {
|
||||
id: number;
|
||||
team: FluxTeam;
|
||||
bot: boolean;
|
||||
thrust: boolean;
|
||||
energy: number;
|
||||
overheated: boolean;
|
||||
}
|
||||
|
||||
export interface FluxAuthorityState {
|
||||
core: number;
|
||||
velocity: number;
|
||||
round: number;
|
||||
cyanScore: number;
|
||||
orangeScore: number;
|
||||
resetTicks: number;
|
||||
elapsedTicks: number;
|
||||
nextEventId: number;
|
||||
players: FluxAuthorityPlayer[];
|
||||
}
|
||||
|
||||
export interface FluxPlayerView {
|
||||
id: number;
|
||||
team: FluxTeam;
|
||||
bot: boolean;
|
||||
thrust: boolean;
|
||||
/** Exact energy is private: null for every player except the viewer. */
|
||||
energy: number | null;
|
||||
}
|
||||
|
||||
export type FluxPerception =
|
||||
| {
|
||||
id: number;
|
||||
type: "round-won";
|
||||
team: FluxTeam;
|
||||
round: number;
|
||||
}
|
||||
| {
|
||||
id: number;
|
||||
type: "overheated";
|
||||
team: FluxTeam;
|
||||
scope: "self" | "field";
|
||||
};
|
||||
|
||||
export interface FluxPresentationEvent {
|
||||
receivedTick: number;
|
||||
event: FluxPerception;
|
||||
}
|
||||
|
||||
export interface FluxClientState {
|
||||
core: number;
|
||||
velocity: number;
|
||||
round: number;
|
||||
cyanScore: number;
|
||||
orangeScore: number;
|
||||
resetTicks: number;
|
||||
elapsedTicks: number;
|
||||
players: FluxPlayerView[];
|
||||
events: FluxPresentationEvent[];
|
||||
}
|
||||
|
||||
export type FluxAuthorityEvent =
|
||||
| {
|
||||
id: number;
|
||||
type: "round-won";
|
||||
team: FluxTeam;
|
||||
round: number;
|
||||
}
|
||||
| {
|
||||
id: number;
|
||||
type: "overheated";
|
||||
playerId: number;
|
||||
team: FluxTeam;
|
||||
};
|
||||
|
||||
export interface FluxGameContract {
|
||||
authority: FluxAuthorityState;
|
||||
client: FluxClientState;
|
||||
input: FluxInput;
|
||||
authorityEvent: FluxAuthorityEvent;
|
||||
perceptionEvent: FluxPerception;
|
||||
}
|
||||
16
packages/shared/src/index.ts
Normal file
16
packages/shared/src/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export const API_PATHS = { message: "/api/message" } as const;
|
||||
export const GAME_SOCKET_PATH = "/ws/game";
|
||||
|
||||
export interface ApiMessage {
|
||||
message: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export * from "./arena.js";
|
||||
export * from "./flux-types.js";
|
||||
export * from "./flux-game.js";
|
||||
export * from "./royale-types.js";
|
||||
export * from "./royale-map.js";
|
||||
export * from "./royale-game.js";
|
||||
export * from "./shooter-types.js";
|
||||
export * from "./shooter-game.js";
|
||||
834
packages/shared/src/royale-game.ts
Normal file
834
packages/shared/src/royale-game.ts
Normal file
@@ -0,0 +1,834 @@
|
||||
import {
|
||||
createJsonCodec,
|
||||
defineMultiplayerGame,
|
||||
withSpatialReplication,
|
||||
} from "@syncer/engine";
|
||||
import {
|
||||
ROYALE_CHUNK_SIZE,
|
||||
ROYALE_MAP_SEED,
|
||||
ROYALE_WORLD_RADIUS,
|
||||
ROYALE_WORLD_SIZE,
|
||||
collidesWithRoyaleMap,
|
||||
hasRoyaleLineOfSight,
|
||||
royaleChunkCoordinate,
|
||||
royaleRandom,
|
||||
} from "./royale-map.js";
|
||||
import type {
|
||||
RoyaleAuthorityEvent,
|
||||
RoyaleAuthorityLoot,
|
||||
RoyaleAuthorityPlayer,
|
||||
RoyaleAuthorityState,
|
||||
RoyaleClientState,
|
||||
RoyaleGameContract,
|
||||
RoyaleInput,
|
||||
RoyaleLootKind,
|
||||
RoyalePerception,
|
||||
RoyalePlayerView,
|
||||
RoyaleWeapon,
|
||||
} from "./royale-types.js";
|
||||
|
||||
export const ROYALE_SOCKET_PATH = "/ws/royale";
|
||||
export const ROYALE_TICK_RATE = 30;
|
||||
export const ROYALE_SNAPSHOT_RATE = 10;
|
||||
export const ROYALE_BOT_COUNT = 31;
|
||||
export const ROYALE_BOT_ID_BASE = 30_000;
|
||||
|
||||
export const ROYALE_WEAPONS: Record<
|
||||
RoyaleWeapon,
|
||||
{
|
||||
name: string;
|
||||
damage: number;
|
||||
range: number;
|
||||
cooldownTicks: number;
|
||||
magazine: number;
|
||||
reserve: number;
|
||||
reloadTicks: number;
|
||||
}
|
||||
> = {
|
||||
pistol: { name: "Kestrel P9", damage: 19, range: 125, cooldownTicks: 8, magazine: 12, reserve: 48, reloadTicks: 38 },
|
||||
rifle: { name: "VX-4 Carbine", damage: 15, range: 205, cooldownTicks: 4, magazine: 30, reserve: 120, reloadTicks: 52 },
|
||||
marksman: { name: "Longwatch DMR", damage: 48, range: 340, cooldownTicks: 17, magazine: 8, reserve: 32, reloadTicks: 64 },
|
||||
};
|
||||
|
||||
const movementAcceleration = 52;
|
||||
const movementFriction = 10;
|
||||
const walkSpeed = 11.5;
|
||||
const sprintSpeed = 16.5;
|
||||
const parachuteSpeed = 22;
|
||||
const parachuteDescentPerSecond = 18;
|
||||
const playerEyeHeight = 1.55;
|
||||
const playerHitRadius = 1.25;
|
||||
const presentationEventLifetime = ROYALE_TICK_RATE * 6;
|
||||
const initialStormRadius = 900;
|
||||
const finalStormRadius = 78;
|
||||
const stormShrinkPerTick = 0.085;
|
||||
const lootCount = 260;
|
||||
|
||||
const inputCodec = createJsonCodec<RoyaleInput>();
|
||||
const stateCodec = createJsonCodec<RoyaleClientState>();
|
||||
const eventCodec = createJsonCodec<RoyalePerception>();
|
||||
|
||||
const baseRoyaleGame = defineMultiplayerGame<RoyaleGameContract>({
|
||||
clock: {
|
||||
ticksPerSecond: ROYALE_TICK_RATE,
|
||||
snapshotsPerSecond: ROYALE_SNAPSHOT_RATE,
|
||||
},
|
||||
|
||||
authority: {
|
||||
createInitialState: createAuthorityState,
|
||||
cloneState: cloneAuthorityState,
|
||||
addPlayer(state, { playerId }) {
|
||||
state.players.push(createPlayer(state, playerId, false, 110));
|
||||
state.players.sort((left, right) => left.id - right.id);
|
||||
},
|
||||
removePlayer(state, { playerId }) {
|
||||
state.players = state.players.filter((player) => player.id !== playerId);
|
||||
},
|
||||
applyInput(state, input, { playerId }) {
|
||||
const player = findPlayer(state, playerId);
|
||||
if (!player || player.bot || !player.alive) return;
|
||||
applyCommand(player, input);
|
||||
if (input.reload) beginReload(player);
|
||||
},
|
||||
step(state, { tick, deltaSeconds, emit }) {
|
||||
if (state.resetTicks > 0) {
|
||||
state.resetTicks -= 1;
|
||||
if (state.resetTicks === 0) resetMatch(state);
|
||||
return;
|
||||
}
|
||||
|
||||
state.elapsedTicks += 1;
|
||||
state.stormRadius = Math.max(
|
||||
finalStormRadius,
|
||||
initialStormRadius - state.elapsedTicks * stormShrinkPerTick,
|
||||
);
|
||||
|
||||
for (const player of state.players) {
|
||||
if (!player.alive) continue;
|
||||
updateTimers(player);
|
||||
if (player.botState) updateBot(state, player, tick);
|
||||
const landed = simulatePlayer(player, deltaSeconds);
|
||||
if (landed) {
|
||||
emit({ id: state.nextEventId++, type: "landed", playerId: player.id });
|
||||
}
|
||||
if (player.altitude <= 0) {
|
||||
collectLoot(state, player, emit);
|
||||
if (player.firing) tryFire(state, player, emit);
|
||||
applyStormDamage(state, player, tick, emit);
|
||||
}
|
||||
}
|
||||
|
||||
const alive = state.players.filter((player) => player.alive);
|
||||
if (state.elapsedTicks > ROYALE_TICK_RATE * 8 && alive.length <= 1 && state.players.length > 1) {
|
||||
const winner = alive[0] ?? null;
|
||||
state.winnerId = winner?.id ?? null;
|
||||
state.resetTicks = ROYALE_TICK_RATE * 5;
|
||||
if (winner) {
|
||||
emit({
|
||||
id: state.nextEventId++,
|
||||
type: "winner",
|
||||
playerId: winner.id,
|
||||
round: state.round,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
validateState(state) {
|
||||
return (
|
||||
Number.isFinite(state.stormRadius) &&
|
||||
state.stormRadius >= finalStormRadius &&
|
||||
state.players.every(
|
||||
(player) =>
|
||||
Number.isFinite(player.x + player.z + player.health + player.altitude) &&
|
||||
Number.isFinite(player.yaw + player.pitch) &&
|
||||
player.health >= 0 &&
|
||||
player.health <= 100 &&
|
||||
player.armor >= 0 &&
|
||||
player.armor <= 100,
|
||||
)
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
prediction: {
|
||||
createInitialState: createClientState,
|
||||
cloneState: cloneClientState,
|
||||
applyInput(state, input, { playerId }) {
|
||||
const player = state.players.find((candidate) => candidate.id === playerId);
|
||||
if (!player || !player.alive) return;
|
||||
player.inputForward = input.forward;
|
||||
player.inputStrafe = input.strafe;
|
||||
player.yaw = normalizeAngle(input.yaw);
|
||||
player.pitch = clamp(input.pitch, -1.25, 1.25);
|
||||
player.firing = input.fire;
|
||||
player.sprinting = input.sprint;
|
||||
if (input.reload && player.reloadTicks === 0) {
|
||||
player.reloadTicks = ROYALE_WEAPONS[player.weapon].reloadTicks;
|
||||
}
|
||||
},
|
||||
step(state, { tick, deltaSeconds }) {
|
||||
state.elapsedTicks += 1;
|
||||
state.stormRadius = Math.max(
|
||||
finalStormRadius,
|
||||
initialStormRadius - state.elapsedTicks * stormShrinkPerTick,
|
||||
);
|
||||
for (const player of state.players) {
|
||||
if (!player.alive) continue;
|
||||
if (player.cooldownTicks > 0) player.cooldownTicks -= 1;
|
||||
if (player.reloadTicks > 0) player.reloadTicks -= 1;
|
||||
simulateVisiblePlayer(player, deltaSeconds);
|
||||
}
|
||||
state.events = state.events.filter(
|
||||
(entry) => entry.receivedTick >= tick - presentationEventLifetime,
|
||||
);
|
||||
},
|
||||
mergeSnapshot(predicted, snapshot, { tick }) {
|
||||
const merged = cloneClientState(snapshot);
|
||||
merged.events = predicted.events
|
||||
.filter((entry) => entry.receivedTick >= tick - presentationEventLifetime)
|
||||
.map((entry) => ({ receivedTick: entry.receivedTick, event: { ...entry.event } }));
|
||||
return merged;
|
||||
},
|
||||
applyEvent(state, event, { tick }) {
|
||||
if (state.events.some((entry) => entry.event.id === event.id)) return;
|
||||
state.events.push({ receivedTick: tick, event: { ...event } });
|
||||
if (state.events.length > 64) state.events.shift();
|
||||
},
|
||||
validateState(state) {
|
||||
return (
|
||||
Number.isFinite(state.stormRadius) &&
|
||||
state.players.every(
|
||||
(player) =>
|
||||
Number.isFinite(player.x + player.z + player.altitude) &&
|
||||
Number.isFinite(player.yaw + player.pitch) &&
|
||||
(player.health === null || (player.health >= 0 && player.health <= 100)),
|
||||
)
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
visibility: {
|
||||
createSnapshot(authority, { playerId }) {
|
||||
const viewer = findPlayer(authority, playerId);
|
||||
const players = viewer
|
||||
? authority.players
|
||||
.filter((target) => target.id === playerId || canSeePlayer(viewer, target))
|
||||
.map((player) => clonePlayerView(player, player.id === playerId))
|
||||
: [];
|
||||
const loot = viewer
|
||||
? authority.loot
|
||||
.filter(
|
||||
(item) =>
|
||||
item.active && Math.hypot(item.x - viewer.x, item.z - viewer.z) <= 155,
|
||||
)
|
||||
.map(({ id, kind, weapon, amount, x, z }) => ({ id, kind, weapon, amount, x, z }))
|
||||
: [];
|
||||
return {
|
||||
mapSeed: ROYALE_MAP_SEED,
|
||||
worldSize: ROYALE_WORLD_SIZE,
|
||||
chunkSize: ROYALE_CHUNK_SIZE,
|
||||
players,
|
||||
loot,
|
||||
elapsedTicks: authority.elapsedTicks,
|
||||
round: authority.round,
|
||||
aliveCount: authority.players.filter((player) => player.alive).length,
|
||||
stormX: authority.stormX,
|
||||
stormZ: authority.stormZ,
|
||||
stormRadius: authority.stormRadius,
|
||||
resetTicks: authority.resetTicks,
|
||||
winnerId: authority.winnerId,
|
||||
stream: {
|
||||
candidateCount: players.length + loot.length,
|
||||
droppedCount: 0,
|
||||
usedBytes: 0,
|
||||
budgetBytes: 0,
|
||||
chunkX: viewer ? royaleChunkCoordinate(viewer.x) : 0,
|
||||
chunkZ: viewer ? royaleChunkCoordinate(viewer.z) : 0,
|
||||
},
|
||||
events: [],
|
||||
};
|
||||
},
|
||||
validateClientState(authority, candidate, { playerId }) {
|
||||
const expected = findPlayer(authority, playerId);
|
||||
const reported = candidate.players.find((player) => player.id === playerId);
|
||||
return Boolean(
|
||||
expected &&
|
||||
reported &&
|
||||
reported.health === expected.health &&
|
||||
Math.hypot(reported.x - expected.x, reported.z - expected.z) <= 5,
|
||||
);
|
||||
},
|
||||
perceive(authority, event, { playerId }) {
|
||||
return perceiveEvent(authority, event, playerId);
|
||||
},
|
||||
},
|
||||
|
||||
input: {
|
||||
validate(input) {
|
||||
return (
|
||||
typeof input === "object" &&
|
||||
input !== null &&
|
||||
Number.isFinite(input.forward) &&
|
||||
Number.isFinite(input.strafe) &&
|
||||
Number.isFinite(input.yaw) &&
|
||||
Number.isFinite(input.pitch) &&
|
||||
Math.abs(input.forward) <= 1 &&
|
||||
Math.abs(input.strafe) <= 1 &&
|
||||
input.pitch >= -1.35 &&
|
||||
input.pitch <= 1.35 &&
|
||||
typeof input.fire === "boolean" &&
|
||||
typeof input.sprint === "boolean" &&
|
||||
typeof input.reload === "boolean"
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
encoding: {
|
||||
input: inputCodec,
|
||||
clientState: stateCodec,
|
||||
perception: eventCodec,
|
||||
},
|
||||
});
|
||||
|
||||
export const royaleGame = withSpatialReplication(baseRoyaleGame, {
|
||||
cellSize: ROYALE_CHUNK_SIZE,
|
||||
bandwidthBudgetBytesPerSecond: 30_000,
|
||||
reservedBytesPerSnapshot: 300,
|
||||
viewerPosition(authority, playerId) {
|
||||
const viewer = findPlayer(authority, playerId);
|
||||
return viewer ? { x: viewer.x, z: viewer.z } : null;
|
||||
},
|
||||
sources: [
|
||||
{
|
||||
category: "players",
|
||||
maximumDistance: 360,
|
||||
entities: (authority) => authority.players.filter((player) => player.alive),
|
||||
estimatedBytes: 76,
|
||||
priority: (_entity, distance) => 1_000 - distance,
|
||||
required: (entity, playerId) => entity.id === playerId,
|
||||
},
|
||||
{
|
||||
category: "loot",
|
||||
maximumDistance: 165,
|
||||
entities: (authority) => authority.loot.filter((item) => item.active),
|
||||
estimatedBytes: 44,
|
||||
priority: (_entity, distance) => 300 - distance,
|
||||
},
|
||||
],
|
||||
projectSnapshot(snapshot, selection) {
|
||||
const visibleCandidates = snapshot.players.length + snapshot.loot.length;
|
||||
const players = snapshot.players.filter((player) => selection.has("players", player.id));
|
||||
const loot = snapshot.loot.filter((item) => selection.has("loot", item.id));
|
||||
return {
|
||||
...snapshot,
|
||||
players,
|
||||
loot,
|
||||
stream: {
|
||||
candidateCount: visibleCandidates,
|
||||
droppedCount: visibleCandidates - players.length - loot.length,
|
||||
usedBytes: players.length * 76 + loot.length * 44,
|
||||
budgetBytes: selection.budgetBytes,
|
||||
chunkX: royaleChunkCoordinate(selection.position.x),
|
||||
chunkZ: royaleChunkCoordinate(selection.position.z),
|
||||
},
|
||||
};
|
||||
},
|
||||
perceive(_authority, _event, perception, selection) {
|
||||
if (perception.type === "shot" && !selection.has("players", perception.sourceId)) {
|
||||
return null;
|
||||
}
|
||||
return perception;
|
||||
},
|
||||
});
|
||||
|
||||
function createAuthorityState(): RoyaleAuthorityState {
|
||||
const secretSeed = randomSeed();
|
||||
const state: RoyaleAuthorityState = {
|
||||
players: [],
|
||||
loot: [],
|
||||
elapsedTicks: 0,
|
||||
round: 1,
|
||||
stormX: (royaleRandom(secretSeed, 901) - 0.5) * 180,
|
||||
stormZ: (royaleRandom(secretSeed, 902) - 0.5) * 180,
|
||||
stormRadius: initialStormRadius,
|
||||
resetTicks: 0,
|
||||
winnerId: null,
|
||||
nextEventId: 1,
|
||||
secretSeed,
|
||||
};
|
||||
state.loot = createLoot(secretSeed);
|
||||
for (let index = 0; index < ROYALE_BOT_COUNT; index += 1) {
|
||||
const id = ROYALE_BOT_ID_BASE + index;
|
||||
state.players.push(createPlayer(state, id, true, 0));
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
function createClientState(): RoyaleClientState {
|
||||
return {
|
||||
mapSeed: ROYALE_MAP_SEED,
|
||||
worldSize: ROYALE_WORLD_SIZE,
|
||||
chunkSize: ROYALE_CHUNK_SIZE,
|
||||
players: [],
|
||||
loot: [],
|
||||
elapsedTicks: 0,
|
||||
round: 1,
|
||||
aliveCount: 0,
|
||||
stormX: 0,
|
||||
stormZ: 0,
|
||||
stormRadius: initialStormRadius,
|
||||
resetTicks: 0,
|
||||
winnerId: null,
|
||||
stream: { candidateCount: 0, droppedCount: 0, usedBytes: 0, budgetBytes: 0, chunkX: 0, chunkZ: 0 },
|
||||
events: [],
|
||||
};
|
||||
}
|
||||
|
||||
function createPlayer(
|
||||
state: RoyaleAuthorityState,
|
||||
id: number,
|
||||
bot: boolean,
|
||||
altitude: number,
|
||||
): RoyaleAuthorityPlayer {
|
||||
const spawn = findSpawn(state.secretSeed, id + state.round * 101);
|
||||
const weapon = bot && id % 5 === 0 ? "rifle" : "pistol";
|
||||
const config = ROYALE_WEAPONS[weapon];
|
||||
return {
|
||||
id,
|
||||
bot,
|
||||
alive: true,
|
||||
x: spawn.x,
|
||||
z: spawn.z,
|
||||
velocityX: 0,
|
||||
velocityZ: 0,
|
||||
yaw: spawn.yaw,
|
||||
pitch: 0,
|
||||
altitude,
|
||||
health: 100,
|
||||
armor: bot ? 20 : 0,
|
||||
weapon,
|
||||
magazine: config.magazine,
|
||||
reserve: config.reserve,
|
||||
cooldownTicks: 0,
|
||||
reloadTicks: 0,
|
||||
kills: 0,
|
||||
inputForward: 0,
|
||||
inputStrafe: 0,
|
||||
firing: false,
|
||||
sprinting: false,
|
||||
botState: bot ? { phase: id % 360, targetId: null } : null,
|
||||
};
|
||||
}
|
||||
|
||||
function applyCommand(player: RoyaleAuthorityPlayer, input: RoyaleInput): void {
|
||||
player.inputForward = input.forward;
|
||||
player.inputStrafe = input.strafe;
|
||||
player.yaw = normalizeAngle(input.yaw);
|
||||
player.pitch = clamp(input.pitch, -1.25, 1.25);
|
||||
player.firing = input.fire;
|
||||
player.sprinting = input.sprint;
|
||||
}
|
||||
|
||||
function updateBot(state: RoyaleAuthorityState, bot: RoyaleAuthorityPlayer, tick: number): void {
|
||||
const target = nearestTarget(state, bot, 280);
|
||||
bot.botState!.targetId = target?.id ?? null;
|
||||
let targetX = state.stormX;
|
||||
let targetZ = state.stormZ;
|
||||
if (target) {
|
||||
targetX = target.x;
|
||||
targetZ = target.z;
|
||||
} else {
|
||||
const angle = (bot.botState!.phase + tick * 0.18) * (Math.PI / 180);
|
||||
targetX += Math.cos(angle) * Math.min(190, state.stormRadius * 0.45);
|
||||
targetZ += Math.sin(angle) * Math.min(190, state.stormRadius * 0.45);
|
||||
}
|
||||
const distance = Math.hypot(targetX - bot.x, targetZ - bot.z);
|
||||
bot.yaw = Math.atan2(targetX - bot.x, targetZ - bot.z) + Math.sin(tick * 0.17 + bot.id) * 0.035;
|
||||
bot.pitch = target
|
||||
? clamp(Math.atan2(target.altitude - bot.altitude, Math.max(0.01, distance)), -1.25, 1.25)
|
||||
: 0;
|
||||
bot.inputForward = distance > 45 ? 1 : 0.24;
|
||||
bot.inputStrafe = target ? Math.sin(tick * 0.08 + bot.id) * 0.7 : 0;
|
||||
bot.sprinting = distance > 90;
|
||||
bot.firing = Boolean(
|
||||
target &&
|
||||
distance <= ROYALE_WEAPONS[bot.weapon].range &&
|
||||
hasRoyaleLineOfSight(bot.x, bot.z, target.x, target.z),
|
||||
);
|
||||
if (bot.magazine === 0) beginReload(bot);
|
||||
}
|
||||
|
||||
function simulatePlayer(player: RoyaleAuthorityPlayer, deltaSeconds: number): boolean {
|
||||
const wasAirborne = player.altitude > 0;
|
||||
const speed =
|
||||
player.altitude > 0 ? parachuteSpeed : player.sprinting ? sprintSpeed : walkSpeed;
|
||||
const sin = Math.sin(player.yaw);
|
||||
const cos = Math.cos(player.yaw);
|
||||
let directionX = sin * player.inputForward - cos * player.inputStrafe;
|
||||
let directionZ = cos * player.inputForward + sin * player.inputStrafe;
|
||||
const magnitude = Math.hypot(directionX, directionZ);
|
||||
if (magnitude > 1) {
|
||||
directionX /= magnitude;
|
||||
directionZ /= magnitude;
|
||||
}
|
||||
player.velocityX = approach(player.velocityX, directionX * speed, movementAcceleration * deltaSeconds);
|
||||
player.velocityZ = approach(player.velocityZ, directionZ * speed, movementAcceleration * deltaSeconds);
|
||||
if (magnitude < 0.01) {
|
||||
player.velocityX *= Math.max(0, 1 - movementFriction * deltaSeconds);
|
||||
player.velocityZ *= Math.max(0, 1 - movementFriction * deltaSeconds);
|
||||
}
|
||||
const nextX = player.x + player.velocityX * deltaSeconds;
|
||||
const nextZ = player.z + player.velocityZ * deltaSeconds;
|
||||
if (player.altitude > 0 || !collidesWithRoyaleMap(nextX, player.z)) player.x = nextX;
|
||||
else player.velocityX = 0;
|
||||
if (player.altitude > 0 || !collidesWithRoyaleMap(player.x, nextZ)) player.z = nextZ;
|
||||
else player.velocityZ = 0;
|
||||
if (player.altitude > 0) {
|
||||
player.altitude = Math.max(0, player.altitude - parachuteDescentPerSecond * deltaSeconds);
|
||||
}
|
||||
return wasAirborne && player.altitude === 0;
|
||||
}
|
||||
|
||||
function simulateVisiblePlayer(player: RoyalePlayerView & { inputForward?: number; inputStrafe?: number }, deltaSeconds: number): void {
|
||||
const inputForward = player.inputForward ?? 0;
|
||||
const inputStrafe = player.inputStrafe ?? 0;
|
||||
const speed = player.altitude > 0 ? parachuteSpeed : player.sprinting ? sprintSpeed : walkSpeed;
|
||||
const sin = Math.sin(player.yaw);
|
||||
const cos = Math.cos(player.yaw);
|
||||
let directionX = sin * inputForward - cos * inputStrafe;
|
||||
let directionZ = cos * inputForward + sin * inputStrafe;
|
||||
const magnitude = Math.hypot(directionX, directionZ);
|
||||
if (magnitude > 1) {
|
||||
directionX /= magnitude;
|
||||
directionZ /= magnitude;
|
||||
}
|
||||
player.velocityX = approach(player.velocityX, directionX * speed, movementAcceleration * deltaSeconds);
|
||||
player.velocityZ = approach(player.velocityZ, directionZ * speed, movementAcceleration * deltaSeconds);
|
||||
const nextX = player.x + player.velocityX * deltaSeconds;
|
||||
const nextZ = player.z + player.velocityZ * deltaSeconds;
|
||||
if (player.altitude > 0 || !collidesWithRoyaleMap(nextX, player.z)) player.x = nextX;
|
||||
if (player.altitude > 0 || !collidesWithRoyaleMap(player.x, nextZ)) player.z = nextZ;
|
||||
if (player.altitude > 0) player.altitude = Math.max(0, player.altitude - parachuteDescentPerSecond * deltaSeconds);
|
||||
}
|
||||
|
||||
function tryFire(
|
||||
state: RoyaleAuthorityState,
|
||||
shooter: RoyaleAuthorityPlayer,
|
||||
emit: (event: RoyaleAuthorityEvent) => void,
|
||||
): void {
|
||||
if (shooter.cooldownTicks > 0 || shooter.reloadTicks > 0 || shooter.magazine <= 0) return;
|
||||
const weapon = ROYALE_WEAPONS[shooter.weapon];
|
||||
shooter.magazine -= 1;
|
||||
shooter.cooldownTicks = weapon.cooldownTicks;
|
||||
emit({
|
||||
id: state.nextEventId++,
|
||||
type: "shot",
|
||||
sourceId: shooter.id,
|
||||
x: shooter.x,
|
||||
y: shooter.altitude + playerEyeHeight,
|
||||
z: shooter.z,
|
||||
yaw: shooter.yaw,
|
||||
pitch: shooter.pitch,
|
||||
});
|
||||
|
||||
const horizontal = Math.cos(shooter.pitch);
|
||||
const directionX = Math.sin(shooter.yaw) * horizontal;
|
||||
const directionY = Math.sin(shooter.pitch);
|
||||
const directionZ = Math.cos(shooter.yaw) * horizontal;
|
||||
const originY = shooter.altitude + playerEyeHeight;
|
||||
let nearest: RoyaleAuthorityPlayer | null = null;
|
||||
let nearestDistance = weapon.range;
|
||||
for (const target of state.players) {
|
||||
if (!target.alive || target.id === shooter.id) continue;
|
||||
const offsetX = target.x - shooter.x;
|
||||
const offsetY = target.altitude + playerEyeHeight - originY;
|
||||
const offsetZ = target.z - shooter.z;
|
||||
const forward =
|
||||
offsetX * directionX + offsetY * directionY + offsetZ * directionZ;
|
||||
if (forward <= 0 || forward >= nearestDistance) continue;
|
||||
const offsetSquared = offsetX * offsetX + offsetY * offsetY + offsetZ * offsetZ;
|
||||
const missDistance = Math.sqrt(Math.max(0, offsetSquared - forward * forward));
|
||||
if (
|
||||
missDistance > playerHitRadius ||
|
||||
!hasRoyaleLineOfSight(shooter.x, shooter.z, target.x, target.z)
|
||||
) continue;
|
||||
nearest = target;
|
||||
nearestDistance = forward;
|
||||
}
|
||||
if (nearest) damagePlayer(state, shooter.id, nearest, weapon.damage, emit);
|
||||
if (shooter.magazine === 0) beginReload(shooter);
|
||||
}
|
||||
|
||||
function damagePlayer(
|
||||
state: RoyaleAuthorityState,
|
||||
sourceId: number,
|
||||
target: RoyaleAuthorityPlayer,
|
||||
amount: number,
|
||||
emit: (event: RoyaleAuthorityEvent) => void,
|
||||
): void {
|
||||
const absorbed = Math.min(target.armor, Math.ceil(amount * 0.55));
|
||||
target.armor -= absorbed;
|
||||
const healthDamage = Math.min(target.health, amount - absorbed);
|
||||
target.health -= healthDamage;
|
||||
emit({
|
||||
id: state.nextEventId++,
|
||||
type: "damage",
|
||||
sourceId,
|
||||
targetId: target.id,
|
||||
amount: healthDamage,
|
||||
health: target.health,
|
||||
});
|
||||
if (target.health > 0) return;
|
||||
target.alive = false;
|
||||
target.firing = false;
|
||||
target.velocityX = 0;
|
||||
target.velocityZ = 0;
|
||||
const killer = findPlayer(state, sourceId);
|
||||
if (killer) killer.kills += 1;
|
||||
emit({ id: state.nextEventId++, type: "elimination", killerId: sourceId, victimId: target.id });
|
||||
}
|
||||
|
||||
function applyStormDamage(
|
||||
state: RoyaleAuthorityState,
|
||||
player: RoyaleAuthorityPlayer,
|
||||
tick: number,
|
||||
emit: (event: RoyaleAuthorityEvent) => void,
|
||||
): void {
|
||||
if (tick % 15 !== 0) return;
|
||||
if (Math.hypot(player.x - state.stormX, player.z - state.stormZ) <= state.stormRadius) return;
|
||||
damagePlayer(state, 0, player, 3, emit);
|
||||
}
|
||||
|
||||
function collectLoot(
|
||||
state: RoyaleAuthorityState,
|
||||
player: RoyaleAuthorityPlayer,
|
||||
emit: (event: RoyaleAuthorityEvent) => void,
|
||||
): void {
|
||||
const item = state.loot.find(
|
||||
(candidate) => candidate.active && Math.hypot(candidate.x - player.x, candidate.z - player.z) < 2.4,
|
||||
);
|
||||
if (!item) return;
|
||||
item.active = false;
|
||||
if (item.kind === "weapon") {
|
||||
player.weapon = item.weapon;
|
||||
player.magazine = ROYALE_WEAPONS[item.weapon].magazine;
|
||||
player.reserve = Math.max(player.reserve, ROYALE_WEAPONS[item.weapon].reserve / 2);
|
||||
} else if (item.kind === "ammo") {
|
||||
player.reserve = Math.min(240, player.reserve + item.amount);
|
||||
} else if (item.kind === "armor") {
|
||||
player.armor = Math.min(100, player.armor + item.amount);
|
||||
} else {
|
||||
player.health = Math.min(100, player.health + item.amount);
|
||||
}
|
||||
emit({
|
||||
id: state.nextEventId++,
|
||||
type: "pickup",
|
||||
playerId: player.id,
|
||||
kind: item.kind,
|
||||
weapon: item.weapon,
|
||||
amount: item.amount,
|
||||
});
|
||||
}
|
||||
|
||||
function perceiveEvent(
|
||||
authority: RoyaleAuthorityState,
|
||||
event: RoyaleAuthorityEvent,
|
||||
playerId: number,
|
||||
): RoyalePerception | null {
|
||||
const viewer = findPlayer(authority, playerId);
|
||||
if (!viewer) return null;
|
||||
switch (event.type) {
|
||||
case "shot": {
|
||||
const source = findPlayer(authority, event.sourceId);
|
||||
if (source && (source.id === viewer.id || canSeePlayer(viewer, source))) {
|
||||
return { ...event };
|
||||
}
|
||||
const dx = event.x - viewer.x;
|
||||
const dz = event.z - viewer.z;
|
||||
const distance = Math.hypot(dx, dz);
|
||||
if (distance > 560) return null;
|
||||
return {
|
||||
id: event.id,
|
||||
type: "gunfire",
|
||||
bearing: normalizeAngle(Math.atan2(dx, dz) - viewer.yaw),
|
||||
intensity: clamp(1 - distance / 560, 0.08, 1),
|
||||
};
|
||||
}
|
||||
case "damage":
|
||||
if (event.targetId === playerId) {
|
||||
return { id: event.id, type: "damage", amount: event.amount, health: event.health };
|
||||
}
|
||||
if (event.sourceId === playerId) {
|
||||
return { id: event.id, type: "hit", amount: event.amount, eliminated: event.health === 0 };
|
||||
}
|
||||
return null;
|
||||
case "elimination":
|
||||
return { ...event };
|
||||
case "pickup":
|
||||
return event.playerId === playerId
|
||||
? { id: event.id, type: "pickup", kind: event.kind, weapon: event.weapon, amount: event.amount }
|
||||
: null;
|
||||
case "landed":
|
||||
return event.playerId === playerId ? { id: event.id, type: "landed" } : null;
|
||||
case "winner":
|
||||
return { ...event };
|
||||
}
|
||||
}
|
||||
|
||||
function canSeePlayer(viewer: RoyaleAuthorityPlayer, target: RoyaleAuthorityPlayer): boolean {
|
||||
if (!target.alive || target.id === viewer.id) return false;
|
||||
const distance = Math.hypot(target.x - viewer.x, target.z - viewer.z);
|
||||
return distance <= 225 && hasRoyaleLineOfSight(viewer.x, viewer.z, target.x, target.z);
|
||||
}
|
||||
|
||||
function clonePlayerView(player: RoyaleAuthorityPlayer, owner: boolean): RoyalePlayerView {
|
||||
return {
|
||||
id: player.id,
|
||||
bot: player.bot,
|
||||
alive: player.alive,
|
||||
x: player.x,
|
||||
z: player.z,
|
||||
velocityX: player.velocityX,
|
||||
velocityZ: player.velocityZ,
|
||||
yaw: player.yaw,
|
||||
pitch: player.pitch,
|
||||
altitude: player.altitude,
|
||||
health: owner ? player.health : null,
|
||||
armor: owner ? player.armor : null,
|
||||
weapon: player.weapon,
|
||||
magazine: owner ? player.magazine : null,
|
||||
reserve: owner ? player.reserve : null,
|
||||
cooldownTicks: player.cooldownTicks,
|
||||
reloadTicks: player.reloadTicks,
|
||||
kills: player.kills,
|
||||
firing: player.firing,
|
||||
sprinting: player.sprinting,
|
||||
inputForward: owner ? player.inputForward : 0,
|
||||
inputStrafe: owner ? player.inputStrafe : 0,
|
||||
};
|
||||
}
|
||||
|
||||
function createLoot(seed: number): RoyaleAuthorityLoot[] {
|
||||
const loot: RoyaleAuthorityLoot[] = [];
|
||||
for (let id = 1; id <= lootCount; id += 1) {
|
||||
let position = findSpawn(seed ^ 0xa51c, id * 31);
|
||||
const roll = royaleRandom(seed ^ 0xb771, id);
|
||||
const kind: RoyaleLootKind = roll > 0.82 ? "weapon" : roll > 0.55 ? "ammo" : roll > 0.28 ? "armor" : "medkit";
|
||||
const weapon: RoyaleWeapon = royaleRandom(seed ^ 0x991a, id) > 0.78 ? "marksman" : "rifle";
|
||||
if (collidesWithRoyaleMap(position.x, position.z, 1.2)) position = findSpawn(seed ^ 0x4411, id * 67);
|
||||
loot.push({
|
||||
id,
|
||||
kind,
|
||||
weapon,
|
||||
amount: kind === "ammo" ? 30 : kind === "armor" ? 35 : kind === "medkit" ? 30 : 1,
|
||||
x: position.x,
|
||||
z: position.z,
|
||||
active: true,
|
||||
});
|
||||
}
|
||||
return loot;
|
||||
}
|
||||
|
||||
function findSpawn(seed: number, index: number): { x: number; z: number; yaw: number } {
|
||||
for (let attempt = 0; attempt < 24; attempt += 1) {
|
||||
const radius = 120 + Math.sqrt(royaleRandom(seed, index + attempt * 3)) * 690;
|
||||
const angle = royaleRandom(seed ^ 0x7f4a, index + attempt * 3 + 1) * Math.PI * 2;
|
||||
const x = Math.cos(angle) * radius;
|
||||
const z = Math.sin(angle) * radius;
|
||||
if (!collidesWithRoyaleMap(x, z, 1.2)) return { x, z, yaw: angle + Math.PI };
|
||||
}
|
||||
return { x: 0, z: 0, yaw: 0 };
|
||||
}
|
||||
|
||||
function nearestTarget(
|
||||
state: RoyaleAuthorityState,
|
||||
source: RoyaleAuthorityPlayer,
|
||||
maximumDistance: number,
|
||||
): RoyaleAuthorityPlayer | null {
|
||||
let nearest: RoyaleAuthorityPlayer | null = null;
|
||||
let distance = maximumDistance;
|
||||
for (const candidate of state.players) {
|
||||
if (!candidate.alive || candidate.id === source.id || candidate.altitude > 0) continue;
|
||||
const candidateDistance = Math.hypot(candidate.x - source.x, candidate.z - source.z);
|
||||
if (candidateDistance < distance) {
|
||||
nearest = candidate;
|
||||
distance = candidateDistance;
|
||||
}
|
||||
}
|
||||
return nearest;
|
||||
}
|
||||
|
||||
function beginReload(player: RoyaleAuthorityPlayer): void {
|
||||
const weapon = ROYALE_WEAPONS[player.weapon];
|
||||
if (player.reloadTicks > 0 || player.magazine >= weapon.magazine || player.reserve <= 0) return;
|
||||
player.reloadTicks = weapon.reloadTicks;
|
||||
player.firing = false;
|
||||
}
|
||||
|
||||
function updateTimers(player: RoyaleAuthorityPlayer): void {
|
||||
if (player.cooldownTicks > 0) player.cooldownTicks -= 1;
|
||||
if (player.reloadTicks > 0) {
|
||||
player.reloadTicks -= 1;
|
||||
if (player.reloadTicks === 0) {
|
||||
const capacity = ROYALE_WEAPONS[player.weapon].magazine;
|
||||
const loaded = Math.min(capacity - player.magazine, player.reserve);
|
||||
player.magazine += loaded;
|
||||
player.reserve -= loaded;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resetMatch(state: RoyaleAuthorityState): void {
|
||||
state.elapsedTicks = 0;
|
||||
state.round += 1;
|
||||
state.winnerId = null;
|
||||
state.stormRadius = initialStormRadius;
|
||||
state.stormX = (royaleRandom(state.secretSeed, 901 + state.round) - 0.5) * 180;
|
||||
state.stormZ = (royaleRandom(state.secretSeed, 902 + state.round) - 0.5) * 180;
|
||||
state.loot = createLoot(state.secretSeed ^ state.round * 0x9e37);
|
||||
for (const player of state.players) {
|
||||
const fresh = createPlayer(state, player.id, player.bot, player.bot ? 0 : 110);
|
||||
Object.assign(player, fresh, { kills: player.kills });
|
||||
}
|
||||
}
|
||||
|
||||
function cloneAuthorityState(state: RoyaleAuthorityState): RoyaleAuthorityState {
|
||||
return {
|
||||
...state,
|
||||
players: state.players.map((player) => ({
|
||||
...player,
|
||||
botState: player.botState ? { ...player.botState } : null,
|
||||
})),
|
||||
loot: state.loot.map((item) => ({ ...item })),
|
||||
};
|
||||
}
|
||||
|
||||
function cloneClientState(state: RoyaleClientState): RoyaleClientState {
|
||||
return {
|
||||
...state,
|
||||
players: state.players.map((player) => ({ ...player })),
|
||||
loot: state.loot.map((item) => ({ ...item })),
|
||||
stream: { ...state.stream },
|
||||
events: state.events.map((entry) => ({ receivedTick: entry.receivedTick, event: { ...entry.event } })),
|
||||
};
|
||||
}
|
||||
|
||||
function findPlayer(state: RoyaleAuthorityState, playerId: number): RoyaleAuthorityPlayer | undefined {
|
||||
return state.players.find((player) => player.id === playerId);
|
||||
}
|
||||
|
||||
function randomSeed(): number {
|
||||
return Math.floor(Math.random() * 0x1_0000_0000) >>> 0;
|
||||
}
|
||||
|
||||
function approach(value: number, target: number, amount: number): number {
|
||||
return value < target ? Math.min(target, value + amount) : Math.max(target, value - amount);
|
||||
}
|
||||
|
||||
function normalizeAngle(value: number): number {
|
||||
return Math.atan2(Math.sin(value), Math.cos(value));
|
||||
}
|
||||
|
||||
function clamp(value: number, minimum: number, maximum: number): number {
|
||||
return Math.max(minimum, Math.min(maximum, value));
|
||||
}
|
||||
143
packages/shared/src/royale-map.ts
Normal file
143
packages/shared/src/royale-map.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
export const ROYALE_MAP_SEED = 0x51_9c_ae_27;
|
||||
export const ROYALE_WORLD_SIZE = 2_000;
|
||||
export const ROYALE_WORLD_RADIUS = 960;
|
||||
export const ROYALE_CHUNK_SIZE = 100;
|
||||
|
||||
export type RoyaleBiome = "meadow" | "forest" | "highland" | "coast";
|
||||
export type RoyaleObstacleKind = "building" | "rock" | "tree";
|
||||
|
||||
export interface RoyaleObstacle {
|
||||
id: string;
|
||||
kind: RoyaleObstacleKind;
|
||||
x: number;
|
||||
z: number;
|
||||
width: number;
|
||||
depth: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface RoyaleMapChunk {
|
||||
x: number;
|
||||
z: number;
|
||||
biome: RoyaleBiome;
|
||||
tint: number;
|
||||
elevation: number;
|
||||
roadX: boolean;
|
||||
roadZ: boolean;
|
||||
obstacles: RoyaleObstacle[];
|
||||
}
|
||||
|
||||
const chunkCache = new Map<string, RoyaleMapChunk>();
|
||||
|
||||
/** Public, deterministic static world generation shared by server and clients. */
|
||||
export function generateRoyaleChunk(chunkX: number, chunkZ: number): RoyaleMapChunk {
|
||||
const key = `${chunkX}:${chunkZ}`;
|
||||
const cached = chunkCache.get(key);
|
||||
if (cached) return cached;
|
||||
|
||||
const centerX = (chunkX + 0.5) * ROYALE_CHUNK_SIZE;
|
||||
const centerZ = (chunkZ + 0.5) * ROYALE_CHUNK_SIZE;
|
||||
const normalizedRadius = Math.hypot(centerX, centerZ) / ROYALE_WORLD_RADIUS;
|
||||
const biomeNoise = random01(PROCEDURAL_SEED, chunkX, chunkZ, 1);
|
||||
const biome: RoyaleBiome =
|
||||
normalizedRadius > 0.84
|
||||
? "coast"
|
||||
: biomeNoise > 0.68
|
||||
? "forest"
|
||||
: biomeNoise < 0.2
|
||||
? "highland"
|
||||
: "meadow";
|
||||
const roadX = Math.abs(chunkZ) % 5 === 0;
|
||||
const roadZ = Math.abs(chunkX + 2) % 6 === 0;
|
||||
const obstacleCount = biome === "coast" ? 2 : 4 + Math.floor(random01(PROCEDURAL_SEED, chunkX, chunkZ, 2) * 5);
|
||||
const obstacles: RoyaleObstacle[] = [];
|
||||
|
||||
for (let index = 0; index < obstacleCount; index += 1) {
|
||||
const localX = (random01(PROCEDURAL_SEED, chunkX, chunkZ, 10 + index * 5) - 0.5) * 82;
|
||||
const localZ = (random01(PROCEDURAL_SEED, chunkX, chunkZ, 11 + index * 5) - 0.5) * 82;
|
||||
if ((roadZ && Math.abs(localX) < 11) || (roadX && Math.abs(localZ) < 11)) continue;
|
||||
const kindRoll = random01(PROCEDURAL_SEED, chunkX, chunkZ, 12 + index * 5);
|
||||
const kind: RoyaleObstacleKind =
|
||||
kindRoll > 0.72 ? "building" : kindRoll > 0.38 ? "tree" : "rock";
|
||||
const scale = random01(PROCEDURAL_SEED, chunkX, chunkZ, 13 + index * 5);
|
||||
const width = kind === "building" ? 10 + scale * 15 : kind === "tree" ? 3.2 : 4 + scale * 7;
|
||||
const depth = kind === "building" ? 9 + random01(PROCEDURAL_SEED, chunkX, chunkZ, 14 + index * 5) * 14 : width;
|
||||
obstacles.push({
|
||||
id: `${chunkX}:${chunkZ}:${index}`,
|
||||
kind,
|
||||
x: chunkX * ROYALE_CHUNK_SIZE + ROYALE_CHUNK_SIZE / 2 + localX,
|
||||
z: chunkZ * ROYALE_CHUNK_SIZE + ROYALE_CHUNK_SIZE / 2 + localZ,
|
||||
width,
|
||||
depth,
|
||||
height: kind === "building" ? 7 + scale * 13 : kind === "tree" ? 8 + scale * 7 : 2.5 + scale * 5,
|
||||
});
|
||||
}
|
||||
|
||||
const chunk: RoyaleMapChunk = {
|
||||
x: chunkX,
|
||||
z: chunkZ,
|
||||
biome,
|
||||
tint:
|
||||
biome === "coast" ? 0xb9a56e : biome === "forest" ? 0x315d3f : biome === "highland" ? 0x697064 : 0x52764b,
|
||||
elevation: Math.round(random01(PROCEDURAL_SEED, chunkX, chunkZ, 3) * 3) * 0.16,
|
||||
roadX,
|
||||
roadZ,
|
||||
obstacles,
|
||||
};
|
||||
chunkCache.set(key, chunk);
|
||||
return chunk;
|
||||
}
|
||||
|
||||
export function collidesWithRoyaleMap(x: number, z: number, radius = 0.72): boolean {
|
||||
if (Math.hypot(x, z) + radius > ROYALE_WORLD_RADIUS) return true;
|
||||
const chunkX = Math.floor(x / ROYALE_CHUNK_SIZE);
|
||||
const chunkZ = Math.floor(z / ROYALE_CHUNK_SIZE);
|
||||
for (let offsetX = -1; offsetX <= 1; offsetX += 1) {
|
||||
for (let offsetZ = -1; offsetZ <= 1; offsetZ += 1) {
|
||||
for (const obstacle of generateRoyaleChunk(chunkX + offsetX, chunkZ + offsetZ).obstacles) {
|
||||
const nearestX = clamp(x, obstacle.x - obstacle.width / 2, obstacle.x + obstacle.width / 2);
|
||||
const nearestZ = clamp(z, obstacle.z - obstacle.depth / 2, obstacle.z + obstacle.depth / 2);
|
||||
const dx = x - nearestX;
|
||||
const dz = z - nearestZ;
|
||||
if (dx * dx + dz * dz < radius * radius) return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function hasRoyaleLineOfSight(fromX: number, fromZ: number, toX: number, toZ: number): boolean {
|
||||
const distance = Math.hypot(toX - fromX, toZ - fromZ);
|
||||
const steps = Math.max(2, Math.ceil(distance / 4));
|
||||
for (let step = 1; step < steps; step += 1) {
|
||||
const ratio = step / steps;
|
||||
if (collidesWithRoyaleMap(fromX + (toX - fromX) * ratio, fromZ + (toZ - fromZ) * ratio, 0.12)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function royaleChunkCoordinate(value: number): number {
|
||||
return Math.floor(value / ROYALE_CHUNK_SIZE);
|
||||
}
|
||||
|
||||
export function royaleRandom(seed: number, index: number): number {
|
||||
return random01(seed, index, index * 17 + 3, index * 97 + 11);
|
||||
}
|
||||
|
||||
const PROCEDURAL_SEED = ROYALE_MAP_SEED;
|
||||
|
||||
function random01(seed: number, a: number, b: number, c: number): number {
|
||||
let value = seed ^ Math.imul(a, 0x9e3779b1) ^ Math.imul(b, 0x85ebca77) ^ Math.imul(c, 0xc2b2ae3d);
|
||||
value ^= value >>> 16;
|
||||
value = Math.imul(value, 0x7feb352d);
|
||||
value ^= value >>> 15;
|
||||
value = Math.imul(value, 0x846ca68b);
|
||||
value ^= value >>> 16;
|
||||
return (value >>> 0) / 0x1_0000_0000;
|
||||
}
|
||||
|
||||
function clamp(value: number, minimum: number, maximum: number): number {
|
||||
return Math.max(minimum, Math.min(maximum, value));
|
||||
}
|
||||
160
packages/shared/src/royale-types.ts
Normal file
160
packages/shared/src/royale-types.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
export type RoyaleWeapon = "pistol" | "rifle" | "marksman";
|
||||
export type RoyaleLootKind = "weapon" | "ammo" | "armor" | "medkit";
|
||||
|
||||
export interface RoyaleInput {
|
||||
forward: number;
|
||||
strafe: number;
|
||||
yaw: number;
|
||||
pitch: number;
|
||||
fire: boolean;
|
||||
sprint: boolean;
|
||||
reload: boolean;
|
||||
}
|
||||
|
||||
export interface RoyaleBotState {
|
||||
phase: number;
|
||||
targetId: number | null;
|
||||
}
|
||||
|
||||
export interface RoyaleAuthorityPlayer {
|
||||
id: number;
|
||||
bot: boolean;
|
||||
alive: boolean;
|
||||
x: number;
|
||||
z: number;
|
||||
velocityX: number;
|
||||
velocityZ: number;
|
||||
yaw: number;
|
||||
pitch: number;
|
||||
altitude: number;
|
||||
health: number;
|
||||
armor: number;
|
||||
weapon: RoyaleWeapon;
|
||||
magazine: number;
|
||||
reserve: number;
|
||||
cooldownTicks: number;
|
||||
reloadTicks: number;
|
||||
kills: number;
|
||||
inputForward: number;
|
||||
inputStrafe: number;
|
||||
firing: boolean;
|
||||
sprinting: boolean;
|
||||
botState: RoyaleBotState | null;
|
||||
}
|
||||
|
||||
export interface RoyaleAuthorityLoot {
|
||||
id: number;
|
||||
kind: RoyaleLootKind;
|
||||
weapon: RoyaleWeapon;
|
||||
amount: number;
|
||||
x: number;
|
||||
z: number;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export interface RoyaleAuthorityState {
|
||||
players: RoyaleAuthorityPlayer[];
|
||||
loot: RoyaleAuthorityLoot[];
|
||||
elapsedTicks: number;
|
||||
round: number;
|
||||
stormX: number;
|
||||
stormZ: number;
|
||||
stormRadius: number;
|
||||
resetTicks: number;
|
||||
winnerId: number | null;
|
||||
nextEventId: number;
|
||||
secretSeed: number;
|
||||
}
|
||||
|
||||
export interface RoyalePlayerView {
|
||||
id: number;
|
||||
bot: boolean;
|
||||
alive: boolean;
|
||||
x: number;
|
||||
z: number;
|
||||
velocityX: number;
|
||||
velocityZ: number;
|
||||
yaw: number;
|
||||
pitch: number;
|
||||
altitude: number;
|
||||
/** Exact combat state exists only on the owning player's projection. */
|
||||
health: number | null;
|
||||
armor: number | null;
|
||||
weapon: RoyaleWeapon;
|
||||
magazine: number | null;
|
||||
reserve: number | null;
|
||||
cooldownTicks: number;
|
||||
reloadTicks: number;
|
||||
kills: number;
|
||||
firing: boolean;
|
||||
sprinting: boolean;
|
||||
inputForward: number;
|
||||
inputStrafe: number;
|
||||
}
|
||||
|
||||
export interface RoyaleLootView {
|
||||
id: number;
|
||||
kind: RoyaleLootKind;
|
||||
weapon: RoyaleWeapon;
|
||||
amount: number;
|
||||
x: number;
|
||||
z: number;
|
||||
}
|
||||
|
||||
export interface RoyaleStreamStats {
|
||||
candidateCount: number;
|
||||
droppedCount: number;
|
||||
usedBytes: number;
|
||||
budgetBytes: number;
|
||||
chunkX: number;
|
||||
chunkZ: number;
|
||||
}
|
||||
|
||||
export interface RoyaleClientState {
|
||||
mapSeed: number;
|
||||
worldSize: number;
|
||||
chunkSize: number;
|
||||
players: RoyalePlayerView[];
|
||||
loot: RoyaleLootView[];
|
||||
elapsedTicks: number;
|
||||
round: number;
|
||||
aliveCount: number;
|
||||
stormX: number;
|
||||
stormZ: number;
|
||||
stormRadius: number;
|
||||
resetTicks: number;
|
||||
winnerId: number | null;
|
||||
stream: RoyaleStreamStats;
|
||||
events: RoyalePresentationEvent[];
|
||||
}
|
||||
|
||||
export type RoyaleAuthorityEvent =
|
||||
| { id: number; type: "shot"; sourceId: number; x: number; y: number; z: number; yaw: number; pitch: number }
|
||||
| { id: number; type: "damage"; sourceId: number; targetId: number; amount: number; health: number }
|
||||
| { id: number; type: "elimination"; killerId: number; victimId: number }
|
||||
| { id: number; type: "pickup"; playerId: number; kind: RoyaleLootKind; weapon: RoyaleWeapon; amount: number }
|
||||
| { id: number; type: "landed"; playerId: number }
|
||||
| { id: number; type: "winner"; playerId: number; round: number };
|
||||
|
||||
export type RoyalePerception =
|
||||
| { id: number; type: "shot"; sourceId: number; x: number; y: number; z: number; yaw: number; pitch: number }
|
||||
| { id: number; type: "gunfire"; bearing: number; intensity: number }
|
||||
| { id: number; type: "damage"; amount: number; health: number }
|
||||
| { id: number; type: "hit"; amount: number; eliminated: boolean }
|
||||
| { id: number; type: "elimination"; killerId: number; victimId: number }
|
||||
| { id: number; type: "pickup"; kind: RoyaleLootKind; weapon: RoyaleWeapon; amount: number }
|
||||
| { id: number; type: "landed" }
|
||||
| { id: number; type: "winner"; playerId: number; round: number };
|
||||
|
||||
export interface RoyalePresentationEvent {
|
||||
receivedTick: number;
|
||||
event: RoyalePerception;
|
||||
}
|
||||
|
||||
export interface RoyaleGameContract {
|
||||
authority: RoyaleAuthorityState;
|
||||
client: RoyaleClientState;
|
||||
input: RoyaleInput;
|
||||
authorityEvent: RoyaleAuthorityEvent;
|
||||
perceptionEvent: RoyalePerception;
|
||||
}
|
||||
506
packages/shared/src/shooter-codecs.ts
Normal file
506
packages/shared/src/shooter-codecs.ts
Normal file
@@ -0,0 +1,506 @@
|
||||
import type { BinaryCodec } from "@syncer/engine";
|
||||
import {
|
||||
isWeaponId,
|
||||
type DistanceBand,
|
||||
type ImpactKind,
|
||||
type LoadoutState,
|
||||
type PickupKind,
|
||||
type PlayerKind,
|
||||
type PlayerState,
|
||||
type PickupState,
|
||||
type ScoreEntry,
|
||||
type ShooterInput,
|
||||
type ShooterPerception,
|
||||
type ShooterWorldState,
|
||||
type WeaponId,
|
||||
} from "./shooter-types.js";
|
||||
|
||||
const INPUT_BYTES = 18;
|
||||
const WORLD_HEADER_BYTES = 17;
|
||||
const PLAYER_BYTES = 55;
|
||||
const SCORE_BYTES = 9;
|
||||
const PICKUP_BYTES = 15;
|
||||
|
||||
export const shooterInputCodec: BinaryCodec<ShooterInput> = {
|
||||
encode(input) {
|
||||
const payload = new Uint8Array(INPUT_BYTES);
|
||||
const view = new DataView(payload.buffer);
|
||||
view.setFloat32(0, input.strafe, true);
|
||||
view.setFloat32(4, input.forward, true);
|
||||
view.setFloat32(8, input.yaw, true);
|
||||
view.setFloat32(12, input.pitch, true);
|
||||
view.setUint8(
|
||||
16,
|
||||
(input.fire ? 1 : 0) | (input.sprint ? 2 : 0) | (input.reload ? 4 : 0),
|
||||
);
|
||||
view.setUint8(17, input.weapon);
|
||||
return payload;
|
||||
},
|
||||
decode(payload) {
|
||||
assertLength(payload, INPUT_BYTES, "Shooter input");
|
||||
const view = viewOf(payload);
|
||||
const weapon = view.getUint8(17);
|
||||
if (!isWeaponId(weapon)) {
|
||||
throw new RangeError("Unknown weapon in shooter input");
|
||||
}
|
||||
const flags = view.getUint8(16);
|
||||
return {
|
||||
strafe: view.getFloat32(0, true),
|
||||
forward: view.getFloat32(4, true),
|
||||
yaw: view.getFloat32(8, true),
|
||||
pitch: view.getFloat32(12, true),
|
||||
fire: Boolean(flags & 1),
|
||||
sprint: Boolean(flags & 2),
|
||||
reload: Boolean(flags & 4),
|
||||
weapon,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export const shooterWorldCodec: BinaryCodec<ShooterWorldState> = {
|
||||
encode(state) {
|
||||
if (
|
||||
state.players.size > 65_535 ||
|
||||
state.scoreboard.length > 65_535 ||
|
||||
state.pickups.size > 65_535
|
||||
) {
|
||||
throw new RangeError("Shooter world exceeds the binary protocol limit");
|
||||
}
|
||||
|
||||
const length =
|
||||
WORLD_HEADER_BYTES +
|
||||
state.players.size * PLAYER_BYTES +
|
||||
state.scoreboard.length * SCORE_BYTES +
|
||||
state.pickups.size * PICKUP_BYTES;
|
||||
const payload = new Uint8Array(length);
|
||||
const view = new DataView(payload.buffer);
|
||||
let offset = 0;
|
||||
|
||||
view.setUint8(offset, 1);
|
||||
offset += 1;
|
||||
view.setUint32(offset, state.match.elapsedTicks, true);
|
||||
offset += 4;
|
||||
view.setUint16(offset, state.match.scoreLimit, true);
|
||||
offset += 2;
|
||||
view.setUint32(offset, state.match.leaderId, true);
|
||||
offset += 4;
|
||||
view.setUint16(offset, state.players.size, true);
|
||||
offset += 2;
|
||||
view.setUint16(offset, state.scoreboard.length, true);
|
||||
offset += 2;
|
||||
view.setUint16(offset, state.pickups.size, true);
|
||||
offset += 2;
|
||||
|
||||
const players = [...state.players.values()].sort((a, b) => a.id - b.id);
|
||||
for (const player of players) {
|
||||
offset = encodePlayer(view, offset, player);
|
||||
}
|
||||
for (const score of [...state.scoreboard].sort((a, b) => a.id - b.id)) {
|
||||
offset = encodeScore(view, offset, score);
|
||||
}
|
||||
for (const pickup of [...state.pickups.values()].sort((a, b) => a.id - b.id)) {
|
||||
view.setUint16(offset, pickup.id, true);
|
||||
view.setUint8(offset + 2, pickupKindToByte(pickup.kind));
|
||||
view.setUint8(offset + 3, pickup.weapon);
|
||||
view.setFloat32(offset + 4, pickup.x, true);
|
||||
view.setFloat32(offset + 8, pickup.z, true);
|
||||
view.setUint8(offset + 12, pickup.active ? 1 : 0);
|
||||
view.setUint16(offset + 13, clampUint16(pickup.respawnTicks), true);
|
||||
offset += PICKUP_BYTES;
|
||||
}
|
||||
|
||||
return payload;
|
||||
},
|
||||
decode(payload) {
|
||||
if (payload.byteLength < WORLD_HEADER_BYTES) {
|
||||
throw new RangeError("Shooter world is missing its header");
|
||||
}
|
||||
const view = viewOf(payload);
|
||||
let offset = 0;
|
||||
const version = view.getUint8(offset);
|
||||
offset += 1;
|
||||
if (version !== 1) {
|
||||
throw new RangeError(`Unsupported shooter world version ${version}`);
|
||||
}
|
||||
|
||||
const elapsedTicks = view.getUint32(offset, true);
|
||||
offset += 4;
|
||||
const scoreLimit = view.getUint16(offset, true);
|
||||
offset += 2;
|
||||
const leaderId = view.getUint32(offset, true);
|
||||
offset += 4;
|
||||
const playerCount = view.getUint16(offset, true);
|
||||
offset += 2;
|
||||
const scoreCount = view.getUint16(offset, true);
|
||||
offset += 2;
|
||||
const pickupCount = view.getUint16(offset, true);
|
||||
offset += 2;
|
||||
assertLength(
|
||||
payload,
|
||||
WORLD_HEADER_BYTES +
|
||||
playerCount * PLAYER_BYTES +
|
||||
scoreCount * SCORE_BYTES +
|
||||
pickupCount * PICKUP_BYTES,
|
||||
"Shooter world",
|
||||
);
|
||||
|
||||
const players = new Map<number, PlayerState>();
|
||||
for (let index = 0; index < playerCount; index += 1) {
|
||||
const decoded = decodePlayer(view, offset);
|
||||
players.set(decoded.player.id, decoded.player);
|
||||
offset = decoded.offset;
|
||||
}
|
||||
|
||||
const scoreboard: ScoreEntry[] = [];
|
||||
for (let index = 0; index < scoreCount; index += 1) {
|
||||
const id = view.getUint32(offset, true);
|
||||
const kind = byteToPlayerKind(view.getUint8(offset + 4));
|
||||
scoreboard.push({
|
||||
id,
|
||||
kind,
|
||||
kills: view.getUint16(offset + 5, true),
|
||||
deaths: view.getUint16(offset + 7, true),
|
||||
});
|
||||
offset += SCORE_BYTES;
|
||||
}
|
||||
|
||||
const pickups = new Map<number, PickupState>();
|
||||
for (let index = 0; index < pickupCount; index += 1) {
|
||||
const id = view.getUint16(offset, true);
|
||||
const weapon = view.getUint8(offset + 3);
|
||||
if (!isWeaponId(weapon)) {
|
||||
throw new RangeError("Unknown pickup weapon");
|
||||
}
|
||||
pickups.set(id, {
|
||||
id,
|
||||
kind: byteToPickupKind(view.getUint8(offset + 2)),
|
||||
weapon,
|
||||
x: view.getFloat32(offset + 4, true),
|
||||
z: view.getFloat32(offset + 8, true),
|
||||
active: Boolean(view.getUint8(offset + 12)),
|
||||
respawnTicks: view.getUint16(offset + 13, true),
|
||||
});
|
||||
offset += PICKUP_BYTES;
|
||||
}
|
||||
|
||||
return {
|
||||
players,
|
||||
pickups,
|
||||
scoreboard,
|
||||
match: { elapsedTicks, scoreLimit, leaderId },
|
||||
events: [],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export const shooterEventCodec: BinaryCodec<ShooterPerception> = {
|
||||
encode(event) {
|
||||
const length = perceptionLength(event);
|
||||
const payload = new Uint8Array(length);
|
||||
const view = new DataView(payload.buffer);
|
||||
view.setUint32(0, event.id, true);
|
||||
view.setUint8(4, perceptionTypeToByte(event.type));
|
||||
|
||||
switch (event.type) {
|
||||
case "shot":
|
||||
view.setUint8(5, event.weapon);
|
||||
view.setUint32(6, event.sourceId, true);
|
||||
view.setFloat32(10, event.originX, true);
|
||||
view.setFloat32(14, event.originY, true);
|
||||
view.setFloat32(18, event.originZ, true);
|
||||
view.setFloat32(22, event.endX, true);
|
||||
view.setFloat32(26, event.endY, true);
|
||||
view.setFloat32(30, event.endZ, true);
|
||||
view.setUint8(34, impactToByte(event.impact));
|
||||
break;
|
||||
case "sound":
|
||||
view.setUint8(5, event.sound === "gunshot" ? 0 : 1);
|
||||
view.setUint8(6, event.weapon);
|
||||
view.setFloat32(7, event.bearingRadians, true);
|
||||
view.setFloat32(11, event.intensity, true);
|
||||
view.setUint8(15, distanceBandToByte(event.distanceBand));
|
||||
break;
|
||||
case "damage":
|
||||
view.setUint16(5, clampUint16(event.amount), true);
|
||||
view.setUint16(7, clampUint16(event.health), true);
|
||||
view.setFloat32(9, event.bearingRadians, true);
|
||||
view.setUint8(13, event.critical ? 1 : 0);
|
||||
break;
|
||||
case "hit":
|
||||
view.setUint16(5, clampUint16(event.amount), true);
|
||||
view.setUint8(7, event.eliminated ? 1 : 0);
|
||||
view.setUint8(8, event.critical ? 1 : 0);
|
||||
break;
|
||||
case "elimination":
|
||||
view.setUint32(5, event.killerId, true);
|
||||
view.setUint32(9, event.victimId, true);
|
||||
view.setUint8(13, event.weapon);
|
||||
view.setUint8(14, event.critical ? 1 : 0);
|
||||
break;
|
||||
case "pickup":
|
||||
view.setUint8(5, pickupKindToByte(event.pickup));
|
||||
view.setUint8(6, event.weapon);
|
||||
view.setUint16(7, clampUint16(event.amount), true);
|
||||
break;
|
||||
case "respawn":
|
||||
break;
|
||||
}
|
||||
return payload;
|
||||
},
|
||||
decode(payload) {
|
||||
if (payload.byteLength < 5) {
|
||||
throw new RangeError("Shooter event is missing its header");
|
||||
}
|
||||
const view = viewOf(payload);
|
||||
const id = view.getUint32(0, true);
|
||||
const type = byteToPerceptionType(view.getUint8(4));
|
||||
|
||||
switch (type) {
|
||||
case "shot": {
|
||||
assertLength(payload, 35, "Shot event");
|
||||
const weapon = decodeWeapon(view.getUint8(5));
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
weapon,
|
||||
sourceId: view.getUint32(6, true),
|
||||
originX: view.getFloat32(10, true),
|
||||
originY: view.getFloat32(14, true),
|
||||
originZ: view.getFloat32(18, true),
|
||||
endX: view.getFloat32(22, true),
|
||||
endY: view.getFloat32(26, true),
|
||||
endZ: view.getFloat32(30, true),
|
||||
impact: byteToImpact(view.getUint8(34)),
|
||||
};
|
||||
}
|
||||
case "sound": {
|
||||
assertLength(payload, 16, "Sound event");
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
sound: view.getUint8(5) === 0 ? "gunshot" : "footstep",
|
||||
weapon: decodeWeapon(view.getUint8(6)),
|
||||
bearingRadians: view.getFloat32(7, true),
|
||||
intensity: view.getFloat32(11, true),
|
||||
distanceBand: byteToDistanceBand(view.getUint8(15)),
|
||||
};
|
||||
}
|
||||
case "damage":
|
||||
assertLength(payload, 14, "Damage event");
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
amount: view.getUint16(5, true),
|
||||
health: view.getUint16(7, true),
|
||||
bearingRadians: view.getFloat32(9, true),
|
||||
critical: Boolean(view.getUint8(13)),
|
||||
};
|
||||
case "hit":
|
||||
assertLength(payload, 9, "Hit event");
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
amount: view.getUint16(5, true),
|
||||
eliminated: Boolean(view.getUint8(7)),
|
||||
critical: Boolean(view.getUint8(8)),
|
||||
};
|
||||
case "elimination":
|
||||
assertLength(payload, 15, "Elimination event");
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
killerId: view.getUint32(5, true),
|
||||
victimId: view.getUint32(9, true),
|
||||
weapon: decodeWeapon(view.getUint8(13)),
|
||||
critical: Boolean(view.getUint8(14)),
|
||||
};
|
||||
case "pickup":
|
||||
assertLength(payload, 9, "Pickup event");
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
pickup: byteToPickupKind(view.getUint8(5)),
|
||||
weapon: decodeWeapon(view.getUint8(6)),
|
||||
amount: view.getUint16(7, true),
|
||||
};
|
||||
case "respawn":
|
||||
assertLength(payload, 5, "Respawn event");
|
||||
return { id, type };
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
function encodePlayer(view: DataView, offset: number, player: PlayerState): number {
|
||||
view.setUint32(offset, player.id, true);
|
||||
view.setUint8(
|
||||
offset + 4,
|
||||
(player.kind === "bot" ? 1 : 0) | (player.alive ? 2 : 0),
|
||||
);
|
||||
view.setFloat32(offset + 5, player.x, true);
|
||||
view.setFloat32(offset + 9, player.z, true);
|
||||
view.setFloat32(offset + 13, player.velocityX, true);
|
||||
view.setFloat32(offset + 17, player.velocityZ, true);
|
||||
view.setFloat32(offset + 21, player.yaw, true);
|
||||
view.setFloat32(offset + 25, player.pitch, true);
|
||||
view.setUint8(offset + 29, clampByte(player.health));
|
||||
view.setUint8(offset + 30, clampByte(player.armor));
|
||||
view.setUint8(offset + 31, player.weapon);
|
||||
let ammoOffset = offset + 32;
|
||||
for (const ammo of player.ammo) {
|
||||
view.setUint16(ammoOffset, clampUint16(ammo.magazine), true);
|
||||
view.setUint16(ammoOffset + 2, clampUint16(ammo.reserve), true);
|
||||
ammoOffset += 4;
|
||||
}
|
||||
view.setUint16(offset + 44, clampUint16(player.cooldownTicks), true);
|
||||
view.setUint16(offset + 46, clampUint16(player.reloadTicks), true);
|
||||
view.setUint16(offset + 48, clampUint16(player.respawnTicks), true);
|
||||
view.setUint16(offset + 50, clampUint16(player.spawnProtectionTicks), true);
|
||||
view.setInt8(offset + 52, Math.round(clamp(player.inputStrafe, -1, 1) * 127));
|
||||
view.setInt8(offset + 53, Math.round(clamp(player.inputForward, -1, 1) * 127));
|
||||
view.setUint8(offset + 54, (player.firing ? 1 : 0) | (player.sprinting ? 2 : 0));
|
||||
return offset + PLAYER_BYTES;
|
||||
}
|
||||
|
||||
function decodePlayer(view: DataView, offset: number): { player: PlayerState; offset: number } {
|
||||
const flags = view.getUint8(offset + 4);
|
||||
const weapon = decodeWeapon(view.getUint8(offset + 31));
|
||||
const ammo = [] as unknown as LoadoutState;
|
||||
let ammoOffset = offset + 32;
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
ammo[index] = {
|
||||
magazine: view.getUint16(ammoOffset, true),
|
||||
reserve: view.getUint16(ammoOffset + 2, true),
|
||||
};
|
||||
ammoOffset += 4;
|
||||
}
|
||||
const inputFlags = view.getUint8(offset + 54);
|
||||
return {
|
||||
player: {
|
||||
id: view.getUint32(offset, true),
|
||||
kind: flags & 1 ? "bot" : "human",
|
||||
alive: Boolean(flags & 2),
|
||||
x: view.getFloat32(offset + 5, true),
|
||||
z: view.getFloat32(offset + 9, true),
|
||||
velocityX: view.getFloat32(offset + 13, true),
|
||||
velocityZ: view.getFloat32(offset + 17, true),
|
||||
yaw: view.getFloat32(offset + 21, true),
|
||||
pitch: view.getFloat32(offset + 25, true),
|
||||
health: view.getUint8(offset + 29),
|
||||
armor: view.getUint8(offset + 30),
|
||||
weapon,
|
||||
ammo,
|
||||
cooldownTicks: view.getUint16(offset + 44, true),
|
||||
reloadTicks: view.getUint16(offset + 46, true),
|
||||
respawnTicks: view.getUint16(offset + 48, true),
|
||||
spawnProtectionTicks: view.getUint16(offset + 50, true),
|
||||
inputStrafe: view.getInt8(offset + 52) / 127,
|
||||
inputForward: view.getInt8(offset + 53) / 127,
|
||||
firing: Boolean(inputFlags & 1),
|
||||
sprinting: Boolean(inputFlags & 2),
|
||||
},
|
||||
offset: offset + PLAYER_BYTES,
|
||||
};
|
||||
}
|
||||
|
||||
function encodeScore(view: DataView, offset: number, score: ScoreEntry): number {
|
||||
view.setUint32(offset, score.id, true);
|
||||
view.setUint8(offset + 4, score.kind === "bot" ? 1 : 0);
|
||||
view.setUint16(offset + 5, clampUint16(score.kills), true);
|
||||
view.setUint16(offset + 7, clampUint16(score.deaths), true);
|
||||
return offset + SCORE_BYTES;
|
||||
}
|
||||
|
||||
function perceptionLength(event: ShooterPerception): number {
|
||||
switch (event.type) {
|
||||
case "shot": return 35;
|
||||
case "sound": return 16;
|
||||
case "damage": return 14;
|
||||
case "hit": return 9;
|
||||
case "elimination": return 15;
|
||||
case "pickup": return 9;
|
||||
case "respawn": return 5;
|
||||
}
|
||||
}
|
||||
|
||||
function perceptionTypeToByte(type: ShooterPerception["type"]): number {
|
||||
return type === "shot" ? 0 : type === "sound" ? 1 : type === "damage" ? 2 : type === "elimination" ? 3 : type === "pickup" ? 4 : type === "respawn" ? 5 : 6;
|
||||
}
|
||||
|
||||
function byteToPerceptionType(value: number): ShooterPerception["type"] {
|
||||
if (value === 0) return "shot";
|
||||
if (value === 1) return "sound";
|
||||
if (value === 2) return "damage";
|
||||
if (value === 3) return "elimination";
|
||||
if (value === 4) return "pickup";
|
||||
if (value === 5) return "respawn";
|
||||
if (value === 6) return "hit";
|
||||
throw new RangeError("Unknown shooter perception type");
|
||||
}
|
||||
|
||||
function pickupKindToByte(kind: PickupKind): number {
|
||||
return kind === "health" ? 0 : kind === "ammo" ? 1 : 2;
|
||||
}
|
||||
|
||||
function byteToPickupKind(value: number): PickupKind {
|
||||
if (value === 0) return "health";
|
||||
if (value === 1) return "ammo";
|
||||
if (value === 2) return "weapon";
|
||||
throw new RangeError("Unknown pickup kind");
|
||||
}
|
||||
|
||||
function distanceBandToByte(band: DistanceBand): number {
|
||||
return band === "near" ? 0 : band === "medium" ? 1 : 2;
|
||||
}
|
||||
|
||||
function byteToDistanceBand(value: number): DistanceBand {
|
||||
if (value === 0) return "near";
|
||||
if (value === 1) return "medium";
|
||||
if (value === 2) return "far";
|
||||
throw new RangeError("Unknown distance band");
|
||||
}
|
||||
|
||||
function impactToByte(impact: ImpactKind): number {
|
||||
return impact === "wall" ? 0 : impact === "player" ? 1 : 2;
|
||||
}
|
||||
|
||||
function byteToImpact(value: number): ImpactKind {
|
||||
if (value === 0) return "wall";
|
||||
if (value === 1) return "player";
|
||||
if (value === 2) return "miss";
|
||||
throw new RangeError("Unknown impact kind");
|
||||
}
|
||||
|
||||
function byteToPlayerKind(value: number): PlayerKind {
|
||||
if (value === 0) return "human";
|
||||
if (value === 1) return "bot";
|
||||
throw new RangeError("Unknown player kind");
|
||||
}
|
||||
|
||||
function decodeWeapon(value: number): WeaponId {
|
||||
if (!isWeaponId(value)) {
|
||||
throw new RangeError("Unknown weapon");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function clamp(value: number, minimum: number, maximum: number): number {
|
||||
return Math.max(minimum, Math.min(maximum, value));
|
||||
}
|
||||
|
||||
function clampByte(value: number): number {
|
||||
return Math.round(clamp(value, 0, 255));
|
||||
}
|
||||
|
||||
function clampUint16(value: number): number {
|
||||
return Math.round(clamp(value, 0, 65_535));
|
||||
}
|
||||
|
||||
function viewOf(payload: Uint8Array): DataView {
|
||||
return new DataView(payload.buffer, payload.byteOffset, payload.byteLength);
|
||||
}
|
||||
|
||||
function assertLength(payload: Uint8Array, expected: number, name: string): void {
|
||||
if (payload.byteLength !== expected) {
|
||||
throw new RangeError(`${name} must be exactly ${expected} bytes`);
|
||||
}
|
||||
}
|
||||
1200
packages/shared/src/shooter-game.ts
Normal file
1200
packages/shared/src/shooter-game.ts
Normal file
File diff suppressed because it is too large
Load Diff
141
packages/shared/src/shooter-types.ts
Normal file
141
packages/shared/src/shooter-types.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
export const Weapon = {
|
||||
PulseRifle: 0,
|
||||
Scattergun: 1,
|
||||
RailRifle: 2,
|
||||
} as const;
|
||||
|
||||
export type WeaponId = (typeof Weapon)[keyof typeof Weapon];
|
||||
export type PlayerKind = "human" | "bot";
|
||||
export type PickupKind = "health" | "ammo" | "weapon";
|
||||
export type DistanceBand = "near" | "medium" | "far";
|
||||
|
||||
export interface WeaponDefinition {
|
||||
id: WeaponId;
|
||||
name: string;
|
||||
shortName: string;
|
||||
damage: number;
|
||||
pellets: number;
|
||||
spread: number;
|
||||
range: number;
|
||||
cooldownTicks: number;
|
||||
reloadTicks: number;
|
||||
magazineSize: number;
|
||||
startingReserve: number;
|
||||
automatic: boolean;
|
||||
accent: string;
|
||||
}
|
||||
|
||||
export const WEAPONS: Readonly<Record<WeaponId, WeaponDefinition>> = {
|
||||
[Weapon.PulseRifle]: { id: Weapon.PulseRifle, name: "Nova-9 Pulse Rifle", shortName: "NOVA-9", damage: 18, pellets: 1, spread: 0.012, range: 42, cooldownTicks: 6, reloadTicks: 92, magazineSize: 30, startingReserve: 120, automatic: true, accent: "#5de4c7" },
|
||||
[Weapon.Scattergun]: { id: Weapon.Scattergun, name: "Arc-12 Scattergun", shortName: "ARC-12", damage: 10, pellets: 8, spread: 0.105, range: 16, cooldownTicks: 48, reloadTicks: 112, magazineSize: 8, startingReserve: 32, automatic: false, accent: "#ffad5c" },
|
||||
[Weapon.RailRifle]: { id: Weapon.RailRifle, name: "Vantage Rail Rifle", shortName: "VANTAGE", damage: 72, pellets: 1, spread: 0.002, range: 58, cooldownTicks: 76, reloadTicks: 138, magazineSize: 4, startingReserve: 16, automatic: false, accent: "#a98cff" },
|
||||
};
|
||||
|
||||
export interface ShooterInput {
|
||||
strafe: number;
|
||||
forward: number;
|
||||
yaw: number;
|
||||
pitch: number;
|
||||
fire: boolean;
|
||||
sprint: boolean;
|
||||
reload: boolean;
|
||||
weapon: WeaponId;
|
||||
}
|
||||
|
||||
export interface AmmoState { magazine: number; reserve: number }
|
||||
export type LoadoutState = [AmmoState, AmmoState, AmmoState];
|
||||
|
||||
export interface PlayerState {
|
||||
id: number;
|
||||
kind: PlayerKind;
|
||||
alive: boolean;
|
||||
x: number;
|
||||
z: number;
|
||||
velocityX: number;
|
||||
velocityZ: number;
|
||||
yaw: number;
|
||||
pitch: number;
|
||||
health: number;
|
||||
armor: number;
|
||||
weapon: WeaponId;
|
||||
ammo: LoadoutState;
|
||||
cooldownTicks: number;
|
||||
reloadTicks: number;
|
||||
respawnTicks: number;
|
||||
spawnProtectionTicks: number;
|
||||
inputStrafe: number;
|
||||
inputForward: number;
|
||||
firing: boolean;
|
||||
sprinting: boolean;
|
||||
}
|
||||
|
||||
export interface ScoreEntry { id: number; kind: PlayerKind; kills: number; deaths: number }
|
||||
|
||||
export interface PickupState {
|
||||
id: number;
|
||||
kind: PickupKind;
|
||||
weapon: WeaponId;
|
||||
x: number;
|
||||
z: number;
|
||||
active: boolean;
|
||||
respawnTicks: number;
|
||||
}
|
||||
|
||||
export interface MatchState { elapsedTicks: number; scoreLimit: number; leaderId: number }
|
||||
export type SoundKind = "gunshot" | "footstep";
|
||||
export type ImpactKind = "wall" | "player" | "miss";
|
||||
|
||||
export type ShooterPerception =
|
||||
| { id: number; type: "shot"; weapon: WeaponId; sourceId: number; originX: number; originY: number; originZ: number; endX: number; endY: number; endZ: number; impact: ImpactKind }
|
||||
| { id: number; type: "sound"; sound: SoundKind; weapon: WeaponId; bearingRadians: number; intensity: number; distanceBand: DistanceBand }
|
||||
| { id: number; type: "damage"; amount: number; health: number; bearingRadians: number; critical: boolean }
|
||||
| { id: number; type: "hit"; amount: number; eliminated: boolean; critical: boolean }
|
||||
| { id: number; type: "elimination"; killerId: number; victimId: number; weapon: WeaponId; critical: boolean }
|
||||
| { id: number; type: "pickup"; pickup: PickupKind; weapon: WeaponId; amount: number }
|
||||
| { id: number; type: "respawn" };
|
||||
|
||||
export interface TimedPerception { receivedTick: number; event: ShooterPerception }
|
||||
|
||||
export interface ShooterWorldState {
|
||||
players: Map<number, PlayerState>;
|
||||
pickups: Map<number, PickupState>;
|
||||
scoreboard: ScoreEntry[];
|
||||
match: MatchState;
|
||||
events: TimedPerception[];
|
||||
}
|
||||
|
||||
export interface BotMind { targetId: number | null; patrolIndex: number; nextThinkTick: number; strafeSign: number }
|
||||
|
||||
export interface AuthoritativePlayer extends PlayerState {
|
||||
kills: number;
|
||||
deaths: number;
|
||||
lastFirePressed: boolean;
|
||||
lastReloadPressed: boolean;
|
||||
lastFootstepTick: number;
|
||||
bot: BotMind | null;
|
||||
}
|
||||
|
||||
export interface AuthoritativeShooterState {
|
||||
players: Map<number, AuthoritativePlayer>;
|
||||
pickups: Map<number, PickupState>;
|
||||
elapsedTicks: number;
|
||||
nextEventId: number;
|
||||
}
|
||||
|
||||
export type ShooterAuthorityEvent =
|
||||
| { id: number; type: "shot"; sourceId: number; weapon: WeaponId; originX: number; originY: number; originZ: number; endX: number; endY: number; endZ: number; impact: ImpactKind }
|
||||
| { id: number; type: "damage"; sourceId: number; targetId: number; amount: number; health: number; critical: boolean }
|
||||
| { id: number; type: "elimination"; killerId: number; victimId: number; weapon: WeaponId; critical: boolean }
|
||||
| { id: number; type: "pickup"; playerId: number; pickup: PickupKind; weapon: WeaponId; amount: number }
|
||||
| { id: number; type: "respawn"; playerId: number }
|
||||
| { id: number; type: "footstep"; sourceId: number };
|
||||
|
||||
export function isWeaponId(value: number): value is WeaponId {
|
||||
return value === 0 || value === 1 || value === 2;
|
||||
}
|
||||
|
||||
export function playerDisplayName(id: number, kind: PlayerKind): string {
|
||||
if (kind === "human") return `RANGER ${id}`;
|
||||
const names = ["CIPHER", "ROOK", "MARA", "KESTREL", "VEX", "HALO"];
|
||||
return names[Math.abs(id) % names.length] ?? `BOT ${id}`;
|
||||
}
|
||||
111
packages/shared/test/flux.test.mjs
Normal file
111
packages/shared/test/flux.test.mjs
Normal file
@@ -0,0 +1,111 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
FLUX_BOT_IDS,
|
||||
FLUX_SNAPSHOT_RATE,
|
||||
FLUX_TICK_RATE,
|
||||
fluxGame,
|
||||
} from "../dist/index.js";
|
||||
|
||||
function createThrustPacket(sequence, targetTick, thrust = true) {
|
||||
return {
|
||||
sequence,
|
||||
targetTick,
|
||||
observedTick: Math.max(0, targetTick - 1),
|
||||
input: { thrust },
|
||||
};
|
||||
}
|
||||
|
||||
test("Flux Relay is a complete game defined through the public authoring API", () => {
|
||||
assert.equal(fluxGame.tickRateHz, FLUX_TICK_RATE);
|
||||
assert.equal(fluxGame.snapshotRateHz, FLUX_SNAPSHOT_RATE);
|
||||
|
||||
const server = fluxGame.createServer();
|
||||
server.addPlayer(1);
|
||||
assert.deepEqual(server.submitInput(1, createThrustPacket(1, 1)), {
|
||||
accepted: true,
|
||||
});
|
||||
|
||||
const events = [];
|
||||
for (let tick = 0; tick < 180; tick += 1) {
|
||||
events.push(...server.step().events);
|
||||
}
|
||||
|
||||
assert.ok(server.currentState.cyanScore >= 2);
|
||||
assert.equal(server.currentState.orangeScore, 0);
|
||||
assert.ok(
|
||||
events.some((event) => event.type === "round-won" && event.team === "cyan"),
|
||||
);
|
||||
assert.ok(server.currentState.players.some((player) => player.id === FLUX_BOT_IDS[0]));
|
||||
});
|
||||
|
||||
test("Flux snapshots disclose exact energy only to its owner", () => {
|
||||
const server = fluxGame.createServer();
|
||||
server.addPlayer(1);
|
||||
server.addPlayer(2);
|
||||
|
||||
const playerOneView = server.createSnapshot(1, 0).state;
|
||||
const playerTwoView = server.createSnapshot(2, 0).state;
|
||||
|
||||
assert.equal(playerOneView.players.find((player) => player.id === 1)?.energy, 100);
|
||||
assert.equal(playerOneView.players.find((player) => player.id === 2)?.energy, null);
|
||||
assert.equal(playerTwoView.players.find((player) => player.id === 1)?.energy, null);
|
||||
assert.equal(playerTwoView.players.find((player) => player.id === 2)?.energy, 100);
|
||||
assert.ok(
|
||||
playerOneView.players
|
||||
.filter((player) => player.bot)
|
||||
.every((player) => player.energy === null),
|
||||
);
|
||||
assert.equal("nextEventId" in playerOneView, false);
|
||||
});
|
||||
|
||||
test("the same private Flux event becomes a self cue or anonymous field cue", () => {
|
||||
const server = fluxGame.createServer();
|
||||
server.addPlayer(1);
|
||||
server.addPlayer(2);
|
||||
const player = server.currentState.players.find((candidate) => candidate.id === 1);
|
||||
player.energy = 0.2;
|
||||
|
||||
server.submitInput(1, createThrustPacket(1, 1));
|
||||
const result = server.step();
|
||||
const overheat = result.events.find((event) => event.type === "overheated");
|
||||
assert.ok(overheat);
|
||||
|
||||
const ownCue = server.createPerceptions(1, [overheat])[0];
|
||||
const fieldCue = server.createPerceptions(2, [overheat])[0];
|
||||
assert.equal(ownCue.scope, "self");
|
||||
assert.equal(fieldCue.scope, "field");
|
||||
assert.equal("playerId" in ownCue, false);
|
||||
assert.equal("playerId" in fieldCue, false);
|
||||
});
|
||||
|
||||
test("Flux uses the generic protocol, prediction, acknowledgement, and reconciliation", () => {
|
||||
const server = fluxGame.createServer();
|
||||
server.addPlayer(7);
|
||||
const client = fluxGame.createClient();
|
||||
client.initialize(7, server.createSnapshot(7, 10));
|
||||
|
||||
const packet = client.createInput({ thrust: true }, 1);
|
||||
const wirePacket = fluxGame.protocol.decodeClient(
|
||||
fluxGame.protocol.encodeClient({ kind: "input", packet }),
|
||||
);
|
||||
assert.equal(wirePacket.kind, "input");
|
||||
assert.deepEqual(wirePacket.packet, packet);
|
||||
assert.equal(server.submitInput(7, packet).accepted, true);
|
||||
|
||||
client.step();
|
||||
const result = server.step();
|
||||
client.acknowledge(result.acknowledgements[0].sequence);
|
||||
const wireSnapshot = fluxGame.protocol.decodeServer(
|
||||
fluxGame.protocol.encodeServer({
|
||||
kind: "snapshot",
|
||||
snapshot: server.createSnapshot(7, 20),
|
||||
}),
|
||||
);
|
||||
assert.equal(wireSnapshot.kind, "snapshot");
|
||||
client.reconcile(wireSnapshot.snapshot);
|
||||
|
||||
const local = client.currentState.players.find((player) => player.id === 7);
|
||||
assert.equal(local.thrust, true);
|
||||
assert.ok(local.energy < 100);
|
||||
});
|
||||
219
packages/shared/test/royale.test.mjs
Normal file
219
packages/shared/test/royale.test.mjs
Normal file
@@ -0,0 +1,219 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
ROYALE_BOT_COUNT,
|
||||
ROYALE_BOT_ID_BASE,
|
||||
ROYALE_CHUNK_SIZE,
|
||||
ROYALE_MAP_SEED,
|
||||
collidesWithRoyaleMap,
|
||||
generateRoyaleChunk,
|
||||
hasRoyaleLineOfSight,
|
||||
royaleGame,
|
||||
} from "../dist/index.js";
|
||||
|
||||
test("the public world seed deterministically generates a 2 km chunked island", () => {
|
||||
const first = generateRoyaleChunk(-3, 4);
|
||||
const second = generateRoyaleChunk(-3, 4);
|
||||
const neighbor = generateRoyaleChunk(-2, 4);
|
||||
assert.deepEqual(first, second);
|
||||
assert.notDeepEqual(first, neighbor);
|
||||
assert.equal(ROYAALE_MAP_SEED_FOR_TEST(), ROYALE_MAP_SEED);
|
||||
assert.equal(ROYAALE_CHUNK_SIZE_FOR_TEST(), ROYALE_CHUNK_SIZE);
|
||||
assert.ok(first.obstacles.every((obstacle) => obstacle.id.startsWith("-3:4:")));
|
||||
});
|
||||
|
||||
test("Syncer Royale simulates a complete authority battle headlessly", () => {
|
||||
const server = royaleGame.createServer();
|
||||
const totals = { shots: 0, damage: 0, eliminations: 0, winners: 0 };
|
||||
for (let tick = 0; tick < 6_000; tick += 1) {
|
||||
for (const event of server.step().events) {
|
||||
if (event.type === "shot") totals.shots += 1;
|
||||
if (event.type === "damage") totals.damage += 1;
|
||||
if (event.type === "elimination") totals.eliminations += 1;
|
||||
if (event.type === "winner") totals.winners += 1;
|
||||
}
|
||||
}
|
||||
assert.equal(server.currentState.players.length, ROYALE_BOT_COUNT);
|
||||
assert.ok(totals.shots > 150, `expected firefights, saw ${totals.shots}`);
|
||||
assert.ok(totals.damage > 50, `expected damage, saw ${totals.damage}`);
|
||||
assert.ok(totals.eliminations > 15, `expected eliminations, saw ${totals.eliminations}`);
|
||||
assert.ok(server.currentState.players.every((player) => Number.isFinite(player.x + player.z)));
|
||||
});
|
||||
|
||||
test("Royale projections hide authority secrets and distant combatants", () => {
|
||||
const server = royaleGame.createServer();
|
||||
server.addPlayer(1);
|
||||
const authority = server.currentState;
|
||||
const viewer = authority.players.find((player) => player.id === 1);
|
||||
const hidden = authority.players.find((player) => player.id === ROYALE_BOT_ID_BASE);
|
||||
viewer.altitude = 0;
|
||||
viewer.x = 0;
|
||||
viewer.z = 0;
|
||||
hidden.x = 430;
|
||||
hidden.z = 0;
|
||||
hidden.alive = true;
|
||||
|
||||
const snapshot = server.createSnapshot(1, 0).state;
|
||||
assert.equal(snapshot.mapSeed, ROYALE_MAP_SEED);
|
||||
assert.equal("secretSeed" in snapshot, false);
|
||||
assert.ok(snapshot.players.some((player) => player.id === 1));
|
||||
assert.ok(!snapshot.players.some((player) => player.id === hidden.id));
|
||||
assert.ok(
|
||||
snapshot.players
|
||||
.filter((player) => player.id !== 1)
|
||||
.every((player) => player.health === null && player.armor === null),
|
||||
);
|
||||
|
||||
const [sound] = server.createPerceptions(1, [
|
||||
{ id: 99, type: "shot", sourceId: hidden.id, x: hidden.x, y: 1.55, z: hidden.z, yaw: 0, pitch: 0 },
|
||||
]);
|
||||
assert.equal(sound.type, "gunfire");
|
||||
assert.equal("sourceId" in sound, false);
|
||||
assert.equal("x" in sound, false);
|
||||
|
||||
const [ownShot] = server.createPerceptions(1, [
|
||||
{
|
||||
id: 100,
|
||||
type: "shot",
|
||||
sourceId: viewer.id,
|
||||
x: viewer.x,
|
||||
y: 1.55,
|
||||
z: viewer.z,
|
||||
yaw: viewer.yaw,
|
||||
pitch: viewer.pitch,
|
||||
},
|
||||
]);
|
||||
assert.equal(ownShot.type, "shot");
|
||||
assert.equal(ownShot.sourceId, viewer.id);
|
||||
});
|
||||
|
||||
test("Royale enforces a viewer bandwidth budget after ordinary visibility", () => {
|
||||
const server = royaleGame.createServer();
|
||||
server.addPlayer(1);
|
||||
const authority = server.currentState;
|
||||
const viewer = authority.players.find((player) => player.id === 1);
|
||||
viewer.altitude = 0;
|
||||
viewer.x = 0;
|
||||
viewer.z = 0;
|
||||
for (const bot of authority.players.filter((player) => player.bot)) {
|
||||
bot.x = 800;
|
||||
bot.z = 0;
|
||||
}
|
||||
for (let index = 0; index < 100; index += 1) {
|
||||
const item = authority.loot[index];
|
||||
item.active = true;
|
||||
item.x = (index % 10) * 2 - 9;
|
||||
item.z = Math.floor(index / 10) * 2 - 9;
|
||||
}
|
||||
|
||||
const snapshot = server.createSnapshot(1, 0).state;
|
||||
assert.ok(snapshot.stream.droppedCount > 0);
|
||||
assert.ok(snapshot.stream.usedBytes <= snapshot.stream.budgetBytes);
|
||||
assert.ok(snapshot.loot.length < 100);
|
||||
assert.equal(snapshot.players.filter((player) => player.id === 1).length, 1);
|
||||
});
|
||||
|
||||
test("Royale input and projected state round-trip through the generic protocol", () => {
|
||||
const server = royaleGame.createServer();
|
||||
server.addPlayer(7);
|
||||
const client = royaleGame.createClient();
|
||||
const welcome = server.createSnapshot(7, 10);
|
||||
client.initialize(7, welcome);
|
||||
const packet = client.createInput({
|
||||
forward: 1,
|
||||
strafe: 0.25,
|
||||
yaw: 0.7,
|
||||
pitch: -0.2,
|
||||
fire: false,
|
||||
sprint: true,
|
||||
reload: false,
|
||||
}, 1);
|
||||
const decoded = royaleGame.protocol.decodeClient(
|
||||
royaleGame.protocol.encodeClient({ kind: "input", packet }),
|
||||
);
|
||||
assert.equal(decoded.kind, "input");
|
||||
assert.deepEqual(decoded.packet, packet);
|
||||
assert.equal(server.submitInput(7, decoded.packet).accepted, true);
|
||||
server.step();
|
||||
assert.equal(server.currentState.players.find((player) => player.id === 7).pitch, -0.2);
|
||||
client.step();
|
||||
client.reconcile(server.createSnapshot(7, 20));
|
||||
assert.ok(client.currentState.players.some((player) => player.id === 7));
|
||||
});
|
||||
|
||||
test("Royale pitch drives the authoritative three-dimensional hit ray", () => {
|
||||
const server = royaleGame.createServer();
|
||||
server.addPlayer(7);
|
||||
server.addPlayer(8);
|
||||
const authority = server.currentState;
|
||||
for (const bot of authority.players.filter((player) => player.bot)) bot.alive = false;
|
||||
const shooter = authority.players.find((player) => player.id === 7);
|
||||
const target = authority.players.find((player) => player.id === 8);
|
||||
let origin = null;
|
||||
for (let x = -100; x <= 100 && !origin; x += 10) {
|
||||
for (let z = -100; z <= 60; z += 10) {
|
||||
if (
|
||||
!collidesWithRoyaleMap(x, z) &&
|
||||
!collidesWithRoyaleMap(x, z + 40) &&
|
||||
hasRoyaleLineOfSight(x, z, x, z + 40)
|
||||
) {
|
||||
origin = { x, z };
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.ok(origin, "expected an unobstructed public-map firing lane");
|
||||
Object.assign(shooter, { x: origin.x, z: origin.z, altitude: 0, yaw: 0 });
|
||||
Object.assign(target, { x: origin.x, z: origin.z + 40, altitude: 20, health: 100, armor: 0 });
|
||||
const pitch = Math.atan2(20, 40);
|
||||
assert.equal(server.submitInput(7, {
|
||||
sequence: 1,
|
||||
targetTick: 1,
|
||||
observedTick: 0,
|
||||
input: {
|
||||
forward: 0,
|
||||
strafe: 0,
|
||||
yaw: 0,
|
||||
pitch,
|
||||
fire: true,
|
||||
sprint: false,
|
||||
reload: false,
|
||||
},
|
||||
}).accepted, true);
|
||||
const result = server.step();
|
||||
assert.ok(result.events.some((event) => event.type === "damage" && event.targetId === 8));
|
||||
assert.ok(target.health < 100);
|
||||
});
|
||||
|
||||
test("Royale strafe-right matches the first-person camera's screen right", () => {
|
||||
const server = royaleGame.createServer();
|
||||
server.addPlayer(7);
|
||||
const authority = server.currentState;
|
||||
for (const bot of authority.players.filter((player) => player.bot)) bot.alive = false;
|
||||
const player = authority.players.find((candidate) => candidate.id === 7);
|
||||
Object.assign(player, { x: 0, z: 0, altitude: 20, yaw: 0 });
|
||||
assert.equal(server.submitInput(7, {
|
||||
sequence: 1,
|
||||
targetTick: 1,
|
||||
observedTick: 0,
|
||||
input: {
|
||||
forward: 0,
|
||||
strafe: 1,
|
||||
yaw: 0,
|
||||
pitch: 0,
|
||||
fire: false,
|
||||
sprint: false,
|
||||
reload: false,
|
||||
},
|
||||
}).accepted, true);
|
||||
server.step();
|
||||
assert.ok(player.velocityX < 0, "screen-right is world -X while facing world +Z");
|
||||
});
|
||||
|
||||
function ROYAALE_MAP_SEED_FOR_TEST() {
|
||||
return ROYALE_MAP_SEED;
|
||||
}
|
||||
|
||||
function ROYAALE_CHUNK_SIZE_FOR_TEST() {
|
||||
return ROYALE_CHUNK_SIZE;
|
||||
}
|
||||
282
packages/shared/test/shooter.test.mjs
Normal file
282
packages/shared/test/shooter.test.mjs
Normal file
@@ -0,0 +1,282 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
BOT_COUNT,
|
||||
BOT_ID_BASE,
|
||||
Weapon,
|
||||
shooterGame,
|
||||
} from "../dist/index.js";
|
||||
|
||||
function runHeadlessMatch(ticks) {
|
||||
const server = shooterGame.createServer();
|
||||
const totals = {
|
||||
shots: 0,
|
||||
damageEvents: 0,
|
||||
eliminations: 0,
|
||||
respawns: 0,
|
||||
};
|
||||
|
||||
for (let tick = 0; tick < ticks; tick += 1) {
|
||||
const result = server.step();
|
||||
for (const event of result.events) {
|
||||
if (event.type === "shot") totals.shots += 1;
|
||||
if (event.type === "damage") totals.damageEvents += 1;
|
||||
if (event.type === "elimination") totals.eliminations += 1;
|
||||
if (event.type === "respawn") totals.respawns += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...totals,
|
||||
tick: server.tick,
|
||||
scores: [...server.currentState.players.values()]
|
||||
.map((player) => ({
|
||||
id: player.id,
|
||||
kills: player.kills,
|
||||
deaths: player.deaths,
|
||||
alive: player.alive,
|
||||
x: Number(player.x.toFixed(5)),
|
||||
z: Number(player.z.toFixed(5)),
|
||||
}))
|
||||
.sort((a, b) => a.id - b.id),
|
||||
};
|
||||
}
|
||||
|
||||
test("the complete shooter match simulates headlessly on the backend", () => {
|
||||
const match = runHeadlessMatch(7_200);
|
||||
assert.equal(match.tick, 7_200);
|
||||
assert.equal(match.scores.length, BOT_COUNT);
|
||||
assert.ok(match.shots > 700, `expected active combat, saw ${match.shots} shots`);
|
||||
assert.ok(match.damageEvents > 50, `expected hits, saw ${match.damageEvents}`);
|
||||
assert.ok(match.eliminations >= 10, `expected kills, saw ${match.eliminations}`);
|
||||
assert.ok(match.respawns >= 8, `expected respawns, saw ${match.respawns}`);
|
||||
assert.equal(
|
||||
match.scores.reduce((sum, score) => sum + score.kills, 0),
|
||||
match.eliminations,
|
||||
);
|
||||
assert.ok(match.scores.every((score) => Number.isFinite(score.x + score.z)));
|
||||
});
|
||||
|
||||
test("backend simulation is deterministic", () => {
|
||||
assert.deepEqual(runHeadlessMatch(2_400), runHeadlessMatch(2_400));
|
||||
});
|
||||
|
||||
test("human hitscan damages the historical target without rewinding live positions", () => {
|
||||
const server = shooterGame.createServer();
|
||||
server.addPlayer(1);
|
||||
const shooter = server.currentState.players.get(1);
|
||||
const target = server.currentState.players.get(BOT_ID_BASE);
|
||||
|
||||
for (const bot of server.currentState.players.values()) {
|
||||
if (bot.id === BOT_ID_BASE || bot.id === 1) continue;
|
||||
bot.alive = false;
|
||||
bot.respawnTicks = 10_000;
|
||||
}
|
||||
shooter.x = -4;
|
||||
shooter.z = -15;
|
||||
shooter.yaw = Math.PI / 2;
|
||||
shooter.pitch = 0;
|
||||
shooter.spawnProtectionTicks = 0;
|
||||
target.x = 0;
|
||||
target.z = -15;
|
||||
target.velocityX = 0;
|
||||
target.velocityZ = 0;
|
||||
target.inputForward = 0;
|
||||
target.inputStrafe = 0;
|
||||
target.spawnProtectionTicks = 0;
|
||||
target.bot = null;
|
||||
|
||||
server.step();
|
||||
target.z = -12.5;
|
||||
const healthBefore = target.health;
|
||||
server.submitInput(1, {
|
||||
sequence: 1,
|
||||
targetTick: 2,
|
||||
observedTick: 1,
|
||||
input: {
|
||||
strafe: 0,
|
||||
forward: 0,
|
||||
yaw: Math.PI / 2,
|
||||
pitch: 0,
|
||||
fire: true,
|
||||
sprint: false,
|
||||
reload: false,
|
||||
weapon: Weapon.PulseRifle,
|
||||
},
|
||||
});
|
||||
const result = server.step();
|
||||
|
||||
assert.equal(target.z, -12.5, "the live target must not move backward");
|
||||
assert.ok(target.health < healthBefore, "the rewound hitbox should take damage");
|
||||
assert.ok(
|
||||
result.events.some(
|
||||
(event) => event.type === "damage" && event.targetId === BOT_ID_BASE,
|
||||
),
|
||||
);
|
||||
const shot = result.events.find(
|
||||
(event) => event.type === "shot" && event.sourceId === 1,
|
||||
);
|
||||
assert.ok(shot);
|
||||
assert.ok(Math.abs(shot.endZ + 15) < 0.01);
|
||||
});
|
||||
|
||||
test("shooter recordings seek through checkpoints with privacy intact", () => {
|
||||
const server = shooterGame.createServer({ replaySeed: 99 });
|
||||
server.addPlayer(1);
|
||||
for (let tick = 0; tick < 420; tick += 1) server.step();
|
||||
|
||||
const recording = server.exportRecording();
|
||||
assert.equal(recording.durationTicks, 420);
|
||||
assert.deepEqual(
|
||||
recording.checkpoints.map(({ tick }) => tick),
|
||||
[0, 120, 240, 360],
|
||||
);
|
||||
|
||||
const replay = shooterGame.createReplay(recording);
|
||||
replay.seek(135);
|
||||
const finalFrame = replay.seek(420);
|
||||
assert.deepEqual(finalFrame.state, server.currentState);
|
||||
|
||||
const playerView = replay.viewAs(1).state;
|
||||
assert.ok(playerView.players.has(1));
|
||||
assert.equal("nextEventId" in playerView, false);
|
||||
assert.ok(
|
||||
[...playerView.players.values()].every((player) => !("bot" in player)),
|
||||
);
|
||||
});
|
||||
|
||||
test("a human elimination creates a bounded attacker-perspective killcam", () => {
|
||||
const server = shooterGame.createServer();
|
||||
server.addPlayer(1);
|
||||
let ticket = null;
|
||||
for (let tick = 0; tick < 600 && !ticket; tick += 1) {
|
||||
server.step();
|
||||
ticket = server.drainReplayTickets(1)[0] ?? null;
|
||||
}
|
||||
|
||||
assert.ok(ticket, "expected the standing human to receive a bot killcam");
|
||||
assert.equal(ticket.requesterId, 1);
|
||||
assert.ok(ticket.perspectiveId >= BOT_ID_BASE);
|
||||
assert.equal(ticket.toTick, ticket.issuedAtTick);
|
||||
assert.ok(ticket.frames.length > 60);
|
||||
assert.ok(ticket.frames.length < 300);
|
||||
assert.ok(
|
||||
ticket.frames.every(({ state }) =>
|
||||
[...state.players.values()].every(
|
||||
(player) => !("bot" in player) && !("kills" in player),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test("snapshots and perception events cross the binary protocol", () => {
|
||||
const server = shooterGame.createServer();
|
||||
server.addPlayer(1);
|
||||
const snapshot = server.createSnapshot(1, 123.5);
|
||||
const snapshotFrame = shooterGame.protocol.encodeServer({
|
||||
kind: "snapshot",
|
||||
snapshot,
|
||||
});
|
||||
const decodedSnapshot = shooterGame.protocol.decodeServer(snapshotFrame);
|
||||
assert.equal(decodedSnapshot.kind, "snapshot");
|
||||
assert.equal(decodedSnapshot.snapshot.state.players.get(1).ammo.length, 3);
|
||||
|
||||
const event = {
|
||||
id: 99,
|
||||
type: "shot",
|
||||
weapon: Weapon.RailRifle,
|
||||
sourceId: 1,
|
||||
originX: 1,
|
||||
originY: 1.58,
|
||||
originZ: 2,
|
||||
endX: 11,
|
||||
endY: 1.4,
|
||||
endZ: -4,
|
||||
impact: "wall",
|
||||
};
|
||||
const eventFrame = shooterGame.protocol.encodeServer({
|
||||
kind: "event",
|
||||
tick: 50,
|
||||
event,
|
||||
});
|
||||
const decodedEvent = shooterGame.protocol.decodeServer(eventFrame);
|
||||
assert.equal(decodedEvent.kind, "event");
|
||||
assert.equal(decodedEvent.tick, 50);
|
||||
assert.deepEqual(
|
||||
{
|
||||
...decodedEvent.event,
|
||||
originY: Number(decodedEvent.event.originY.toFixed(2)),
|
||||
endY: Number(decodedEvent.event.endY.toFixed(2)),
|
||||
},
|
||||
event,
|
||||
);
|
||||
});
|
||||
|
||||
test("occluded combatants stay private while their gunfire becomes anonymous audio", () => {
|
||||
const server = shooterGame.createServer();
|
||||
server.addPlayer(1);
|
||||
const authority = server.currentState;
|
||||
const viewer = authority.players.get(1);
|
||||
const hiddenBot = authority.players.get(BOT_ID_BASE);
|
||||
viewer.x = 0;
|
||||
viewer.z = -6;
|
||||
viewer.yaw = 0;
|
||||
hiddenBot.x = 0;
|
||||
hiddenBot.z = 6;
|
||||
|
||||
const snapshot = server.createSnapshot(1, 0);
|
||||
assert.equal(snapshot.state.players.has(BOT_ID_BASE), false);
|
||||
|
||||
const perceptions = server.createPerceptions(1, [
|
||||
{
|
||||
id: 777,
|
||||
type: "shot",
|
||||
sourceId: BOT_ID_BASE,
|
||||
weapon: Weapon.PulseRifle,
|
||||
originX: hiddenBot.x,
|
||||
originY: 1.58,
|
||||
originZ: hiddenBot.z,
|
||||
endX: hiddenBot.x,
|
||||
endY: 1.58,
|
||||
endZ: hiddenBot.z - 10,
|
||||
impact: "wall",
|
||||
},
|
||||
]);
|
||||
assert.equal(perceptions.length, 1);
|
||||
assert.equal(perceptions[0].type, "sound");
|
||||
assert.equal("sourceId" in perceptions[0], false);
|
||||
assert.equal("originX" in perceptions[0], false);
|
||||
});
|
||||
|
||||
test("visible opponents expose exact health but redact armor and inventory", () => {
|
||||
const server = shooterGame.createServer();
|
||||
server.addPlayer(1);
|
||||
const viewer = server.currentState.players.get(1);
|
||||
const opponent = server.currentState.players.get(BOT_ID_BASE);
|
||||
viewer.x = -4;
|
||||
viewer.z = -15;
|
||||
viewer.yaw = Math.PI / 2;
|
||||
opponent.x = 0;
|
||||
opponent.z = -15;
|
||||
opponent.health = 37;
|
||||
opponent.armor = 19;
|
||||
opponent.ammo[Weapon.PulseRifle].magazine = 3;
|
||||
|
||||
const replicated = server.createSnapshot(1, 0).state.players.get(BOT_ID_BASE);
|
||||
assert.ok(replicated, "opponent should be visible in the clear corridor");
|
||||
assert.equal(replicated.health, 37);
|
||||
assert.equal(replicated.armor, 0);
|
||||
assert.ok(
|
||||
replicated.ammo.every(
|
||||
(ammo) => ammo.magazine === 0 && ammo.reserve === 0,
|
||||
),
|
||||
);
|
||||
assert.equal(replicated.x, opponent.x);
|
||||
assert.equal(replicated.weapon, opponent.weapon);
|
||||
|
||||
viewer.yaw = -Math.PI / 2;
|
||||
assert.equal(
|
||||
server.createSnapshot(1, 0).state.players.has(BOT_ID_BASE),
|
||||
false,
|
||||
);
|
||||
});
|
||||
14
packages/shared/tsconfig.json
Normal file
14
packages/shared/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
11
tsconfig.base.json
Normal file
11
tsconfig.base.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"skipLibCheck": true
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user