From 1295828ac86e7b1dd10249a3fd4786960e671925 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sat, 22 Aug 2026 11:03:58 +0100 Subject: [PATCH 01/15] Claude fixes --- backend/src/default-options.ts | 4 +- backend/src/game-server.ts | 176 ++++++++-- backend/src/main.ts | 3 +- backend/src/objects/character-physical.ts | 42 ++- backend/src/objects/circle-physical.ts | 11 +- backend/src/objects/lamp-physical.ts | 2 + backend/src/objects/planet-physical.ts | 21 +- backend/src/objects/projectile-physical.ts | 29 +- backend/src/players/player-base.ts | 21 +- backend/src/players/player-container.ts | 87 ++++- backend/src/players/player.ts | 196 ++++++++++- backend/src/players/server-full-error.ts | 5 + frontend/src/index.ts | 17 +- frontend/src/main.scss | 30 +- .../src/scripts/commands/command-socket.ts | 45 ++- .../src/scripts/commands/keyboard-listener.ts | 26 +- .../src/scripts/commands/mouse-listener.ts | 25 +- .../src/scripts/commands/touch-listener.ts | 105 ++++-- frontend/src/scripts/feedback-hud.ts | 12 + frontend/src/scripts/game.ts | 120 ++++++- .../prediction/local-character-predictor.ts | 72 ++-- .../scripts/objects/game-object-container.ts | 98 +++++- frontend/src/scripts/sound-handler.ts | 17 +- package.json | 2 +- .../types/actions/client-heartbeat.ts | 19 ++ .../commands/types/actions/primary-action.ts | 8 +- .../commands/types/input-acknowledgement.ts | 15 +- .../src/communication/player-information.ts | 3 + shared/src/communication/transport-events.ts | 9 + shared/src/helper/finite.ts | 60 ++++ shared/src/main.ts | 2 + shared/src/objects/game-object.ts | 21 +- shared/src/physics/character-movement.ts | 10 +- shared/src/physics/depenetrate-circle.ts | 7 +- shared/src/physics/march-circle.ts | 72 ++-- shared/src/settings.ts | 52 +++ test/backend-regressions.test.ts | 225 ++++++++++++ test/backpressure.test.ts | 137 ++++++++ test/client-send-cadence.test.ts | 80 +++++ test/connection-lifecycle.test.ts | 319 ++++++++++++++++++ test/prediction-reconciliation.test.ts | 86 +++++ 41 files changed, 2086 insertions(+), 205 deletions(-) create mode 100644 backend/src/players/server-full-error.ts create mode 100644 shared/src/commands/types/actions/client-heartbeat.ts create mode 100644 shared/src/helper/finite.ts create mode 100644 test/backend-regressions.test.ts create mode 100644 test/backpressure.test.ts create mode 100644 test/client-send-cadence.test.ts create mode 100644 test/connection-lifecycle.test.ts diff --git a/backend/src/default-options.ts b/backend/src/default-options.ts index 1580103..338088a 100644 --- a/backend/src/default-options.ts +++ b/backend/src/default-options.ts @@ -1,8 +1,10 @@ import { Options } from './options'; +const isDevelopment = process.env.NODE_ENV !== 'production'; + export const defaultOptions: Options = { port: 3000, - name: 'Test server', + name: isDevelopment ? 'Dev server' : 'Doppler server', playerLimit: 16, npcCount: 8, seed: Math.random(), diff --git a/backend/src/game-server.ts b/backend/src/game-server.ts index 5e92f3b..ce02d58 100644 --- a/backend/src/game-server.ts +++ b/backend/src/game-server.ts @@ -14,17 +14,27 @@ import { CommandReceiver, CommandExecutors, ServerAnnouncement, + JoinRejectionReason, + beginPropertyUpdateGeneration, } from 'shared'; import { createWorld } from './create-world'; import { DeltaTimeCalculator } from './helper/delta-time-calculator'; import { Options } from './options'; import { PlayerContainer } from './players/player-container'; +import { ServerFullError } from './players/server-full-error'; +import { Player } from './players/player'; import { StepCommand } from './commands/step'; import { GeneratePointsCommand } from './commands/generate-points'; import { AnnounceCommand } from './commands/announce'; const gameStateSubscribedRoom = 'gameStateSubscribedRoom'; +interface JoinedSocket { + player: Player; + onPlayerToServer: (json: string) => void; + onDisconnect: () => void; +} + export class GameServer extends CommandReceiver { private objects!: PhysicalContainer; private players!: PlayerContainer; @@ -43,6 +53,7 @@ export class GameServer extends CommandReceiver { private initialize() { const previousPlayers = this.players; + this.releaseJoinedSockets(); this.objects = new PhysicalContainer(); createWorld(this.objects); this.objects.initialize(); @@ -80,30 +91,9 @@ export class GameServer extends CommandReceiver { this.initialize(); io.on('connection', (socket: Socket) => { - socket.on(TransportEvents.PlayerJoining, (playerInfo: PlayerInformation) => { - try { - const player = this.players.createPlayer(playerInfo, socket); - socket.on(TransportEvents.PlayerToServer, (json: string) => { - try { - const commands: Array = deserialize(json); - commands.forEach((c) => player.handleCommand(c)); - } catch (e) { - console.error('Error while processing command', e); - } - }); - - this.sendServerStateUpdate(); - - socket.on('disconnect', () => { - player.destroy(); - this.players.deletePlayer(player); - this.sendServerStateUpdate(); - }); - } catch (e) { - console.error('Failed to register joining player; disconnecting socket', e); - socket.disconnect(); - } - }); + socket.on(TransportEvents.PlayerJoining, (playerInfo: PlayerInformation) => + this.handleJoin(socket, playerInfo), + ); socket.on(TransportEvents.SubscribeForServerInfoUpdates, () => { socket.join(gameStateSubscribedRoom); @@ -111,6 +101,133 @@ export class GameServer extends CommandReceiver { }); } + private readonly joinedSockets = new Map(); + + private handleJoin(socket: Socket, playerInfo: PlayerInformation) { + if (this.joinedSockets.has(socket)) { + socket.emit(TransportEvents.JoinRejected, JoinRejectionReason.AlreadyJoined); + return; + } + + if (this.isInEndGame) { + // The round is already over and the world is about to be rebuilt; joining + // now drops the player into an arena running in slow motion with no + // end-of-round card. + socket.emit(TransportEvents.JoinRejected, JoinRejectionReason.RoundEnding); + return; + } + + if (this.players.isFull) { + socket.emit(TransportEvents.JoinRejected, JoinRejectionReason.ServerFull); + return; + } + + let player: Player; + try { + player = this.players.createPlayer(playerInfo, socket); + } catch (e) { + console.error('Failed to register joining player', e); + socket.emit( + TransportEvents.JoinRejected, + e instanceof ServerFullError + ? JoinRejectionReason.ServerFull + : JoinRejectionReason.InvalidRequest, + ); + socket.disconnect(); + return; + } + + const onPlayerToServer = (json: string) => { + try { + if (typeof json !== 'string' || json.length > settings.maxInboundMessageBytes) { + return; + } + if (!this.allowInboundMessage(socket)) { + return; + } + const commands: Array = deserialize(json); + if (!Array.isArray(commands)) { + return; + } + commands.forEach((c) => player.handleCommand(c)); + } catch (e) { + console.error('Error while processing command', e); + } + }; + + const onDisconnect = () => { + const record = this.joinedSockets.get(socket); + this.joinedSockets.delete(socket); + // The bucket is keyed by the Socket object, so leaving the entry behind + // pins the socket for the rest of the round. + this.inboundBudget.delete(socket); + if (!record) { + return; + } + record.player.detachFromSocket(); + const { kills, deaths } = player.scoreSnapshot; + // Hold the score briefly so a client whose transport blipped can rejoin as + // itself rather than as a blank slate. + this.players.reserveScore( + player.reconnectToken, + player.team, + kills, + deaths, + Date.now(), + ); + player.destroy(); + this.players.deletePlayer(player); + this.sendServerStateUpdate(); + }; + + socket.on(TransportEvents.PlayerToServer, onPlayerToServer); + socket.on('disconnect', onDisconnect); + this.joinedSockets.set(socket, { player, onPlayerToServer, onDisconnect }); + + player.reconnectToken = this.players.issueToken(); + socket.emit(TransportEvents.PlayerJoined, player.reconnectToken); + + this.sendServerStateUpdate(); + } + + // Cheap per-socket token bucket, sized well above what a legitimate client + // produces so it only ever trips on a flood. + private readonly inboundBudget = new Map(); + private droppedInboundMessages = 0; + private allowInboundMessage(socket: Socket): boolean { + const nowMs = Date.now(); + const burst = settings.maxInboundMessageBurst; + const perSecond = settings.maxInboundMessagesPerSecond; + + const budget = this.inboundBudget.get(socket) ?? { tokens: burst, lastMs: nowMs }; + budget.tokens = Math.min( + burst, + budget.tokens + ((nowMs - budget.lastMs) / 1000) * perSecond, + ); + budget.lastMs = nowMs; + this.inboundBudget.set(socket, budget); + + if (budget.tokens < 1) { + this.droppedInboundMessages++; + return false; + } + + budget.tokens -= 1; + return true; + } + + // Detach the per-join listeners of every socket still attached to the round + // that is ending, so a client that survives the restart can join the new one. + private releaseJoinedSockets() { + for (const [socket, record] of this.joinedSockets) { + socket.off(TransportEvents.PlayerToServer, record.onPlayerToServer); + socket.off('disconnect', record.onDisconnect); + record.player.detachFromSocket(); + } + this.joinedSockets.clear(); + this.inboundBudget.clear(); + } + private timeSinceLastServerStateUpdate = 0; public sendServerStateUpdate() { this.io @@ -225,6 +342,7 @@ export class GameServer extends CommandReceiver { this.players.step(scaledDelta); } + beginPropertyUpdateGeneration(); this.players.stepCommunication(delta); this.objects.resetRemoteCalls(); @@ -259,10 +377,18 @@ export class GameServer extends CommandReceiver { rtts.sort((a, b) => a - b); console.info( `Player RTT median ${rtts[Math.floor(rtts.length / 2)].toFixed(0)} ms ` + - `(min ${rtts[0].toFixed(0)}, max ${rtts[rtts.length - 1].toFixed(0)}, n=${rtts.length})`, + `(min ${rtts[0].toFixed(0)}, max ${rtts[rtts.length - 1].toFixed(0)}, n=${rtts.length})`, ); } + if (this.droppedInboundMessages > 0) { + console.warn( + `Rate limited ${this.droppedInboundMessages} inbound message(s) since last report — ` + + 'client input was discarded', + ); + this.droppedInboundMessages = 0; + } + if (this.saturatedFrames > 0) { console.warn( `Physics saturated on ${this.saturatedFrames} frame(s) since last report — shedding backlog`, diff --git a/backend/src/main.ts b/backend/src/main.ts index c492c7a..fa2ad92 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -2,7 +2,7 @@ import { Server as IoServer } from 'socket.io'; import express from 'express'; import { Server } from 'http'; import cors from 'cors'; -import { applyArrayPlugins, Random, serverInformationEndpoint } from 'shared'; +import { applyArrayPlugins, Random, serverInformationEndpoint, settings } from 'shared'; import minimist from 'minimist'; import { glMatrix } from 'gl-matrix'; import { GameServer } from './game-server'; @@ -24,6 +24,7 @@ const app = express(); const server = new Server(app); const io = new IoServer(server, { parser, + maxHttpBufferSize: settings.maxInboundMessageBytes, cors: { origin: true, credentials: true, diff --git a/backend/src/objects/character-physical.ts b/backend/src/objects/character-physical.ts index bdc8873..de85b1d 100644 --- a/backend/src/objects/character-physical.ts +++ b/backend/src/objects/character-physical.ts @@ -171,17 +171,25 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical }; } + private hasFiredSinceSpawn = false; private get isSpawnProtected(): boolean { return ( + !this.hasFiredSinceSpawn && this.timeAlive < settings.spawnDespawnTime + settings.spawnInvulnerabilityExtraSeconds ); } private hasGeneratedPoints = false; + private wasKilledInCombat = false; private getPoints(game: CommandReceiver) { if (!this.isAlive && !this.hasGeneratedPoints) { this.hasGeneratedPoints = true; + + if (!this.wasKilledInCombat) { + return; + } + const blue = this.team === CharacterTeam.blue ? 0 : settings.playerKillPoint; const red = this.team === CharacterTeam.red ? 0 : settings.playerKillPoint; @@ -260,7 +268,7 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical other.direction, mix(settings.deathImpulseMin, settings.deathImpulseMax, other.charge), ); - this.onDie(); + this.onDie(true); other.originator.addKill(this.name, other.charge); } else { other.originator.registerHit(other.charge); @@ -268,7 +276,7 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical } } - public shootTowards(position: vec2, charge = 0) { + public shootTowards(position: vec2, charge = 0, catchUpSeconds = 0) { if ( !this.isAlive || this.timeSinceLastShot < settings.projectileCreationInterval || @@ -277,21 +285,31 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical return; } - this.timeSinceLastShot = 0; + const direction = vec2.subtract(vec2.create(), position, this.center); + if (vec2.length(direction) === 0) { + return; + } - const c = clamp01(charge); + this.timeSinceLastShot = 0; + this.hasFiredSinceSpawn = true; + + const requestedCharge = clamp01(charge); const desiredStrength = mix( settings.chargeShotStrengthMin, settings.chargeShotStrengthMax, - c, + requestedCharge, ); const strength = Math.min(desiredStrength, this.projectileStrength); this.projectileStrength -= strength; + const c = clamp01( + (strength - settings.chargeShotStrengthMin) / + (settings.chargeShotStrengthMax - settings.chargeShotStrengthMin), + ); + const radius = mix(settings.chargeShotRadiusMin, settings.chargeShotRadiusMax, c); const speed = mix(settings.chargeShotSpeedMin, settings.chargeShotSpeedMax, c); - const direction = vec2.subtract(vec2.create(), position, this.center); vec2.normalize(direction, direction); // Keep the unit direction before vec2.scale repurposes it as the velocity. const shotDirection = vec2.clone(direction); @@ -307,6 +325,10 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical c, ); this.container.addObject(projectile); + // Lag compensation: advance the shot by the time the command spent reaching + // us, so the shooter does not have to lead by their own latency as well as + // by the projectile's travel time. + projectile.fastForward(catchUpSeconds); if (c > 0) { vec2.scaleAndAdd( @@ -435,8 +457,6 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical ), (this.rightFoot.radius - oldRightFoot.radius) / deltaTime, ); - - this.animateScaling(1); } private step({ deltaTimeInSeconds, game }: StepCommand) { @@ -467,6 +487,7 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical if (this.hasJustBorn) { if ((this.timeSinceBorn += deltaTimeInSeconds) > settings.spawnDespawnTime) { this.hasJustBorn = false; + this.animateScaling(1); } else { this.animateScaling(this.timeSinceBorn / settings.spawnDespawnTime); } @@ -486,7 +507,7 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical this.projectileStrength = Math.min( settings.playerMaxStrength, this.projectileStrength + - settings.playerStrengthRegenerationPerSeconds * deltaTimeInSeconds, + settings.playerStrengthRegenerationPerSeconds * deltaTimeInSeconds, ); this.regenerateHealth(deltaTimeInSeconds); @@ -550,7 +571,8 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical } } - public onDie() { + public onDie(killedInCombat = false) { + this.wasKilledInCombat = killedInCombat; this.isDestroyed = true; this.remoteCall('onDie'); } diff --git a/backend/src/objects/circle-physical.ts b/backend/src/objects/circle-physical.ts index e1a6abf..037708f 100644 --- a/backend/src/objects/circle-physical.ts +++ b/backend/src/objects/circle-physical.ts @@ -39,7 +39,7 @@ export class CirclePhysical extends CommandReceiver implements Circle, DynamicPh ) { super(); this._boundingBox = new BoundingBox(); - this.recalculateBoundingBox(); + this.syncBoundingBox(); } public get boundingBox(): BoundingBoxBase { @@ -52,7 +52,7 @@ export class CirclePhysical extends CommandReceiver implements Circle, DynamicPh public set center(value: vec2) { this._center = value; - this.recalculateBoundingBox(); + this.syncBoundingBox(); } public onCollision(c: ReactToCollisionCommand) { @@ -69,14 +69,14 @@ export class CirclePhysical extends CommandReceiver implements Circle, DynamicPh public set radius(value: number) { this._radius = value; - this.recalculateBoundingBox(); + this.syncBoundingBox(); } public distance(target: vec2): number { return vec2.distance(target, this.center) - this.radius; } - private recalculateBoundingBox() { + public syncBoundingBox() { this._boundingBox.xMin = this.center.x - this._radius; this._boundingBox.xMax = this.center.x + this._radius; this._boundingBox.yMin = this.center.y - this._radius; @@ -121,6 +121,9 @@ export class CirclePhysical extends CommandReceiver implements Circle, DynamicPh }, ); + // The body has moved; re-register it where it actually is. + this.syncBoundingBox(); + return { hitObject: (hitObject as Physical | undefined)?.gameObject, velocity }; } diff --git a/backend/src/objects/lamp-physical.ts b/backend/src/objects/lamp-physical.ts index 591b17c..020c412 100644 --- a/backend/src/objects/lamp-physical.ts +++ b/backend/src/objects/lamp-physical.ts @@ -33,6 +33,8 @@ export class LampPhysical extends LampBase implements StaticPhysical { } public queueSetLight(color: vec3, lightness: number) { + this.color = vec3.clone(color); + this.lightness = lightness; this.remoteCall('setLight', color, lightness); } diff --git a/backend/src/objects/planet-physical.ts b/backend/src/objects/planet-physical.ts index 4305b18..d6ea396 100644 --- a/backend/src/objects/planet-physical.ts +++ b/backend/src/objects/planet-physical.ts @@ -69,7 +69,7 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical { const sizeClass = clamp01( (this.radius - settings.planetMinReferenceRadius) / - (settings.planetMaxReferenceRadius - settings.planetMinReferenceRadius), + (settings.planetMaxReferenceRadius - settings.planetMinReferenceRadius), ); this.sizePointMultiplier = mix(1, settings.planetSizePointMultiplierMax, sizeClass); @@ -222,8 +222,25 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical { } } + // hysteresis + private get flipTeam(): CharacterTeam { + const control = this.ownership - 0.5; + const enter = settings.planetControlThreshold + settings.planetFlipHysteresis; + + if (control > enter) { + return CharacterTeam.red; + } + if (control < -enter) { + return CharacterTeam.blue; + } + if (Math.abs(control) < settings.planetControlThreshold) { + return CharacterTeam.neutral; + } + return this.lastTeam; + } + private detectFlip(game: CommandReceiver) { - const currentTeam = this.team; + const currentTeam = this.flipTeam; if (currentTeam === this.lastTeam) { return; } diff --git a/backend/src/objects/projectile-physical.ts b/backend/src/objects/projectile-physical.ts index 8a967f1..1de3872 100644 --- a/backend/src/objects/projectile-physical.ts +++ b/backend/src/objects/projectile-physical.ts @@ -73,15 +73,38 @@ export class ProjectilePhysical extends ProjectileBase implements DynamicPhysica vec2.normalize(vec2.create(), this.velocity), 10, ); - while (wasCollision) { + // Bounded: each pass advances by `delta`, but a degenerate direction would + // otherwise spin here forever inside the physics tick. + let passes = 0; + while (wasCollision && passes++ < 32) { const intersecting = this.container .findIntersecting(this.boundingBox) .filter((g) => g instanceof CharacterPhysical && g.team === this.team); const { hitSurface } = marchCircle(this.object, delta, intersecting, true); wasCollision = hitSurface; + this.object.syncBoundingBox(); } vec2.add(this.center, this.center, delta); vec2.add(this.center, this.center, delta); + this.object.syncBoundingBox(); + } + + /** + * Lag compensation. Runs the projectile forward by the time its command spent + * travelling to the server, so a shot lands where the shooter aimed rather + * than forcing them to lead by their own latency on top of the projectile's + * travel time. Capped by settings.maxProjectileCatchUpSeconds so a bad or + * forged timestamp cannot spawn a shot arbitrarily far downrange. + */ + public fastForward(seconds: number) { + const step = settings.targetPhysicsDeltaTimeInSeconds; + let remaining = Math.min(Math.max(seconds, 0), settings.maxProjectileCatchUpSeconds); + + while (remaining > 0 && this.isAlive) { + const delta = Math.min(step, remaining); + this.advance(delta); + remaining -= delta; + } } public get boundingBox(): ImmutableBoundingBox { @@ -123,6 +146,10 @@ export class ProjectilePhysical extends ProjectileBase implements DynamicPhysica } private handleStep({ deltaTimeInSeconds }: StepCommand) { + this.advance(deltaTimeInSeconds); + } + + private advance(deltaTimeInSeconds: number) { super.step(deltaTimeInSeconds); if (this.strength <= 0) { diff --git a/backend/src/players/player-base.ts b/backend/src/players/player-base.ts index 6de34cd..c69e7ae 100644 --- a/backend/src/players/player-base.ts +++ b/backend/src/players/player-base.ts @@ -6,6 +6,7 @@ import { CharacterTeam, Random, settings, + sanitizeName, } from 'shared'; import { PhysicalContainer } from '../physics/containers/physical-container'; import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle'; @@ -14,6 +15,8 @@ import { CharacterPhysical } from '../objects/character-physical'; import { PlanetPhysical } from '../objects/planet-physical'; import { PlayerContainer } from './player-container'; +const maximumNameLength = 40; + export abstract class PlayerBase extends CommandReceiver { public character?: CharacterPhysical | null; public center: vec2 = vec2.create(); @@ -32,7 +35,12 @@ export abstract class PlayerBase extends CommandReceiver { protected createCharacter() { this.character = new CharacterPhysical( - this.playerInfo.name.slice(0, 20), + // Coerced, not just truncated. `.slice()` also exists on arrays, so a + // name that is an array passed straight through, was serialized into + // CreateObjects, and was revived as a class on every peer that received + // it — throwing inside the deserializer's reviver and killing that + // client's whole message batch. + sanitizeName(this.playerInfo.name, maximumNameLength), this.sumKills, this.sumDeaths, this.team, @@ -72,10 +80,17 @@ export abstract class PlayerBase extends CommandReceiver { return vec2.clone(Random.choose(safe.length ? safe : candidates)!.center); } + // Seed a reconnecting player with the score it held before the drop, so a + // network blip costs you your character but not your match. + public restoreScore(kills: number, deaths: number) { + this.sumKills = kills; + this.sumDeaths = deaths; + } + protected findEmptyPositionForPlayer(preferredCenter: vec2): vec2 { let rotation = 0; let radius = 0; - for (;;) { + for (let attempt = 0; attempt < 512; attempt++) { const playerPosition = vec2.fromValues( radius * Math.cos(rotation) + preferredCenter.x, radius * Math.sin(rotation) + preferredCenter.y, @@ -96,6 +111,8 @@ export abstract class PlayerBase extends CommandReceiver { rotation += Math.PI / 8; radius += 30; } + + return vec2.clone(preferredCenter); } public destroy() { diff --git a/backend/src/players/player-container.ts b/backend/src/players/player-container.ts index 01a1c96..1df8720 100644 --- a/backend/src/players/player-container.ts +++ b/backend/src/players/player-container.ts @@ -4,10 +4,22 @@ import { PhysicalContainer } from '../physics/containers/physical-container'; import { NPC } from './npc'; import { Player } from './player'; import { PlayerBase } from './player-base'; +import { ServerFullError } from './server-full-error'; +import { randomUUID } from 'node:crypto'; + +// Score held for a player whose socket dropped, so a reconnect inside the grace +// window resumes the match instead of starting from zero. +interface ReservedScore { + team: CharacterTeam; + kills: number; + deaths: number; + expiresAtMs: number; +} export class PlayerContainer { private _players: Array = []; private _npcs: Array = []; + private reservedScores = new Map(); constructor( private readonly objects: PhysicalContainer, @@ -31,11 +43,15 @@ export class PlayerContainer { } public createPlayer(playerInfo: PlayerInformation, socket: Socket): Player { - if (this._players.length === this.playerMaxCount) { - throw new Error('Too many players'); + if (this._players.length >= this.playerMaxCount) { + throw new ServerFullError(); } - const team = this.getTeamOfNextPlayer(); + const reserved = this.claimReservedScore(playerInfo.reconnectToken); + const team = reserved ? reserved.team : this.getTeamOfNextPlayer(); + + const player = new Player(playerInfo, this, this.objects, team, socket); + let npcToReplace = this._npcs.find((n) => n.team === team); if (!npcToReplace) { npcToReplace = this._npcs.find((n) => n.team !== team); @@ -43,12 +59,63 @@ export class PlayerContainer { npcToReplace?.destroy(); this._npcs = this._npcs.filter((n) => n !== npcToReplace); - const player = new Player(playerInfo, this, this.objects, team, socket); this._players.push(player); + if (reserved) { + player.restoreScore(reserved.kills, reserved.deaths); + } + return player; } + /** + * A fresh token for a joining player. Handed to the client immediately; it + * only becomes redeemable once the player actually drops (see reserveScore). + */ + public issueToken(): string { + return randomUUID(); + } + + /** + * Hold a dropped player's team and score against its token for the grace + * window, so a client whose transport blipped rejoins as itself. + */ + public reserveScore( + token: string, + team: CharacterTeam, + kills: number, + deaths: number, + nowMs: number, + ) { + this.expireReservations(nowMs); + this.reservedScores.set(token, { + team, + kills, + deaths, + expiresAtMs: nowMs + settings.reconnectGraceSeconds * 1000, + }); + } + + private claimReservedScore(token?: string): ReservedScore | undefined { + if (!token || typeof token !== 'string') { + return undefined; + } + const reserved = this.reservedScores.get(token); + if (!reserved) { + return undefined; + } + this.reservedScores.delete(token); + return reserved; + } + + private expireReservations(nowMs: number) { + for (const [token, reserved] of this.reservedScores) { + if (reserved.expiresAtMs <= nowMs) { + this.reservedScores.delete(token); + } + } + } + public get players(): Array { return [...this._players, ...this._npcs]; } @@ -57,6 +124,10 @@ export class PlayerContainer { return this._players.length; } + public get isFull(): boolean { + return this._players.length >= this.playerMaxCount; + } + // Measured round-trip times (ms) of the real connected players, for // server-side latency stats. NPCs have no socket and are excluded. public get connectedPlayerRttsMs(): Array { @@ -96,7 +167,13 @@ export class PlayerContainer { } public deletePlayer(player: Player) { + const had = this._players.includes(player); this._players = this._players.filter((p) => p !== player); - this.createNPCs(); + // Only refill bots if this player was actually ours. A socket left over from + // a previous round reports its disconnect against the new container, and + // topping up on that would over-fill the roster. + if (had) { + this.createNPCs(); + } } } diff --git a/backend/src/players/player.ts b/backend/src/players/player.ts index 923be65..459a88e 100644 --- a/backend/src/players/player.ts +++ b/backend/src/players/player.ts @@ -25,16 +25,30 @@ import { PrimaryActionCommand, LeapActionCommand, InputAcknowledgement, + ClientHeartbeatCommand, + finiteInRange, + finiteVec2, + isFiniteNumber, } from 'shared'; import { Socket } from 'socket.io'; import { BoundingBox } from '../physics/bounding-boxes/bounding-box'; import { PhysicalContainer } from '../physics/containers/physical-container'; import { PlayerContainer } from './player-container'; import { PlayerBase } from './player-base'; +import { CharacterPhysical } from '../objects/character-physical'; // How often the server pings each client to measure round-trip time. const pingIntervalSeconds = 1; +// Aspect ratio is used to size the streamed view area; a zero, negative or +// non-finite value collapses it and the player stops receiving the world. +const minimumAspectRatio = 0.2; +const maximumAspectRatio = 8; + +// How much unflushed data on the socket counts as "this client is not keeping +// up". See sendQueuedCommandsToClient. +const maximumBufferedBytes = settings.maxBufferedBytesPerClient; + export class Player extends PlayerBase { // default, until the clients sends its real value private aspectRatio: number = 16 / 9; @@ -42,34 +56,98 @@ export class Player extends PlayerBase { private timeSinceLastMessage = 0; private objectsPreviouslyInViewArea: Array = []; private lastInputClientTimeMs = 0; + private lastInputReceiptMs = 0; private lastLeapClientTimeMs = 0; - // Measured round-trip time to this client (ms) — the latency primitive that - // lag compensation, a latency HUD, and adaptive interpolation build on. + // Token this session was issued at join. On a drop it is what the held score + // is filed under, and what the returning client presents to reclaim it. + public reconnectToken = ''; + + // Measured round-trip time to this client (ms) — drives projectile lag + // compensation as well as the server-side latency stats. public rttMs = 0; private timeSinceLastPing = 0; private lastPingSentMs = 0; + // Nonce of the ping currently awaiting a reply, cleared once answered. Without + // it a Pong was just a bare event: any client could send a stream of them, or + // answer a ping it had sat on for a minute, and the RTT it produced feeds + // projectile lag compensation. + private pendingPingNonce = 0; + private nextPingNonce = 1; + // Nothing below trusts a field it was handed. Every one of these arrives + // straight from `deserialize`, which is a bare JSON.parse — and JSON carries + // Infinity happily as `1e999`. A single non-finite number reaching the + // simulation is unrecoverable: it spreads through the shared SDF and leaves a + // character that never dies and never respawns. protected commandExecutors: CommandExecutors = { - [SetAspectRatioActionCommand.type]: (v: SetAspectRatioActionCommand) => - (this.aspectRatio = v.aspectRatio), + [SetAspectRatioActionCommand.type]: (v: SetAspectRatioActionCommand) => { + this.aspectRatio = finiteInRange( + v.aspectRatio, + minimumAspectRatio, + maximumAspectRatio, + this.aspectRatio, + ); + }, [MoveActionCommand.type]: (c: MoveActionCommand) => { - // Remember how far into this client's input timeline we've consumed, to - // echo back for client-side prediction reconciliation. - this.lastInputClientTimeMs = c.clientTimeMs; - this.character?.handleMovementAction(c); + const direction = finiteVec2(c.direction, 1); + if (!direction) { + return; + } + this.observeClientTime(c.clientTimeMs); + this.character?.handleMovementAction(new MoveActionCommand(direction, 0)); + }, + [PrimaryActionCommand.type]: (c: PrimaryActionCommand) => { + const position = finiteVec2(c.position, settings.maxClientPositionMagnitude); + if (!position) { + return; + } + this.observeClientTime(c.clientTimeMs); + this.character?.shootTowards( + position, + finiteInRange(c.charge, 0, 1, 0), + this.catchUpSeconds, + ); }, - [PrimaryActionCommand.type]: (c: PrimaryActionCommand) => - this.character?.shootTowards(c.position, c.charge), [LeapActionCommand.type]: (c: LeapActionCommand) => { + if (!isFiniteNumber(c.clientTimeMs)) { + return; + } // Record receipt (whether or not leap() accepts it): either way its effect // on bodyVelocity is now reflected in the streamed launch momentum, so the // predictor must stop replaying this leap. this.lastLeapClientTimeMs = c.clientTimeMs; + this.observeClientTime(c.clientTimeMs); this.character?.leap(); }, + // Closes every batch. Movement is only sent when it changes, so a held key + // used to freeze the acknowledged input time — and with it the window the + // client's predictor is allowed to replay. This advances the acknowledgement + // at the client's send rate regardless of whether the input changed. + [ClientHeartbeatCommand.type]: (c: ClientHeartbeatCommand) => + this.observeClientTime(c.clientTimeMs), }; + // The newest client-clock instant whose commands have all been applied, and + // the server-clock instant it arrived at. The client's predictor needs both: + // the first tells it which of its inputs are already reflected in the + // snapshot, the second how stale that answer was by the time the snapshot was + // taken (see InputAcknowledgement.ackAgeMs). + private observeClientTime(clientTimeMs: number) { + if (isFiniteNumber(clientTimeMs) && clientTimeMs > this.lastInputClientTimeMs) { + this.lastInputClientTimeMs = clientTimeMs; + this.lastInputReceiptMs = performance.now(); + } + } + + // How long a command spent reaching us, for projectile lag compensation. + // Deliberately derived from the measured RTT and NOT from the client's own + // timestamp, so a forged stamp cannot buy extra compensation — which is why it + // takes no argument. ProjectilePhysical.fastForward caps the result. + private get catchUpSeconds(): number { + return Math.max(0, this.rttMs / 2 / 1000); + } + constructor( playerInfo: PlayerInformation, playerContainer: PlayerContainer, @@ -84,11 +162,29 @@ export class Player extends PlayerBase { // The client already echoes a Pong for every Ping (see game.ts). Only one // ping is ever in flight, so RTT is simply now − send-time; no payload // needed and no client change required. - this.socket.on(TransportEvents.Pong, () => { - if (this.lastPingSentMs > 0) { - this.rttMs = performance.now() - this.lastPingSentMs; - } - }); + this.socket.on(TransportEvents.Pong, this.onPong); + } + + // Only the outstanding ping counts, and only once: a stale, duplicated or + // invented reply is ignored. A client can still sit on the live ping to + // inflate its measured RTT, so the result is clamped — and the next ping + // retires the nonce, bounding the stall at one ping interval. + private readonly onPong = (nonce: unknown) => { + if (this.pendingPingNonce === 0 || nonce !== this.pendingPingNonce) { + return; + } + this.pendingPingNonce = 0; + this.rttMs = Math.min( + performance.now() - this.lastPingSentMs, + settings.maxMeasuredRttMs, + ); + }; + + // Fix 7a: the Pong listener outlives the round it belongs to unless it is + // taken off explicitly — the socket survives a restart, so a retired Player + // would stay reachable (and keep updating) through it. + public detachFromSocket() { + this.socket.off(TransportEvents.Pong, this.onPong); } protected createCharacter() { @@ -103,6 +199,11 @@ export class Player extends PlayerBase { this.winnerTeam = winnerTeam; } + // The corpse a projectile may still credit a kill to. Kept so a shot that was + // already in flight when we died is not thrown away: killCount is re-read at + // respawn instead of being snapshotted the instant the body died. + private dyingCharacter?: CharacterPhysical | null; + public step(deltaTimeInSeconds: number) { if (this.character) { this.center = this.character?.center; @@ -111,11 +212,16 @@ export class Player extends PlayerBase { this.sumDeaths++; this.sumKills = this.character.killCount; + this.dyingCharacter = this.character; this.character = null; this.timeUntilRespawn = settings.playerDiedTimeout; } } else { if ((this.timeUntilRespawn -= deltaTimeInSeconds) < 0) { + if (this.dyingCharacter) { + this.sumKills = Math.max(this.sumKills, this.dyingCharacter.killCount); + this.dyingCharacter = null; + } this.createCharacter(); this.center = this.character!.center; } @@ -164,7 +270,7 @@ export class Player extends PlayerBase { this.queueCommandSend( new PropertyUpdatesForObjects( this.objectsPreviouslyInViewArea - .map((o) => o.getPropertyUpdates()) + .map((o) => o.getPropertyUpdatesForFrame()) .filter((u) => u) as Array, performance.now() / 1000, ), @@ -179,6 +285,9 @@ export class Player extends PlayerBase { this.lastInputClientTimeMs, this.character.launchMomentum, this.lastLeapClientTimeMs, + this.lastInputReceiptMs > 0 + ? Math.max(0, performance.now() - this.lastInputReceiptMs) + : 0, ), ); } @@ -212,7 +321,8 @@ export class Player extends PlayerBase { if ((this.timeSinceLastPing += deltaTime) > pingIntervalSeconds) { this.timeSinceLastPing = 0; this.lastPingSentMs = performance.now(); - this.socket.emit(TransportEvents.Ping); + this.pendingPingNonce = this.nextPingNonce++; + this.socket.emit(TransportEvents.Ping, this.pendingPingNonce); } if ((this.timeSinceLastMessage += deltaTime) > settings.updateMessageInterval) { @@ -223,11 +333,63 @@ export class Player extends PlayerBase { } } + // Bytes written to this client's socket but not yet flushed to the network. + // engine.io's own Socket exposes no such number — it lives on the underlying + // ws socket, and only the websocket transport has one — so reading + // `socket.conn.bufferedAmount` (as this did) always produced undefined and the + // backpressure check below never once ran. + private get bufferedBytes(): number { + const conn = this.socket.conn as + | { transport?: { socket?: { bufferedAmount?: unknown } } } + | undefined; + const buffered = conn?.transport?.socket?.bufferedAmount; + return typeof buffered === 'number' ? buffered : 0; + } + public sendQueuedCommandsToClient() { + if (this.commandsToBeSent.length === 0) { + return; + } + + // A client that is not draining gets the cheap half of the snapshot shed: + // piling more state behind it only grows a backlog it will never catch up + // on, and the freshest property update is the only one that matters. + // + // Only the state that next tick regenerates in full may be dropped. Create + // and delete are one-shot: handleViewAreaUpdate has ALREADY advanced + // objectsPreviouslyInViewArea by the time this runs, so dropping them leaves + // this client permanently missing objects it was never told about and + // holding ghosts it was never told to remove. Announcements and the end-game + // card are one-shot for the same reason. They are small; they go out even + // while backed up. + if (this.bufferedBytes > maximumBufferedBytes) { + // Matched by command type rather than `instanceof`, the same way every + // other dispatch in the codebase does it — the identity of a class from a + // bundled shared module is not something to depend on. + const sheddable: ReadonlyArray = [ + PropertyUpdatesForObjects.type, + UpdateMinimap.type, + ]; + this.commandsToBeSent = this.commandsToBeSent.filter( + (c) => !sheddable.includes(c.type), + ); + if (this.commandsToBeSent.length === 0) { + return; + } + } + this.socket.emit(TransportEvents.ServerToPlayer, serialize(this.commandsToBeSent)); this.commandsToBeSent = []; } + /** Score to hold for this player if it reconnects inside the grace window. */ + public get scoreSnapshot(): { kills: number; deaths: number } { + return { + kills: Math.max(this.sumKills, this.character?.killCount ?? 0), + deaths: this.sumDeaths, + }; + } + private handleAnnouncements() { let announcement = ''; if (this.winnerTeam) { diff --git a/backend/src/players/server-full-error.ts b/backend/src/players/server-full-error.ts new file mode 100644 index 0000000..720e5b3 --- /dev/null +++ b/backend/src/players/server-full-error.ts @@ -0,0 +1,5 @@ +export class ServerFullError extends Error { + constructor() { + super('Too many players'); + } +} diff --git a/frontend/src/index.ts b/frontend/src/index.ts index 00f04c0..5dad0b3 100644 --- a/frontend/src/index.ts +++ b/frontend/src/index.ts @@ -1,5 +1,6 @@ import { glMatrix } from 'gl-matrix'; import { + applyArrayPlugins, LampBase, overrideDeserialization, PlanetBase, @@ -29,6 +30,7 @@ import { PlanetView } from './scripts/objects/types/planet-view'; import { ProjectileView } from './scripts/objects/types/projectile-view'; glMatrix.setMatrixArrayType(Array); +applyArrayPlugins(); overrideDeserialization(CharacterBase, CharacterView); overrideDeserialization(PlanetBase, PlanetView); @@ -53,6 +55,11 @@ const enableMusic = document.querySelector('#enable-music') as HTMLInputElement; const enableVibration = document.querySelector('#enable-vibration') as HTMLInputElement; const spinner = document.querySelector('#spinner-container') as HTMLElement; +const joinNotice = document.createElement('p'); +joinNotice.className = 'join-notice'; +joinNotice.style.display = 'none'; +joinGameForm.prepend(joinNotice); + const toggleSettings = () => { settings.className = settings.className === 'open' ? '' : 'open'; SoundHandler.play(Sounds.click); @@ -122,7 +129,7 @@ const main = async () => { }); window.onpopstate = () => game.destroy(); - for (;;) { + for (; ;) { show(spinner); hide(logoutButton, true); show(landingUI, true, 'flex'); @@ -150,6 +157,14 @@ const main = async () => { hide(spinner); show(logoutButton, true, 'block'); await gameOver; + + const reason = game.lastRejectionReason; + if (reason) { + joinNotice.innerText = Game.rejectionText(reason); + joinNotice.style.display = 'block'; + } else { + joinNotice.style.display = 'none'; + } } } catch (e) { console.error(e); diff --git a/frontend/src/main.scss b/frontend/src/main.scss index 3ab46d2..0009666 100644 --- a/frontend/src/main.scss +++ b/frontend/src/main.scss @@ -247,9 +247,8 @@ body { border-radius: 1000px; background-color: rgba(0, 0, 0, 0.35); - box-shadow: - inset 0 0 3px 0 rgba(0, 0, 0, 0.4), - 0 0 10px rgba(0, 0, 0, 0.3); + box-shadow: inset 0 0 3px 0 rgba(0, 0, 0, 0.4), + 0 0 10px rgba(0, 0, 0, 0.3); border: $border-width solid rgba(255, 255, 255, 0.25); z-index: 100; } @@ -937,4 +936,29 @@ body { opacity: 1; transform: translate(-50%, -50%) scale(1); } +} + +.connection-banner { + position: absolute; + top: 12px; + left: 50%; + transform: translateX(-50%); + padding: 6px 14px; + border-radius: 4px; + background: rgba(0, 0, 0, 0.62); + color: #fff; + font-size: 0.9rem; + letter-spacing: 0.02em; + pointer-events: none; + z-index: 20; +} + +.join-notice { + margin: 0 0 0.75rem; + padding: 0.5rem 0.75rem; + border-radius: 4px; + background: rgba(209, 86, 82, 0.18); + color: #f2c9c7; + font-size: 0.85rem; + text-align: center; } \ No newline at end of file diff --git a/frontend/src/scripts/commands/command-socket.ts b/frontend/src/scripts/commands/command-socket.ts index 830b19b..b7d1129 100644 --- a/frontend/src/scripts/commands/command-socket.ts +++ b/frontend/src/scripts/commands/command-socket.ts @@ -1,5 +1,21 @@ -import { Command, CommandReceiver, serialize, TransportEvents } from 'shared'; +import { + ClientHeartbeatCommand, + Command, + CommandReceiver, + serialize, + settings, + TransportEvents, +} from 'shared'; import { Socket } from 'socket.io-client'; +import { predictorNowMs } from '../helper/prediction/local-character-predictor'; + +const heartbeatIntervalMs = settings.clientSendInterval * 1000; + +// A frame that lands a hair short of the deadline still counts as due. At an +// exact ratio of frame rate to interval — 60 Hz against 30 Hz — whether the gap +// compares as reached is decided by floating-point noise, and the heartbeat +// slips a whole frame every third beat, drifting to ~21 Hz instead of 30. +const dueToleranceMs = 1; export class CommandSocket extends CommandReceiver { constructor(private readonly socket: Socket) { @@ -7,14 +23,35 @@ export class CommandSocket extends CommandReceiver { } private commandQueue: Array = []; + private lastSendMs = -Infinity; + protected defaultCommandExecutor(command: Command) { this.commandQueue.push(command); } public sendQueuedCommands() { - if (this.commandQueue.length > 0) { - this.socket.emit(TransportEvents.PlayerToServer, serialize(this.commandQueue)); - this.commandQueue = []; + const nowMs = predictorNowMs(); + + // Real input goes out on the frame it happens — that is the whole latency + // budget the player feels. An idle client still has to keep the server's + // input acknowledgement moving (a held key generates no fresh command), but + // that heartbeat is paced by the clock rather than by the frame rate. + // + // Sending once per rendered frame instead put every display above + // settings.maxInboundMessagesPerSecond permanently over the server's inbound + // allowance; once the burst was spent it silently discarded whole batches, + // and since movement commands are edge-triggered and never re-sent, a lost + // batch meant the direction change simply never happened. + if ( + this.commandQueue.length === 0 && + nowMs - this.lastSendMs < heartbeatIntervalMs - dueToleranceMs + ) { + return; } + this.lastSendMs = nowMs; + + this.commandQueue.push(new ClientHeartbeatCommand(Math.round(nowMs))); + this.socket.emit(TransportEvents.PlayerToServer, serialize(this.commandQueue)); + this.commandQueue = []; } } diff --git a/frontend/src/scripts/commands/keyboard-listener.ts b/frontend/src/scripts/commands/keyboard-listener.ts index 342d851..971a0c9 100644 --- a/frontend/src/scripts/commands/keyboard-listener.ts +++ b/frontend/src/scripts/commands/keyboard-listener.ts @@ -14,20 +14,26 @@ export class KeyboardListener extends CommandGenerator { } private keyDownListener = (event: KeyboardEvent) => { - const key = event.key.toLowerCase(); - // Space leaps (W / ArrowUp already cover walking up). Edge-triggered so a - // held key's auto-repeat doesn't spam leaps. - if ((key === ' ' || key === 'shift') && !this.keysDown.has(key)) { + const key = event.code; + if ( + (key === 'Space' || key === 'ShiftLeft' || key === 'ShiftRight') && + !this.keysDown.has(key) + ) { const clientTimeMs = localCharacterPredictor.recordLeap(); this.sendCommandToSubscribers(new LeapActionCommand(clientTimeMs)); } + + if (this.keysDown.has(key)) { + return; + } this.keysDown.add(key); this.generateCommands(); }; private keyUpListener = (event: KeyboardEvent) => { - this.keysDown.delete(event.key.toLowerCase()); - this.generateCommands(); + if (this.keysDown.delete(event.code)) { + this.generateCommands(); + } }; private blurListener = () => { @@ -36,10 +42,10 @@ export class KeyboardListener extends CommandGenerator { }; private generateCommands() { - const up = ~~(this.keysDown.has('w') || this.keysDown.has('arrowup')); - const down = ~~(this.keysDown.has('s') || this.keysDown.has('arrowdown')); - const left = ~~(this.keysDown.has('a') || this.keysDown.has('arrowleft')); - const right = ~~(this.keysDown.has('d') || this.keysDown.has('arrowright')); + const up = ~~(this.keysDown.has('KeyW') || this.keysDown.has('ArrowUp')); + const down = ~~(this.keysDown.has('KeyS') || this.keysDown.has('ArrowDown')); + const left = ~~(this.keysDown.has('KeyA') || this.keysDown.has('ArrowLeft')); + const right = ~~(this.keysDown.has('KeyD') || this.keysDown.has('ArrowRight')); const movement = vec2.fromValues(right - left, up - down); if (vec2.squaredLength(movement) > 0) { diff --git a/frontend/src/scripts/commands/mouse-listener.ts b/frontend/src/scripts/commands/mouse-listener.ts index c4bd30d..cff0be2 100644 --- a/frontend/src/scripts/commands/mouse-listener.ts +++ b/frontend/src/scripts/commands/mouse-listener.ts @@ -2,6 +2,7 @@ import { vec2 } from 'gl-matrix'; import { CommandGenerator, PrimaryActionCommand, holdDurationToCharge } from 'shared'; import { Game } from '../game'; import { ChargeIndicator } from '../charge-indicator'; +import { predictorNowMs } from '../helper/prediction/local-character-predictor'; import { Pointer } from '../helper/pointer'; export class MouseListener extends CommandGenerator { @@ -16,9 +17,13 @@ export class MouseListener extends CommandGenerator { super(); target.addEventListener('mousedown', this.mouseDownListener); - target.addEventListener('mouseup', this.mouseUpListener); target.addEventListener('mousemove', this.mouseMoveListener); target.addEventListener('contextmenu', this.contextMenuListener); + // Release is watched on the window, not the canvas: a charge released over + // the settings gear, the fullscreen icon, or outside the window otherwise + // never fired the shot and left the charge ring up until the next press. + window.addEventListener('mouseup', this.mouseUpListener); + window.addEventListener('blur', this.cancelPrimary); } // Only the screen position is stored; it is reprojected to world space each @@ -46,10 +51,23 @@ export class MouseListener extends CommandGenerator { const charge = holdDurationToCharge((performance.now() - this.primaryDownAt) / 1000); this.primaryDownAt = null; this.sendCommandToSubscribers( - new PrimaryActionCommand(this.positionFromEvent(event), charge), + new PrimaryActionCommand( + this.positionFromEvent(event), + charge, + Math.round(predictorNowMs()), + ), ); }; + // Losing the window mid-charge cannot produce a meaningful aim point, so drop + // the charge rather than leaving the indicator running. + private cancelPrimary = () => { + if (this.primaryDownAt !== null) { + this.primaryDownAt = null; + ChargeIndicator.end(); + } + }; + // Suppress the browser context menu on the canvas; right-click has no action. private contextMenuListener = (event: MouseEvent) => { event.preventDefault(); @@ -64,8 +82,9 @@ export class MouseListener extends CommandGenerator { public destroy() { ChargeIndicator.end(); this.target.removeEventListener('mousedown', this.mouseDownListener); - this.target.removeEventListener('mouseup', this.mouseUpListener); this.target.removeEventListener('mousemove', this.mouseMoveListener); this.target.removeEventListener('contextmenu', this.contextMenuListener); + window.removeEventListener('mouseup', this.mouseUpListener); + window.removeEventListener('blur', this.cancelPrimary); } } diff --git a/frontend/src/scripts/commands/touch-listener.ts b/frontend/src/scripts/commands/touch-listener.ts index ab91ce8..ae3a827 100644 --- a/frontend/src/scripts/commands/touch-listener.ts +++ b/frontend/src/scripts/commands/touch-listener.ts @@ -10,7 +10,10 @@ import { } from 'shared'; import { Game } from '../game'; import { ChargeIndicator } from '../charge-indicator'; -import { localCharacterPredictor } from '../helper/prediction/local-character-predictor'; +import { + localCharacterPredictor, + predictorNowMs, +} from '../helper/prediction/local-character-predictor'; export class TouchListener extends CommandGenerator { private static readonly deadZone = 8; @@ -24,6 +27,7 @@ export class TouchListener extends CommandGenerator { private isJoystickActive = false; private touchStartPosition!: vec2; private primaryDownAt: number | null = null; + private gestureTouchId: number | null = null; private fireButton: HTMLElement; private fireStrengthRing: HTMLElement; @@ -69,23 +73,43 @@ export class TouchListener extends CommandGenerator { target.addEventListener('touchstart', this.touchStartListener); target.addEventListener('touchmove', this.touchMoveListener); target.addEventListener('touchend', this.touchEndListener); + target.addEventListener('touchcancel', this.touchCancelListener); + } + + // The contact that started this gesture, or undefined if it is not in `list`. + private findGestureTouch(list: TouchList): Touch | undefined { + if (this.gestureTouchId === null) { + return undefined; + } + for (let i = 0; i < list.length; i++) { + if (list[i].identifier === this.gestureTouchId) { + return list[i]; + } + } + return undefined; } private touchStartListener = (event: TouchEvent) => { event.preventDefault(); if (this.isJoystickActive) { const center = vec2.fromValues( - last(event.touches)!.clientX, - last(event.touches)!.clientY, + last(event.changedTouches)!.clientX, + last(event.changedTouches)!.clientY, ); this.sendCommandToSubscribers( - new PrimaryActionCommand(this.game.displayToWorldCoordinates(center)), + new PrimaryActionCommand( + this.game.displayToWorldCoordinates(center), + 0, + Math.round(predictorNowMs()), + ), ); } else { - this.touchStartPosition = vec2.fromValues( - event.touches[0].clientX, - event.touches[0].clientY, - ); + const touch = event.changedTouches[0]; + if (!touch) { + return; + } + this.gestureTouchId = touch.identifier; + this.touchStartPosition = vec2.fromValues(touch.clientX, touch.clientY); this.primaryDownAt = performance.now(); ChargeIndicator.begin(this.touchStartPosition.x, this.touchStartPosition.y); } @@ -94,10 +118,12 @@ export class TouchListener extends CommandGenerator { private touchMoveListener = (event: TouchEvent) => { event.preventDefault(); - const touchPosition = vec2.fromValues( - event.touches[0].clientX, - event.touches[0].clientY, - ); + const touch = this.findGestureTouch(event.touches); + if (!touch || !this.touchStartPosition) { + return; + } + + const touchPosition = vec2.fromValues(touch.clientX, touch.clientY); const delta = vec2.subtract(vec2.create(), touchPosition, this.touchStartPosition); vec2.scale(delta, delta, TouchListener.deltaScaling); @@ -133,6 +159,14 @@ export class TouchListener extends CommandGenerator { private touchEndListener = (event: TouchEvent) => { event.preventDefault(); + // Only the contact that owns the gesture ends it — not whichever finger + // happens to lift, and not "the screen is now empty". + const touch = this.findGestureTouch(event.changedTouches); + if (!touch) { + return; + } + this.gestureTouchId = null; + if (!this.isJoystickActive) { ChargeIndicator.end(); const charge = @@ -140,20 +174,39 @@ export class TouchListener extends CommandGenerator { ? 0 : holdDurationToCharge((performance.now() - this.primaryDownAt) / 1000); this.primaryDownAt = null; - const center = vec2.fromValues( - event.changedTouches[0].clientX, - event.changedTouches[0].clientY, - ); + const center = vec2.fromValues(touch.clientX, touch.clientY); this.sendCommandToSubscribers( - new PrimaryActionCommand(this.game.displayToWorldCoordinates(center), charge), + new PrimaryActionCommand( + this.game.displayToWorldCoordinates(center), + charge, + Math.round(predictorNowMs()), + ), ); - } else if (event.touches.length === 0) { - this.isJoystickActive = false; - this.joystick.parentElement?.removeChild(this.joystick); - this.sendMove(vec2.create()); + } else { + this.releaseJoystick(); } }; + // Also reached from touchcancel: a system gesture, an incoming call or a + // palm rejection would otherwise leave the stick latched at full tilt. + private touchCancelListener = (event: TouchEvent) => { + if (!this.findGestureTouch(event.changedTouches)) { + return; + } + this.gestureTouchId = null; + this.primaryDownAt = null; + ChargeIndicator.end(); + if (this.isJoystickActive) { + this.releaseJoystick(); + } + }; + + private releaseJoystick() { + this.isJoystickActive = false; + this.joystick.parentElement?.removeChild(this.joystick); + this.sendMove(vec2.create()); + } + private swallowTouch = (event: TouchEvent) => { event.preventDefault(); event.stopPropagation(); @@ -237,7 +290,9 @@ export class TouchListener extends CommandGenerator { direction, settings.touchAimRange, ); - this.sendCommandToSubscribers(new PrimaryActionCommand(aim, charge)); + this.sendCommandToSubscribers( + new PrimaryActionCommand(aim, charge, Math.round(predictorNowMs())), + ); }; public update(_deltaTimeInSeconds: number) { @@ -250,9 +305,8 @@ export class TouchListener extends CommandGenerator { const character = this.game.gameObjects.player; if (character) { - this.fireStrengthRing.style.background = `conic-gradient(rgba(255, 255, 255, 0.75) ${ - character.strengthFraction * 360 - }deg, transparent 0deg)`; + this.fireStrengthRing.style.background = `conic-gradient(rgba(255, 255, 255, 0.75) ${character.strengthFraction * 360 + }deg, transparent 0deg)`; } } @@ -261,6 +315,7 @@ export class TouchListener extends CommandGenerator { this.target.removeEventListener('touchstart', this.touchStartListener); this.target.removeEventListener('touchmove', this.touchMoveListener); this.target.removeEventListener('touchend', this.touchEndListener); + this.target.removeEventListener('touchcancel', this.touchCancelListener); this.fireButton.removeEventListener('touchstart', this.fireButtonDownListener); this.fireButton.removeEventListener('touchmove', this.fireButtonMoveListener); diff --git a/frontend/src/scripts/feedback-hud.ts b/frontend/src/scripts/feedback-hud.ts index adbf068..553a5a9 100644 --- a/frontend/src/scripts/feedback-hud.ts +++ b/frontend/src/scripts/feedback-hud.ts @@ -99,6 +99,18 @@ export abstract class FeedbackHud { this.elimination = undefined; } + /** + * Tear the HUD down entirely. Its root lives on document.body rather than the + * game overlay, so leaving a match while dead used to leave "Eliminated / + * Respawning…" painted over the landing page. + */ + public static reset(): void { + this.hideElimination(); + this.root?.parentElement?.removeChild(this.root); + this.root = undefined; + this.killfeed = undefined; + } + private static streakName(streak: number): string | undefined { switch (streak) { case 2: diff --git a/frontend/src/scripts/game.ts b/frontend/src/scripts/game.ts index 4b8678f..b63ad51 100644 --- a/frontend/src/scripts/game.ts +++ b/frontend/src/scripts/game.ts @@ -21,6 +21,7 @@ import { Command, settings, InputAcknowledgement, + JoinRejectionReason, } from 'shared'; import { io, Socket } from 'socket.io-client'; import { KeyboardListener } from './commands/keyboard-listener'; @@ -40,6 +41,7 @@ import { Tutorial } from './tutorial'; import { Scoreboard } from './scoreboard'; import { Minimap } from './minimap'; import { ScreenShake } from './screen-shake'; +import { FeedbackHud } from './feedback-hud'; export class Game extends CommandReceiver { public gameObjects = new GameObjectContainer(this); @@ -60,6 +62,11 @@ export class Game extends CommandReceiver { private keystoneArrow?: HTMLElement; private socketReceiver!: CommandSocket; private tutorial!: Tutorial; + // Issued by the server on join; presented on reconnect to reclaim this + // player's team and score instead of coming back as a blank slate. + private reconnectToken?: string; + private connectionBanner?: HTMLElement; + private rejectionReason?: JoinRejectionReason; constructor( private readonly playerDecision: PlayerDecision, @@ -110,19 +117,76 @@ export class Game extends CommandReceiver { this.socket.io.opts.transports = ['polling', 'websocket']; }); + // A transport drop is not the end of the match. Previously this tore the + // game down on the first `disconnect`, which stopped the render loop, which + // closed the socket — cancelling the reconnection the client is configured + // for before it could ever run, and dumping the player on the server list. this.socket.on('disconnect', () => { - if (!this.isBetweenGames) { - this.destroy(); + if (this.isBetweenGames) { + return; } + this.showConnectionBanner('Reconnecting…'); }); - this.socket.on(TransportEvents.Ping, () => { - this.socket.emit(TransportEvents.Pong); + this.socket.on('connect', () => { + if (this.isBetweenGames) { + return; + } + // A reconnect is a brand-new server-side connection that has never seen a + // join, so the join has to be re-sent or the client sits connected and + // invisible forever. + this.hideConnectionBanner(); + serverTimeline.reset(); + localCharacterPredictor.reset(); + // ...and it is a brand-new server-side *player*, whose view-area + // bookkeeping starts empty. Anything the dropped session was told about + // will never be retracted, so the old world has to go here. + this.gameObjects.reset(); + this.socket.emit(TransportEvents.PlayerJoining, { + ...this.playerDecision, + reconnectToken: this.reconnectToken, + }); + }); + + this.socket.io.on('reconnect_failed', () => { + this.showConnectionBanner('Connection lost'); + this.destroy(); + }); + + this.socket.on(TransportEvents.PlayerJoined, (token: string) => { + this.reconnectToken = typeof token === 'string' ? token : undefined; + this.hideConnectionBanner(); + }); + + this.socket.on(TransportEvents.JoinRejected, (reason: JoinRejectionReason) => { + this.rejectionReason = reason; + this.showConnectionBanner(Game.rejectionText(reason)); + this.destroy(); + }); + + // Echo the nonce: the server only accepts a reply that matches the ping + // still outstanding, so a stale or duplicated Pong cannot move its RTT. + this.socket.on(TransportEvents.Ping, (nonce: unknown) => { + this.socket.emit(TransportEvents.Pong, nonce); }); this.socket.on(TransportEvents.ServerToPlayer, (serializedCommands: string) => { - const commands: Array = deserialize(serializedCommands); - commands.forEach((c) => this.handleCommand(c)); + // One malformed object must not take down the message pump. deserialize + // revives classes by name from the payload, so a hostile or corrupt field + // can throw inside JSON.parse's reviver — and every later batch would be + // lost with it. + try { + const commands: Array = deserialize(serializedCommands); + commands.forEach((c) => { + try { + this.handleCommand(c); + } catch (e) { + console.error('Failed to apply a server command', e); + } + }); + } catch (e) { + console.error('Dropped an undecodable server message', e); + } }); this.socketReceiver = new CommandSocket(this.socket); @@ -138,9 +202,10 @@ export class Game extends CommandReceiver { this.touchListener.subscribe(this.socketReceiver); this.touchListener.subscribe(this.tutorial); + // The join is emitted from the socket's `connect` handler above, which fires + // for the first connection and for every reconnection alike — so one code + // path covers both, and a reconnect can never be left unjoined. this.isBetweenGames = false; - - this.socket.emit(TransportEvents.PlayerJoining, this.playerDecision); } protected defaultCommandExecutor(c: Command) { @@ -163,6 +228,7 @@ export class Game extends CommandReceiver { c.clientTimeMs, c.bodyVelocity, c.lastLeapClientTimeMs, + c.ackAgeMs, ), [GameEndCommand.type]: () => (this.isEnding = true), [UpdateMinimap.type]: (c: UpdateMinimap) => (this.lastMinimap = c), @@ -208,11 +274,49 @@ export class Game extends CommandReceiver { ); this.socket.close(); this.overlay.innerHTML = ''; + this.hideConnectionBanner(); + // The HUD root lives on document.body, not the overlay, so it has to be torn + // down explicitly or it stays painted over the landing page. + FeedbackHud.reset(); this.keyboardListener.destroy(); this.mouseListener.destroy(); this.touchListener.destroy(); } + /** Why the player was sent back to the server list, if they were. */ + public get lastRejectionReason(): JoinRejectionReason | undefined { + return this.rejectionReason; + } + + public static rejectionText(reason: JoinRejectionReason): string { + switch (reason) { + case JoinRejectionReason.ServerFull: + return 'That server is full — pick another'; + case JoinRejectionReason.RoundEnding: + return 'That round is just finishing — try again in a moment'; + case JoinRejectionReason.AlreadyJoined: + return 'Already joined on this connection'; + default: + return 'The server refused the connection'; + } + } + + private showConnectionBanner(text: string) { + if (!this.connectionBanner) { + this.connectionBanner = document.createElement('div'); + this.connectionBanner.className = 'connection-banner'; + this.overlay.appendChild(this.connectionBanner); + } + this.connectionBanner.innerText = text; + this.connectionBanner.style.display = 'block'; + } + + private hideConnectionBanner() { + if (this.connectionBanner) { + this.connectionBanner.style.display = 'none'; + } + } + public displayToWorldCoordinates(p: vec2): vec2 { return this.renderer?.displayToWorldCoordinates(p) ?? vec2.create(); } diff --git a/frontend/src/scripts/helper/prediction/local-character-predictor.ts b/frontend/src/scripts/helper/prediction/local-character-predictor.ts index 5d23c1c..5862347 100644 --- a/frontend/src/scripts/helper/prediction/local-character-predictor.ts +++ b/frontend/src/scripts/helper/prediction/local-character-predictor.ts @@ -25,10 +25,16 @@ export const setPredictorClockForTesting = (clock: () => number): void => { nowMs = clock; }; +// The same clock the predictor stamps input with. Anything that tells the server +// "this is how far my input timeline has got" must read it from here, or the +// acknowledgement comes back in a different time base than the replay window. +export const predictorNowMs = (): number => nowMs(); + // Don't replay more than this far back: if the last acknowledged input is older -// (a stall, or a backgrounded tab catching up) snap to the authoritative pose -// instead of grinding through hundreds of steps. -const maxReplayMs = 300; +// (a stall, or a backgrounded tab catching up) fall back to a shorter window +// instead of grinding through hundreds of steps. Comfortably covers the replay +// a 300 ms round trip needs; beyond that the body simply trails a little. +const maxReplayMs = 400; // Render-side easing of the correction the reconciliation produces each frame, // so a snapshot that disagrees with the prediction is smoothed out instead of @@ -60,15 +66,33 @@ export class LocalCharacterPredictor { private readonly world = new ClientCharacterWorld(); private authoritative?: { head: Circle; leftFoot: Circle; rightFoot: Circle }; - private lastAckClientTimeMs?: number; - // Wall-clock (client) time the latest authoritative snapshot was received. The - // replay predicts forward from HERE by the snapshot's age, so the local pose - // advances smoothly with real time between the 25 Hz snapshots. Anchoring to - // the last *acked input* time instead breaks when input is sent only on change - // (a held key sends nothing): that time freezes, the window pins to the - // maxReplayMs clamp, the replay displacement goes constant, and the pose - // stair-steps at the snapshot rate. - private authReceiptMs = 0; + // Client-clock instant the authoritative pose below belongs to, less one + // one-way trip — which is exactly the lead the local body should have, since + // the input the player is giving right now will not reach the server for that + // long. Replaying from here reproduces every input the server has not applied + // yet, so the body responds immediately at any latency. + // + // It is built from BOTH halves of the acknowledgement: the client-clock time + // of the newest input the server had applied, plus how long after that the + // snapshot was actually taken. Both halves are needed. + // + // Anchoring on the acknowledged input time ALONE beats against the send + // cadence: input is sent once per frame, so at snapshot time the newest input + // the server holds is 0..1 frame old depending on where the client's frames + // fell, and that age lands directly in the replay window. Measured on + // localhost it walked a sawtooth — 17, 10, 6, 1, 11, 6, 2, 12 ms — so the + // window jumped by up to a frame of travel every snapshot and the body + // stuttered at 25 Hz. ackAgeMs cancels it exactly. + // + // Anchoring on the snapshot's ARRIVAL is free of that beat, but it under- + // advances by a one-way trip, so the local body trails its own input at any + // real latency. This anchor degenerates to arrival on a zero-latency link and + // grows into full compensation as latency rises. + // + // A held key sends no fresh movement, which would freeze the acknowledgement + // altogether; that is fixed at the source, by closing every outgoing batch + // with a ClientHeartbeatCommand. + private replayAnchorMs?: number; // Authoritative launch momentum at the last snapshot — seeds each replay so a // leap/slingshot/recoil flight is reproduced and continuously corrected. private authoritativeBodyVelocity = vec2.create(); @@ -123,14 +147,13 @@ export class LocalCharacterPredictor { clientTimeMs: number, bodyVelocity: vec2, lastLeapClientTimeMs: number, + ackAgeMs = 0, ): void { // Inputs only advance the acknowledgement forward; the launch momentum and // leap boundary always adopt the latest authoritative values. - if ( - this.lastAckClientTimeMs === undefined || - clientTimeMs > this.lastAckClientTimeMs - ) { - this.lastAckClientTimeMs = clientTimeMs; + const anchor = clientTimeMs + Math.max(0, ackAgeMs); + if (this.replayAnchorMs === undefined || anchor > this.replayAnchorMs) { + this.replayAnchorMs = anchor; } vec2.set(this.authoritativeBodyVelocity, bodyVelocity[0], bodyVelocity[1]); this.lastLeapAckMs = lastLeapClientTimeMs; @@ -138,7 +161,6 @@ export class LocalCharacterPredictor { public setAuthoritative(head: Circle, leftFoot: Circle, rightFoot: Circle): void { this.authoritative = { head, leftFoot, rightFoot }; - this.authReceiptMs = Math.round(nowMs()); } // The player pressed leap; remember when, so the replay applies the same @@ -168,8 +190,7 @@ export class LocalCharacterPredictor { this.leapHistory = []; this.lastLeapAckMs = -Infinity; this.authoritative = undefined; - this.lastAckClientTimeMs = undefined; - this.authReceiptMs = 0; + this.replayAnchorMs = undefined; vec2.zero(this.authoritativeBodyVelocity); this.currentStrength = settings.playerMaxStrength; this.carriedPlanetId = undefined; @@ -179,7 +200,7 @@ export class LocalCharacterPredictor { } public get canPredict(): boolean { - return this.authoritative !== undefined && this.lastAckClientTimeMs !== undefined; + return this.authoritative !== undefined && this.replayAnchorMs !== undefined; } // During spawn-in and death the server freezes walking and only scales the @@ -232,11 +253,10 @@ export class LocalCharacterPredictor { private simulate(): CharacterMovementState { const auth = this.authoritative!; const now = Math.round(nowMs()); - // Predict forward from the latest snapshot by its age, clamped so a stall - // (or a backgrounded tab catching up) snaps instead of grinding hundreds of - // steps. This advances with wall-clock time even while a held key sends no - // fresh input, so the pose no longer pins to the 25 Hz snapshot cadence. - const startMs = Math.max(this.authReceiptMs, now - maxReplayMs); + // Replay every input the server has not confirmed yet: from the anchor up to + // now, clamped so a stall (or a backgrounded tab catching up) cannot grind + // through hundreds of steps. + const startMs = Math.min(now, Math.max(this.replayAnchorMs!, now - maxReplayMs)); const windowMs = Math.max(0, now - startMs); const steps = Math.floor(windowMs / stepMs); const remainderSeconds = (windowMs - steps * stepMs) / 1000; diff --git a/frontend/src/scripts/objects/game-object-container.ts b/frontend/src/scripts/objects/game-object-container.ts index 2b03836..2ee5507 100644 --- a/frontend/src/scripts/objects/game-object-container.ts +++ b/frontend/src/scripts/objects/game-object-container.ts @@ -15,6 +15,7 @@ import { UpdatePropertyCommand, } from 'shared'; import { BeforeDestroyCommand } from '../commands/types/before-destroy'; +import { RenderCommand } from '../commands/types/render'; import { StepCommand } from '../commands/types/step'; import { FeedbackHud } from '../feedback-hud'; import { Game } from '../game'; @@ -31,6 +32,19 @@ export class GameObjectContainer extends CommandReceiver { public camera: Camera = new Camera(this.game); private wasLocalPlayerAlive = false; + // Create and delete arrive on the wire, but positions are drawn + // interpolationDelaySeconds in the past — so applying them the instant a + // packet lands made every projectile pop into existence and hang at its muzzle + // for 100 ms, then disappear a few hundred units before its rendered impact. + // Objects are therefore admitted and retired on the render timeline instead: + // they exist (and accumulate interpolation frames) from the moment they + // arrive, but only draw once the cursor reaches the snapshot that introduced + // them, and keep drawing until it reaches the one that removed them. + private visibleFrom = new Map(); + private deleteAt = new Map(); + private awaitingCreateStamp: Array = []; + private awaitingDeleteStamp: Array = []; + protected commandExecutors: CommandExecutors = { [CreatePlayerCommand.type]: (c: CreatePlayerCommand) => { this.player = c.character as CharacterView; @@ -45,9 +59,17 @@ export class GameObjectContainer extends CommandReceiver { }, [CreateObjectsCommand.type]: (c: CreateObjectsCommand) => - c.objects.forEach((o) => this.addObject(o as GameObject)), + c.objects.forEach((o) => { + this.addObject(o as GameObject); + this.awaitingCreateStamp.push((o as GameObject).id); + }), [StepCommand.type]: (c: StepCommand) => { + // A batch carrying no property updates (a bare announcement, say) leaves + // entries unstamped; fall back to the newest known snapshot so nothing is + // stranded invisible. + this.stampPending(serverTimeline.snapshotTime); + this.retireDueObjects(); this.defaultCommandExecutor(c); // The local body is alive only while its object still exists (the server @@ -91,6 +113,9 @@ export class GameObjectContainer extends CommandReceiver { [PropertyUpdatesForObjects.type]: (c: PropertyUpdatesForObjects) => { serverTimeline.onSnapshot(c.timestamp); + // Create/delete precede the property updates inside a batch, so this is + // where the batch's server timestamp becomes known to them. + this.stampPending(c.timestamp); c.updates.forEach((u) => { u.updates.forEach((au) => this.objects.get(u.id)?.handleCommand(au)); if (this.player && u.id === this.player.id) { @@ -100,17 +125,36 @@ export class GameObjectContainer extends CommandReceiver { }, [DeleteObjectsCommand.type]: (c: DeleteObjectsCommand) => - c.ids.forEach((id: Id) => this.deleteObject(id)), + c.ids.forEach((id: Id) => this.awaitingDeleteStamp.push(id)), }; constructor(private game: Game) { super(); } - // The local player's world position, but only while the body is alive. On - // death the server deletes the character object (yet `player` keeps pointing - // at the now-stale view), so gate on the object still being present — otherwise - // the minimap would pin the "you" dot at the death spot for the whole respawn. + /** + * Forget everything the previous connection was told about. + * + * A reconnect is a brand-new server-side player whose view-area bookkeeping + * starts empty, so it re-announces what is in view NOW and never sends a + * delete for anything the dropped session held. Without this, every object + * that has since moved out of view — the old character included — stays in the + * map and on screen forever, as a frozen ghost. + * + * `player` is deliberately left pointing at the stale view: every read of it + * is gated on the object still being in `objects`, so clearing the map is what + * makes it inert, and the next CreatePlayerCommand replaces it. + */ + public reset() { + this.objects.forEach((o) => o.handleCommand(new BeforeDestroyCommand())); + this.objects.clear(); + this.visibleFrom.clear(); + this.deleteAt.clear(); + this.awaitingCreateStamp = []; + this.awaitingDeleteStamp = []; + this.wasLocalPlayerAlive = false; + } + public get localPlayerPosition(): vec2 | undefined { return this.player && this.objects.has(this.player.id) ? this.player.position @@ -128,10 +172,48 @@ export class GameObjectContainer extends CommandReceiver { } protected defaultCommandExecutor(c: Command) { - this.objects.forEach((o) => o.handleCommand(c)); + const isRender = c.type === RenderCommand.type; + this.objects.forEach((o) => { + // Stepping always runs, so an object that has not appeared yet is still + // accumulating interpolation frames and enters the scene already moving. + if (isRender && !this.isVisible(o.id)) { + return; + } + o.handleCommand(c); + }); this.camera.handleCommand(c); } + private isVisible(id: Id): boolean { + const from = this.visibleFrom.get(id); + return from === undefined || serverTimeline.renderTime >= from; + } + + private stampPending(timestamp: number) { + for (const id of this.awaitingCreateStamp) { + this.visibleFrom.set(id, timestamp); + } + this.awaitingCreateStamp = []; + + for (const id of this.awaitingDeleteStamp) { + this.deleteAt.set(id, timestamp); + } + this.awaitingDeleteStamp = []; + } + + private retireDueObjects() { + if (this.deleteAt.size === 0) { + return; + } + const renderTime = serverTimeline.renderTime; + for (const [id, at] of [...this.deleteAt]) { + if (renderTime >= at) { + this.deleteAt.delete(id); + this.deleteObject(id); + } + } + } + // Hand the local player's raw authoritative pose to the predictor (the // interpolated pose would already be ~100 ms stale). The three body parts // arrive together in one snapshot. @@ -168,5 +250,7 @@ export class GameObjectContainer extends CommandReceiver { const object = this.objects.get(id); object?.handleCommand(new BeforeDestroyCommand()); this.objects.delete(id); + this.visibleFrom.delete(id); + this.deleteAt.delete(id); } } diff --git a/frontend/src/scripts/sound-handler.ts b/frontend/src/scripts/sound-handler.ts index 81607ad..7346f64 100644 --- a/frontend/src/scripts/sound-handler.ts +++ b/frontend/src/scripts/sound-handler.ts @@ -57,13 +57,20 @@ export abstract class SoundHandler { return; } - const audio = - this.sounds[sound].currentTime > 0 - ? (this.sounds[sound].cloneNode(true) as HTMLAudioElement) - : this.sounds[sound]; + // Reuse the pooled element unless it is still mid-playback; only overlapping + // sounds need a clone. Testing `currentTime > 0` instead stayed true forever + // after the first play, so every later effect allocated a fresh element. + const pooled = this.sounds[sound]; + const isBusy = !pooled.paused && !pooled.ended; + const audio = isBusy ? (pooled.cloneNode(true) as HTMLAudioElement) : pooled; + if (!isBusy) { + audio.currentTime = 0; + } audio.volume = Math.max(0, Math.min(1, volume)); audio.playbackRate = playbackRate; - audio.play(); + // A rejected play() (autoplay policy, or a clone GC'd mid-play) is expected + // and must not surface as an unhandled rejection. + void audio.play().catch(() => undefined); } public static playAmbient() { diff --git a/package.json b/package.json index 3e123bd..8edb68a 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,6 @@ "test": "vitest run", "test:watch": "vitest", "init": "cd shared && npm install && cd ../frontend && npm install && cd ../backend && npm install", - "dev": "concurrently --kill-others-on-fail \"cd shared && npm dev\" \"cd backend && npm dev\" \"cd frontend && npm dev\"" + "dev": "concurrently --kill-others-on-fail --names shared,backend,frontend \"cd shared && npm run dev\" \"cd backend && npm run dev\" \"cd frontend && npm run dev\"" } } diff --git a/shared/src/commands/types/actions/client-heartbeat.ts b/shared/src/commands/types/actions/client-heartbeat.ts new file mode 100644 index 0000000..0724817 --- /dev/null +++ b/shared/src/commands/types/actions/client-heartbeat.ts @@ -0,0 +1,19 @@ +import { serializable } from '../../../serialization/serializable'; +import { Command } from '../../command'; + +// Appended to every client -> server batch. Movement is only transmitted when it +// changes, so on its own the server's "newest input I have applied" marker +// freezes whenever a key is simply held down — which pins the client predictor's +// replay window and stops reconciliation from covering the un-acknowledged +// input. This carries the client's wall-clock for the batch, so the marker keeps +// advancing at the send rate whether or not the input changed. +@serializable +export class ClientHeartbeatCommand extends Command { + public constructor(public readonly clientTimeMs: number = 0) { + super(); + } + + public toArray(): Array { + return [this.clientTimeMs]; + } +} diff --git a/shared/src/commands/types/actions/primary-action.ts b/shared/src/commands/types/actions/primary-action.ts index 54b92c9..32ec1b8 100644 --- a/shared/src/commands/types/actions/primary-action.ts +++ b/shared/src/commands/types/actions/primary-action.ts @@ -7,11 +7,17 @@ export class PrimaryActionCommand extends Command { public constructor( public readonly position: vec2, public readonly charge: number = 0, + // Client wall-clock at the moment the shot was released. Appended last so + // the existing wire order is unchanged. The server uses it (together with + // the measured RTT) to fast-forward the projectile by the time the command + // spent in flight, so the shooter does not have to lead by their own + // latency on top of the projectile's travel time. + public readonly clientTimeMs: number = 0, ) { super(); } public toArray(): Array { - return [this.position, this.charge]; + return [this.position, this.charge, this.clientTimeMs]; } } diff --git a/shared/src/commands/types/input-acknowledgement.ts b/shared/src/commands/types/input-acknowledgement.ts index 3f488e7..f11021b 100644 --- a/shared/src/commands/types/input-acknowledgement.ts +++ b/shared/src/commands/types/input-acknowledgement.ts @@ -18,11 +18,24 @@ export class InputAcknowledgement extends Command { // or before it is already reflected in bodyVelocity, so the predictor must // not replay it. public readonly lastLeapClientTimeMs: number, + // How long before this snapshot the acknowledged input arrived. The client + // sends at its frame rate, so by the time a snapshot is taken the newest + // input it has is already 0..1 frame old — and that age varies with where + // the client's frames happened to fall between two snapshots. Adding it + // back places the replay anchor at the snapshot instant itself instead of + // at whichever input happened to land last, which is what keeps the + // predicted pose free of the send-cadence beat. See LocalCharacterPredictor. + public readonly ackAgeMs: number = 0, ) { super(); } public toArray(): Array { - return [this.clientTimeMs, this.bodyVelocity, this.lastLeapClientTimeMs]; + return [ + this.clientTimeMs, + this.bodyVelocity, + this.lastLeapClientTimeMs, + this.ackAgeMs, + ]; } } diff --git a/shared/src/communication/player-information.ts b/shared/src/communication/player-information.ts index 1130b7b..3180ecd 100644 --- a/shared/src/communication/player-information.ts +++ b/shared/src/communication/player-information.ts @@ -1,3 +1,6 @@ export interface PlayerInformation { name: string; + // Issued by the server on a previous join. Presented on reconnect to reclaim + // the team and score held during the grace window; absent on a first join. + reconnectToken?: string; } diff --git a/shared/src/communication/transport-events.ts b/shared/src/communication/transport-events.ts index 395a564..da2b01d 100644 --- a/shared/src/communication/transport-events.ts +++ b/shared/src/communication/transport-events.ts @@ -6,4 +6,13 @@ export enum TransportEvents { ServerInfoUpdate = 'ServerInfoUpdate', Ping = 'Ping', Pong = 'Pong', + PlayerJoined = 'PlayerJoined', + JoinRejected = 'JoinRejected', +} + +export enum JoinRejectionReason { + ServerFull = 'ServerFull', + RoundEnding = 'RoundEnding', + AlreadyJoined = 'AlreadyJoined', + InvalidRequest = 'InvalidRequest', } diff --git a/shared/src/helper/finite.ts b/shared/src/helper/finite.ts new file mode 100644 index 0000000..f5c79d8 --- /dev/null +++ b/shared/src/helper/finite.ts @@ -0,0 +1,60 @@ +import { vec2 } from 'gl-matrix'; + + +export const isFiniteNumber = (value: unknown): value is number => + typeof value === 'number' && Number.isFinite(value); + +/** A finite number within [min, max], or `fallback` if the input is unusable. */ +export const finiteInRange = ( + value: unknown, + min: number, + max: number, + fallback: number, +): number => { + if (!isFiniteNumber(value)) { + return fallback; + } + return Math.min(max, Math.max(min, value)); +}; + +/** + * A finite 2-vector whose magnitude is within `maxMagnitude`, or `undefined` if + * the input is not a usable pair of finite numbers. Callers should treat + * `undefined` as "drop this command" rather than substituting a default, so a + * malformed action is ignored instead of being silently reinterpreted. + */ +export const finiteVec2 = (value: unknown, maxMagnitude: number): vec2 | undefined => { + if (!value || typeof value !== 'object') { + return undefined; + } + + const candidate = value as ArrayLike; + if (candidate.length < 2) { + return undefined; + } + + const x = candidate[0]; + const y = candidate[1]; + if (!isFiniteNumber(x) || !isFiniteNumber(y)) { + return undefined; + } + + const result = vec2.fromValues(x, y); + const length = vec2.length(result); + if (length > maxMagnitude) { + vec2.scale(result, result, maxMagnitude / length); + } + + return result; +}; + +/** + * A display name that is safe to store and re-serialize. Anything that is not a + * string is coerced, because a non-string survives `.slice()` — an array name + * round-trips through the serializer and is revived as a class on every peer + * that receives it, throwing inside the deserializer's reviver. + */ +export const sanitizeName = (value: unknown, maxLength: number): string => { + const text = typeof value === 'string' ? value : String(value ?? ''); + return text.slice(0, maxLength); +}; diff --git a/shared/src/main.ts b/shared/src/main.ts index 7adfcf6..a26a4ce 100644 --- a/shared/src/main.ts +++ b/shared/src/main.ts @@ -15,6 +15,7 @@ export * from './commands/command-generator'; export * from './commands/types/actions/move-action'; export * from './commands/types/actions/primary-action'; export * from './commands/types/actions/leap-action'; +export * from './commands/types/actions/client-heartbeat'; export * from './commands/types/actions/set-aspect-ratio-action'; export * from './helper/array'; export * from './helper/last'; @@ -23,6 +24,7 @@ export * from './helper/hsl'; export * from './helper/rgb255'; export * from './helper/mix-rgb'; export * from './helper/clamp'; +export * from './helper/finite'; export * from './helper/charge'; export * from './helper/calculate-view-area'; export * from './helper/circle'; diff --git a/shared/src/objects/game-object.ts b/shared/src/objects/game-object.ts index f851137..beb03b9 100644 --- a/shared/src/objects/game-object.ts +++ b/shared/src/objects/game-object.ts @@ -8,7 +8,7 @@ export class RemoteCall { constructor( public readonly functionName: string, public readonly args: Array, - ) {} + ) { } public toArray(): Array { return [this.functionName, this.args]; @@ -49,15 +49,22 @@ export class PropertyUpdatesForObject { constructor( public readonly id: Id, public readonly updates: Array, - ) {} + ) { } public toArray(): Array { return [this.id, this.updates]; } } +let currentUpdateGeneration = 0; +export const beginPropertyUpdateGeneration = (): void => { + currentUpdateGeneration++; +}; + export abstract class GameObject extends CommandReceiver { private remoteCalls: Array = []; + private updateGeneration = -1; + private cachedPropertyUpdates?: PropertyUpdatesForObject; // The only methods a peer may invoke over the wire. processRemoteCalls // dispatches by a raw string taken straight off the network, so without this @@ -95,7 +102,15 @@ export abstract class GameObject extends CommandReceiver { }); } - public getPropertyUpdates(): PropertyUpdatesForObject | void {} + public getPropertyUpdates(): PropertyUpdatesForObject | void { } + + public getPropertyUpdatesForFrame(): PropertyUpdatesForObject | undefined { + if (this.updateGeneration !== currentUpdateGeneration) { + this.updateGeneration = currentUpdateGeneration; + this.cachedPropertyUpdates = this.getPropertyUpdates() ?? undefined; + } + return this.cachedPropertyUpdates; + } public getRemoteCalls(): Array { return this.remoteCalls; diff --git a/shared/src/physics/character-movement.ts b/shared/src/physics/character-movement.ts index 21355a5..084ce65 100644 --- a/shared/src/physics/character-movement.ts +++ b/shared/src/physics/character-movement.ts @@ -255,7 +255,7 @@ export const decayMomentum = ( : settings.airMomentumFriction; const target = Math.min( speed * Math.exp(-friction * deltaTimeInSeconds) - - settings.momentumStopDeceleration * deltaTimeInSeconds, + settings.momentumStopDeceleration * deltaTimeInSeconds, settings.maxBodyMomentum, ); if (target <= 1) { @@ -326,9 +326,13 @@ export const stepCharacterMovement = ( vec2.add(leftFootGravity, leftFootGravity, rightFootGravity); const gravity = vec2.scale(leftFootGravity, leftFootGravity, 0.5); + const movementLength = vec2.length(movementForce); + const gravityLength = vec2.length(gravity); if ( + movementLength > 0 && + gravityLength > 0 && vec2.dot(movementForce, gravity) < - -vec2.length(movementForce) * settings.climbDotThreshold + -movementLength * gravityLength * settings.climbDotThreshold ) { vec2.scale(gravity, gravity, settings.climbGravityScale); } @@ -359,7 +363,7 @@ export const stepCharacterMovement = ( latchGround(state, world.stepBody(state.leftFoot, deltaTimeInSeconds)); latchGround(state, world.stepBody(state.rightFoot, deltaTimeInSeconds)); - latchGround(state, world.stepBody(state.head, deltaTimeInSeconds)); + world.stepBody(state.head, deltaTimeInSeconds); decayBodyMomentum(state, deltaTimeInSeconds); }; diff --git a/shared/src/physics/depenetrate-circle.ts b/shared/src/physics/depenetrate-circle.ts index 7232f73..9a0a356 100644 --- a/shared/src/physics/depenetrate-circle.ts +++ b/shared/src/physics/depenetrate-circle.ts @@ -26,11 +26,6 @@ export const depenetrateCircle = ( } vec2.copy(body.lastNormal, normal); - body.center = vec2.scaleAndAdd( - vec2.create(), - body.center, - normal, - body.radius - distance + 0.01, - ); + vec2.scaleAndAdd(body.center, body.center, normal, body.radius - distance + 0.01); } }; diff --git a/shared/src/physics/march-circle.ts b/shared/src/physics/march-circle.ts index 0a8f0c8..bd8d9d2 100644 --- a/shared/src/physics/march-circle.ts +++ b/shared/src/physics/march-circle.ts @@ -9,12 +9,18 @@ export interface MarchResult { hitObject?: Sdf; } +const minimumStep = 0.5; +const maximumSteps = 256; + // Raymarch a circle by `delta`, stopping at the first surface it would overlap. -// Extracted verbatim from the backend's move-circle so server and client -// resolve motion identically. The collision *reaction* is no longer dispatched -// here: on a real (non-ignored) hit, `onHit(intersecting)` is invoked at the -// exact point the backend used to dispatch its ReactToCollisionCommands, and -// the backend wrapper supplies that callback. The client passes none. +// Shared so server and client resolve motion identically. The collision +// *reaction* is not dispatched here: on a real (non-ignored) hit, +// `onHit(intersecting)` is invoked at the point the backend used to dispatch its +// ReactToCollisionCommands, and the backend wrapper supplies that callback. The +// client passes none. +// +// The march advances by the *free gap* (distance minus the circle's radius), +// which is what sphere-tracing a circle of radius r requires. export const marchCircle = ( body: PhysicsBody, delta: vec2, @@ -22,23 +28,23 @@ export const marchCircle = ( ignoreCollision = false, onHit?: (intersecting: Sdf) => void, ): MarchResult => { - const direction = vec2.clone(delta); + const deltaLength = vec2.length(delta); - if (vec2.length(delta) > 0) { - vec2.normalize(direction, direction); + // A zero-length move cannot resolve a contact by advancing, so report no hit + // rather than letting a caller that loops on `hitSurface` spin forever. + if (!(deltaLength > 0)) { + return { hitSurface: false }; } - const deltaLength = vec2.length(delta); - let travelled = 0; + const direction = vec2.normalize(vec2.create(), delta); + const rayEnd = vec2.create(); - let prevMinDistance = 0; - while (travelled < deltaLength) { - travelled += prevMinDistance; - vec2.add( - rayEnd, - body.center, - vec2.scale(vec2.create(), direction, Math.min(travelled, deltaLength)), - ); + let travelled = 0; + // Furthest point along the ray known to be overlap-free; a hit rewinds here. + let lastFreeDistance = 0; + + for (let step = 0; step < maximumSteps; step++) { + vec2.scaleAndAdd(rayEnd, body.center, direction, travelled); const minDistance = evaluateSdf(rayEnd, possibleIntersectors); @@ -48,31 +54,37 @@ export const marchCircle = ( )!; if (ignoreCollision) { - body.center = vec2.add(body.center, body.center, delta); - } else { - onHit?.(intersecting); + // Pass straight through, but still report the contact so callers that + // march repeatedly to escape geometry keep making progress. + vec2.scaleAndAdd(body.center, body.center, direction, deltaLength); + return { hitSurface: true, hitObject: intersecting }; } - vec2.add( - rayEnd, - body.center, - vec2.scale(vec2.create(), direction, travelled - prevMinDistance), - ); + onHit?.(intersecting); + vec2.scaleAndAdd(rayEnd, body.center, direction, lastFreeDistance); vec2.copy(body.center, rayEnd); - const normal = sdfNormal(rayEnd, [intersecting]); return { hitSurface: true, - normal, + normal: sdfNormal(rayEnd, [intersecting]), hitObject: intersecting, }; } - prevMinDistance = minDistance; + lastFreeDistance = travelled; + + if (travelled >= deltaLength) { + break; + } + + travelled = Math.min( + travelled + Math.max(minDistance - body.radius, minimumStep), + deltaLength, + ); } - vec2.add(body.center, body.center, delta); + vec2.scaleAndAdd(body.center, body.center, direction, deltaLength); return { hitSurface: false, diff --git a/shared/src/settings.ts b/shared/src/settings.ts index 61041a5..3de1367 100644 --- a/shared/src/settings.ts +++ b/shared/src/settings.ts @@ -38,6 +38,11 @@ export const settings = { // exceeds this, and the rendered ownership ring stays neutral until the same // point — so what you see matches what scores. planetControlThreshold: 0.12, + // Extra margin a planet must clear *beyond* planetControlThreshold before a + // capture flip pays out again (a Schmitt trigger). Without it, ownership + // hovering on the dead-band edge re-triggers the reward, the announcement and + // the flare every time it grazes the boundary. + planetFlipHysteresis: 0.06, playerMaxHealth: 100, maxGravityStrength: 50000, planetMinReferenceRadius: 150, @@ -202,6 +207,53 @@ export const settings = { // Recoil speed imparted opposite a shot, scaled by its charge (0 for taps). chargeShotRecoilMax: 650, + // Lag compensation for projectiles. A shot is fast-forwarded by the time its + // command spent reaching the server, so the shooter does not have to lead by + // their own latency on top of the projectile's travel time. Capped so a very + // bad (or forged) timestamp cannot spawn a shot arbitrarily far downrange. + maxProjectileCatchUpSeconds: 0.2, + + // How long a dropped player's score and team are held so a reconnect within + // the window rejoins as the same player instead of a blank slate. + reconnectGraceSeconds: 30, + + // Hard ceiling on one inbound client message. socket.io's 1 MB default is far + // more than any legitimate command batch, and parsing is synchronous with the + // physics loop — a single oversized message stalls the tick for everyone. + maxInboundMessageBytes: 16 * 1024, + + // How often an idle client flushes its command queue. Input is flushed on the + // frame it happens, so this only paces the heartbeat that keeps the server's + // input acknowledgement moving while a key is simply held. + // + // It must NOT be the frame rate. Sending once per rendered frame put a 144 Hz + // display permanently over the inbound allowance below, and once the burst was + // spent the server silently discarded whole batches — including the + // edge-triggered movement commands, which are never re-sent, so the player + // kept walking the old way until they pressed something else. + clientSendInterval: 1 / 30, + + // Inbound allowance per socket: a token bucket sized far above anything a + // legitimate client produces (a 30 Hz heartbeat plus whatever discrete input + // events a human generates), so it only ever trips on a flood. Dropping a + // batch loses real input, so the headroom is deliberate. + maxInboundMessagesPerSecond: 240, + maxInboundMessageBurst: 480, + + // Ceiling on a measured round-trip time. The Pong that carries it comes from + // the client, which can always answer late; the nonce check makes the reply + // unforgeable and single-use, and this bounds what stalling one can buy. + maxMeasuredRttMs: 1000, + + // Skip the bulky part of a snapshot when the socket already has this much + // unflushed. A slow or stalled client otherwise accumulates a backlog it will + // never catch up on, and the newest snapshot is the only one that matters. + maxBufferedBytesPerClient: 256 * 1024, + + // Sanity bound on a client-supplied world position (the arena is + // worldRadius across, so anything past this is nonsense). + maxClientPositionMagnitude: 1e6, + // The central giant is a named, always-contested focus. Its neutral decay is // slowed so control lingers and teams keep fighting over it; flips are // announced to everyone and an off-screen arrow points the way. diff --git a/test/backend-regressions.test.ts b/test/backend-regressions.test.ts new file mode 100644 index 0000000..2231356 --- /dev/null +++ b/test/backend-regressions.test.ts @@ -0,0 +1,225 @@ +// Regression net for the backend defects found in the audit. These drive the +// real classes rather than re-implementing their logic, so they fail if the +// behaviour regresses rather than if the code is merely reshaped. +import { describe, it, expect, beforeEach } from 'vitest'; +import { createRequire } from 'node:module'; + +import { PhysicalContainer } from '../backend/src/physics/containers/physical-container'; +import { BoundingBox } from '../backend/src/physics/bounding-boxes/bounding-box'; +import { ProjectilePhysical } from '../backend/src/objects/projectile-physical'; +import { CharacterPhysical } from '../backend/src/objects/character-physical'; +import { StepCommand } from '../backend/src/commands/step'; +import { GeneratePointsCommand } from '../backend/src/commands/generate-points'; + +const require = createRequire(import.meta.url); +const shared = require('../shared/lib/main.js'); +const { vec2 } = require('../shared/node_modules/gl-matrix'); +const { applyArrayPlugins, Random, settings, CharacterTeam } = shared; + +applyArrayPlugins(); + +const step = settings.targetPhysicsDeltaTimeInSeconds; + +// Collects the points a stepped object asks the game to award. +class PointsSpy { + public blue = 0; + public red = 0; + public handleCommand(command: unknown) { + if (command instanceof GeneratePointsCommand) { + this.blue += command.blue; + this.red += command.red; + } + } +} + +const boxAround = (center: number[], radius: number) => { + const box = new BoundingBox(); + box.xMin = center[0] - radius; + box.xMax = center[0] + radius; + box.yMin = center[1] - radius; + box.yMax = center[1] + radius; + return box; +}; + +beforeEach(() => { + Random.seed = 1; +}); + +describe('projectile broadphase registration', () => { + // The bounding box is only rebuilt by the center/radius setters, but the + // geometry helpers write `center` in place — so a projectile stayed registered + // at its muzzle for its whole flight. View-area streaming is driven by that + // box, so shots fired off-screen were never sent, and shots in view vanished + // as soon as their muzzle scrolled away. + it('follows the projectile instead of staying at the muzzle', () => { + const container = new PhysicalContainer(); + container.initialize(); + + const projectile = new ProjectilePhysical( + vec2.fromValues(0, 0), + 20, + 40, + CharacterTeam.blue, + vec2.fromValues(3000, 0), + { team: CharacterTeam.blue } as never, + container, + 0, + ); + container.addObject(projectile); + + for (let i = 0; i < 40; i++) { + container.handleCommand(new StepCommand(step, new PointsSpy() as never)); + } + + expect(projectile.center[0]).toBeGreaterThan(400); + expect(container.findIntersecting(boxAround(projectile.center, 100))).toContain( + projectile, + ); + expect(container.findIntersecting(boxAround([0, 0], 100))).not.toContain(projectile); + }); + + it('fast-forwards a shot by the sender latency', () => { + const container = new PhysicalContainer(); + container.initialize(); + const spawn = vec2.fromValues(0, 0); + + const make = () => + new ProjectilePhysical( + vec2.clone(spawn), + 20, + 40, + CharacterTeam.blue, + vec2.fromValues(3000, 0), + { team: CharacterTeam.blue } as never, + container, + 0, + ); + + const plain = make(); + const compensated = make(); + compensated.fastForward(0.1); + + expect(compensated.center[0]).toBeGreaterThan(plain.center[0] + 200); + }); + + it('never fast-forwards further than the cap', () => { + const container = new PhysicalContainer(); + container.initialize(); + const projectile = new ProjectilePhysical( + vec2.fromValues(0, 0), + 20, + 40, + CharacterTeam.blue, + vec2.fromValues(3000, 0), + { team: CharacterTeam.blue } as never, + container, + 0, + ); + projectile.fastForward(10); + const cap = settings.chargeShotSpeedMax * settings.maxProjectileCatchUpSeconds; + expect(projectile.center[0]).toBeLessThanOrEqual(cap + 50); + }); +}); + +describe('scoring', () => { + const spawnCharacter = (container: PhysicalContainer) => { + const character = new CharacterPhysical( + 'tester', + 0, + 0, + CharacterTeam.blue, + container, + vec2.fromValues(0, 0), + ); + container.addObject(character); + return character; + }; + + // A disconnect and an NPC being retired on join both run through onDie(), and + // used to award the opposing team a full kill, so ordinary connection churn + // moved the match score with nobody playing. + it('awards nothing for an administrative death', () => { + const container = new PhysicalContainer(); + container.initialize(); + const character = spawnCharacter(container); + const game = new PointsSpy(); + + character.onDie(); // what PlayerBase.destroy() does on a socket drop + container.handleCommand(new StepCommand(step, game as never)); + + expect(game.blue).toBe(0); + expect(game.red).toBe(0); + }); + + it('still awards a kill for a combat death', () => { + const container = new PhysicalContainer(); + container.initialize(); + const character = spawnCharacter(container); + const game = new PointsSpy(); + + character.onDie(true); + container.handleCommand(new StepCommand(step, game as never)); + + expect(game.red).toBe(settings.playerKillPoint); + expect(game.blue).toBe(0); + }); +}); + +describe('charged shots', () => { + // Capping only the COST meant a max-charge request on a nearly empty pool paid + // a tap's price while still getting full-charge radius, speed and recoil. + it('scales the shot by the strength actually paid, not the requested charge', () => { + const container = new PhysicalContainer(); + container.initialize(); + const character = new CharacterPhysical( + 'tester', + 0, + 0, + CharacterTeam.blue, + container, + vec2.fromValues(0, 0), + ); + container.addObject(character); + + // Drain the pool down to roughly a tap's worth. + (character as unknown as { projectileStrength: number }).projectileStrength = + settings.chargeShotStrengthMin; + + const before = container.findIntersecting(boxAround([0, 0], 5000)).length; + character.shootTowards(vec2.fromValues(1000, 0), 1); + const spawned = container + .findIntersecting(boxAround([0, 0], 5000)) + .filter((o) => o.gameObject instanceof ProjectilePhysical) + .map((o) => o.gameObject as ProjectilePhysical); + + expect(container.findIntersecting(boxAround([0, 0], 5000)).length).toBeGreaterThan( + before, + ); + expect(spawned[0].charge).toBeCloseTo(0, 5); + expect(spawned[0].radius).toBeCloseTo(settings.chargeShotRadiusMin, 5); + }); + + it('ignores a shot aimed at the shooter itself', () => { + const container = new PhysicalContainer(); + container.initialize(); + const character = new CharacterPhysical( + 'tester', + 0, + 0, + CharacterTeam.blue, + container, + vec2.fromValues(0, 0), + ); + container.addObject(character); + + const projectilesBefore = container + .findIntersecting(boxAround([0, 0], 5000)) + .filter((o) => o.gameObject instanceof ProjectilePhysical).length; + character.shootTowards(vec2.clone(character.center), 1); + const projectilesAfter = container + .findIntersecting(boxAround([0, 0], 5000)) + .filter((o) => o.gameObject instanceof ProjectilePhysical).length; + + expect(projectilesAfter).toBe(projectilesBefore); + }); +}); diff --git a/test/backpressure.test.ts b/test/backpressure.test.ts new file mode 100644 index 0000000..0fc2dea --- /dev/null +++ b/test/backpressure.test.ts @@ -0,0 +1,137 @@ +// What a Player sheds when its socket stops draining. The guard used to read +// `socket.conn.bufferedAmount`, which engine.io's Socket does not have, so it +// never once ran — and had it run, it dropped the whole batch, including the +// create/delete that handleViewAreaUpdate had already committed to having sent. +import { describe, it, expect, beforeEach } from 'vitest'; +import { createRequire } from 'node:module'; + +import { PhysicalContainer } from '../backend/src/physics/containers/physical-container'; +import { PlayerContainer } from '../backend/src/players/player-container'; +import { createWorld } from '../backend/src/create-world'; + +const require = createRequire(import.meta.url); +const shared = require('../shared/lib/main.js'); +const { + applyArrayPlugins, + Random, + settings, + TransportEvents, + deserialize, + CreateObjectsCommand, + DeleteObjectsCommand, + PropertyUpdatesForObjects, + UpdateMinimap, + ServerAnnouncement, +} = shared; + +applyArrayPlugins(); + +// A socket whose write buffer we control, shaped the way engine.io really nests +// it: the byte count lives on the ws socket under the transport. +class FakeSocket { + public readonly sent: Array<{ event: string; payload: unknown }> = []; + public readonly conn = { transport: { socket: { bufferedAmount: 0 } } }; + + public set bufferedAmount(bytes: number) { + this.conn.transport.socket.bufferedAmount = bytes; + } + + public on() {} + public off() {} + public emit(event: string, payload?: unknown) { + this.sent.push({ event, payload }); + } + + public get lastBatch(): Array<{ type: string }> { + const last = [...this.sent] + .reverse() + .find((m) => m.event === TransportEvents.ServerToPlayer); + return last ? deserialize(last.payload as string) : []; + } +} + +const makePlayer = () => { + Random.seed = 3; + const objects = new PhysicalContainer(); + createWorld(objects); + objects.initialize(); + const players = new PlayerContainer(objects, 4, 0); + const socket = new FakeSocket(); + const player = players.createPlayer({ name: 'player' }, socket as never); + // The constructor already queues this player's own CreatePlayerCommand; flush + // it so each case starts from an empty queue. + player.sendQueuedCommandsToClient(); + socket.sent.length = 0; + return { player, socket }; +}; + +// One snapshot's worth of queued traffic: the bulky repeated state, and the +// one-shot bookkeeping that is never regenerated. +const queueOneSnapshot = (player: ReturnType['player']) => { + player.queueCommandSend(new CreateObjectsCommand([])); + player.queueCommandSend(new DeleteObjectsCommand([1234])); + player.queueCommandSend(new ServerAnnouncement('team blue wins')); + player.queueCommandSend(new UpdateMinimap([])); + player.queueCommandSend(new PropertyUpdatesForObjects([], 1)); +}; + +const typesIn = (batch: Array<{ type: string }>) => batch.map((c) => c.type); + +describe('slow-client backpressure', () => { + beforeEach(() => { + Random.seed = 3; + }); + + it('sends everything while the socket is draining', () => { + const { player, socket } = makePlayer(); + queueOneSnapshot(player); + player.sendQueuedCommandsToClient(); + + expect(typesIn(socket.lastBatch)).toEqual([ + CreateObjectsCommand.name, + DeleteObjectsCommand.name, + ServerAnnouncement.name, + UpdateMinimap.name, + PropertyUpdatesForObjects.name, + ]); + }); + + it('reads the buffered byte count engine.io actually exposes', () => { + const { player, socket } = makePlayer(); + socket.bufferedAmount = settings.maxBufferedBytesPerClient + 1; + queueOneSnapshot(player); + player.sendQueuedCommandsToClient(); + + // If the guard cannot see the buffer it degrades to sending everything, so + // anything shed at all proves it is reading the right property. + expect(typesIn(socket.lastBatch)).not.toContain(PropertyUpdatesForObjects.name); + }); + + it('sheds only the state the next tick regenerates', () => { + const { player, socket } = makePlayer(); + socket.bufferedAmount = settings.maxBufferedBytesPerClient + 1; + queueOneSnapshot(player); + player.sendQueuedCommandsToClient(); + + const types = typesIn(socket.lastBatch); + // Repeated in full every tick — safe to drop. + expect(types).not.toContain(PropertyUpdatesForObjects.name); + expect(types).not.toContain(UpdateMinimap.name); + // One-shot. handleViewAreaUpdate has already advanced its record of what + // this client knows, so these are never re-derived: dropping them leaves the + // client permanently missing objects and holding ghosts. + expect(types).toContain(CreateObjectsCommand.name); + expect(types).toContain(DeleteObjectsCommand.name); + expect(types).toContain(ServerAnnouncement.name); + }); + + it('stays quiet when a backed-up client has nothing but sheddable state', () => { + const { player, socket } = makePlayer(); + socket.bufferedAmount = settings.maxBufferedBytesPerClient + 1; + player.queueCommandSend(new PropertyUpdatesForObjects([], 1)); + const before = socket.sent.length; + player.sendQueuedCommandsToClient(); + + expect(socket.sent.length).toBe(before); + }); +}); diff --git a/test/client-send-cadence.test.ts b/test/client-send-cadence.test.ts new file mode 100644 index 0000000..d80bc3a --- /dev/null +++ b/test/client-send-cadence.test.ts @@ -0,0 +1,80 @@ +// The client's outbound cadence, which is what the server's inbound allowance +// has to be sized against. Sending once per rendered frame put every display +// above the allowance permanently over it; once the burst was spent the server +// silently discarded whole batches, and movement commands are edge-triggered +// and never re-sent, so a lost batch meant the direction change never happened. +import { describe, it, expect } from 'vitest'; +import { createRequire } from 'node:module'; + +import { CommandSocket } from '../frontend/src/scripts/commands/command-socket'; +import { setPredictorClockForTesting } from '../frontend/src/scripts/helper/prediction/local-character-predictor'; + +const require = createRequire(import.meta.url); +const shared = require('../shared/lib/main.js'); +const { vec2 } = require('../shared/node_modules/gl-matrix'); +const { applyArrayPlugins, settings, MoveActionCommand, ClientHeartbeatCommand } = shared; + +applyArrayPlugins(); + +// Collects what would go on the wire. +const drive = (frameRate: number, seconds: number, onFrame?: (socket: any) => void) => { + let clock = 0; + setPredictorClockForTesting(() => clock); + + const batches: Array = []; + const socket = { emit: (_event: string, payload: string) => batches.push(payload) }; + const commandSocket = new CommandSocket(socket as never); + + const frames = Math.round(frameRate * seconds); + for (let f = 0; f < frames; f++) { + clock = (f * 1000) / frameRate; + onFrame?.(commandSocket); + commandSocket.sendQueuedCommands(); + } + return batches; +}; + +describe('client send cadence', () => { + it('is paced by the clock, not by the frame rate', () => { + const seconds = 3; + const expected = seconds / settings.clientSendInterval; + + for (const frameRate of [60, 144, 240]) { + const batches = drive(frameRate, seconds); + // One heartbeat per interval, give or take where the frames fall. The + // point is that the count barely moves across a 4x range of frame rates — + // sending once per frame gave 180, 432 and 720 here. + expect(batches.length).toBeLessThanOrEqual(expected * 1.1); + expect(batches.length).toBeGreaterThanOrEqual(expected * 0.85); + } + }); + + it('stays under the server allowance even at an absurd frame rate', () => { + const seconds = 5; + const batches = drive(1000, seconds); + + expect(batches.length / seconds).toBeLessThan(settings.maxInboundMessagesPerSecond); + }); + + it('still flushes real input on the frame it happens', () => { + // Input on frame 1 of a 144 Hz client: far inside the heartbeat interval, so + // pacing must not hold it back. Latency the player feels is one frame. + let sentOn = -1; + const batches = drive(144, 0.1, (commandSocket) => { + if (sentOn === -1) { + commandSocket.handleCommand(new MoveActionCommand(vec2.fromValues(1, 0), 0)); + sentOn = 0; + } + }); + + expect(batches.length).toBeGreaterThan(0); + expect(batches[0]).toContain('MoveActionCommand'); + }); + + it('closes every batch with a heartbeat, so a held key keeps the ack moving', () => { + const batches = drive(60, 1); + + expect(batches.length).toBeGreaterThan(0); + expect(batches.every((b) => b.includes(ClientHeartbeatCommand.name))).toBe(true); + }); +}); diff --git a/test/connection-lifecycle.test.ts b/test/connection-lifecycle.test.ts new file mode 100644 index 0000000..cfb91f2 --- /dev/null +++ b/test/connection-lifecycle.test.ts @@ -0,0 +1,319 @@ +// Drives the real GameServer through a fake socket.io to cover the connection +// lifecycle: the join guard, refusal reasons, and reconnect. None of this is +// reachable from a unit test of any single class, and it is where the audit +// found the most severe defects. +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { createRequire } from 'node:module'; + +import { GameServer } from '../backend/src/game-server'; +import { defaultOptions } from '../backend/src/default-options'; + +const require = createRequire(import.meta.url); +const shared = require('../shared/lib/main.js'); +const { applyArrayPlugins, Random, TransportEvents, JoinRejectionReason, settings } = + shared; + +applyArrayPlugins(); + +class FakeSocket { + public readonly sent: Array<{ event: string; payload: unknown }> = []; + public disconnected = false; + private handlers = new Map void>>(); + + public on(event: string, handler: (...args: never[]) => void) { + const list = this.handlers.get(event) ?? []; + list.push(handler); + this.handlers.set(event, list); + } + + public off(event: string, handler: (...args: never[]) => void) { + this.handlers.set( + event, + (this.handlers.get(event) ?? []).filter((h) => h !== handler), + ); + } + + public emit(event: string, payload?: unknown) { + this.sent.push({ event, payload }); + } + + public join() {} + + public disconnect() { + this.disconnected = true; + } + + /** Number of listeners the server has attached for an event. */ + public listenerCount(event: string): number { + return (this.handlers.get(event) ?? []).length; + } + + public fire(event: string, ...args: unknown[]) { + for (const handler of [...(this.handlers.get(event) ?? [])]) { + (handler as (...a: unknown[]) => void)(...args); + } + } + + public lastPayloadFor(event: string): unknown { + return [...this.sent].reverse().find((m) => m.event === event)?.payload; + } +} + +class FakeIo { + public connect?: (socket: FakeSocket) => void; + public on(event: string, handler: (socket: FakeSocket) => void) { + if (event === 'connection') { + this.connect = handler; + } + } + public to() { + return { emit: () => undefined }; + } +} + +const startServer = (overrides: Partial = {}) => { + const io = new FakeIo(); + const server = new GameServer(io as never, { + ...defaultOptions, + seed: 1, + ...overrides, + }); + const attach = () => { + const socket = new FakeSocket(); + io.connect!(socket); + return socket; + }; + return { server, attach }; +}; + +beforeEach(() => { + Random.seed = 1; +}); + +describe('join guard', () => { + // One socket used to be able to emit PlayerJoining as often as it liked, each + // pass spawning another Player and stacking another set of listeners, until it + // held every slot on the server. + it('accepts exactly one join per connection', () => { + const { server, attach } = startServer({ playerLimit: 4, npcCount: 1 }); + const socket = attach(); + + for (let i = 0; i < 8; i++) { + socket.fire(TransportEvents.PlayerJoining, { name: 'greedy' }); + } + + expect(server.serverInfo.playerCount).toBe(1); + expect(socket.listenerCount(TransportEvents.PlayerToServer)).toBe(1); + expect(socket.listenerCount('disconnect')).toBe(1); + expect(socket.lastPayloadFor(TransportEvents.JoinRejected)).toBe( + JoinRejectionReason.AlreadyJoined, + ); + }); + + it('leaves room for everyone else', () => { + const { server, attach } = startServer({ playerLimit: 4, npcCount: 1 }); + const hog = attach(); + for (let i = 0; i < 8; i++) { + hog.fire(TransportEvents.PlayerJoining, { name: 'greedy' }); + } + + const other = attach(); + other.fire(TransportEvents.PlayerJoining, { name: 'someone else' }); + + expect(server.serverInfo.playerCount).toBe(2); + expect(other.disconnected).toBe(false); + }); + + it('says why instead of hanging up silently when full', () => { + const { attach } = startServer({ playerLimit: 1, npcCount: 0 }); + attach().fire(TransportEvents.PlayerJoining, { name: 'first' }); + + const refused = attach(); + refused.fire(TransportEvents.PlayerJoining, { name: 'second' }); + + expect(refused.lastPayloadFor(TransportEvents.JoinRejected)).toBe( + JoinRejectionReason.ServerFull, + ); + }); +}); + +describe('hostile payloads', () => { + it('survives a name that is not a string, without leaking a bot', () => { + const { server, attach } = startServer({ playerLimit: 4, npcCount: 2 }); + + for (let i = 0; i < 4; i++) { + attach().fire(TransportEvents.PlayerJoining, { name: ['CharacterBase'] }); + } + + // The joins are accepted (the name is coerced) and no bot is stranded. + expect(server.serverInfo.playerCount).toBe(4); + }); + + it('ignores an oversized command batch instead of parsing it', () => { + const { attach } = startServer({ playerLimit: 4, npcCount: 0 }); + const socket = attach(); + socket.fire(TransportEvents.PlayerJoining, { name: 'sender' }); + + // Would previously be handed straight to JSON.parse on the physics thread. + expect(() => + socket.fire(TransportEvents.PlayerToServer, 'x'.repeat(2 * 1024 * 1024)), + ).not.toThrow(); + }); +}); + +describe('reconnect', () => { + // A transport blip used to end the match: the client tore itself down and the + // server deleted the character, with no way back to the same player. + it('hands out a token on join and honours it on the way back', () => { + const { server, attach } = startServer({ playerLimit: 4, npcCount: 0 }); + + const first = attach(); + first.fire(TransportEvents.PlayerJoining, { name: 'flaky' }); + const token = first.lastPayloadFor(TransportEvents.PlayerJoined); + expect(typeof token).toBe('string'); + + first.fire('disconnect'); + expect(server.serverInfo.playerCount).toBe(0); + + const second = attach(); + second.fire(TransportEvents.PlayerJoining, { name: 'flaky', reconnectToken: token }); + + expect(server.serverInfo.playerCount).toBe(1); + expect(second.disconnected).toBe(false); + expect(second.lastPayloadFor(TransportEvents.JoinRejected)).toBeUndefined(); + // A fresh token for the new session, so the old one cannot be replayed. + expect(second.lastPayloadFor(TransportEvents.PlayerJoined)).not.toBe(token); + }); + + it('treats an unknown token as an ordinary first join', () => { + const { server, attach } = startServer({ playerLimit: 4, npcCount: 0 }); + const socket = attach(); + socket.fire(TransportEvents.PlayerJoining, { + name: 'stranger', + reconnectToken: 'not-a-real-token', + }); + + expect(server.serverInfo.playerCount).toBe(1); + expect(socket.lastPayloadFor(TransportEvents.JoinRejected)).toBeUndefined(); + }); +}); + +describe('inbound rate limiting', () => { + // The bucket exists to survive a flood, but a dropped batch is LOST input, + // not delayed input: movement is edge-triggered and nothing is ever re-sent, + // so a discarded batch means the direction change simply never happened. The + // ceiling used to be 120/s on the assumption of "one batch per frame", which + // any display above 120 Hz exceeds outright — after the burst was spent, a + // 144 Hz client had roughly one batch in six silently swallowed. + // + // Each message that gets past the limiter is unparseable and so logs exactly + // once, which counts admissions without reaching into the server. + const admitted = (socket: { fire: (e: string, p: string) => void }, count: number) => { + const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + for (let i = 0; i < count; i++) { + socket.fire(TransportEvents.PlayerToServer, '{'); + } + const seen = spy.mock.calls.length; + spy.mockRestore(); + return seen; + }; + + // The allowance has to sit well clear of what a well-behaved client actually + // sends, or the limiter eats gameplay input instead of floods. It used to be + // 120/s against a client that sent once per rendered frame — a ratio below 1 + // for anyone on a 144 Hz display. + it('allows several times what a well-behaved client sends', () => { + const clientMessagesPerSecond = 1 / settings.clientSendInterval; + + expect(settings.maxInboundMessagesPerSecond).toBeGreaterThanOrEqual( + clientMessagesPerSecond * 4, + ); + expect(settings.maxInboundMessageBurst).toBeGreaterThanOrEqual( + settings.maxInboundMessagesPerSecond, + ); + }); + + it('does not drop a second of a real client arriving at once', () => { + const { attach } = startServer({ playerLimit: 4, npcCount: 0 }); + const socket = attach(); + socket.fire(TransportEvents.PlayerJoining, { name: 'player' }); + + // The paced heartbeat plus a generous allowance for discrete input events. + const oneSecond = Math.ceil(1 / settings.clientSendInterval) + 100; + + expect(admitted(socket, oneSecond)).toBe(oneSecond); + }); + + it('still clamps an actual flood', () => { + const { attach } = startServer({ playerLimit: 4, npcCount: 0 }); + const socket = attach(); + socket.fire(TransportEvents.PlayerJoining, { name: 'flooder' }); + + const flood = settings.maxInboundMessageBurst * 4; + + expect(admitted(socket, flood)).toBeLessThan(flood); + }); +}); + +describe('reconnect tokens', () => { + // They used to be minted from `Random` — the seeded Mulberry32 stream shared + // with world generation, NPC decisions and planet spin, the last of which is + // streamed to every client. That made them guessable, and made every draw + // after a join shift, so a seed no longer reproduced a match. + const tokenFromFreshServer = () => { + Random.seed = 1; + const { attach } = startServer({ playerLimit: 4, npcCount: 0, seed: 1 }); + const socket = attach(); + socket.fire(TransportEvents.PlayerJoining, { name: 'player' }); + return socket.lastPayloadFor(TransportEvents.PlayerJoined) as string; + }; + + it('does not derive them from the seeded gameplay stream', () => { + expect(tokenFromFreshServer()).not.toBe(tokenFromFreshServer()); + }); + + // Shape, because that is what distinguishes the two sources: 122 random bits + // from the platform CSPRNG, rather than a counter and one 32-bit draw off the + // gameplay stream. Minting from `Random` also advanced the stream the world + // generator runs on, so the same seed stopped replaying the same match — but + // that is not assertable here: this harness seeds a different instance of the + // shared bundle than the server source resolves. + it('mints them from the platform CSPRNG, not the gameplay stream', () => { + const { attach } = startServer({ playerLimit: 4, npcCount: 0 }); + const socket = attach(); + socket.fire(TransportEvents.PlayerJoining, { name: 'player' }); + + expect(socket.lastPayloadFor(TransportEvents.PlayerJoined)).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ); + }); +}); + +describe('socket listeners', () => { + // The Player registers its own Pong listener, so nothing else knew to take it + // off. A socket outlives the round it joined, so the retired Player stayed + // reachable — and kept updating its RTT — through a listener nobody removed. + it('takes every listener back off when the player drops', () => { + const { attach } = startServer({ playerLimit: 4, npcCount: 0 }); + const socket = attach(); + socket.fire(TransportEvents.PlayerJoining, { name: 'leaver' }); + + expect(socket.listenerCount(TransportEvents.Pong)).toBe(1); + + socket.fire('disconnect'); + + expect(socket.listenerCount(TransportEvents.Pong)).toBe(0); + expect(socket.listenerCount(TransportEvents.PlayerToServer)).toBe(1); + }); + + it('does not stack a listener per reconnect', () => { + const { attach } = startServer({ playerLimit: 4, npcCount: 0 }); + + for (let i = 0; i < 5; i++) { + const socket = attach(); + socket.fire(TransportEvents.PlayerJoining, { name: 'flaky' }); + expect(socket.listenerCount(TransportEvents.Pong)).toBe(1); + socket.fire('disconnect'); + } + }); +}); diff --git a/test/prediction-reconciliation.test.ts b/test/prediction-reconciliation.test.ts index 98ffc1d..b3b4a5c 100644 --- a/test/prediction-reconciliation.test.ts +++ b/test/prediction-reconciliation.test.ts @@ -93,4 +93,90 @@ describe('local prediction reconciliation', () => { // not be predicted/moved — that was the "move while dead" bug. expect(predictor.update([], 1 / 60)).toBe(false); }); + + // The replay window spans everything the server has not acknowledged, not just + // the snapshot's age. Anchoring on the snapshot's arrival made the window just + // that age, so prediction cancelled the interpolation buffer and nothing else + // and the local body sat a full one-way trip behind the server. + it('replays the whole un-acknowledged span, so a longer trip predicts further', () => { + const driveWithUnackedSpan = (ackAgeMs: number) => { + let clock = 0; + setPredictorClockForTesting(() => clock); + + const predictor = new LocalCharacterPredictor(); + predictor.setStrength(80); + // The key went down well before the acknowledged span and is still held — + // the case that used to freeze the acknowledgement, because movement is + // only transmitted when it changes. + clock = 9_000; + predictor.recordInput([1, 0]); + + clock = 10_000; + const auth = poseAt(0, 0); + // The server has confirmed input only up to `ackAgeMs` ago; the snapshot + // carrying that acknowledgement arrives now. + predictor.acknowledge(clock - ackAgeMs, [0, 0], -Infinity); + predictor.setAuthoritative( + auth.head as never, + auth.leftFoot as never, + auth.rightFoot as never, + ); + + predictor.update([], 1 / 60); + return predictor.head.center[0]; + }; + + const shortTrip = driveWithUnackedSpan(40); + const longTrip = driveWithUnackedSpan(200); + + expect(shortTrip).toBeGreaterThan(0); + // A 200 ms un-acknowledged span must predict meaningfully further ahead than + // a 40 ms one; when the window was pinned to the snapshot they were equal. + expect(longTrip).toBeGreaterThan(shortTrip * 2); + }); + // ...but the anchor must not also inherit the AGE of whichever input happened + // to be the newest one the server had when it took the snapshot. Input is sent + // once per frame, so that age is 0..1 frame depending on where the client's + // frames fell between two snapshots, and it walks a sawtooth: measured against + // a real server on localhost it ran 5, 16, 12, 8, 3, 14, 9, 1 ms and so on. + // Left in the window, it jumped the predicted body by up to a frame of travel + // 25 times a second — the local body was choppy on a link with no latency at + // all to hide it. The server reports the age with the acknowledgement so the + // anchor lands on the snapshot instant instead. + it('cancels the age of the acknowledged input, so the send cadence cannot beat', () => { + // Both runs describe the SAME snapshot: taken 20 ms ago, of the same pose. + // They differ only in how long before it the server's newest input arrived. + const snapshotAgeMs = 20; + const drive = (inputAgeMs: number, serverReportsAge: boolean) => { + let clock = 0; + setPredictorClockForTesting(() => clock); + + const predictor = new LocalCharacterPredictor(); + predictor.setStrength(80); + clock = 9_000; + predictor.recordInput([1, 0]); // held down throughout + + clock = 10_000; + predictor.acknowledge( + clock - snapshotAgeMs - inputAgeMs, + [0, 0], + -Infinity, + serverReportsAge ? inputAgeMs : 0, + ); + const auth = poseAt(0, 0); + predictor.setAuthoritative( + auth.head as never, + auth.leftFoot as never, + auth.rightFoot as never, + ); + + predictor.update([], 1 / 60); + return predictor.head.center[0]; + }; + + expect(drive(16, true)).toBeCloseTo(drive(0, true), 6); + // And the difference being cancelled is a real one: uncancelled, the same + // 16 ms moves the body, so the assertion above does not hold vacuously. + expect(Math.abs(drive(16, false) - drive(0, false))).toBeGreaterThan(0.5); + }); }); -- 2.54.0 From aa02d0ab6f13b0e0d2af9370ae978bc8a55545a7 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sat, 22 Aug 2026 15:29:30 +0100 Subject: [PATCH 02/15] fix movement --- backend/src/objects/character-physical.ts | 17 +- backend/src/players/player.ts | 6 +- frontend/src/scripts/game.ts | 15 +- .../prediction/client-character-world.ts | 4 - .../prediction/local-character-predictor.ts | 139 ++++++++-------- .../commands/types/input-acknowledgement.ts | 76 ++++++--- shared/src/helper/rotate-minus-90-deg.ts | 3 - shared/src/helper/wait.ts | 3 - test/client-send-cadence.test.ts | 7 +- test/prediction-reconciliation.test.ts | 148 +++++++++++++----- 10 files changed, 269 insertions(+), 149 deletions(-) delete mode 100644 shared/src/helper/rotate-minus-90-deg.ts delete mode 100644 shared/src/helper/wait.ts diff --git a/backend/src/objects/character-physical.ts b/backend/src/objects/character-physical.ts index de85b1d..ae72c0f 100644 --- a/backend/src/objects/character-physical.ts +++ b/backend/src/objects/character-physical.ts @@ -12,6 +12,7 @@ import { UpdatePropertyCommand, CommandExecutors, CommandReceiver, + CharacterMovementSnapshot, mix, clamp01, stepCharacterMovement, @@ -209,10 +210,18 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical return this.currentPlanet; } - // Persistent launch momentum, streamed to the owning client so its predictor - // can reproduce a leap/slingshot/recoil flight instead of only snapping to it. - public get launchMomentum(): vec2 { - return this.bodyVelocity; + // The continuous movement state streamed to the owning client, so its + // predictor resumes this simulation instead of guessing at the parts of it + // the pose does not show. See CharacterMovementSnapshot. + public get movementSnapshot(): CharacterMovementSnapshot { + return new CharacterMovementSnapshot( + this.direction, + vec2.clone(this.bodyVelocity), + vec2.clone(this.leftFoot.lastNormal), + vec2.clone(this.rightFoot.lastNormal), + this.currentPlanet?.id ?? null, + this.secondsSinceOnSurface, + ); } public addKill(victimName: string, charge = 0) { diff --git a/backend/src/players/player.ts b/backend/src/players/player.ts index 459a88e..0a2ca8b 100644 --- a/backend/src/players/player.ts +++ b/backend/src/players/player.ts @@ -283,7 +283,7 @@ export class Player extends PlayerBase { this.queueCommandSend( new InputAcknowledgement( this.lastInputClientTimeMs, - this.character.launchMomentum, + this.character.movementSnapshot, this.lastLeapClientTimeMs, this.lastInputReceiptMs > 0 ? Math.max(0, performance.now() - this.lastInputReceiptMs) @@ -369,6 +369,10 @@ export class Player extends PlayerBase { const sheddable: ReadonlyArray = [ PropertyUpdatesForObjects.type, UpdateMinimap.type, + // Only meaningful together with the pose it describes, and regenerated + // in full next tick — so it is shed with it rather than advancing the + // client's replay anchor past a snapshot it never received. + InputAcknowledgement.type, ]; this.commandsToBeSent = this.commandsToBeSent.filter( (c) => !sheddable.includes(c.type), diff --git a/frontend/src/scripts/game.ts b/frontend/src/scripts/game.ts index b63ad51..8fb4379 100644 --- a/frontend/src/scripts/game.ts +++ b/frontend/src/scripts/game.ts @@ -36,7 +36,10 @@ import { PlanetShape } from './shapes/planet-shape'; import { RenderCommand } from './commands/types/render'; import { StepCommand } from './commands/types/step'; import { serverTimeline } from './helper/server-timeline'; -import { localCharacterPredictor } from './helper/prediction/local-character-predictor'; +import { + localCharacterPredictor, + setFrameTimeMs, +} from './helper/prediction/local-character-predictor'; import { Tutorial } from './tutorial'; import { Scoreboard } from './scoreboard'; import { Minimap } from './minimap'; @@ -226,7 +229,7 @@ export class Game extends CommandReceiver { [InputAcknowledgement.type]: (c: InputAcknowledgement) => localCharacterPredictor.acknowledge( c.clientTimeMs, - c.bodyVelocity, + c.movement, c.lastLeapClientTimeMs, c.ackAgeMs, ), @@ -334,10 +337,16 @@ export class Game extends CommandReceiver { private framesSinceLastLayoutUpdate = 0; private gameLoop( renderer: Renderer, - _: DOMHighResTimeStamp, + currentTime: DOMHighResTimeStamp, deltaTime: DOMHighResTimeStamp, ): boolean { this.resolveStarted(); + // The client's one clock, for input stamps, the outgoing send cadence and + // the prediction replay window alike. It has to be the frame's timestamp + // rather than performance.now(): the two differ by however long the browser + // took to dispatch this callback, and that difference would land straight + // in the replay window and jitter the predicted body. + setFrameTimeMs(currentTime); deltaTime /= 1000; // Decay the camera impact effects on raw wall-clock time, before any of the diff --git a/frontend/src/scripts/helper/prediction/client-character-world.ts b/frontend/src/scripts/helper/prediction/client-character-world.ts index a08dbc5..b09390d 100644 --- a/frontend/src/scripts/helper/prediction/client-character-world.ts +++ b/frontend/src/scripts/helper/prediction/client-character-world.ts @@ -118,10 +118,6 @@ export class ClientCharacterWorld implements CharacterWorld { return id == null ? undefined : this.surfaces.get(id); } - public idOf(surface: GroundSurface | undefined): Id | undefined { - return surface instanceof PlanetSurface ? surface.id : undefined; - } - public groundsNear(): Array { return this.ordered; } diff --git a/frontend/src/scripts/helper/prediction/local-character-predictor.ts b/frontend/src/scripts/helper/prediction/local-character-predictor.ts index 5862347..5363224 100644 --- a/frontend/src/scripts/helper/prediction/local-character-predictor.ts +++ b/frontend/src/scripts/helper/prediction/local-character-predictor.ts @@ -1,8 +1,8 @@ import { vec2 } from 'gl-matrix'; import { Circle, + CharacterMovementSnapshot, CharacterMovementState, - Id, PhysicsBody, settings, stepCharacterMovement, @@ -17,18 +17,22 @@ import { InputHistory } from './input-history'; const stepMs = 1000 / 200; // match the server's 200 Hz fixed tick const stepSeconds = 1 / 200; -// Clock source for the predictor. Injectable so deterministic reconciliation -// tests can drive prediction without real time passing; defaults to the -// browser wall clock in production. -let nowMs: () => number = () => performance.now(); -export const setPredictorClockForTesting = (clock: () => number): void => { - nowMs = clock; +// The client's clock: the timestamp of the frame currently being rendered, set +// once per frame by the game loop (requestAnimationFrame hands it to us +// already). Everything time-related on the client reads it from here, so input +// stamps, the outgoing send cadence and the replay window all sit on one +// timeline. +// +// Sampling performance.now() ad hoc instead — which is what this used to do — +// reads the frame's *dispatch* time, and the few ms of scheduling jitter +// between the frame's timestamp and the callback actually running went straight +// into the replay window. The body then advanced by a slightly different amount +// than the frame it was drawn in. +let frameTimeMs = 0; +export const setFrameTimeMs = (timeMs: number): void => { + frameTimeMs = timeMs; }; - -// The same clock the predictor stamps input with. Anything that tells the server -// "this is how far my input timeline has got" must read it from here, or the -// acknowledgement comes back in a different time base than the replay window. -export const predictorNowMs = (): number => nowMs(); +export const predictorNowMs = (): number => frameTimeMs; // Don't replay more than this far back: if the last acknowledged input is older // (a stall, or a backgrounded tab catching up) fall back to a shorter window @@ -46,11 +50,15 @@ const smoothSeconds = 0.06; // the predictor doesn't model. Snap to it rather than gliding across the gap. const snapDistance = 250; -const makeBody = (center: vec2, radius: number): PhysicsBody => ({ +const upwards = vec2.fromValues(0, 1); + +// Velocity is deliberately zero: the movement simulation rebuilds it from +// scratch every tick and zeroes it again at the end, so it is not carried state. +const makeBody = (center: vec2, radius: number, normal: vec2): PhysicsBody => ({ center: vec2.clone(center), radius, velocity: vec2.create(), - lastNormal: vec2.fromValues(0, 1), + lastNormal: vec2.fromValues(normal[0], normal[1]), restitution: 0, }); @@ -77,12 +85,12 @@ export class LocalCharacterPredictor { // snapshot was actually taken. Both halves are needed. // // Anchoring on the acknowledged input time ALONE beats against the send - // cadence: input is sent once per frame, so at snapshot time the newest input - // the server holds is 0..1 frame old depending on where the client's frames - // fell, and that age lands directly in the replay window. Measured on - // localhost it walked a sawtooth — 17, 10, 6, 1, 11, 6, 2, 12 ms — so the - // window jumped by up to a frame of travel every snapshot and the body - // stuttered at 25 Hz. ackAgeMs cancels it exactly. + // cadence: at snapshot time the newest input the server holds is 0..1 send + // interval old depending on where the client's sends fell, and that age lands + // directly in the replay window. Measured on localhost it walked a sawtooth — + // 17, 10, 6, 1, 11, 6, 2, 12 ms — so the window jumped by up to a frame of + // travel every snapshot and the body stuttered at 25 Hz. ackAgeMs cancels it + // exactly. // // Anchoring on the snapshot's ARRIVAL is free of that beat, but it under- // advances by a one-way trip, so the local body trails its own input at any @@ -93,14 +101,17 @@ export class LocalCharacterPredictor { // altogether; that is fixed at the source, by closing every outgoing batch // with a ClientHeartbeatCommand. private replayAnchorMs?: number; - // Authoritative launch momentum at the last snapshot — seeds each replay so a - // leap/slingshot/recoil flight is reproduced and continuously corrected. - private authoritativeBodyVelocity = vec2.create(); - // Wall-clock times the player issued a leap, replayed (with the impulse - // applied locally) so the launch is felt immediately, not after a round trip. + // The rest of the server's movement state at that same instant: facing + // direction, launch momentum, foot contact normals and the latched planet. + // Together with the pose above it is everything stepCharacterMovement carries + // between ticks, so a replay continues the server's simulation instead of + // approximating it. + private movement?: CharacterMovementSnapshot; + // Frame times the player issued a leap, replayed (with the impulse applied + // locally) so the launch is felt immediately, not after a round trip. private leapHistory: Array = []; // clientTimeMs of the last leap the server has folded into the streamed - // momentum. Leaps at or before this are already in authoritativeBodyVelocity; + // momentum. Leaps at or before this are already in movement.bodyVelocity; // only newer ones are replayed, so a leap is never applied twice. private lastLeapAckMs = -Infinity; // Latest streamed shooting-strength, to gate predicted leaps as the server does. @@ -112,13 +123,6 @@ export class LocalCharacterPredictor { // that still moved would be a pure client-side desync. private alive = true; - // Continuous state carried between replays (the snapshot carries only poses). - // The facing direction is NOT carried — it is re-derived from the pose each - // frame (see directionFromPose / simulate); only the latched planet and the - // time-since-surface persist. - private carriedPlanetId?: Id; - private carriedSecondsSinceSurface = 1; - // The eased, rendered pose handed to the view. private renderHead = new Circle(vec2.create(), headRadius); private renderLeftFoot = new Circle(vec2.create(), feetRadius); @@ -135,27 +139,27 @@ export class LocalCharacterPredictor { return this.renderRightFoot; } - // Stamp a movement command and record it for replay. Returns the wall-clock + // Stamp a movement command and record it for replay. Returns the client-clock // time the command should carry so the server can echo it back. public recordInput(direction: vec2): number { - const timeMs = Math.round(nowMs()); + const timeMs = Math.round(frameTimeMs); this.inputHistory.record(direction, timeMs); return timeMs; } public acknowledge( clientTimeMs: number, - bodyVelocity: vec2, + movement: CharacterMovementSnapshot, lastLeapClientTimeMs: number, ackAgeMs = 0, ): void { - // Inputs only advance the acknowledgement forward; the launch momentum and + // Inputs only advance the acknowledgement forward; the movement state and // leap boundary always adopt the latest authoritative values. const anchor = clientTimeMs + Math.max(0, ackAgeMs); if (this.replayAnchorMs === undefined || anchor > this.replayAnchorMs) { this.replayAnchorMs = anchor; } - vec2.set(this.authoritativeBodyVelocity, bodyVelocity[0], bodyVelocity[1]); + this.movement = movement; this.lastLeapAckMs = lastLeapClientTimeMs; } @@ -168,7 +172,7 @@ export class LocalCharacterPredictor { // it — a rejected leap (no strength/cooldown) self-corrects via the streamed // authoritative momentum. public recordLeap(): number { - const timeMs = Math.round(nowMs()); + const timeMs = Math.round(frameTimeMs); this.leapHistory.push(timeMs); const cutoff = timeMs - 1500; while (this.leapHistory.length > 0 && this.leapHistory[0] <= cutoff) { @@ -191,16 +195,18 @@ export class LocalCharacterPredictor { this.lastLeapAckMs = -Infinity; this.authoritative = undefined; this.replayAnchorMs = undefined; - vec2.zero(this.authoritativeBodyVelocity); + this.movement = undefined; this.currentStrength = settings.playerMaxStrength; - this.carriedPlanetId = undefined; - this.carriedSecondsSinceSurface = 1; this.hasRender = false; this.alive = true; } public get canPredict(): boolean { - return this.authoritative !== undefined && this.replayAnchorMs !== undefined; + return ( + this.authoritative !== undefined && + this.movement !== undefined && + this.replayAnchorMs !== undefined + ); } // During spawn-in and death the server freezes walking and only scales the @@ -237,22 +243,10 @@ export class LocalCharacterPredictor { return true; } - // The body's facing angle is encoded in the pose: each part is sprung toward - // center + R(direction)*offset and the head's offset points +y, so - // direction = atan2(head - center) - PI/2. Re-deriving it from the snapshot - // each frame (rather than carrying the previous frame's evolved value onto - // this past pose, re-evolved by a variable substep count) keeps the posture - // seed a pure function of the snapshot — carrying it fed a frame-rate-dependent - // loop that wobbled the rendered limbs. - private directionFromPose(head: Circle, leftFoot: Circle, rightFoot: Circle): number { - const cx = (head.center[0] + leftFoot.center[0] + rightFoot.center[0]) / 3; - const cy = (head.center[1] + leftFoot.center[1] + rightFoot.center[1]) / 3; - return Math.atan2(head.center[1] - cy, head.center[0] - cx) - Math.PI / 2; - } - private simulate(): CharacterMovementState { const auth = this.authoritative!; - const now = Math.round(nowMs()); + const movement = this.movement!; + const now = Math.round(frameTimeMs); // Replay every input the server has not confirmed yet: from the anchor up to // now, clamped so a stall (or a backgrounded tab catching up) cannot grind // through hundreds of steps. @@ -261,16 +255,30 @@ export class LocalCharacterPredictor { const steps = Math.floor(windowMs / stepMs); const remainderSeconds = (windowMs - steps * stepMs) / 1000; + // The full authoritative state: the pose from the property updates, the + // rest from the acknowledgement. Nothing here is inferred or carried over + // from the previous frame, so a replay is a pure function of (snapshot, + // input history, window) and cannot drift. const state: CharacterMovementState = { - head: makeBody(auth.head.center, auth.head.radius), - leftFoot: makeBody(auth.leftFoot.center, auth.leftFoot.radius), - rightFoot: makeBody(auth.rightFoot.center, auth.rightFoot.radius), - direction: this.directionFromPose(auth.head, auth.leftFoot, auth.rightFoot), - currentPlanet: this.world.surfaceById(this.carriedPlanetId), - secondsSinceOnSurface: this.carriedSecondsSinceSurface, + // The head's contact normal is written by the collision but never read by + // the movement, so it does not need to travel. + head: makeBody(auth.head.center, auth.head.radius, upwards), + leftFoot: makeBody( + auth.leftFoot.center, + auth.leftFoot.radius, + movement.leftFootNormal, + ), + rightFoot: makeBody( + auth.rightFoot.center, + auth.rightFoot.radius, + movement.rightFootNormal, + ), + direction: movement.direction, + currentPlanet: this.world.surfaceById(movement.groundPlanetId), + secondsSinceOnSurface: movement.secondsSinceOnSurface, // Leaps before the replay window are already baked into this; leaps inside // the window are re-applied below, so neither is double-counted. - bodyVelocity: vec2.clone(this.authoritativeBodyVelocity), + bodyVelocity: vec2.fromValues(movement.bodyVelocity[0], movement.bodyVelocity[1]), }; // The planet collision frames were synced (in update(), just before this) @@ -332,9 +340,6 @@ export class LocalCharacterPredictor { this.world.advance(remainderSeconds); } - this.carriedPlanetId = this.world.idOf(state.currentPlanet); - this.carriedSecondsSinceSurface = state.secondsSinceOnSurface; - return state; } diff --git a/shared/src/commands/types/input-acknowledgement.ts b/shared/src/commands/types/input-acknowledgement.ts index f11021b..077ac4d 100644 --- a/shared/src/commands/types/input-acknowledgement.ts +++ b/shared/src/commands/types/input-acknowledgement.ts @@ -1,29 +1,72 @@ import { vec2 } from 'gl-matrix'; +import { Id } from '../../communication/id'; import { serializable } from '../../serialization/serializable'; import { Command } from '../command'; +/** + * Everything the character's movement simulation carries from one tick to the + * next that is *not* in the pose. + * + * The pose (head/feet circles) travels as ordinary property updates because + * every viewer needs it; this travels to the owning client only. Together they + * are the complete state `stepCharacterMovement` needs, so the predictor can + * resume the server's simulation rather than approximate it. + * + * It all has to be here. `direction` in particular is not recoverable from the + * pose: the body is sprung *towards* the posture that direction implies and + * lags it while walking, so deriving the angle back out of a walking pose is + * off by tens of degrees — and every replay then spent its first ticks + * twisting the body back, by an amount that varied with the replay length. + */ +@serializable +export class CharacterMovementSnapshot { + public constructor( + public readonly direction: number, + // Persistent launch momentum (leap / slingshot / recoil), so the predictor + // reproduces a flight rather than only snapping to it. + public readonly bodyVelocity: vec2, + // Foot contact normals: the on-surface branch projects gravity onto these, + // so a body standing on a slope needs the real ones. + public readonly leftFootNormal: vec2, + public readonly rightFootNormal: vec2, + // The planet being stood on, or null while airborne. + public readonly groundPlanetId: Id, + public readonly secondsSinceOnSurface: number, + ) {} + + public toArray(): Array { + return [ + this.direction, + this.bodyVelocity, + this.leftFootNormal, + this.rightFootNormal, + this.groundPlanetId, + this.secondsSinceOnSurface, + ]; + } +} + // Sent server -> owning client only, alongside that client's own character -// snapshot. Carries the clientTimeMs of the most recent movement input the -// server had received when the snapshot was taken (so the client's predictor -// can reset to the snapshot and replay just the inputs the server hasn't seen -// yet) and the authoritative launch momentum (so the predictor reproduces a -// leap/slingshot/recoil flight rather than only snapping to it). See +// snapshot. Carries the clientTimeMs of the most recent input the server had +// received when the snapshot was taken (so the client's predictor can reset to +// the snapshot and replay just the inputs the server hasn't seen yet) and the +// authoritative movement state the replay resumes from. See // LocalCharacterPredictor. @serializable export class InputAcknowledgement extends Command { public constructor( public readonly clientTimeMs: number, - public readonly bodyVelocity: vec2, + public readonly movement: CharacterMovementSnapshot, // clientTimeMs of the most recent leap the server has received: any leap at - // or before it is already reflected in bodyVelocity, so the predictor must - // not replay it. + // or before it is already reflected in movement.bodyVelocity, so the + // predictor must not replay it. public readonly lastLeapClientTimeMs: number, // How long before this snapshot the acknowledged input arrived. The client - // sends at its frame rate, so by the time a snapshot is taken the newest - // input it has is already 0..1 frame old — and that age varies with where - // the client's frames happened to fall between two snapshots. Adding it - // back places the replay anchor at the snapshot instant itself instead of - // at whichever input happened to land last, which is what keeps the + // sends at a fixed cadence, so by the time a snapshot is taken the newest + // input it has is already 0..1 send interval old — and that age varies with + // where the client's sends happened to fall between two snapshots. Adding + // it back places the replay anchor at the snapshot instant itself instead + // of at whichever input happened to land last, which is what keeps the // predicted pose free of the send-cadence beat. See LocalCharacterPredictor. public readonly ackAgeMs: number = 0, ) { @@ -31,11 +74,6 @@ export class InputAcknowledgement extends Command { } public toArray(): Array { - return [ - this.clientTimeMs, - this.bodyVelocity, - this.lastLeapClientTimeMs, - this.ackAgeMs, - ]; + return [this.clientTimeMs, this.movement, this.lastLeapClientTimeMs, this.ackAgeMs]; } } diff --git a/shared/src/helper/rotate-minus-90-deg.ts b/shared/src/helper/rotate-minus-90-deg.ts deleted file mode 100644 index 3f4773f..0000000 --- a/shared/src/helper/rotate-minus-90-deg.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { vec2 } from 'gl-matrix'; - -export const rotateMinus90Deg = (vec: vec2): vec2 => vec2.fromValues(vec[1], -vec[0]); diff --git a/shared/src/helper/wait.ts b/shared/src/helper/wait.ts deleted file mode 100644 index 73dd2c2..0000000 --- a/shared/src/helper/wait.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const wait = (ms: number): Promise => { - return new Promise((resolve, _) => setTimeout(resolve, ms)); -}; diff --git a/test/client-send-cadence.test.ts b/test/client-send-cadence.test.ts index d80bc3a..61fa4f4 100644 --- a/test/client-send-cadence.test.ts +++ b/test/client-send-cadence.test.ts @@ -7,7 +7,7 @@ import { describe, it, expect } from 'vitest'; import { createRequire } from 'node:module'; import { CommandSocket } from '../frontend/src/scripts/commands/command-socket'; -import { setPredictorClockForTesting } from '../frontend/src/scripts/helper/prediction/local-character-predictor'; +import { setFrameTimeMs } from '../frontend/src/scripts/helper/prediction/local-character-predictor'; const require = createRequire(import.meta.url); const shared = require('../shared/lib/main.js'); @@ -18,16 +18,13 @@ applyArrayPlugins(); // Collects what would go on the wire. const drive = (frameRate: number, seconds: number, onFrame?: (socket: any) => void) => { - let clock = 0; - setPredictorClockForTesting(() => clock); - const batches: Array = []; const socket = { emit: (_event: string, payload: string) => batches.push(payload) }; const commandSocket = new CommandSocket(socket as never); const frames = Math.round(frameRate * seconds); for (let f = 0; f < frames; f++) { - clock = (f * 1000) / frameRate; + setFrameTimeMs((f * 1000) / frameRate); onFrame?.(commandSocket); commandSocket.sendQueuedCommands(); } diff --git a/test/prediction-reconciliation.test.ts b/test/prediction-reconciliation.test.ts index b3b4a5c..f155de6 100644 --- a/test/prediction-reconciliation.test.ts +++ b/test/prediction-reconciliation.test.ts @@ -1,15 +1,16 @@ // Reconciliation guard for the REAL client predictor. It exercises -// LocalCharacterPredictor end-to-end with an injected clock, verifying the two +// LocalCharacterPredictor end-to-end with a driven frame clock, verifying the // properties reconciliation depends on: // 1. a fully-acknowledged snapshot reproduces the authoritative pose exactly -// (no spurious drift on top of server truth), and -// 2. un-acknowledged input is replayed forward deterministically. +// (no spurious drift on top of server truth), +// 2. un-acknowledged input is replayed forward deterministically, and +// 3. the replayed body advances smoothly as the window grows. // This is the net for prediction-touching changes (the death-while-dead fix, // future netcode work) — a regression shows up as drift or non-determinism. import { describe, it, expect } from 'vitest'; import { LocalCharacterPredictor, - setPredictorClockForTesting, + setFrameTimeMs, } from '../frontend/src/scripts/helper/prediction/local-character-predictor'; const HEAD_RADIUS = 50; @@ -23,18 +24,33 @@ const poseAt = (cx: number, cy: number) => ({ rightFoot: { center: [cx + 33, cy - 18], radius: FEET_RADIUS }, }); +// The authoritative movement state that rides along with every snapshot: the +// pose is only half of what the simulation carries between ticks. Upright, +// airborne and with no launch momentum unless a test says otherwise. +const movementState = (direction = 0) => ({ + direction, + bodyVelocity: [0, 0], + leftFootNormal: [0, 1], + rightFootNormal: [0, 1], + groundPlanetId: null, + secondsSinceOnSurface: 1, +}); + describe('local prediction reconciliation', () => { it('reproduces the authoritative pose exactly when all input is acknowledged', () => { - let clock = 1000; - setPredictorClockForTesting(() => clock); + setFrameTimeMs(1000); const predictor = new LocalCharacterPredictor(); const auth = poseAt(500, 500); const t = predictor.recordInput([1, 0]); - predictor.acknowledge(t, [0, 0], -Infinity); // server has consumed this input + predictor.acknowledge(t, movementState() as never, -Infinity); // server has consumed this input predictor.setStrength(80); - predictor.setAuthoritative(auth.head as never, auth.leftFoot as never, auth.rightFoot as never); + predictor.setAuthoritative( + auth.head as never, + auth.leftFoot as never, + auth.rightFoot as never, + ); // No clock advance → zero replay window → predicted pose == authoritative. const used = predictor.update([], 1 / 60); @@ -48,18 +64,19 @@ describe('local prediction reconciliation', () => { it('replays un-acknowledged input deterministically', () => { const drive = () => { - let clock = 0; - setPredictorClockForTesting(() => clock); - const predictor = new LocalCharacterPredictor(); - clock = 1000; - predictor.acknowledge(900, [0, 0], -Infinity); // baseline ack (older than the input below) + setFrameTimeMs(1000); + predictor.acknowledge(900, movementState() as never, -Infinity); // baseline ack (older than the input below) predictor.setStrength(80); const auth = poseAt(0, 0); - predictor.setAuthoritative(auth.head as never, auth.leftFoot as never, auth.rightFoot as never); + predictor.setAuthoritative( + auth.head as never, + auth.leftFoot as never, + auth.rightFoot as never, + ); predictor.recordInput([1, 0]); // unacked rightward input at t=1000 - clock = 1100; // replay ~100 ms forward + setFrameTimeMs(1100); // replay ~100 ms forward predictor.update([], 1 / 60); return [ predictor.head.center[0], @@ -77,18 +94,21 @@ describe('local prediction reconciliation', () => { }); it('suppresses prediction while the local player is dead', () => { - let clock = 5000; - setPredictorClockForTesting(() => clock); + setFrameTimeMs(5000); const predictor = new LocalCharacterPredictor(); const auth = poseAt(200, 200); const t = predictor.recordInput([1, 0]); - predictor.acknowledge(t, [0, 0], -Infinity); + predictor.acknowledge(t, movementState() as never, -Infinity); predictor.setStrength(80); - predictor.setAuthoritative(auth.head as never, auth.leftFoot as never, auth.rightFoot as never); + predictor.setAuthoritative( + auth.head as never, + auth.leftFoot as never, + auth.rightFoot as never, + ); predictor.setAlive(false); // dead, awaiting respawn - clock = 5200; // input + elapsed time that would otherwise be replayed forward + setFrameTimeMs(5200); // input + elapsed time that would otherwise be replayed forward // Even with a valid authoritative pose and pending input, a dead body must // not be predicted/moved — that was the "move while dead" bug. expect(predictor.update([], 1 / 60)).toBe(false); @@ -100,22 +120,19 @@ describe('local prediction reconciliation', () => { // and the local body sat a full one-way trip behind the server. it('replays the whole un-acknowledged span, so a longer trip predicts further', () => { const driveWithUnackedSpan = (ackAgeMs: number) => { - let clock = 0; - setPredictorClockForTesting(() => clock); - const predictor = new LocalCharacterPredictor(); predictor.setStrength(80); // The key went down well before the acknowledged span and is still held — // the case that used to freeze the acknowledgement, because movement is // only transmitted when it changes. - clock = 9_000; + setFrameTimeMs(9_000); predictor.recordInput([1, 0]); - clock = 10_000; + setFrameTimeMs(10_000); const auth = poseAt(0, 0); // The server has confirmed input only up to `ackAgeMs` ago; the snapshot // carrying that acknowledgement arrives now. - predictor.acknowledge(clock - ackAgeMs, [0, 0], -Infinity); + predictor.acknowledge(10_000 - ackAgeMs, movementState() as never, -Infinity); predictor.setAuthoritative( auth.head as never, auth.leftFoot as never, @@ -134,32 +151,30 @@ describe('local prediction reconciliation', () => { // a 40 ms one; when the window was pinned to the snapshot they were equal. expect(longTrip).toBeGreaterThan(shortTrip * 2); }); + // ...but the anchor must not also inherit the AGE of whichever input happened // to be the newest one the server had when it took the snapshot. Input is sent - // once per frame, so that age is 0..1 frame depending on where the client's - // frames fell between two snapshots, and it walks a sawtooth: measured against - // a real server on localhost it ran 5, 16, 12, 8, 3, 14, 9, 1 ms and so on. - // Left in the window, it jumped the predicted body by up to a frame of travel - // 25 times a second — the local body was choppy on a link with no latency at - // all to hide it. The server reports the age with the acknowledgement so the - // anchor lands on the snapshot instant instead. + // at a fixed cadence, so that age is 0..1 send interval depending on where the + // client's sends fell between two snapshots, and it walks a sawtooth: measured + // against a real server on localhost it ran 5, 16, 12, 8, 3, 14, 9, 1 ms and so + // on. Left in the window, it jumped the predicted body by up to a frame of + // travel 25 times a second — the local body was choppy on a link with no + // latency at all to hide it. The server reports the age with the + // acknowledgement so the anchor lands on the snapshot instant instead. it('cancels the age of the acknowledged input, so the send cadence cannot beat', () => { // Both runs describe the SAME snapshot: taken 20 ms ago, of the same pose. // They differ only in how long before it the server's newest input arrived. const snapshotAgeMs = 20; const drive = (inputAgeMs: number, serverReportsAge: boolean) => { - let clock = 0; - setPredictorClockForTesting(() => clock); - const predictor = new LocalCharacterPredictor(); predictor.setStrength(80); - clock = 9_000; + setFrameTimeMs(9_000); predictor.recordInput([1, 0]); // held down throughout - clock = 10_000; + setFrameTimeMs(10_000); predictor.acknowledge( - clock - snapshotAgeMs - inputAgeMs, - [0, 0], + 10_000 - snapshotAgeMs - inputAgeMs, + movementState() as never, -Infinity, serverReportsAge ? inputAgeMs : 0, ); @@ -179,4 +194,57 @@ describe('local prediction reconciliation', () => { // 16 ms moves the body, so the assertion above does not hold vacuously. expect(Math.abs(drive(16, false) - drive(0, false))).toBeGreaterThan(0.5); }); + + // The jitter this file exists to catch. A walking body's posture springs lag, + // so its head trails the upright position — and the facing direction is NOT + // recoverable from such a pose: the angle you get back is tens of degrees off. + // The predictor used to derive it that way rather than take the server's, so + // every replay opened by twisting the body towards the pose-implied posture, + // by an amount that depended on how many ticks the window happened to contain. + // The rendered body then advanced a different distance each frame. + it('replays the streamed facing direction, not one guessed from the pose', () => { + // Head pulled 35 units behind the upright position: the steady-state posture + // lag of a body walking to the right. atan2 on this pose gives ~0.57 rad; + // the server's own direction is 0. + const walkingPose = () => ({ + head: { center: [-35, 37], radius: HEAD_RADIUS }, + leftFoot: { center: [-33, -18], radius: FEET_RADIUS }, + rightFoot: { center: [33, -18], radius: FEET_RADIUS }, + }); + + // One snapshot interval of replay, which is as much as reconciliation ever + // asks for on a healthy link. + const predictWithDirection = (direction: number) => { + const predictor = new LocalCharacterPredictor(); + predictor.setStrength(80); + setFrameTimeMs(0); + predictor.recordInput([1, 0]); + predictor.acknowledge(0, movementState(direction) as never, -Infinity); + const auth = walkingPose(); + predictor.setAuthoritative( + auth.head as never, + auth.leftFoot as never, + auth.rightFoot as never, + ); + + // A fresh predictor renders its first pose by snapping, so this is the raw + // prediction with no easing laid over it. + setFrameTimeMs(40); + predictor.update([], 1 / 60); + return [predictor.head.center[0], predictor.head.center[1]]; + }; + + const upright = predictWithDirection(0); + const asThePoseImplies = predictWithDirection(0.566); + const apart = Math.hypot( + upright[0] - asThePoseImplies[0], + upright[1] - asThePoseImplies[1], + ); + + // The guess moved the body by a visible amount every 40 ms — and by a + // different amount each frame, because the correction it triggers decays + // over the replay. If the streamed direction were ignored again the two + // would come out identical (this was exactly 0 before the fix). + expect(apart).toBeGreaterThan(1); + }); }); -- 2.54.0 From b9274a42c72f6e458fad8a9f78da1f8898a304ec Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sat, 22 Aug 2026 15:29:33 +0100 Subject: [PATCH 03/15] clean up --- backend/src/game-server.ts | 25 +-- backend/src/objects/character-physical.ts | 203 +++++++----------- backend/src/objects/circle-physical.ts | 17 +- backend/src/objects/lamp-physical.ts | 10 +- backend/src/objects/planet-physical.ts | 100 +++------ backend/src/objects/projectile-physical.ts | 40 ++-- .../bounding-boxes/bounding-box-base.ts | 10 - .../physics/bounding-boxes/bounding-box.ts | 5 - .../functions/get-bounding-box-of-circle.ts | 3 +- backend/src/players/npc.ts | 34 +-- backend/src/players/player-base.ts | 45 +++- backend/src/players/player-container.ts | 6 +- backend/src/players/player.ts | 85 ++++---- frontend/src/index.ts | 2 +- .../src/scripts/commands/touch-listener.ts | 7 +- frontend/src/scripts/configuration.ts | 12 +- frontend/src/scripts/game.ts | 15 +- .../prediction/local-character-predictor.ts | 7 +- frontend/src/scripts/join-form-handler.ts | 8 +- .../scripts/objects/types/character-view.ts | 24 +-- .../src/scripts/objects/types/planet-view.ts | 14 +- shared/src/commands/types/game-end.ts | 14 +- shared/src/helper/charge.ts | 10 + shared/src/helper/finite.ts | 1 - shared/src/main.ts | 1 - shared/src/objects/game-object.ts | 6 +- shared/src/objects/types/planet-base.ts | 11 + shared/src/physics/character-movement.ts | 41 ++-- shared/src/physics/evaluate-sdf.ts | 18 +- shared/src/physics/march-circle.ts | 2 +- shared/src/physics/planet-sdf.ts | 49 +++-- shared/src/physics/sdf-normal.ts | 19 +- shared/src/settings.ts | 1 - .../physics-determinism.test.mjs.snap | 2 + test/physics-determinism.test.mjs | 88 ++++++++ 35 files changed, 479 insertions(+), 456 deletions(-) diff --git a/backend/src/game-server.ts b/backend/src/game-server.ts index ce02d58..795eb34 100644 --- a/backend/src/game-server.ts +++ b/backend/src/game-server.ts @@ -48,9 +48,6 @@ export class GameServer extends CommandReceiver { private isInEndGame = false; private timeScaling = 1; - private serverName: string; - private playerLimit: number; - private initialize() { const previousPlayers = this.players; this.releaseJoinedSockets(); @@ -85,9 +82,6 @@ export class GameServer extends CommandReceiver { ) { super(); - this.serverName = options.name; - this.playerLimit = options.playerLimit; - this.initialize(); io.on('connection', (socket: Socket) => { @@ -274,14 +268,9 @@ export class GameServer extends CommandReceiver { this.isInEndGame = true; const endTitleLength = 6; this.players.endGame(winningTeam); - this.players.queueCommandForEachClient( - new GameEndCommand(winningTeam, endTitleLength, true), - ); - setTimeout(() => this.destroy(), endTitleLength * 1000 * 1.1); - } - - private destroy() { - this.initialize(); + this.players.queueCommandForEachClient(new GameEndCommand()); + // Rebuild the world for the next round once the end card has been shown. + setTimeout(() => this.initialize(), endTitleLength * 1000 * 1.1); } private timeSinceLastPointUpdate = 0; @@ -377,14 +366,14 @@ export class GameServer extends CommandReceiver { rtts.sort((a, b) => a - b); console.info( `Player RTT median ${rtts[Math.floor(rtts.length / 2)].toFixed(0)} ms ` + - `(min ${rtts[0].toFixed(0)}, max ${rtts[rtts.length - 1].toFixed(0)}, n=${rtts.length})`, + `(min ${rtts[0].toFixed(0)}, max ${rtts[rtts.length - 1].toFixed(0)}, n=${rtts.length})`, ); } if (this.droppedInboundMessages > 0) { console.warn( `Rate limited ${this.droppedInboundMessages} inbound message(s) since last report — ` + - 'client input was discarded', + 'client input was discarded', ); this.droppedInboundMessages = 0; } @@ -406,9 +395,9 @@ export class GameServer extends CommandReceiver { public get serverInfo(): ServerInformation { return { - serverName: this.serverName, + serverName: this.options.name, playerCount: this.players.count, - playerLimit: this.playerLimit, + playerLimit: this.options.playerLimit, gameStatePercent: this.gameProgress, }; } diff --git a/backend/src/objects/character-physical.ts b/backend/src/objects/character-physical.ts index ae72c0f..5eb57ca 100644 --- a/backend/src/objects/character-physical.ts +++ b/backend/src/objects/character-physical.ts @@ -15,10 +15,12 @@ import { CharacterMovementSnapshot, mix, clamp01, + strengthToCharge, stepCharacterMovement, applyLeapImpulse, decayMomentum, - CharacterMovementState, + tickPlanetDetachment, + characterCenter, CharacterWorld, GroundSurface, headRadius, @@ -40,6 +42,14 @@ import { StepCommand } from '../commands/step'; import { ReactToCollisionCommand } from '../commands/react-to-collision'; import { GeneratePointsCommand } from '../commands/generate-points'; +// The three body circles, as a value — used to diff a tick's movement into the +// rate of change the client interpolates on. +interface BodyPose { + head: Circle; + leftFoot: Circle; + rightFoot: Circle; +} + @serializesTo(CharacterBase) export class CharacterPhysical extends CharacterBase implements DynamicPhysical { public readonly canCollide = true; @@ -47,13 +57,6 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical private projectileStrength = settings.playerMaxStrength; - // Body geometry (head/foot radii, posture offsets, bound radius) is defined - // once in the shared movement module and imported here, so the authoritative - // body and the client's predicted body are bit-identical by construction - // instead of by a hand-synced "copied verbatim" duplicate. Re-exposed as a - // static only because external callers reference CharacterPhysical.boundRadius. - public static readonly boundRadius = boundRadius; - private timeSinceDying = 0; private isDestroyed = false; private timeSinceBorn = 0; @@ -66,20 +69,20 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical private killStreak = 0; - private direction = 0; - private currentPlanet?: PlanetPhysical; - private secondsSinceOnSurface = settings.planetDetachmentSeconds; + // CharacterMovementState, held directly rather than mirrored through an + // adapter: the shared simulation reads and writes these in place, so the + // character simply IS the state it is stepped as. + public direction = 0; + public currentPlanet: GroundSurface | undefined; + public secondsSinceOnSurface = settings.planetDetachmentSeconds; + public readonly bodyVelocity = vec2.create(); - private bodyVelocity = vec2.create(); private timeSinceLastLeap = settings.leapCooldownSeconds; public head: CirclePhysical; public leftFoot: CirclePhysical; public rightFoot: CirclePhysical; - private movementState!: CharacterMovementState; - private movementWorld!: CharacterWorld; - private movementActions: Array = []; private lastMovementAction: MoveActionCommand = new MoveActionCommand(vec2.create()); @@ -122,62 +125,29 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical container.addObject(this.head); container.addObject(this.leftFoot); container.addObject(this.rightFoot); - - this.initMovementBridge(); } - private initMovementBridge() { - // The movementState object-literal getters/setters below can't use `this` - // (it would bind to the literal), so alias the character instance. - // eslint-disable-next-line @typescript-eslint/no-this-alias - const self = this; - this.movementState = { - head: this.head, - leftFoot: this.leftFoot, - rightFoot: this.rightFoot, - get direction() { - return self.direction; - }, - set direction(value: number) { - self.direction = value; - }, - get currentPlanet() { - return self.currentPlanet; - }, - set currentPlanet(value: GroundSurface | undefined) { - // On the server every ground is a PlanetPhysical (the world only ever - // hands back planets), so this narrowing is safe. - self.currentPlanet = value as PlanetPhysical | undefined; - }, - get secondsSinceOnSurface() { - return self.secondsSinceOnSurface; - }, - set secondsSinceOnSurface(value: number) { - self.secondsSinceOnSurface = value; - }, - bodyVelocity: this.bodyVelocity, - }; - - this.movementWorld = { - // Same set and order forceAtPosition used: planets in the force field, - // in container-traversal order (so the f64 gravity sum is unchanged). - groundsNear: (center, radius) => - self.container - .findIntersecting(getBoundingBoxOfCircle(new Circle(center, radius))) - .filter((o): o is PlanetPhysical => o instanceof PlanetPhysical), - stepBody: (body, deltaTimeInSeconds) => { - const { hitObject } = (body as CirclePhysical).stepManually(deltaTimeInSeconds); - return hitObject instanceof PlanetPhysical ? hitObject : undefined; - }, - }; - } + // The container, presented as the collision/gravity world the shared movement + // queries. + private readonly movementWorld: CharacterWorld = { + // Same set and order forceAtPosition used: planets in the force field, in + // container-traversal order (so the f64 gravity sum is unchanged). + groundsNear: (center, radius) => + this.container + .findIntersecting(getBoundingBoxOfCircle(new Circle(center, radius))) + .filter((o): o is PlanetPhysical => o instanceof PlanetPhysical), + stepBody: (body, deltaTimeInSeconds) => { + const { hitObject } = (body as CirclePhysical).stepManually(deltaTimeInSeconds); + return hitObject instanceof PlanetPhysical ? hitObject : undefined; + }, + }; private hasFiredSinceSpawn = false; private get isSpawnProtected(): boolean { return ( !this.hasFiredSinceSpawn && this.timeAlive < - settings.spawnDespawnTime + settings.spawnInvulnerabilityExtraSeconds + settings.spawnDespawnTime + settings.spawnInvulnerabilityExtraSeconds ); } @@ -206,8 +176,10 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical this.movementActions.push(c); } + // The world only ever hands back planets, so narrowing the shared + // GroundSurface is safe here. public get groundPlanet(): PlanetPhysical | undefined { - return this.currentPlanet; + return this.currentPlanet as PlanetPhysical | undefined; } // The continuous movement state streamed to the owning client, so its @@ -219,7 +191,7 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical vec2.clone(this.bodyVelocity), vec2.clone(this.leftFoot.lastNormal), vec2.clone(this.rightFoot.lastNormal), - this.currentPlanet?.id ?? null, + this.groundPlanet?.id ?? null, this.secondsSinceOnSurface, ); } @@ -311,10 +283,7 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical const strength = Math.min(desiredStrength, this.projectileStrength); this.projectileStrength -= strength; - const c = clamp01( - (strength - settings.chargeShotStrengthMin) / - (settings.chargeShotStrengthMax - settings.chargeShotStrengthMin), - ); + const c = strengthToCharge(strength); const radius = mix(settings.chargeShotRadiusMin, settings.chargeShotRadiusMax, c); const speed = mix(settings.chargeShotSpeedMin, settings.chargeShotSpeedMax, c); @@ -367,12 +336,12 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical // Same impulse the client predicts with (shared), so a leap launches // identically on both sides. - applyLeapImpulse(this.movementState, this.lastMovementAction.direction); + applyLeapImpulse(this, this.lastMovementAction.direction); this.remoteCall('onLeap'); } public get boundingBox(): BoundingBoxBase { - return getBoundingBoxOfCircle(new Circle(this.center, CharacterPhysical.boundRadius)); + return getBoundingBoxOfCircle(new Circle(this.center, boundRadius)); } public get gameObject(): this { @@ -380,9 +349,7 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical } public get center(): vec2 { - const bodyCenter = vec2.add(vec2.create(), this.head.center, this.leftFoot.center); - vec2.add(bodyCenter, bodyCenter, this.rightFoot.center); - return vec2.scale(bodyCenter, bodyCenter, 1 / 3); + return characterCenter(this.head, this.leftFoot, this.rightFoot); } public distance(target: vec2): number { @@ -434,53 +401,42 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical ]); } - private setPropertyUpdates( - oldHead: Circle, - oldLeftFoot: Circle, - oldRightFoot: Circle, - deltaTime: number, - ) { - this.headVelocity = new Circle( + // Rate of change of one body part over the tick, which the client's + // interpolator coasts on when a snapshot is late. `previous` is consumed as + // scratch so this allocates nothing beyond the Circle it returns. + private static rateOfChange(previous: Circle, current: Circle, deltaTime: number) { + return new Circle( vec2.scale( - oldHead.center, - vec2.subtract(oldHead.center, this.head.center, oldHead.center), + previous.center, + vec2.subtract(previous.center, current.center, previous.center), 1 / deltaTime, ), - (this.head.radius - oldHead.radius) / deltaTime, + (current.radius - previous.radius) / deltaTime, ); + } - this.leftFootVelocity = new Circle( - vec2.scale( - oldLeftFoot.center, - vec2.subtract(oldLeftFoot.center, this.leftFoot.center, oldLeftFoot.center), - 1 / deltaTime, - ), - (this.leftFoot.radius - oldLeftFoot.radius) / deltaTime, - ); + private setPropertyUpdates(previous: BodyPose, deltaTime: number) { + const rate = CharacterPhysical.rateOfChange; + this.headVelocity = rate(previous.head, this.head, deltaTime); + this.leftFootVelocity = rate(previous.leftFoot, this.leftFoot, deltaTime); + this.rightFootVelocity = rate(previous.rightFoot, this.rightFoot, deltaTime); + } - this.rightFootVelocity = new Circle( - vec2.scale( - oldRightFoot.center, - vec2.subtract(oldRightFoot.center, this.rightFoot.center, oldRightFoot.center), - 1 / deltaTime, - ), - (this.rightFoot.radius - oldRightFoot.radius) / deltaTime, - ); + private get pose(): BodyPose { + const snapshot = (circle: Circle) => + new Circle(vec2.clone(circle.center), circle.radius); + return { + head: snapshot(this.head), + leftFoot: snapshot(this.leftFoot), + rightFoot: snapshot(this.rightFoot), + }; } private step({ deltaTimeInSeconds, game }: StepCommand) { this.getPoints(game); this.timeAlive += deltaTimeInSeconds; this.timeSinceLastLeap += deltaTimeInSeconds; - const oldHead = new Circle(vec2.clone(this.head.center), this.head.radius); - const oldLeftFoot = new Circle( - vec2.clone(this.leftFoot.center), - this.leftFoot.radius, - ); - const oldRightFoot = new Circle( - vec2.clone(this.rightFoot.center), - this.rightFoot.radius, - ); + const previousPose = this.pose; if (this.isDestroyed) { if ((this.timeSinceDying += deltaTimeInSeconds) > settings.spawnDespawnTime) { @@ -489,7 +445,7 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical this.freeFallCorpse(deltaTimeInSeconds); this.animateScaling(1 - this.timeSinceDying / settings.spawnDespawnTime); } - this.setPropertyUpdates(oldHead, oldLeftFoot, oldRightFoot, deltaTimeInSeconds); + this.setPropertyUpdates(previousPose, deltaTimeInSeconds); return; } @@ -500,30 +456,25 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical } else { this.animateScaling(this.timeSinceBorn / settings.spawnDespawnTime); } - this.setPropertyUpdates(oldHead, oldLeftFoot, oldRightFoot, deltaTimeInSeconds); + this.setPropertyUpdates(previousPose, deltaTimeInSeconds); return; } - if ( - (this.secondsSinceOnSurface += deltaTimeInSeconds) > - settings.planetDetachmentSeconds - ) { - this.currentPlanet = undefined; - } + tickPlanetDetachment(this, deltaTimeInSeconds); this.timeSinceLastShot += deltaTimeInSeconds; this.projectileStrength = Math.min( settings.playerMaxStrength, this.projectileStrength + - settings.playerStrengthRegenerationPerSeconds * deltaTimeInSeconds, + settings.playerStrengthRegenerationPerSeconds * deltaTimeInSeconds, ); this.regenerateHealth(deltaTimeInSeconds); // The planet tallies who is standing on it and resolves capture itself, so // a contested rock can freeze instead of two squads silently cancelling. - this.currentPlanet?.registerPresence(this); + this.groundPlanet?.registerPresence(this); // The whole walking model — gravity gather, movement force, on/off-planet // branch, posture springs, body-momentum, and stepping the three parts — @@ -531,23 +482,15 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical // drift. Server-only concerns (scoring, health, shooting, spawn/death, // ownership) stay here around it. const direction = this.averageAndResetMovementActions(); - stepCharacterMovement( - this.movementState, - this.movementWorld, - direction, - deltaTimeInSeconds, - ); + stepCharacterMovement(this, this.movementWorld, direction, deltaTimeInSeconds); - this.setPropertyUpdates(oldHead, oldLeftFoot, oldRightFoot, deltaTimeInSeconds); + this.setPropertyUpdates(previousPose, deltaTimeInSeconds); } private freeFallCorpse(deltaTime: number) { const intersecting = this.container.findIntersecting( getBoundingBoxOfCircle( - new Circle( - this.center, - CharacterPhysical.boundRadius + settings.maxGravityDistance, - ), + new Circle(this.center, boundRadius + settings.maxGravityDistance), ), ); let grounded = false; diff --git a/backend/src/objects/circle-physical.ts b/backend/src/objects/circle-physical.ts index 037708f..9554ace 100644 --- a/backend/src/objects/circle-physical.ts +++ b/backend/src/objects/circle-physical.ts @@ -1,5 +1,6 @@ import { vec2 } from 'gl-matrix'; import { + applyForce, Circle, CommandExecutors, CommandReceiver, @@ -12,6 +13,7 @@ import { BoundingBoxBase } from '../physics/bounding-boxes/bounding-box-base'; import { PhysicalContainer } from '../physics/containers/physical-container'; import { DynamicPhysical } from '../physics/physicals/dynamic-physical'; import { Physical } from '../physics/physicals/physical'; +import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle'; import { ReactToCollisionCommand } from '../commands/react-to-collision'; @serializesTo(Circle) @@ -84,11 +86,7 @@ export class CirclePhysical extends CommandReceiver implements Circle, DynamicPh } public applyForce(force: vec2, timeInSeconds: number) { - vec2.add( - this.velocity, - this.velocity, - vec2.scale(vec2.create(), force, timeInSeconds), - ); + applyForce(this, force, timeInSeconds); } // Position-resolution for one tick. Delegates to the shared @@ -133,10 +131,11 @@ export class CirclePhysical extends CommandReceiver implements Circle, DynamicPh const sweep = vec2.length( vec2.scale(vec2.create(), this.velocity, deltaTimeInSeconds), ); - this.radius += sweep; - const intersecting = this.container.findIntersecting(this.boundingBox); - this.radius -= sweep; - return intersecting; + // Grow the query, not the object: inflating and deflating this.radius ran + // the setter (and so re-registered the body's extent) twice mid-tick. + return this.container.findIntersecting( + getBoundingBoxOfCircle(new Circle(this.center, this.radius + sweep)), + ); } public toArray(): Array { diff --git a/backend/src/objects/lamp-physical.ts b/backend/src/objects/lamp-physical.ts index 020c412..7438f7d 100644 --- a/backend/src/objects/lamp-physical.ts +++ b/backend/src/objects/lamp-physical.ts @@ -1,7 +1,8 @@ import { vec2, vec3 } from 'gl-matrix'; -import { LampBase, settings, id, serializesTo } from 'shared'; +import { Circle, LampBase, settings, id, serializesTo } from 'shared'; import { ImmutableBoundingBox } from '../physics/bounding-boxes/immutable-bounding-box'; +import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle'; import { StaticPhysical } from '../physics/physicals/static-physical'; @serializesTo(LampBase) @@ -17,11 +18,8 @@ export class LampPhysical extends LampBase implements StaticPhysical { public get boundingBox(): ImmutableBoundingBox { if (!this._boundingBox) { - this._boundingBox = new ImmutableBoundingBox( - this.center.x - settings.lightCutoffDistance, - this.center.x + settings.lightCutoffDistance, - this.center.y - settings.lightCutoffDistance, - this.center.y + settings.lightCutoffDistance, + this._boundingBox = getBoundingBoxOfCircle( + new Circle(this.center, settings.lightCutoffDistance), ); } diff --git a/backend/src/objects/planet-physical.ts b/backend/src/objects/planet-physical.ts index d6ea396..511049f 100644 --- a/backend/src/objects/planet-physical.ts +++ b/backend/src/objects/planet-physical.ts @@ -1,7 +1,7 @@ import { vec2 } from 'gl-matrix'; import { - clamp, + Circle, clamp01, id, mix, @@ -12,6 +12,8 @@ import { CharacterTeam, PropertyUpdatesForObject, UpdatePropertyCommand, + planetDistance, + planetGravity, CommandExecutors, CommandReceiver, } from 'shared'; @@ -20,6 +22,7 @@ import { AnnounceCommand } from '../commands/announce'; import { StepCommand } from '../commands/step'; import { ImmutableBoundingBox } from '../physics/bounding-boxes/immutable-bounding-box'; +import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle'; import { StaticPhysical } from '../physics/physicals/static-physical'; import { LampPhysical } from './lamp-physical'; import type { CharacterPhysical } from './character-physical'; @@ -69,7 +72,7 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical { const sizeClass = clamp01( (this.radius - settings.planetMinReferenceRadius) / - (settings.planetMaxReferenceRadius - settings.planetMinReferenceRadius), + (settings.planetMaxReferenceRadius - settings.planetMinReferenceRadius), ); this.sizePointMultiplier = mix(1, settings.planetSizePointMultiplierMax, sizeClass); @@ -85,59 +88,26 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical { } public distance(target: vec2): number { - // Evaluate the SDF in the planet's own rotating frame so this collision - // outline turns in lockstep with the rendered planet (see planet-shape.ts). - const local = this.toLocalFrame(target); - - const startEnd = this.vertices[0]; - let vb = startEnd; - - let d = vec2.dist(local, vb); - let sign = 1; - - for (let i = 1; i <= this.vertices.length; i++) { - const va = vb; - vb = i === this.vertices.length ? startEnd : this.vertices[i]; - const targetFromDelta = vec2.subtract(vec2.create(), local, va); - const toFromDelta = vec2.subtract(vec2.create(), vb, va); - const h = clamp01( - vec2.dot(targetFromDelta, toFromDelta) / vec2.squaredLength(toFromDelta), - ); - - const ds = vec2.fromValues( - vec2.dist(targetFromDelta, vec2.scale(vec2.create(), toFromDelta, h)), - toFromDelta.x * targetFromDelta.y - toFromDelta.y * targetFromDelta.x, - ); - - if ( - (local.y >= va.y && local.y < vb.y && ds.y > 0) || - (local.y < va.y && local.y >= vb.y && ds.y <= 0) - ) { - sign *= -1; - } - - d = Math.min(d, ds.x); - } - - return sign * d; + // The one shared planet outline — the same function the client predictor + // collides against, which is what lets prediction reconcile. Only the + // cos/sin memo lives here, because distance() is called many times per tick + // by the raymarcher and the angle rarely changes between those calls. + this.syncRotationTrigonometry(); + return planetDistance( + target, + this.vertices, + this.center, + this.cosRotation, + this.sinRotation, + ); } - // Rotate a world point by -rotation about the centre, matching the shader's - // `localTarget = center + R(rotation) * (target - center)` transform exactly. - private toLocalFrame(target: vec2): vec2 { + private syncRotationTrigonometry() { if (this.rotation !== this.cachedRotation) { this.cachedRotation = this.rotation; this.cosRotation = Math.cos(this.rotation); this.sinRotation = Math.sin(this.rotation); } - - const dx = target.x - this.center.x; - const dy = target.y - this.center.y; - - return vec2.fromValues( - this.center.x + this.cosRotation * dx - this.sinRotation * dy, - this.center.y + this.sinRotation * dx + this.cosRotation * dy, - ); } // Signed angular velocity in rad/s, exposed so a character standing on the @@ -146,14 +116,6 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical { return this.rotationSpeed; } - public get team(): CharacterTeam { - return Math.abs(this.ownership - 0.5) < settings.planetControlThreshold - ? CharacterTeam.neutral - : this.ownership < 0.5 - ? CharacterTeam.blue - : CharacterTeam.red; - } - private timeSinceLastPointGeneration = 0; private getPoints(game: CommandReceiver) { if (this.timeSinceLastPointGeneration > settings.planetPointGenerationInterval) { @@ -317,32 +279,22 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical { 0, ); - this._boundingBox = new ImmutableBoundingBox( - this.center.x - maxVertexDistance, - this.center.x + maxVertexDistance, - this.center.y - maxVertexDistance, - this.center.y + maxVertexDistance, + this._boundingBox = getBoundingBoxOfCircle( + new Circle(this.center, maxVertexDistance), ); } return this._boundingBox; } - public getForce(position: vec2): vec2 { - const diff = vec2.subtract(vec2.create(), this.center, position); - const dist = Math.max(settings.minGravityDistance, vec2.length(diff) - this.radius); - vec2.normalize(diff, diff); - const scale = clamp( - settings.maxGravityQ * ((settings.maxGravityDistance / dist) ** 1.5 - 1), - 0, - settings.maxGravityStrength, - ); - return vec2.scale(diff, diff, scale); + // GroundSurface gravity, shared with the client predictor. + public gravityAt(position: vec2): vec2 { + return planetGravity(this.center, this.radius, position); } - // GroundSurface alias the shared movement simulation calls for gravity. - public gravityAt(position: vec2): vec2 { - return this.getForce(position); + // forceAtPosition's name for the same thing. + public getForce(position: vec2): vec2 { + return this.gravityAt(position); } public get gameObject(): this { diff --git a/backend/src/objects/projectile-physical.ts b/backend/src/objects/projectile-physical.ts index 1de3872..c0d676f 100644 --- a/backend/src/objects/projectile-physical.ts +++ b/backend/src/objects/projectile-physical.ts @@ -11,7 +11,7 @@ import { Circle, marchCircle, } from 'shared'; -import { ImmutableBoundingBox } from '../physics/bounding-boxes/immutable-bounding-box'; +import { BoundingBoxBase } from '../physics/bounding-boxes/bounding-box-base'; import { CirclePhysical } from './circle-physical'; import { DynamicPhysical } from '../physics/physicals/dynamic-physical'; import { PhysicalContainer } from '../physics/containers/physical-container'; @@ -28,7 +28,6 @@ export class ProjectilePhysical extends ProjectileBase implements DynamicPhysica private isDestroyed = false; private bounceCount = 0; - private _boundingBox?: ImmutableBoundingBox; public object: CirclePhysical; @@ -107,12 +106,11 @@ export class ProjectilePhysical extends ProjectileBase implements DynamicPhysica } } - public get boundingBox(): ImmutableBoundingBox { - if (!this._boundingBox) { - this._boundingBox = (this.object as CirclePhysical).boundingBox; - } - - return this._boundingBox; + // The circle keeps its own box in sync as it moves, so this is a live view of + // it, not a snapshot — caching it saved nothing and mistyped a mutable box as + // immutable. + public get boundingBox(): BoundingBoxBase { + return this.object.boundingBox; } public get gameObject(): this { @@ -163,22 +161,18 @@ export class ProjectilePhysical extends ProjectileBase implements DynamicPhysica // This single broadphase is reused for the step below: the gravity radius // (maxGravityDistance) dwarfs one tick's travel, so the set is a superset of // the swept-collision box and the step needn't query the container again. - const intersecting = settings.projectileGravityEnabled - ? this.container.findIntersecting( - getBoundingBoxOfCircle( - new Circle(this.center, this.object.radius + settings.maxGravityDistance), - ), - ) - : undefined; + const intersecting = this.container.findIntersecting( + getBoundingBoxOfCircle( + new Circle(this.center, this.object.radius + settings.maxGravityDistance), + ), + ); - if (intersecting) { - vec2.scaleAndAdd( - this.velocity, - this.velocity, - forceAtPosition(this.center, intersecting), - settings.projectileGravityScale * deltaTimeInSeconds, - ); - } + vec2.scaleAndAdd( + this.velocity, + this.velocity, + forceAtPosition(this.center, intersecting), + settings.projectileGravityScale * deltaTimeInSeconds, + ); vec2.copy(this.object.velocity, this.velocity); const { velocity } = this.object.stepManually(deltaTimeInSeconds, intersecting); diff --git a/backend/src/physics/bounding-boxes/bounding-box-base.ts b/backend/src/physics/bounding-boxes/bounding-box-base.ts index 91946c9..ee881cf 100644 --- a/backend/src/physics/bounding-boxes/bounding-box-base.ts +++ b/backend/src/physics/bounding-boxes/bounding-box-base.ts @@ -1,5 +1,3 @@ -import { vec2 } from 'gl-matrix'; - // axis-aligned export abstract class BoundingBoxBase { constructor( @@ -43,14 +41,6 @@ export abstract class BoundingBoxBase { return this._yMax; } - public get topLeft(): vec2 { - return vec2.fromValues(this._xMin, this._yMax); - } - - public get size(): vec2 { - return vec2.fromValues(this._xMax - this._xMin, this._yMax - this._yMin); - } - public intersects(other: BoundingBoxBase): boolean { return ( this._xMin < other._xMax && diff --git a/backend/src/physics/bounding-boxes/bounding-box.ts b/backend/src/physics/bounding-boxes/bounding-box.ts index 562794f..1a00fb3 100644 --- a/backend/src/physics/bounding-boxes/bounding-box.ts +++ b/backend/src/physics/bounding-boxes/bounding-box.ts @@ -1,6 +1,5 @@ import { vec2 } from 'gl-matrix'; import { BoundingBoxBase } from './bounding-box-base'; -import { ImmutableBoundingBox } from './immutable-bounding-box'; export class BoundingBox extends BoundingBoxBase { public get xMin(): number { @@ -52,8 +51,4 @@ export class BoundingBox extends BoundingBoxBase { public get size(): vec2 { return vec2.fromValues(this._xMax - this._xMin, this._yMax - this._yMin); } - - public cloneAsImmutable(): ImmutableBoundingBox { - return new ImmutableBoundingBox(this.xMin, this.xMax, this.yMin, this.yMax); - } } diff --git a/backend/src/physics/functions/get-bounding-box-of-circle.ts b/backend/src/physics/functions/get-bounding-box-of-circle.ts index 9c5b2eb..43ef937 100644 --- a/backend/src/physics/functions/get-bounding-box-of-circle.ts +++ b/backend/src/physics/functions/get-bounding-box-of-circle.ts @@ -1,8 +1,7 @@ import { Circle } from 'shared'; -import { BoundingBoxBase } from '../bounding-boxes/bounding-box-base'; import { ImmutableBoundingBox } from '../bounding-boxes/immutable-bounding-box'; -export const getBoundingBoxOfCircle = (circle: Circle): BoundingBoxBase => +export const getBoundingBoxOfCircle = (circle: Circle): ImmutableBoundingBox => new ImmutableBoundingBox( circle.center.x - circle.radius, circle.center.x + circle.radius, diff --git a/backend/src/players/npc.ts b/backend/src/players/npc.ts index 655ade7..f7b4ffb 100644 --- a/backend/src/players/npc.ts +++ b/backend/src/players/npc.ts @@ -7,6 +7,7 @@ import { MoveActionCommand, CharacterTeam, Id, + rotate90Deg, } from 'shared'; import { PhysicalContainer } from '../physics/containers/physical-container'; import { PlayerContainer } from './player-container'; @@ -100,23 +101,9 @@ export class NPC extends PlayerBase { this.step(0); } - private timeUntilRespawn = 0; public step(deltaTimeInSeconds: number) { - if (this.character) { - this.center = this.character.center; - - if (!this.character.isAlive) { - this.sumDeaths++; - this.sumKills = this.character.killCount; - this.character = null; - this.timeUntilRespawn = settings.playerDiedTimeout; - return; - } - } else { - if ((this.timeUntilRespawn -= deltaTimeInSeconds) < 0) { - this.createCharacter(); - this.center = this.character!.center; - } + const character = this.stepLifecycle(deltaTimeInSeconds); + if (!character) { return; } @@ -143,16 +130,16 @@ export class NPC extends PlayerBase { this.dodgeCommitRemaining -= deltaTimeInSeconds; this.dodgeCooldownRemaining -= deltaTimeInSeconds; const movement = this.decideMovement(this.nearObjects); - this.character.handleMovementAction(new MoveActionCommand(movement)); + character.handleMovementAction(new MoveActionCommand(movement)); if ( !this.isComingBack && - this.character.groundPlanet && + character.groundPlanet && vec2.length(movement) > 0 && Random.getRandom() < npcTuning.leapChancePerSecond * this.aggression * deltaTimeInSeconds ) { - this.character.leap(); + character.leap(); } if ( @@ -178,18 +165,15 @@ export class NPC extends PlayerBase { const nearObjects = this.observe(npcTuning.planScanRadius); const enemies = this.enemiesByDistance(nearObjects); - if (enemies.length > 0) { - const nearest = enemies[0]; + const nearest = enemies[0]; + if (nearest) { const fleeRange = npcTuning.fleeBaseRange * (npcTuning.fleeAggressionFalloff - this.aggression); if (nearest.distance < fleeRange) { vec2.subtract(this.direction, this.center, nearest.character.center); return; } - } - if (enemies.length > 0) { - const nearest = enemies[0]; const chaseRange = npcTuning.chaseBaseRange + npcTuning.chaseAggressionRange * this.aggression; if (nearest.distance < chaseRange) { @@ -275,7 +259,7 @@ export class NPC extends PlayerBase { return undefined; } - const perpendicular = vec2.fromValues(-threat.direction.y, threat.direction.x); + const perpendicular = rotate90Deg(threat.direction); const toMe = vec2.subtract(vec2.create(), this.center, threat.center); if (vec2.dot(perpendicular, toMe) < 0) { vec2.negate(perpendicular, perpendicular); diff --git a/backend/src/players/player-base.ts b/backend/src/players/player-base.ts index c69e7ae..ba8fb43 100644 --- a/backend/src/players/player-base.ts +++ b/backend/src/players/player-base.ts @@ -7,6 +7,7 @@ import { Random, settings, sanitizeName, + boundRadius, } from 'shared'; import { PhysicalContainer } from '../physics/containers/physical-container'; import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle'; @@ -23,6 +24,7 @@ export abstract class PlayerBase extends CommandReceiver { protected sumKills = 0; protected sumDeaths = 0; + protected timeUntilRespawn = 0; constructor( protected readonly playerInfo: PlayerInformation, @@ -53,6 +55,44 @@ export abstract class PlayerBase extends CommandReceiver { public abstract step(deltaTimeInSeconds: number): void; + /** + * The death/respawn lifecycle both a connected player and an NPC go through: + * bank the score off a body that just died, hold the corpse for the respawn + * timeout, then build a new one. Returns whether there is a living character + * to act with this tick — returned as the character itself, so a subclass can + * `const character = this.stepLifecycle(dt); if (!character) return;` and go + * on to use it without re-narrowing. + */ + protected stepLifecycle(deltaTimeInSeconds: number): CharacterPhysical | undefined { + if (this.character) { + this.center = this.character.center; + + if (this.character.isAlive) { + return this.character; + } + + this.sumDeaths++; + this.sumKills = this.character.killCount; + this.onCharacterDied(this.character); + this.character = null; + this.timeUntilRespawn = settings.playerDiedTimeout; + return undefined; + } + + if ((this.timeUntilRespawn -= deltaTimeInSeconds) < 0) { + this.onBeforeRespawn(); + this.createCharacter(); + this.center = this.character!.center; + } + return undefined; + } + + // Hooks for what a subclass wants to do with the body it just lost, and just + // before a new one is built. Both are no-ops by default. + // eslint-disable-next-line @typescript-eslint/no-unused-vars + protected onCharacterDied(character: CharacterPhysical) {} + protected onBeforeRespawn() {} + private findSpawnCenter(): vec2 { const planets = this.objectContainer .findIntersecting( @@ -96,10 +136,7 @@ export abstract class PlayerBase extends CommandReceiver { radius * Math.sin(rotation) + preferredCenter.y, ); - const playerBoundingCircle = new Circle( - playerPosition, - CharacterPhysical.boundRadius, - ); + const playerBoundingCircle = new Circle(playerPosition, boundRadius); const playerBoundingBox = getBoundingBoxOfCircle(playerBoundingCircle); const possibleIntersectors = diff --git a/backend/src/players/player-container.ts b/backend/src/players/player-container.ts index 1df8720..c5412e9 100644 --- a/backend/src/players/player-container.ts +++ b/backend/src/players/player-container.ts @@ -135,7 +135,11 @@ export class PlayerContainer { } public step(deltaTimeInSeconds: number) { - this.players.forEach((p) => p.step(deltaTimeInSeconds)); + // Iterates the two backing arrays rather than the merged `players` getter: + // this runs once per 200 Hz physics substep, and the getter builds a new + // array every call. + this._players.forEach((p) => p.step(deltaTimeInSeconds)); + this._npcs.forEach((p) => p.step(deltaTimeInSeconds)); } public stepCommunication(deltaTimeInSeconds: number) { diff --git a/backend/src/players/player.ts b/backend/src/players/player.ts index 0a2ca8b..22cd9a3 100644 --- a/backend/src/players/player.ts +++ b/backend/src/players/player.ts @@ -52,9 +52,11 @@ const maximumBufferedBytes = settings.maxBufferedBytesPerClient; export class Player extends PlayerBase { // default, until the clients sends its real value private aspectRatio: number = 16 / 9; - private timeUntilRespawn = 0; private timeSinceLastMessage = 0; - private objectsPreviouslyInViewArea: Array = []; + // What this client has already been told about, in the order it was told. + // A Set because the per-snapshot diff below is membership-heavy; iteration + // order is insertion order, so the streamed object order is unchanged. + private objectsInViewArea = new Set(); private lastInputClientTimeMs = 0; private lastInputReceiptMs = 0; private lastLeapClientTimeMs = 0; @@ -190,7 +192,7 @@ export class Player extends PlayerBase { protected createCharacter() { super.createCharacter(); - this.objectsPreviouslyInViewArea.push(this.character!); + this.objectsInViewArea.add(this.character!); this.queueCommandSend(new CreatePlayerCommand(this.character!)); } @@ -205,26 +207,17 @@ export class Player extends PlayerBase { private dyingCharacter?: CharacterPhysical | null; public step(deltaTimeInSeconds: number) { - if (this.character) { - this.center = this.character?.center; + this.stepLifecycle(deltaTimeInSeconds); + } - if (!this.character.isAlive) { - this.sumDeaths++; - this.sumKills = this.character.killCount; + protected onCharacterDied(character: CharacterPhysical) { + this.dyingCharacter = character; + } - this.dyingCharacter = this.character; - this.character = null; - this.timeUntilRespawn = settings.playerDiedTimeout; - } - } else { - if ((this.timeUntilRespawn -= deltaTimeInSeconds) < 0) { - if (this.dyingCharacter) { - this.sumKills = Math.max(this.sumKills, this.dyingCharacter.killCount); - this.dyingCharacter = null; - } - this.createCharacter(); - this.center = this.character!.center; - } + protected onBeforeRespawn() { + if (this.dyingCharacter) { + this.sumKills = Math.max(this.sumKills, this.dyingCharacter.killCount); + this.dyingCharacter = null; } } @@ -234,26 +227,30 @@ export class Player extends PlayerBase { bb.topLeft = viewArea.topLeft; bb.size = viewArea.size; - const objectsInViewArea = Array.from( - new Set(this.objectContainer.findIntersecting(bb).map((o) => o.gameObject)), + const inViewArea = new Set( + this.objectContainer.findIntersecting(bb).map((o) => o.gameObject), ); // The owning character must always be in its own snapshot, regardless of the // view-area query, so the client predictor never loses its authoritative // anchor (the body can ride a fast spinner to the very edge of the box). - if (this.character && !objectsInViewArea.includes(this.character)) { - objectsInViewArea.push(this.character); + if (this.character) { + inViewArea.add(this.character); } - const newlyIntersecting = objectsInViewArea.filter( - (o) => !this.objectsPreviouslyInViewArea.includes(o), + // Set membership rather than Array.includes: this diff runs per player per + // snapshot over everything on their screen, and was quadratic in that. + // Set membership rather than Array.includes: this diff runs per player per + // snapshot over everything on their screen, and was quadratic in that. + const newlyIntersecting = [...inViewArea].filter( + (o) => !this.objectsInViewArea.has(o), ); - const noLongerIntersecting = this.objectsPreviouslyInViewArea.filter( - (o) => !objectsInViewArea.includes(o), + const noLongerIntersecting = [...this.objectsInViewArea].filter( + (o) => !inViewArea.has(o), ); - this.objectsPreviouslyInViewArea = objectsInViewArea; + this.objectsInViewArea = inViewArea; if (noLongerIntersecting.length > 0) { this.queueCommandSend( @@ -267,13 +264,15 @@ export class Player extends PlayerBase { this.queueCommandSend(new UpdateMinimap(this.getMinimapPlayers())); + const propertyUpdates: Array = []; + for (const object of this.objectsInViewArea) { + const update = object.getPropertyUpdatesForFrame(); + if (update) { + propertyUpdates.push(update); + } + } this.queueCommandSend( - new PropertyUpdatesForObjects( - this.objectsPreviouslyInViewArea - .map((o) => o.getPropertyUpdatesForFrame()) - .filter((u) => u) as Array, - performance.now() / 1000, - ), + new PropertyUpdatesForObjects(propertyUpdates, performance.now() / 1000), ); // Tell the client how much of its own input is reflected in the snapshot it @@ -310,11 +309,17 @@ export class Player extends PlayerBase { } public stepCommunications(deltaTime: number) { - const remoteCalls = this.objectsPreviouslyInViewArea - .map((g) => new RemoteCallsForObject(g.id, g.getRemoteCalls())) - .filter((c) => c.calls.length > 0); + // Runs at the physics rate, and on most ticks nothing has fired — so test + // first and only build the command when there is something to send. + let remoteCalls: Array | undefined; + for (const object of this.objectsInViewArea) { + const calls = object.getRemoteCalls(); + if (calls.length > 0) { + (remoteCalls ??= []).push(new RemoteCallsForObject(object.id, calls)); + } + } - if (remoteCalls.length > 0) { + if (remoteCalls) { this.queueCommandSend(new RemoteCallsForObjects(remoteCalls)); } diff --git a/frontend/src/index.ts b/frontend/src/index.ts index 5dad0b3..272c3b9 100644 --- a/frontend/src/index.ts +++ b/frontend/src/index.ts @@ -129,7 +129,7 @@ const main = async () => { }); window.onpopstate = () => game.destroy(); - for (; ;) { + for (;;) { show(spinner); hide(logoutButton, true); show(landingUI, true, 'flex'); diff --git a/frontend/src/scripts/commands/touch-listener.ts b/frontend/src/scripts/commands/touch-listener.ts index ae3a827..25c6463 100644 --- a/frontend/src/scripts/commands/touch-listener.ts +++ b/frontend/src/scripts/commands/touch-listener.ts @@ -295,7 +295,7 @@ export class TouchListener extends CommandGenerator { ); }; - public update(_deltaTimeInSeconds: number) { + public update() { if (!this.fireButton.parentElement) { this.overlay.appendChild(this.fireButton); } @@ -305,8 +305,9 @@ export class TouchListener extends CommandGenerator { const character = this.game.gameObjects.player; if (character) { - this.fireStrengthRing.style.background = `conic-gradient(rgba(255, 255, 255, 0.75) ${character.strengthFraction * 360 - }deg, transparent 0deg)`; + this.fireStrengthRing.style.background = `conic-gradient(rgba(255, 255, 255, 0.75) ${ + character.strengthFraction * 360 + }deg, transparent 0deg)`; } } diff --git a/frontend/src/scripts/configuration.ts b/frontend/src/scripts/configuration.ts index 9ab3fb6..e254dcb 100644 --- a/frontend/src/scripts/configuration.ts +++ b/frontend/src/scripts/configuration.ts @@ -20,16 +20,6 @@ const isDevelopment = typeof location !== 'undefined' && (location.hostname === 'localhost' || location.hostname === '127.0.0.1'); -const servers: Array = isDevelopment +export const servers: Array = isDevelopment ? [`http://${location.hostname}:3000`, ...productionServers] : productionServers; - -export abstract class Configuration { - public static async initialize(): Promise { - // Kept async for call-site compatibility; the server list is static now. - } - - public static get servers(): Array { - return servers; - } -} diff --git a/frontend/src/scripts/game.ts b/frontend/src/scripts/game.ts index 8fb4379..ca08a83 100644 --- a/frontend/src/scripts/game.ts +++ b/frontend/src/scripts/game.ts @@ -384,7 +384,7 @@ export class Game extends CommandReceiver { new RenderCommand(this.renderer, this.overlay, shouldChangeLayout), ); - this.touchListener.update(deltaTime); + this.touchListener.update(); this.tutorial.step(this.gameObjects); @@ -406,7 +406,9 @@ export class Game extends CommandReceiver { this.handleKeystoneArrow(); - this.announcementText.innerHTML = this.lastAnnouncementText; + if (this.announcementText.innerHTML !== this.lastAnnouncementText) { + this.announcementText.innerHTML = this.lastAnnouncementText; + } } // Points an off-screen chevron toward the keystone "Heart" planet, tinted by @@ -438,14 +440,7 @@ export class Game extends CommandReceiver { display.y >= margin && display.y <= height - margin; - const control = Math.abs(keystone.ownership - 0.5); - const team = - control < settings.planetControlThreshold - ? 'neutral' - : keystone.ownership < 0.5 - ? 'blue' - : 'red'; - this.keystoneArrow.className = 'keystone-arrow ' + team; + this.keystoneArrow.className = 'keystone-arrow ' + keystone.team; if (onScreen) { this.keystoneArrow.style.display = 'none'; diff --git a/frontend/src/scripts/helper/prediction/local-character-predictor.ts b/frontend/src/scripts/helper/prediction/local-character-predictor.ts index 5363224..e5e53c2 100644 --- a/frontend/src/scripts/helper/prediction/local-character-predictor.ts +++ b/frontend/src/scripts/helper/prediction/local-character-predictor.ts @@ -14,8 +14,11 @@ import { import { ClientCharacterWorld, PredictablePlanet } from './client-character-world'; import { InputHistory } from './input-history'; -const stepMs = 1000 / 200; // match the server's 200 Hz fixed tick -const stepSeconds = 1 / 200; +// The server's fixed tick, read from the same place the server reads it — the +// replay integrates over this dt, so a comment promising they match is not +// enough. +const stepSeconds = settings.targetPhysicsDeltaTimeInSeconds; +const stepMs = stepSeconds * 1000; // The client's clock: the timestamp of the frame currently being rendered, set // once per frame by the game loop (requestAnimationFrame hands it to us diff --git a/frontend/src/scripts/join-form-handler.ts b/frontend/src/scripts/join-form-handler.ts index c2bd37f..f18a746 100644 --- a/frontend/src/scripts/join-form-handler.ts +++ b/frontend/src/scripts/join-form-handler.ts @@ -1,6 +1,6 @@ import { ServerInformation, serverInformationEndpoint, TransportEvents } from 'shared'; import { io, Socket } from 'socket.io-client'; -import { Configuration } from './configuration'; +import { servers } from './configuration'; import parser from 'socket.io-msgpack-parser'; import { SoundHandler, Sounds } from './sound-handler'; @@ -69,11 +69,7 @@ export class JoinFormHandler { private servers: Array = []; private async loadServers() { - await Configuration.initialize(); - - const serverList = Configuration.servers.filter( - (u) => !this.servers.find((s) => s.url === u), - ); + const serverList = servers.filter((u) => !this.servers.find((s) => s.url === u)); serverList.map(async (url) => { const controller = new AbortController(); diff --git a/frontend/src/scripts/objects/types/character-view.ts b/frontend/src/scripts/objects/types/character-view.ts index 5c0d751..d23e420 100644 --- a/frontend/src/scripts/objects/types/character-view.ts +++ b/frontend/src/scripts/objects/types/character-view.ts @@ -2,6 +2,7 @@ import { vec2, vec3 } from 'gl-matrix'; import { CircleLight, Renderer } from 'sdf-2d'; import { + characterCenter, Circle, Id, CharacterBase, @@ -10,6 +11,7 @@ import { clamp, clamp01, mix, + strengthToCharge, CommandExecutors, UpdatePropertyCommand, } from 'shared'; @@ -123,9 +125,7 @@ export class CharacterView extends CharacterBase { } public get bodyCenter(): vec2 { - const center = vec2.add(vec2.create(), this.head!.center, this.leftFoot!.center); - vec2.add(center, center, this.rightFoot!.center); - return vec2.scale(center, center, 1 / 3); + return characterCenter(this.head!, this.leftFoot!, this.rightFoot!); } public get facingDirection(): vec2 { @@ -271,10 +271,7 @@ export class CharacterView extends CharacterBase { } public onShoot(strength: number) { - const q = clamp01( - (strength - settings.chargeShotStrengthMin) / - (settings.chargeShotStrengthMax - settings.chargeShotStrengthMin), - ); + const q = strengthToCharge(strength); SoundHandler.play(Sounds.shoot, mix(0.55, 1, q), mix(1.15, 0.8, q)); this.muzzleFlashIntensity = mix(0.35, 1, q); } @@ -332,16 +329,11 @@ export class CharacterView extends CharacterBase { } private calculateTextPosition(): vec2 { - const footAverage = vec2.add( + return vec2.scaleAndAdd( vec2.create(), - this.leftFoot!.center, - this.rightFoot!.center, + this.head!.center, + this.facingDirection, + this.head!.radius + 80, ); - vec2.scale(footAverage, footAverage, 0.5); - - const headFeetDelta = vec2.subtract(footAverage, this.head!.center, footAverage); - vec2.normalize(headFeetDelta, headFeetDelta); - const textOffset = vec2.scale(headFeetDelta, headFeetDelta, this.head!.radius + 80); - return vec2.add(textOffset, this.head!.center, textOffset); } } diff --git a/frontend/src/scripts/objects/types/planet-view.ts b/frontend/src/scripts/objects/types/planet-view.ts index 06e6880..03b6582 100644 --- a/frontend/src/scripts/objects/types/planet-view.ts +++ b/frontend/src/scripts/objects/types/planet-view.ts @@ -69,7 +69,6 @@ export class PlanetView extends PlanetBase { this.ownershipProgress.classList.toggle('contested', contested); } - private renderedRotation = 0; private rotationSpeed = 0; // Newest streamed rotation VALUE — the server-current angle at the latest // snapshot — as opposed to renderedRotation, which the interpolator holds @@ -87,8 +86,7 @@ export class PlanetView extends PlanetBase { } private step({ deltaTimeInSeconds }: StepCommand): void { - this.renderedRotation = this.rotationInterpolator.getValue(deltaTimeInSeconds); - this.shape.rotation = this.renderedRotation; + this.shape.rotation = this.rotationInterpolator.getValue(deltaTimeInSeconds); this.shape.colorMixQ = this.ownership; if (this.flareIntensity > 0) { @@ -167,7 +165,14 @@ export class PlanetView extends PlanetBase { const screenPosition = renderer.worldToDisplayCoordinates(this.center); this.ownershipProgress.style.transform = `translateX(${screenPosition.x}px) translateY(${screenPosition.y}px) translateX(-50%) translateY(-50%)`; - this.ownershipProgress.style.background = this.getGradient(); + // Re-parsing a conic-gradient for every planet on screen every layout + // frame is not free, and ownership moves far more slowly than the ring + // can show. + const gradient = this.getGradient(); + if (gradient !== this.lastGradient) { + this.lastGradient = gradient; + this.ownershipProgress.style.background = gradient; + } if (this.lastGeneratedPoint !== undefined) { const element = document.createElement('div'); @@ -192,6 +197,7 @@ export class PlanetView extends PlanetBase { } } + private lastGradient?: string; private getGradient(): string { const sideBlue = this.ownership < 0.5; // Keep the ring neutral through the same dead-band that gates scoring diff --git a/shared/src/commands/types/game-end.ts b/shared/src/commands/types/game-end.ts index 32bb84c..c209942 100644 --- a/shared/src/commands/types/game-end.ts +++ b/shared/src/commands/types/game-end.ts @@ -1,18 +1,12 @@ -import { CharacterTeam } from '../../objects/types/character-base'; import { serializable } from '../../serialization/serializable'; import { Command } from '../command'; +// "The round is over" — a bare signal, like GameStartCommand. The winner is +// announced through ServerAnnouncement and the end-card length is the server's +// own restart timer, so neither has to travel here. @serializable export class GameEndCommand extends Command { - constructor( - public readonly winningTeam: CharacterTeam, - public readonly endCardLengthInSeconds: number, - public readonly shouldReconnect: boolean, - ) { - super(); - } - public toArray(): Array { - return [this.winningTeam, this.endCardLengthInSeconds, this.shouldReconnect]; + return []; } } diff --git a/shared/src/helper/charge.ts b/shared/src/helper/charge.ts index 1ab6ece..145de12 100644 --- a/shared/src/helper/charge.ts +++ b/shared/src/helper/charge.ts @@ -3,3 +3,13 @@ import { settings } from '../settings'; export const holdDurationToCharge = (heldSeconds: number): number => clamp01(heldSeconds / settings.chargeShotFullHoldSeconds); + +// The charge a shot of this strength represents — the inverse of the mix() the +// server uses to size a projectile. The server needs it to scale radius, speed +// and recoil; the client needs the same number to pitch the sound and the +// muzzle flash, so it lives here rather than being derived twice. +export const strengthToCharge = (strength: number): number => + clamp01( + (strength - settings.chargeShotStrengthMin) / + (settings.chargeShotStrengthMax - settings.chargeShotStrengthMin), + ); diff --git a/shared/src/helper/finite.ts b/shared/src/helper/finite.ts index f5c79d8..00d0880 100644 --- a/shared/src/helper/finite.ts +++ b/shared/src/helper/finite.ts @@ -1,6 +1,5 @@ import { vec2 } from 'gl-matrix'; - export const isFiniteNumber = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value); diff --git a/shared/src/main.ts b/shared/src/main.ts index a26a4ce..ba7ee41 100644 --- a/shared/src/main.ts +++ b/shared/src/main.ts @@ -35,7 +35,6 @@ export * from './communication/id'; export * from './communication/server-information'; export * from './communication/player-information'; export * from './helper/rotate-90-deg'; -export * from './helper/rotate-minus-90-deg'; export * from './objects/game-object'; export * from './serialization/deserialize'; export * from './serialization/serialize'; diff --git a/shared/src/objects/game-object.ts b/shared/src/objects/game-object.ts index beb03b9..1372c99 100644 --- a/shared/src/objects/game-object.ts +++ b/shared/src/objects/game-object.ts @@ -8,7 +8,7 @@ export class RemoteCall { constructor( public readonly functionName: string, public readonly args: Array, - ) { } + ) {} public toArray(): Array { return [this.functionName, this.args]; @@ -49,7 +49,7 @@ export class PropertyUpdatesForObject { constructor( public readonly id: Id, public readonly updates: Array, - ) { } + ) {} public toArray(): Array { return [this.id, this.updates]; @@ -102,7 +102,7 @@ export abstract class GameObject extends CommandReceiver { }); } - public getPropertyUpdates(): PropertyUpdatesForObject | void { } + public getPropertyUpdates(): PropertyUpdatesForObject | void {} public getPropertyUpdatesForFrame(): PropertyUpdatesForObject | undefined { if (this.updateGeneration !== currentUpdateGeneration) { diff --git a/shared/src/objects/types/planet-base.ts b/shared/src/objects/types/planet-base.ts index 2f90eb1..f28ca4e 100644 --- a/shared/src/objects/types/planet-base.ts +++ b/shared/src/objects/types/planet-base.ts @@ -35,6 +35,17 @@ export class PlanetBase extends GameObject { this.vertices.length; } + // Which team controls this planet, with the neutral dead-band around 50% + // applied. Lives here so scoring (server) and every tint the player sees + // (client) read one rule rather than three copies of the same comparison. + public get team(): CharacterTeam { + return Math.abs(this.ownership - 0.5) < settings.planetControlThreshold + ? CharacterTeam.neutral + : this.ownership < 0.5 + ? CharacterTeam.blue + : CharacterTeam.red; + } + // eslint-disable-next-line @typescript-eslint/no-unused-vars public generatedPoints(value: number) {} // eslint-disable-next-line @typescript-eslint/no-unused-vars diff --git a/shared/src/physics/character-movement.ts b/shared/src/physics/character-movement.ts index 084ce65..d447cb5 100644 --- a/shared/src/physics/character-movement.ts +++ b/shared/src/physics/character-movement.ts @@ -64,7 +64,14 @@ export interface CharacterWorld { stepBody(body: PhysicsBody, deltaTimeInSeconds: number): GroundSurface | undefined; } -const applyForce = (body: PhysicsBody, force: vec2, deltaTimeInSeconds: number) => { +// Integrate a force into a body's velocity for one tick. Exported so the +// backend's CirclePhysical accumulates forces through the exact same +// expression, f32 intermediate included. +export const applyForce = ( + body: PhysicsBody, + force: vec2, + deltaTimeInSeconds: number, +) => { vec2.add( body.velocity, body.velocity, @@ -72,11 +79,16 @@ const applyForce = (body: PhysicsBody, force: vec2, deltaTimeInSeconds: number) ); }; -// ((head + leftFoot) + rightFoot) / 3, in the exact association the server uses -// everywhere it reads the character centre — do not reassociate. -export const characterCenter = (state: CharacterMovementState): vec2 => { - const center = vec2.add(vec2.create(), state.head.center, state.leftFoot.center); - vec2.add(center, center, state.rightFoot.center); +// ((head + leftFoot) + rightFoot) / 3, in the exact association every reader of +// the character centre uses — do not reassociate. Takes the three circles +// rather than a whole movement state so the client's view can call it too. +export const characterCenter = ( + head: { center: vec2 }, + leftFoot: { center: vec2 }, + rightFoot: { center: vec2 }, +): vec2 => { + const center = vec2.add(vec2.create(), head.center, leftFoot.center); + vec2.add(center, center, rightFoot.center); return vec2.scale(center, center, 1 / 3); }; @@ -106,7 +118,7 @@ const springMove = ( }; const keepPosture = (state: CharacterMovementState) => { - const center = characterCenter(state); + const center = characterCenter(state.head, state.leftFoot, state.rightFoot); springMove( state, state.leftFoot, @@ -190,7 +202,7 @@ export const applyLeapImpulse = (state: CharacterMovementState, moveDirection: v // Slingshot: add the tangential velocity of the spinning surface under the // body (the same motion carryWithRotatingPlanet imparts). - const center = characterCenter(state); + const center = characterCenter(state.head, state.leftFoot, state.rightFoot); const omega = planet.angularVelocity; const surfaceVelocity = vec2.fromValues( omega * (center[1] - planet.center[1]), @@ -255,7 +267,7 @@ export const decayMomentum = ( : settings.airMomentumFriction; const target = Math.min( speed * Math.exp(-friction * deltaTimeInSeconds) - - settings.momentumStopDeceleration * deltaTimeInSeconds, + settings.momentumStopDeceleration * deltaTimeInSeconds, settings.maxBodyMomentum, ); if (target <= 1) { @@ -297,9 +309,6 @@ export const stepCharacterMovement = ( inputDirection: vec2, deltaTimeInSeconds: number, ) => { - const center = characterCenter(state); - const grounds = world.groundsNear(center, boundRadius + settings.maxGravityDistance); - const movementForce = vec2.scale( inputDirection, inputDirection, @@ -309,6 +318,12 @@ export const stepCharacterMovement = ( applyForce(state.rightFoot, movementForce, deltaTimeInSeconds); if (!state.currentPlanet) { + // Only the airborne branch needs the surrounding planets, and this is the + // widest query the character makes — running it while grounded (the common + // case) walked the server's spatial tree once per character per tick for a + // result nothing read. + const center = characterCenter(state.head, state.leftFoot, state.rightFoot); + const grounds = world.groundsNear(center, boundRadius + settings.maxGravityDistance); const leftFootGravity = sumGravity(grounds, state.leftFoot.center); const rightFootGravity = sumGravity(grounds, state.rightFoot.center); @@ -332,7 +347,7 @@ export const stepCharacterMovement = ( movementLength > 0 && gravityLength > 0 && vec2.dot(movementForce, gravity) < - -movementLength * gravityLength * settings.climbDotThreshold + -movementLength * gravityLength * settings.climbDotThreshold ) { vec2.scale(gravity, gravity, settings.climbGravityScale); } diff --git a/shared/src/physics/evaluate-sdf.ts b/shared/src/physics/evaluate-sdf.ts index f3c60cf..af0dc29 100644 --- a/shared/src/physics/evaluate-sdf.ts +++ b/shared/src/physics/evaluate-sdf.ts @@ -1,7 +1,17 @@ import { vec2 } from 'gl-matrix'; import { Sdf } from './sdf'; -export const evaluateSdf = (target: vec2, objects: Array) => - objects - .filter((i) => i.canCollide) - .reduce((min, i) => (min = Math.min(min, i.distance(target))), 1000000); +// Distance to the nearest collidable surface. This is the innermost leaf of the +// whole physics stack — every raymarch step, every depenetration pass and four +// times per surface normal — so it is a plain indexed loop rather than +// filter().reduce(), which allocated an intermediate array per call. +export const evaluateSdf = (target: vec2, objects: Array): number => { + let minimum = 1000000; + for (let i = 0; i < objects.length; i++) { + const object = objects[i]; + if (object.canCollide) { + minimum = Math.min(minimum, object.distance(target)); + } + } + return minimum; +}; diff --git a/shared/src/physics/march-circle.ts b/shared/src/physics/march-circle.ts index bd8d9d2..0c90b3c 100644 --- a/shared/src/physics/march-circle.ts +++ b/shared/src/physics/march-circle.ts @@ -20,7 +20,7 @@ const maximumSteps = 256; // client passes none. // // The march advances by the *free gap* (distance minus the circle's radius), -// which is what sphere-tracing a circle of radius r requires. +// which is what sphere-tracing a circle of radius r requires. export const marchCircle = ( body: PhysicsBody, delta: vec2, diff --git a/shared/src/physics/planet-sdf.ts b/shared/src/physics/planet-sdf.ts index 24af49d..bbe1364 100644 --- a/shared/src/physics/planet-sdf.ts +++ b/shared/src/physics/planet-sdf.ts @@ -4,8 +4,10 @@ import { settings } from '../settings'; // Rotate a world point into the planet's own frame: R(-rotation) about the // centre, matching the shader's localTarget = center + R(rotation)*(target-center). -// cos/sin are passed in so the server can keep memoising them per angle. +// cos/sin are passed in so callers can memoise them per angle. `out` is written +// in place, so the hot path can hand in a scratch vector instead of allocating. export const toPlanetLocalFrame = ( + out: vec2, target: vec2, center: vec2, cos: number, @@ -13,16 +15,27 @@ export const toPlanetLocalFrame = ( ): vec2 => { const dx = target[0] - center[0]; const dy = target[1] - center[1]; - return vec2.fromValues( - center[0] + cos * dx - sin * dy, - center[1] + sin * dx + cos * dy, - ); + return vec2.set(out, center[0] + cos * dx - sin * dy, center[1] + sin * dx + cos * dy); }; +// Scratch vectors for planetDistance. It is the innermost leaf of the physics +// stack — called for every planet on every SDF sample — and allocating four +// vectors per polygon edge dominated its cost. Nothing re-enters it (callers +// evaluate planets one after another), and none of these outlive the call. +const localPoint = vec2.create(); +const targetFromDelta = vec2.create(); +const toFromDelta = vec2.create(); +const closestOnEdge = vec2.create(); +// [distance to the edge, which side of it]. These two stay in a vec2 rather +// than in plain numbers because a vec2 is a Float32Array: rounding them to f32 +// is part of the outline the server and the client both have to agree on. +const edgeSample = vec2.create(); + // Signed distance to the (smooth, noise-free) planet polygon, evaluated in the -// planet's rotating frame. Extracted verbatim from PlanetPhysical.distance so -// the client collides against the exact same outline the server does. Indexed -// vector access keeps it independent of the .x/.y prototype plugin. +// planet's rotating frame. This is the one and only planet outline: the +// authoritative server and the client predictor both collide against it, which +// is what lets prediction reconcile at all. Indexed vector access keeps it +// independent of the .x/.y prototype plugin. export const planetDistance = ( target: vec2, vertices: Array, @@ -30,7 +43,7 @@ export const planetDistance = ( cos: number, sin: number, ): number => { - const local = toPlanetLocalFrame(target, center, cos, sin); + const local = toPlanetLocalFrame(localPoint, target, center, cos, sin); const startEnd = vertices[0]; let vb = startEnd; @@ -41,32 +54,32 @@ export const planetDistance = ( for (let i = 1; i <= vertices.length; i++) { const va = vb; vb = i === vertices.length ? startEnd : vertices[i]; - const targetFromDelta = vec2.subtract(vec2.create(), local, va); - const toFromDelta = vec2.subtract(vec2.create(), vb, va); + vec2.subtract(targetFromDelta, local, va); + vec2.subtract(toFromDelta, vb, va); const h = clamp01( vec2.dot(targetFromDelta, toFromDelta) / vec2.squaredLength(toFromDelta), ); - const ds = vec2.fromValues( - vec2.dist(targetFromDelta, vec2.scale(vec2.create(), toFromDelta, h)), + vec2.set( + edgeSample, + vec2.dist(targetFromDelta, vec2.scale(closestOnEdge, toFromDelta, h)), toFromDelta[0] * targetFromDelta[1] - toFromDelta[1] * targetFromDelta[0], ); if ( - (local[1] >= va[1] && local[1] < vb[1] && ds[1] > 0) || - (local[1] < va[1] && local[1] >= vb[1] && ds[1] <= 0) + (local[1] >= va[1] && local[1] < vb[1] && edgeSample[1] > 0) || + (local[1] < va[1] && local[1] >= vb[1] && edgeSample[1] <= 0) ) { sign *= -1; } - d = Math.min(d, ds[0]); + d = Math.min(d, edgeSample[0]); } return sign * d; }; -// Gravity a planet exerts at a world position. Verbatim from -// PlanetPhysical.getForce. +// Gravity a planet exerts at a world position. export const planetGravity = (center: vec2, radius: number, position: vec2): vec2 => { const diff = vec2.subtract(vec2.create(), center, position); const dist = Math.max(settings.minGravityDistance, vec2.length(diff) - radius); diff --git a/shared/src/physics/sdf-normal.ts b/shared/src/physics/sdf-normal.ts index 507a8eb..56ad90b 100644 --- a/shared/src/physics/sdf-normal.ts +++ b/shared/src/physics/sdf-normal.ts @@ -2,17 +2,28 @@ import { vec2 } from 'gl-matrix'; import { Sdf } from './sdf'; import { evaluateSdf } from './evaluate-sdf'; +// Reused probe point. sdfNormal is called on every depenetration pass and every +// raymarch hit, so allocating four sample vectors per call was a meaningful +// share of the physics step's garbage. evaluateSdf reads the probe and returns +// before the next vec2.set, so one is enough. +const probe = vec2.create(); + +const sampleAt = (x: number, y: number, objects: Array): number => { + vec2.set(probe, x, y); + return evaluateSdf(probe, objects); +}; + // Central-difference gradient of the combined SDF. Can be zero where the // samples cancel out (e.g. on the medial axis of a shape) — callers must // handle that case. Uses indexed access so it never depends on the vec2 .x/.y // prototype plugin (identical numerically: .x === [0]). export const sdfNormal = (target: vec2, objects: Array): vec2 => { const dx = - evaluateSdf(vec2.fromValues(target[0] + 0.01, target[1]), objects) - - evaluateSdf(vec2.fromValues(target[0] - 0.01, target[1]), objects); + sampleAt(target[0] + 0.01, target[1], objects) - + sampleAt(target[0] - 0.01, target[1], objects); const dy = - evaluateSdf(vec2.fromValues(target[0], target[1] + 0.01), objects) - - evaluateSdf(vec2.fromValues(target[0], target[1] - 0.01), objects); + sampleAt(target[0], target[1] + 0.01, objects) - + sampleAt(target[0], target[1] - 0.01, objects); const normal = vec2.fromValues(dx, dy); return vec2.squaredLength(normal) > 0 ? vec2.normalize(normal, normal) : normal; diff --git a/shared/src/settings.ts b/shared/src/settings.ts index 3de1367..23811b3 100644 --- a/shared/src/settings.ts +++ b/shared/src/settings.ts @@ -157,7 +157,6 @@ export const settings = { // so slower (charged) shots arc. Scale kept tiny: near a surface gravity is // maxGravityStrength=50000, which at full strength would corkscrew a shot into // the planet — 0.04 gives a readable bend instead. - projectileGravityEnabled: true, projectileGravityScale: 0.04, // Speed the corpse is flung at along the killing shot's direction, lerped by diff --git a/test/__snapshots__/physics-determinism.test.mjs.snap b/test/__snapshots__/physics-determinism.test.mjs.snap index 50ab1c3..8c0ab06 100644 --- a/test/__snapshots__/physics-determinism.test.mjs.snap +++ b/test/__snapshots__/physics-determinism.test.mjs.snap @@ -1,3 +1,5 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`shared character simulation determinism > matches the pinned reference pose (changes only with intentional physics edits) 1`] = `"{"head":[76.14,123.412],"leftFoot":[83.214,105.036],"rightFoot":[79.974,123.01],"bodyVelocity":[0,0]}"`; + +exports[`shared character simulation determinism > matches the pinned reference pose on a spinning planet 1`] = `"{"head":[320.9,-33.986],"leftFoot":[248.603,-50.839],"rightFoot":[281.287,-71.994],"direction":-0.314,"onSurface":true,"secondsSinceOnSurface":0}"`; diff --git a/test/physics-determinism.test.mjs b/test/physics-determinism.test.mjs index d0fee1a..d2ca282 100644 --- a/test/physics-determinism.test.mjs +++ b/test/physics-determinism.test.mjs @@ -13,7 +13,12 @@ const { vec2 } = require('../shared/node_modules/gl-matrix'); const { stepCharacterMovement, + tickPlanetDetachment, resolveCircleMovement, + planetDistance, + planetGravity, + PlanetBase, + Random, headRadius, feetRadius, headOffset, @@ -42,6 +47,79 @@ const emptyWorld = { const stepSeconds = 1 / 200; +// A spinning planet, built exactly the way create-world builds one, wired up as +// the GroundSurface the shared movement collides against and is pulled by. The +// free-space run above deliberately avoids planet geometry, so without this the +// SDF, the gravity falloff and the on-surface branch — the parts most likely to +// be touched by a perf rewrite — have no pin at all. +const makeSpinningPlanetWorld = () => { + Random.seed = 20; + const vertices = PlanetBase.createPlanetVertices( + vec2.fromValues(0, -900), + 1700, + 1900, + 70, + ); + const base = new PlanetBase(1, vertices); + let rotation = 0; + const surface = { + canCollide: true, + isGround: true, + center: base.center, + angularVelocity: 0.09, + advance: (dt) => (rotation += surface.angularVelocity * dt), + distance: (target) => + planetDistance(target, vertices, base.center, Math.cos(rotation), Math.sin(rotation)), + gravityAt: (target) => planetGravity(base.center, base.radius, target), + }; + const grounds = [surface]; + return { + surface, + world: { + groundsNear: () => grounds, + stepBody: (body, dt) => { + const { hitObject } = resolveCircleMovement(body, dt, grounds); + return hitObject && hitObject.isGround ? hitObject : undefined; + }, + }, + }; +}; + +// Walk a body across a spinning planet: gravity, the SDF outline, collision +// resolution, surface latching and the rotation carry all participate. +const runPlanetSimulation = () => { + const { surface, world } = makeSpinningPlanetWorld(); + const start = vec2.fromValues(0, 250); + const state = { + head: makeBody(vec2.add(vec2.create(), start, headOffset), headRadius), + leftFoot: makeBody(vec2.add(vec2.create(), start, leftFootOffset), feetRadius), + rightFoot: makeBody(vec2.add(vec2.create(), start, rightFootOffset), feetRadius), + direction: 0, + currentPlanet: undefined, + secondsSinceOnSurface: 1, + bodyVelocity: vec2.create(), + }; + + for (let i = 0; i < 900; i++) { + // Fall in, then walk right, then left. + const input = + i < 200 ? vec2.create() : vec2.fromValues(i < 600 ? 1 : -1, 0); + tickPlanetDetachment(state, stepSeconds); + stepCharacterMovement(state, world, input, stepSeconds); + surface.advance(stepSeconds); + } + + const round = (v) => Math.round(v * 1000) / 1000; + return JSON.stringify({ + head: [round(state.head.center[0]), round(state.head.center[1])], + leftFoot: [round(state.leftFoot.center[0]), round(state.leftFoot.center[1])], + rightFoot: [round(state.rightFoot.center[0]), round(state.rightFoot.center[1])], + direction: round(state.direction), + onSurface: !!state.currentPlanet, + secondsSinceOnSurface: round(state.secondsSinceOnSurface), + }); +}; + // Run a fixed, scripted input sequence through the shared sim and return a // stable string snapshot of the final pose + carried momentum. const runSimulation = () => { @@ -80,4 +158,14 @@ describe('shared character simulation determinism', () => { // Regression pin — update deliberately when physics behaviour changes. expect(runSimulation()).toMatchSnapshot(); }); + + it('is deterministic on a spinning planet too', () => { + expect(runPlanetSimulation()).toBe(runPlanetSimulation()); + }); + + it('matches the pinned reference pose on a spinning planet', () => { + // Covers the planet SDF, the gravity falloff, collision resolution and the + // rotation carry, none of which the free-space run above touches. + expect(runPlanetSimulation()).toMatchSnapshot(); + }); }); -- 2.54.0 From 5017ff168a2320746c5f891174b4b4721e933032 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sat, 22 Aug 2026 18:35:53 +0100 Subject: [PATCH 04/15] GLM --- backend/src/commands/announce.ts | 9 - backend/src/commands/commands.ts | 31 +++ backend/src/commands/generate-points.ts | 10 - backend/src/commands/react-to-collision.ts | 7 - backend/src/commands/step.ts | 10 - backend/src/create-world.ts | 11 +- backend/src/default-options.ts | 12 - backend/src/game-server.ts | 142 +++-------- backend/src/helper/delta-time-calculator.ts | 13 - backend/src/main.ts | 2 +- backend/src/objects/character-physical.ts | 56 ++--- backend/src/objects/circle-physical.ts | 25 +- backend/src/objects/planet-physical.ts | 45 ++-- backend/src/objects/projectile-physical.ts | 23 +- backend/src/options.ts | 11 + .../bounding-boxes/bounding-box-base.ts | 1 - .../physics/functions/force-at-position.ts | 2 +- .../functions/is-circle-intersecting.ts | 7 - backend/src/players/npc.ts | 5 +- backend/src/players/player-base.ts | 28 +-- backend/src/players/player-container.ts | 22 +- backend/src/players/player.ts | 130 ++++------ backend/tsconfig.json | 3 +- .../types/socket.io-msgpack-parser/index.d.ts | 1 - backend/webpack.config.js | 5 +- frontend/src/custom.d.ts | 6 +- frontend/src/index.ts | 165 ++++++------- frontend/src/scripts/analytics.ts | 15 +- frontend/src/scripts/charge-indicator.ts | 104 ++++---- .../src/scripts/commands/command-socket.ts | 11 +- .../src/scripts/commands/mouse-listener.ts | 19 +- .../src/scripts/commands/touch-listener.ts | 19 +- frontend/src/scripts/configuration.ts | 16 +- frontend/src/scripts/feedback-hud.ts | 224 +++++++++--------- frontend/src/scripts/game.ts | 55 +---- frontend/src/scripts/helper/hide.ts | 7 - .../interpolators/circle-interpolator.ts | 22 -- .../interpolators/linear-interpolator.ts | 19 +- .../helper/interpolators/vec2-interpolator.ts | 21 -- frontend/src/scripts/helper/pointer.ts | 12 +- .../prediction/client-character-world.ts | 22 +- .../helper/prediction/input-history.ts | 11 +- .../prediction/local-character-predictor.ts | 137 ++--------- .../src/scripts/helper/server-timeline.ts | 20 +- frontend/src/scripts/helper/show.ts | 7 - frontend/src/scripts/join-form-handler.ts | 9 +- .../src/scripts/landing-page-background.ts | 14 +- frontend/src/scripts/minimap.ts | 14 +- .../scripts/objects/game-object-container.ts | 53 +---- frontend/src/scripts/objects/types/camera.ts | 19 +- .../scripts/objects/types/character-view.ts | 85 ++++--- .../src/scripts/objects/types/lamp-view.ts | 9 +- .../src/scripts/objects/types/planet-view.ts | 24 +- .../scripts/objects/types/projectile-view.ts | 16 +- frontend/src/scripts/scoreboard.ts | 5 - frontend/src/scripts/screen-shake.ts | 151 ++++++------ .../src/scripts/shapes/character-shape.ts | 51 +--- frontend/src/scripts/shapes/planet-shape.ts | 8 +- frontend/src/scripts/sound-handler.ts | 150 ++++++------ frontend/src/scripts/vibration-handler.ts | 24 +- frontend/tsconfig.json | 6 +- .../types/socket.io-msgpack-parser/index.d.ts | 1 - frontend/webpack.config.js | 2 - shared/src/commands/command-executors.ts | 3 - shared/src/commands/command-receiver.ts | 5 +- .../types/actions/client-heartbeat.ts | 10 +- .../src/commands/types/actions/leap-action.ts | 9 +- .../src/commands/types/actions/move-action.ts | 9 +- .../commands/types/actions/primary-action.ts | 8 +- shared/src/commands/types/delete-objects.ts | 2 +- shared/src/commands/types/game-end.ts | 3 - .../commands/types/input-acknowledgement.ts | 49 ++-- .../types/property-updates-for-objects.ts | 3 +- shared/src/commands/types/update-minimap.ts | 2 +- shared/src/communication/communication.ts | 20 ++ shared/src/communication/id.ts | 7 - .../src/communication/player-information.ts | 6 - .../src/communication/server-information.ts | 8 - shared/src/helper/array.ts | 3 +- shared/src/helper/calculate-view-area.ts | 15 +- shared/src/helper/charge.ts | 6 +- shared/src/helper/finite.ts | 6 +- shared/src/helper/hsl.ts | 4 +- shared/src/helper/last.ts | 3 - shared/src/helper/mix-rgb.ts | 12 - shared/src/helper/random.ts | 63 ++--- shared/src/helper/rectangle.ts | 14 -- shared/src/helper/rgb.ts | 3 - shared/src/helper/rotate-90-deg.ts | 3 - shared/src/main.ts | 10 +- shared/src/objects/game-object.ts | 22 +- shared/src/objects/types/character-base.ts | 2 +- shared/src/objects/types/lamp-base.ts | 2 - shared/src/objects/types/planet-base.ts | 10 +- shared/src/objects/types/projectile-base.ts | 2 +- shared/src/physics/character-movement.ts | 89 +++---- shared/src/physics/depenetrate-circle.ts | 10 +- shared/src/physics/evaluate-sdf.ts | 6 +- shared/src/physics/march-circle.ts | 18 +- shared/src/physics/planet-sdf.ts | 25 +- shared/src/physics/resolve-circle-movement.ts | 18 +- shared/src/physics/sdf-normal.ts | 13 +- shared/src/physics/sdf.ts | 13 +- .../src/serialization/deserializable-class.ts | 1 - shared/src/serialization/deserialize.ts | 7 +- shared/src/serialization/mangled-type-key.ts | 1 - .../serialization/override-deserialization.ts | 11 +- .../src/serialization/serializable-class.ts | 4 - .../src/serialization/serializable-mapping.ts | 10 +- shared/src/serialization/serializable.ts | 17 +- shared/src/serialization/serialize.ts | 6 +- shared/src/serialization/serialized-fields.ts | 10 +- shared/src/serialization/serializes-to.ts | 19 +- shared/src/settings.ts | 84 +------ shared/tsconfig.json | 3 +- shared/webpack.config.js | 1 - test/backend-regressions.test.ts | 19 +- test/backpressure.test.ts | 18 +- test/client-send-cadence.test.ts | 12 +- test/connection-lifecycle.test.ts | 43 +--- test/physics-determinism.test.mjs | 23 +- test/prediction-reconciliation.test.ts | 80 +------ test/serialization.test.mjs | 9 +- tsconfig.json | 5 +- 124 files changed, 1030 insertions(+), 2033 deletions(-) delete mode 100644 backend/src/commands/announce.ts create mode 100644 backend/src/commands/commands.ts delete mode 100644 backend/src/commands/generate-points.ts delete mode 100644 backend/src/commands/react-to-collision.ts delete mode 100644 backend/src/commands/step.ts delete mode 100644 backend/src/default-options.ts delete mode 100644 backend/src/helper/delta-time-calculator.ts delete mode 100644 backend/src/physics/functions/is-circle-intersecting.ts delete mode 100644 backend/types/socket.io-msgpack-parser/index.d.ts delete mode 100644 frontend/src/scripts/helper/hide.ts delete mode 100644 frontend/src/scripts/helper/interpolators/circle-interpolator.ts delete mode 100644 frontend/src/scripts/helper/interpolators/vec2-interpolator.ts delete mode 100644 frontend/src/scripts/helper/show.ts delete mode 100644 frontend/types/socket.io-msgpack-parser/index.d.ts delete mode 100644 shared/src/commands/command-executors.ts create mode 100644 shared/src/communication/communication.ts delete mode 100644 shared/src/communication/id.ts delete mode 100644 shared/src/communication/player-information.ts delete mode 100644 shared/src/communication/server-information.ts delete mode 100644 shared/src/helper/last.ts delete mode 100644 shared/src/helper/mix-rgb.ts delete mode 100644 shared/src/helper/rectangle.ts delete mode 100644 shared/src/helper/rgb.ts delete mode 100644 shared/src/helper/rotate-90-deg.ts delete mode 100644 shared/src/serialization/deserializable-class.ts delete mode 100644 shared/src/serialization/mangled-type-key.ts delete mode 100644 shared/src/serialization/serializable-class.ts diff --git a/backend/src/commands/announce.ts b/backend/src/commands/announce.ts deleted file mode 100644 index 2e8aadf..0000000 --- a/backend/src/commands/announce.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Command } from 'shared'; - -// Internal request from a game object (e.g. a keystone planet flipping) asking -// the GameServer to broadcast a one-off announcement to every connected client. -export class AnnounceCommand extends Command { - public constructor(public readonly text: string) { - super(); - } -} diff --git a/backend/src/commands/commands.ts b/backend/src/commands/commands.ts new file mode 100644 index 0000000..981f1fd --- /dev/null +++ b/backend/src/commands/commands.ts @@ -0,0 +1,31 @@ +import { Command, CommandReceiver, GameObject } from 'shared'; + +export class StepCommand extends Command { + constructor( + public readonly deltaTimeInSeconds: number, + public readonly game: CommandReceiver, + ) { + super(); + } +} + +export class GeneratePointsCommand extends Command { + constructor( + public readonly blue: number, + public readonly red: number, + ) { + super(); + } +} + +export class AnnounceCommand extends Command { + constructor(public readonly text: string) { + super(); + } +} + +export class ReactToCollisionCommand extends Command { + constructor(public readonly other: GameObject) { + super(); + } +} diff --git a/backend/src/commands/generate-points.ts b/backend/src/commands/generate-points.ts deleted file mode 100644 index 3f1ac98..0000000 --- a/backend/src/commands/generate-points.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Command } from 'shared'; - -export class GeneratePointsCommand extends Command { - public constructor( - public readonly blue: number, - public readonly red: number, - ) { - super(); - } -} diff --git a/backend/src/commands/react-to-collision.ts b/backend/src/commands/react-to-collision.ts deleted file mode 100644 index fdcd492..0000000 --- a/backend/src/commands/react-to-collision.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Command, GameObject } from 'shared'; - -export class ReactToCollisionCommand extends Command { - public constructor(public readonly other: GameObject) { - super(); - } -} diff --git a/backend/src/commands/step.ts b/backend/src/commands/step.ts deleted file mode 100644 index ee0f6f7..0000000 --- a/backend/src/commands/step.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Command, CommandReceiver } from 'shared'; - -export class StepCommand extends Command { - public constructor( - public readonly deltaTimeInSeconds: number, - public readonly game: CommandReceiver, - ) { - super(); - } -} diff --git a/backend/src/create-world.ts b/backend/src/create-world.ts index a1039fc..a25093c 100644 --- a/backend/src/create-world.ts +++ b/backend/src/create-world.ts @@ -44,9 +44,7 @@ export const createWorld = (objectContainer: PhysicalContainer) => { ) { const planet = objects.length === 0 - ? // The first, central giant is the keystone "Heart": a named, - // always-contested focal objective the whole match orbits. - new PlanetPhysical( + ? new PlanetPhysical( PlanetBase.createPlanetVertices( position, Random.getRandomInRange(1600, 2400), @@ -72,11 +70,8 @@ export const createWorld = (objectContainer: PhysicalContainer) => { console.info(`Generated ${objects.length} planets`); console.info(`Generated ${lights.length} light`); - // Associate each lamp with its NEAREST planet, so a planet can repaint "its" - // lamps to the owning team's colour when it flips. Lamps are already placed by - // proximity during world-gen, so the nearest planet is the one whose capture - // they should advertise. Distances use the planet SDF (negative inside), which - // is exactly the "closest planet" metric we want. + // Each lamp is associated with its nearest planet via the planet SDF + // (negative inside), so it advertises that planet's capture. const planets = objects.filter((o): o is PlanetPhysical => o instanceof PlanetPhysical); lights .filter((l): l is LampPhysical => l instanceof LampPhysical) diff --git a/backend/src/default-options.ts b/backend/src/default-options.ts deleted file mode 100644 index 338088a..0000000 --- a/backend/src/default-options.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Options } from './options'; - -const isDevelopment = process.env.NODE_ENV !== 'production'; - -export const defaultOptions: Options = { - port: 3000, - name: isDevelopment ? 'Dev server' : 'Doppler server', - playerLimit: 16, - npcCount: 8, - seed: Math.random(), - scoreLimit: 2500, -}; diff --git a/backend/src/game-server.ts b/backend/src/game-server.ts index 795eb34..3531be8 100644 --- a/backend/src/game-server.ts +++ b/backend/src/game-server.ts @@ -18,28 +18,18 @@ import { beginPropertyUpdateGeneration, } from 'shared'; import { createWorld } from './create-world'; -import { DeltaTimeCalculator } from './helper/delta-time-calculator'; import { Options } from './options'; import { PlayerContainer } from './players/player-container'; import { ServerFullError } from './players/server-full-error'; import { Player } from './players/player'; -import { StepCommand } from './commands/step'; -import { GeneratePointsCommand } from './commands/generate-points'; -import { AnnounceCommand } from './commands/announce'; +import { StepCommand, GeneratePointsCommand, AnnounceCommand } from './commands/commands'; const gameStateSubscribedRoom = 'gameStateSubscribedRoom'; -interface JoinedSocket { - player: Player; - onPlayerToServer: (json: string) => void; - onDisconnect: () => void; -} - export class GameServer extends CommandReceiver { private objects!: PhysicalContainer; private players!: PlayerContainer; - private deltaTimes!: Array; - private deltaTimeCalculator!: DeltaTimeCalculator; + private lastPhysicsBase: [number, number] = process.hrtime(); private bluePoints = 0; private redPoints = 0; @@ -47,6 +37,7 @@ export class GameServer extends CommandReceiver { private isInEndGame = false; private timeScaling = 1; + private statReportMs = Date.now(); private initialize() { const previousPlayers = this.players; @@ -59,8 +50,7 @@ export class GameServer extends CommandReceiver { this.options.playerLimit, this.options.npcCount, ); - this.deltaTimeCalculator = new DeltaTimeCalculator(); - this.deltaTimes = []; + this.lastPhysicsBase = process.hrtime(); this.bluePoints = 0; this.redPoints = 0; this.matchPointAnnounced = {}; @@ -95,7 +85,7 @@ export class GameServer extends CommandReceiver { }); } - private readonly joinedSockets = new Map(); + private readonly joinedSockets = new Map(); private handleJoin(socket: Socket, playerInfo: PlayerInformation) { if (this.joinedSockets.has(socket)) { @@ -104,9 +94,6 @@ export class GameServer extends CommandReceiver { } if (this.isInEndGame) { - // The round is already over and the world is about to be rebuilt; joining - // now drops the player into an arena running in slow motion with no - // end-of-round card. socket.emit(TransportEvents.JoinRejected, JoinRejectionReason.RoundEnding); return; } @@ -133,16 +120,13 @@ export class GameServer extends CommandReceiver { const onPlayerToServer = (json: string) => { try { - if (typeof json !== 'string' || json.length > settings.maxInboundMessageBytes) { + if (json.length > settings.maxInboundMessageBytes) { return; } if (!this.allowInboundMessage(socket)) { return; } const commands: Array = deserialize(json); - if (!Array.isArray(commands)) { - return; - } commands.forEach((c) => player.handleCommand(c)); } catch (e) { console.error('Error while processing command', e); @@ -150,18 +134,14 @@ export class GameServer extends CommandReceiver { }; const onDisconnect = () => { - const record = this.joinedSockets.get(socket); + const p = this.joinedSockets.get(socket); this.joinedSockets.delete(socket); - // The bucket is keyed by the Socket object, so leaving the entry behind - // pins the socket for the rest of the round. this.inboundBudget.delete(socket); - if (!record) { + if (!p) { return; } - record.player.detachFromSocket(); + p.detachFromSocket(); const { kills, deaths } = player.scoreSnapshot; - // Hold the score briefly so a client whose transport blipped can rejoin as - // itself rather than as a blank slate. this.players.reserveScore( player.reconnectToken, player.team, @@ -176,7 +156,7 @@ export class GameServer extends CommandReceiver { socket.on(TransportEvents.PlayerToServer, onPlayerToServer); socket.on('disconnect', onDisconnect); - this.joinedSockets.set(socket, { player, onPlayerToServer, onDisconnect }); + this.joinedSockets.set(socket, player); player.reconnectToken = this.players.issueToken(); socket.emit(TransportEvents.PlayerJoined, player.reconnectToken); @@ -184,8 +164,6 @@ export class GameServer extends CommandReceiver { this.sendServerStateUpdate(); } - // Cheap per-socket token bucket, sized well above what a legitimate client - // produces so it only ever trips on a flood. private readonly inboundBudget = new Map(); private droppedInboundMessages = 0; private allowInboundMessage(socket: Socket): boolean { @@ -210,13 +188,10 @@ export class GameServer extends CommandReceiver { return true; } - // Detach the per-join listeners of every socket still attached to the round - // that is ending, so a client that survives the restart can join the new one. private releaseJoinedSockets() { - for (const [socket, record] of this.joinedSockets) { - socket.off(TransportEvents.PlayerToServer, record.onPlayerToServer); - socket.off('disconnect', record.onDisconnect); - record.player.detachFromSocket(); + for (const [socket, player] of this.joinedSockets) { + socket.removeAllListeners(); + player.detachFromSocket(); } this.joinedSockets.clear(); this.inboundBudget.clear(); @@ -226,7 +201,10 @@ export class GameServer extends CommandReceiver { public sendServerStateUpdate() { this.io .to(gameStateSubscribedRoom) - .emit(TransportEvents.ServerInfoUpdate, [this.players.count, this.gameProgress]); + .emit(TransportEvents.ServerInfoUpdate, [ + this.players.count, + (Math.max(this.bluePoints, this.redPoints) / this.options.scoreLimit) * 100, + ]); } public start() { @@ -269,21 +247,32 @@ export class GameServer extends CommandReceiver { const endTitleLength = 6; this.players.endGame(winningTeam); this.players.queueCommandForEachClient(new GameEndCommand()); - // Rebuild the world for the next round once the end card has been shown. setTimeout(() => this.initialize(), endTitleLength * 1000 * 1.1); } private timeSinceLastPointUpdate = 0; private physicsAccumulator = 0; - // Frames since the last stats report where physics ran over budget (more - // substeps than the cap). Surfaced by handleStats as a saturation signal. private saturatedFrames = 0; private handlePhysics() { - const delta = this.deltaTimeCalculator.getNextDeltaTimeInSeconds({ setAsBase: true }); - this.deltaTimes.push(delta); + const now = process.hrtime(this.lastPhysicsBase); + const delta = now[0] + now[1] / 1e9; + this.lastPhysicsBase = process.hrtime(); - this.handleStats(); + if (Date.now() - this.statReportMs > 30000) { + this.statReportMs = Date.now(); + const mem = `${(process.memoryUsage().rss / 1024 / 1024).toFixed(2)} MB`; + console.info(`Memory: ${mem}, Players: ${this.players.count}`); + + if (this.droppedInboundMessages > 0) { + console.warn(`Rate limited ${this.droppedInboundMessages} inbound msg(s)`); + this.droppedInboundMessages = 0; + } + if (this.saturatedFrames > 0) { + console.warn(`Physics saturated on ${this.saturatedFrames} frame(s)`); + this.saturatedFrames = 0; + } + } if ((this.timeSinceLastServerStateUpdate += delta) > 4) { this.timeSinceLastServerStateUpdate = 0; @@ -299,18 +288,11 @@ export class GameServer extends CommandReceiver { const fixedDelta = settings.targetPhysicsDeltaTimeInSeconds; const maxSubstepsPerFrame = 5; - // Cap on retained physics backlog when saturated, so a long stall can't - // accumulate an unrecoverable catch-up. const maxBacklogSeconds = 0.25; this.physicsAccumulator += delta; let substeps = Math.floor(this.physicsAccumulator / fixedDelta); if (substeps > maxSubstepsPerFrame) { - // Saturated: run the cap's worth of substeps but KEEP the remaining - // backlog (clamped) instead of zeroing it. Dropping it silently slowed - // simulated time for everyone — and diverged client prediction, whose - // wall-clock keeps running. Clamping bounds the catch-up so a transient - // spike recovers without a death spiral. this.saturatedFrames++; this.physicsAccumulator = Math.min( this.physicsAccumulator - maxSubstepsPerFrame * fixedDelta, @@ -335,7 +317,8 @@ export class GameServer extends CommandReceiver { this.players.stepCommunication(delta); this.objects.resetRemoteCalls(); - const physicsDelta = this.deltaTimeCalculator.getNextDeltaTimeInSeconds(); + const elapsed = process.hrtime(now); + const physicsDelta = elapsed[0] + elapsed[1] / 1e9; setTimeout( this.handlePhysics.bind(this), @@ -343,62 +326,13 @@ export class GameServer extends CommandReceiver { ); } - private handleStats() { - const framesBetweenDeltaTimeCalculation = 10000; - - if (this.deltaTimes.length > framesBetweenDeltaTimeCalculation) { - this.deltaTimes.sort((a, b) => a - b); - console.info( - `Median physics time: ${( - this.deltaTimes[Math.floor(framesBetweenDeltaTimeCalculation / 2)] * 1000 - ).toFixed(2)} ms`, - ); - console.info( - 'Tail times: ', - this.deltaTimes.slice(-20).map((v) => `${(v * 1000).toFixed(2)} ms`), - ); - console.info( - `Memory used: ${(process.memoryUsage().rss / 1024 / 1024).toFixed(2)} MB`, - ); - - const rtts = this.players.connectedPlayerRttsMs.filter((r) => r > 0); - if (rtts.length > 0) { - rtts.sort((a, b) => a - b); - console.info( - `Player RTT median ${rtts[Math.floor(rtts.length / 2)].toFixed(0)} ms ` + - `(min ${rtts[0].toFixed(0)}, max ${rtts[rtts.length - 1].toFixed(0)}, n=${rtts.length})`, - ); - } - - if (this.droppedInboundMessages > 0) { - console.warn( - `Rate limited ${this.droppedInboundMessages} inbound message(s) since last report — ` + - 'client input was discarded', - ); - this.droppedInboundMessages = 0; - } - - if (this.saturatedFrames > 0) { - console.warn( - `Physics saturated on ${this.saturatedFrames} frame(s) since last report — shedding backlog`, - ); - this.saturatedFrames = 0; - } - - this.deltaTimes = []; - } - } - - private get gameProgress(): number { - return (Math.max(this.bluePoints, this.redPoints) / this.options.scoreLimit) * 100; - } - public get serverInfo(): ServerInformation { return { serverName: this.options.name, playerCount: this.players.count, playerLimit: this.options.playerLimit, - gameStatePercent: this.gameProgress, + gameStatePercent: + (Math.max(this.bluePoints, this.redPoints) / this.options.scoreLimit) * 100, }; } } diff --git a/backend/src/helper/delta-time-calculator.ts b/backend/src/helper/delta-time-calculator.ts deleted file mode 100644 index 4d3723e..0000000 --- a/backend/src/helper/delta-time-calculator.ts +++ /dev/null @@ -1,13 +0,0 @@ -export class DeltaTimeCalculator { - private previousTime: [number, number] = process.hrtime(); - - public getNextDeltaTimeInSeconds( - { setAsBase = false }: { setAsBase: boolean } = { setAsBase: false }, - ): number { - const [seconds, nanoSeconds] = process.hrtime(this.previousTime); - if (setAsBase) { - this.previousTime = process.hrtime(); - } - return seconds + nanoSeconds / 1e9; - } -} diff --git a/backend/src/main.ts b/backend/src/main.ts index fa2ad92..6905969 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -6,7 +6,7 @@ import { applyArrayPlugins, Random, serverInformationEndpoint, settings } from ' import minimist from 'minimist'; import { glMatrix } from 'gl-matrix'; import { GameServer } from './game-server'; -import { defaultOptions } from './default-options'; +import { defaultOptions } from './options'; import parser from 'socket.io-msgpack-parser'; glMatrix.setMatrixArrayType(Array); diff --git a/backend/src/objects/character-physical.ts b/backend/src/objects/character-physical.ts index 5eb57ca..6c65b7f 100644 --- a/backend/src/objects/character-physical.ts +++ b/backend/src/objects/character-physical.ts @@ -4,7 +4,6 @@ import { settings, MoveActionCommand, serializesTo, - last, Circle, CharacterBase, CharacterTeam, @@ -38,12 +37,12 @@ import { ProjectilePhysical } from './projectile-physical'; import { forceAtPosition } from '../physics/functions/force-at-position'; import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle'; import { PlanetPhysical } from './planet-physical'; -import { StepCommand } from '../commands/step'; -import { ReactToCollisionCommand } from '../commands/react-to-collision'; -import { GeneratePointsCommand } from '../commands/generate-points'; +import { + StepCommand, + ReactToCollisionCommand, + GeneratePointsCommand, +} from '../commands/commands'; -// The three body circles, as a value — used to diff a tick's movement into the -// rate of change the client interpolates on. interface BodyPose { head: Circle; leftFoot: Circle; @@ -69,9 +68,8 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical private killStreak = 0; - // CharacterMovementState, held directly rather than mirrored through an - // adapter: the shared simulation reads and writes these in place, so the - // character simply IS the state it is stepped as. + // Held in place: the shared simulation reads and writes these directly, so + // the character IS the state it is stepped as (client predictor agrees). public direction = 0; public currentPlanet: GroundSurface | undefined; public secondsSinceOnSurface = settings.planetDetachmentSeconds; @@ -127,11 +125,8 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical container.addObject(this.rightFoot); } - // The container, presented as the collision/gravity world the shared movement - // queries. private readonly movementWorld: CharacterWorld = { - // Same set and order forceAtPosition used: planets in the force field, in - // container-traversal order (so the f64 gravity sum is unchanged). + // Same set and order forceAtPosition uses, so the f64 gravity sum matches. groundsNear: (center, radius) => this.container .findIntersecting(getBoundingBoxOfCircle(new Circle(center, radius))) @@ -176,15 +171,13 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical this.movementActions.push(c); } - // The world only ever hands back planets, so narrowing the shared - // GroundSurface is safe here. + // The world only ever hands back planets, so narrowing GroundSurface is safe. public get groundPlanet(): PlanetPhysical | undefined { return this.currentPlanet as PlanetPhysical | undefined; } - // The continuous movement state streamed to the owning client, so its - // predictor resumes this simulation instead of guessing at the parts of it - // the pose does not show. See CharacterMovementSnapshot. + // Continuous movement state streamed to the owning client so its predictor + // resumes this simulation instead of guessing. See CharacterMovementSnapshot. public get movementSnapshot(): CharacterMovementSnapshot { return new CharacterMovementSnapshot( this.direction, @@ -242,7 +235,6 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical this.remoteCall('setHealth', this.health); if (this.health <= 0 && this.isAlive) { - // Throw the corpse along the killing shot, harder for charged hits. vec2.scaleAndAdd( this.bodyVelocity, this.bodyVelocity, @@ -303,9 +295,6 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical c, ); this.container.addObject(projectile); - // Lag compensation: advance the shot by the time the command spent reaching - // us, so the shooter does not have to lead by their own latency as well as - // by the projectile's travel time. projectile.fastForward(catchUpSeconds); if (c > 0) { @@ -374,7 +363,8 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical vec2.scale(direction, direction, 1 / this.movementActions.length); - this.lastMovementAction = last(this.movementActions)!; + const actions = this.movementActions; + this.lastMovementAction = actions[actions.length - 1]!; this.movementActions = []; } @@ -401,9 +391,8 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical ]); } - // Rate of change of one body part over the tick, which the client's - // interpolator coasts on when a snapshot is late. `previous` is consumed as - // scratch so this allocates nothing beyond the Circle it returns. + // `previous` is consumed as scratch so this allocates nothing beyond the + // Circle it returns. private static rateOfChange(previous: Circle, current: Circle, deltaTime: number) { return new Circle( vec2.scale( @@ -472,15 +461,10 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical this.regenerateHealth(deltaTimeInSeconds); - // The planet tallies who is standing on it and resolves capture itself, so - // a contested rock can freeze instead of two squads silently cancelling. this.groundPlanet?.registerPresence(this); - // The whole walking model — gravity gather, movement force, on/off-planet - // branch, posture springs, body-momentum, and stepping the three parts — - // is the shared simulation the client predicts with, so the two can never - // drift. Server-only concerns (scoring, health, shooting, spawn/death, - // ownership) stay here around it. + // stepCharacterMovement is the shared simulation the client predicts with, + // so the two can never drift. const direction = this.averageAndResetMovementActions(); stepCharacterMovement(this, this.movementWorld, direction, deltaTimeInSeconds); @@ -502,10 +486,8 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical grounded = true; } } - // Brake with the exact shared model the living body uses: stiff on contact - // so the corpse skids to rest, gentle in the air, plus the constant stop and - // the speed cap — so a flung corpse comes to a definite stop instead of - // sliding forever. + // Brake with the shared model the living body uses, so a flung corpse + // comes to a definite stop instead of sliding forever. decayMomentum(this.bodyVelocity, grounded, deltaTime); } diff --git a/backend/src/objects/circle-physical.ts b/backend/src/objects/circle-physical.ts index 9554ace..bf8f716 100644 --- a/backend/src/objects/circle-physical.ts +++ b/backend/src/objects/circle-physical.ts @@ -14,7 +14,7 @@ import { PhysicalContainer } from '../physics/containers/physical-container'; import { DynamicPhysical } from '../physics/physicals/dynamic-physical'; import { Physical } from '../physics/physicals/physical'; import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle'; -import { ReactToCollisionCommand } from '../commands/react-to-collision'; +import { ReactToCollisionCommand } from '../commands/commands'; @serializesTo(Circle) export class CirclePhysical extends CommandReceiver implements Circle, DynamicPhysical { @@ -35,8 +35,8 @@ export class CirclePhysical extends CommandReceiver implements Circle, DynamicPh private _radius: number, public owner: GameObject, private readonly container: PhysicalContainer, - // Public + readonly so a CirclePhysical structurally satisfies the shared - // PhysicsBody interface the movement simulation operates on. + // Public + readonly so CirclePhysical satisfies the shared PhysicsBody + // interface the movement simulation operates on. public readonly restitution = 0, ) { super(); @@ -89,14 +89,10 @@ export class CirclePhysical extends CommandReceiver implements Circle, DynamicPh applyForce(this, force, timeInSeconds); } - // Position-resolution for one tick. Delegates to the shared - // resolveCircleMovement so the server integrates a body with the exact same - // geometry the client predictor runs (shared/physics) — no parallel copy to - // keep in sync. The onHit callback dispatches the collision reactions at the - // same points the old inline move-circle did (both the initial march and the - // post-bounce slide). `possibleIntersectors`, when supplied, lets a caller - // that already broadphased (e.g. a projectile's gravity query) avoid a second - // container query; otherwise it is self-gathered from the swept bounding box. + // Delegates to the shared resolveCircleMovement so the server integrates with + // the exact same geometry the client predictor runs — no parallel copy to + // keep in sync. `possibleIntersectors` lets a caller that already broadphased + // avoid a second container query; otherwise it is self-gathered. public stepManually( deltaTimeInSeconds: number, possibleIntersectors?: Array, @@ -119,20 +115,17 @@ export class CirclePhysical extends CommandReceiver implements Circle, DynamicPh }, ); - // The body has moved; re-register it where it actually is. this.syncBoundingBox(); return { hitObject: (hitObject as Physical | undefined)?.gameObject, velocity }; } - // Query the container with the bounding box grown by this tick's travel, so a - // fast-moving body still sees what it is about to sweep into. private sweptBroadphase(deltaTimeInSeconds: number): Array { const sweep = vec2.length( vec2.scale(vec2.create(), this.velocity, deltaTimeInSeconds), ); - // Grow the query, not the object: inflating and deflating this.radius ran - // the setter (and so re-registered the body's extent) twice mid-tick. + // Grow the query box, not this.radius: mutating the radius would re-register + // the body's extent mid-tick via the setter. return this.container.findIntersecting( getBoundingBoxOfCircle(new Circle(this.center, this.radius + sweep)), ); diff --git a/backend/src/objects/planet-physical.ts b/backend/src/objects/planet-physical.ts index 511049f..b951e11 100644 --- a/backend/src/objects/planet-physical.ts +++ b/backend/src/objects/planet-physical.ts @@ -17,9 +17,11 @@ import { CommandExecutors, CommandReceiver, } from 'shared'; -import { GeneratePointsCommand } from '../commands/generate-points'; -import { AnnounceCommand } from '../commands/announce'; -import { StepCommand } from '../commands/step'; +import { + GeneratePointsCommand, + AnnounceCommand, + StepCommand, +} from '../commands/commands'; import { ImmutableBoundingBox } from '../physics/bounding-boxes/immutable-bounding-box'; import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle'; @@ -31,16 +33,15 @@ import type { CharacterPhysical } from './character-physical'; export class PlanetPhysical extends PlanetBase implements StaticPhysical { public readonly canCollide = true; public readonly canMove = false; - // Marks this as standable ground for the shared movement simulation (a body - // landing on it latches it as currentPlanet). See shared GroundSurface. + // Marks this as standable ground for the shared movement simulation. public readonly isGround = true; public readonly sizePointMultiplier: number; - // Planets slowly spin. The angle is authoritative here and streamed to the - // client (see getPropertyUpdates), so the rendered outline and this collision - // polygon turn as one rigid body. cos/sin are memoised per angle because - // distance() is called many times per tick by the raymarcher and SDF sampling. + // Authoritative spin angle, streamed to the client so the rendered outline + // and this collision polygon turn as one. cos/sin are memoised: distance() + // is called many times per tick by the raymarcher and the angle rarely + // changes between those calls. private rotation = 0; private readonly rotationSpeed: number; private cachedRotation = Number.NaN; @@ -55,7 +56,7 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical { // Characters standing on the planet this tick. Filled by registerPresence as // each grounded character steps, drained when the planet resolves capture in - // its own step(). Drives the head-count tug-of-war. + // its own step(). private presentCharacters: Array = []; private isContested = false; @@ -81,17 +82,13 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical { (0.05 + Random.getRandom() * 0.07) * (Random.getRandom() < 0.5 ? -1 : 1); } - // A grounded character announces itself each tick so the planet can resolve - // contested capture from the net head-count. public registerPresence(character: CharacterPhysical) { this.presentCharacters.push(character); } public distance(target: vec2): number { - // The one shared planet outline — the same function the client predictor - // collides against, which is what lets prediction reconcile. Only the - // cos/sin memo lives here, because distance() is called many times per tick - // by the raymarcher and the angle rarely changes between those calls. + // The shared planet outline — the same function the client predictor + // collides against, which is what lets prediction reconcile. this.syncRotationTrigonometry(); return planetDistance( target, @@ -110,8 +107,8 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical { } } - // Signed angular velocity in rad/s, exposed so a character standing on the - // planet can ride its spin (see carryWithRotatingPlanet in shared). + // Signed angular velocity in rad/s, exposed so a character can ride the + // planet's spin (see carryWithRotatingPlanet in shared). public get angularVelocity(): number { return this.rotationSpeed; } @@ -145,9 +142,6 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical { this.presentCharacters = []; } - // One capture step per tick driven by the net team head-count, so grouping up - // pays off and an equal standoff freezes the planet (contested) instead of - // both sides silently cancelling with no feedback. private resolveCapture(deltaTime: number) { let blue = 0; let red = 0; @@ -168,14 +162,11 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical { deltaTime * lead, ); } else if (!occupied) { - // Empty planets drift back to neutral; the keystone drifts much slower so - // it lingers as a live flashpoint. this.takeControl( CharacterTeam.neutral, this.isKeystone ? deltaTime / settings.keystoneLoseControlScale : deltaTime, ); } - // occupied tie -> frozen tug-of-war: no ownership change, ring pulses. const contested = occupied && net === 0; if (contested !== this.isContested) { @@ -184,7 +175,6 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical { } } - // hysteresis private get flipTeam(): CharacterTeam { const control = this.ownership - 0.5; const enter = settings.planetControlThreshold + settings.planetFlipHysteresis; @@ -292,11 +282,6 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical { return planetGravity(this.center, this.radius, position); } - // forceAtPosition's name for the same thing. - public getForce(position: vec2): vec2 { - return this.gravityAt(position); - } - public get gameObject(): this { return this; } diff --git a/backend/src/objects/projectile-physical.ts b/backend/src/objects/projectile-physical.ts index c0d676f..3fe0075 100644 --- a/backend/src/objects/projectile-physical.ts +++ b/backend/src/objects/projectile-physical.ts @@ -18,8 +18,7 @@ import { PhysicalContainer } from '../physics/containers/physical-container'; import { CharacterPhysical } from './character-physical'; import { forceAtPosition } from '../physics/functions/force-at-position'; import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle'; -import { StepCommand } from '../commands/step'; -import { ReactToCollisionCommand } from '../commands/react-to-collision'; +import { StepCommand, ReactToCollisionCommand } from '../commands/commands'; @serializesTo(ProjectileBase) export class ProjectilePhysical extends ProjectileBase implements DynamicPhysical { @@ -44,8 +43,8 @@ export class ProjectilePhysical extends ProjectileBase implements DynamicPhysica private velocity: vec2, public readonly originator: CharacterPhysical, readonly container: PhysicalContainer, - // Normalised charge (0..1) of the shot that fired this, used by the victim - // to scale hit/kill feedback and the death fling. + // Normalised charge (0..1) of the shot that fired this; the victim scales + // hit/kill feedback and the death fling by it. public readonly charge: number = 0, ) { super(id(), center, radius, team, strength); @@ -89,11 +88,9 @@ export class ProjectilePhysical extends ProjectileBase implements DynamicPhysica } /** - * Lag compensation. Runs the projectile forward by the time its command spent - * travelling to the server, so a shot lands where the shooter aimed rather - * than forcing them to lead by their own latency on top of the projectile's - * travel time. Capped by settings.maxProjectileCatchUpSeconds so a bad or - * forged timestamp cannot spawn a shot arbitrarily far downrange. + * Lag compensation: runs the projectile forward by the time its command spent + * travelling to the server. Capped by settings.maxProjectileCatchUpSeconds so + * a forged timestamp cannot spawn a shot arbitrarily far downrange. */ public fastForward(seconds: number) { const step = settings.targetPhysicsDeltaTimeInSeconds; @@ -106,9 +103,8 @@ export class ProjectilePhysical extends ProjectileBase implements DynamicPhysica } } - // The circle keeps its own box in sync as it moves, so this is a live view of - // it, not a snapshot — caching it saved nothing and mistyped a mutable box as - // immutable. + // Live view of the circle's box, not a snapshot — the circle keeps it in sync + // as it moves. public get boundingBox(): BoundingBoxBase { return this.object.boundingBox; } @@ -155,9 +151,6 @@ export class ProjectilePhysical extends ProjectileBase implements DynamicPhysica return; } - // Curveball: the same planetary gravity that pulls on a free-falling - // character bends the shot, so slower (charged) shots arc and can be lobbed - // over a planet's horizon. Scale is tiny — near-surface gravity is huge. // This single broadphase is reused for the step below: the gravity radius // (maxGravityDistance) dwarfs one tick's travel, so the set is a superset of // the swept-collision box and the step needn't query the container again. diff --git a/backend/src/options.ts b/backend/src/options.ts index ba28c16..99482e3 100644 --- a/backend/src/options.ts +++ b/backend/src/options.ts @@ -6,3 +6,14 @@ export interface Options { npcCount: number; seed: number; } + +const isDevelopment = process.env.NODE_ENV !== 'production'; + +export const defaultOptions: Options = { + port: 3000, + name: isDevelopment ? 'Dev server' : 'Doppler server', + playerLimit: 16, + npcCount: 8, + seed: Math.random(), + scoreLimit: 2500, +}; diff --git a/backend/src/physics/bounding-boxes/bounding-box-base.ts b/backend/src/physics/bounding-boxes/bounding-box-base.ts index ee881cf..6e107ed 100644 --- a/backend/src/physics/bounding-boxes/bounding-box-base.ts +++ b/backend/src/physics/bounding-boxes/bounding-box-base.ts @@ -1,4 +1,3 @@ -// axis-aligned export abstract class BoundingBoxBase { constructor( protected _xMin: number = 0, diff --git a/backend/src/physics/functions/force-at-position.ts b/backend/src/physics/functions/force-at-position.ts index 85bf2e3..20b066e 100644 --- a/backend/src/physics/functions/force-at-position.ts +++ b/backend/src/physics/functions/force-at-position.ts @@ -7,6 +7,6 @@ export const forceAtPosition = (position: vec2, objects: Array) => .filter((o) => o instanceof PlanetPhysical) .reduce( (sum: vec2, o: Physical) => - vec2.add(sum, sum, (o as PlanetPhysical).getForce(position)), + vec2.add(sum, sum, (o as PlanetPhysical).gravityAt(position)), vec2.create(), ); diff --git a/backend/src/physics/functions/is-circle-intersecting.ts b/backend/src/physics/functions/is-circle-intersecting.ts deleted file mode 100644 index 203eb1d..0000000 --- a/backend/src/physics/functions/is-circle-intersecting.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Circle, evaluateSdf } from 'shared'; -import { PhysicalBase } from '../physicals/physical-base'; - -export const isCircleIntersecting = ( - circle: Circle, - intersectors: Array, -): boolean => evaluateSdf(circle.center, intersectors) < circle.radius; diff --git a/backend/src/players/npc.ts b/backend/src/players/npc.ts index f7b4ffb..6881fc3 100644 --- a/backend/src/players/npc.ts +++ b/backend/src/players/npc.ts @@ -7,7 +7,6 @@ import { MoveActionCommand, CharacterTeam, Id, - rotate90Deg, } from 'shared'; import { PhysicalContainer } from '../physics/containers/physical-container'; import { PlayerContainer } from './player-container'; @@ -62,8 +61,6 @@ const npcTuning = { spreadPerDistance: 0.08, spreadAggressionFalloff: 1.3, - // Per-second chance to hop while grounded and on the move, so bots use the - // leap verb too instead of being grounded targets. leapChancePerSecond: 0.35, }; @@ -259,7 +256,7 @@ export class NPC extends PlayerBase { return undefined; } - const perpendicular = rotate90Deg(threat.direction); + const perpendicular = vec2.fromValues(-threat.direction[1], threat.direction[0]); const toMe = vec2.subtract(vec2.create(), this.center, threat.center); if (vec2.dot(perpendicular, toMe) < 0) { vec2.negate(perpendicular, perpendicular); diff --git a/backend/src/players/player-base.ts b/backend/src/players/player-base.ts index ba8fb43..af2b2a3 100644 --- a/backend/src/players/player-base.ts +++ b/backend/src/players/player-base.ts @@ -11,7 +11,7 @@ import { } from 'shared'; import { PhysicalContainer } from '../physics/containers/physical-container'; import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle'; -import { isCircleIntersecting } from '../physics/functions/is-circle-intersecting'; +import { evaluateSdf } from 'shared'; import { CharacterPhysical } from '../objects/character-physical'; import { PlanetPhysical } from '../objects/planet-physical'; import { PlayerContainer } from './player-container'; @@ -37,11 +37,9 @@ export abstract class PlayerBase extends CommandReceiver { protected createCharacter() { this.character = new CharacterPhysical( - // Coerced, not just truncated. `.slice()` also exists on arrays, so a - // name that is an array passed straight through, was serialized into - // CreateObjects, and was revived as a class on every peer that received - // it — throwing inside the deserializer's reviver and killing that - // client's whole message batch. + // Coerce, don't just truncate: input is untrusted (from deserialize), and + // arrays have `.slice()` too — a non-string name would throw inside the + // reviver and kill the recipient's whole message batch. sanitizeName(this.playerInfo.name, maximumNameLength), this.sumKills, this.sumDeaths, @@ -56,12 +54,8 @@ export abstract class PlayerBase extends CommandReceiver { public abstract step(deltaTimeInSeconds: number): void; /** - * The death/respawn lifecycle both a connected player and an NPC go through: - * bank the score off a body that just died, hold the corpse for the respawn - * timeout, then build a new one. Returns whether there is a living character - * to act with this tick — returned as the character itself, so a subclass can - * `const character = this.stepLifecycle(dt); if (!character) return;` and go - * on to use it without re-narrowing. + * Shared death/respawn cycle. Returns the living character to act with this + * tick (or undefined), so a subclass can use it directly without re-narrowing. */ protected stepLifecycle(deltaTimeInSeconds: number): CharacterPhysical | undefined { if (this.character) { @@ -87,8 +81,7 @@ export abstract class PlayerBase extends CommandReceiver { return undefined; } - // Hooks for what a subclass wants to do with the body it just lost, and just - // before a new one is built. Both are no-ops by default. + // Override hooks (no-ops by default). // eslint-disable-next-line @typescript-eslint/no-unused-vars protected onCharacterDied(character: CharacterPhysical) {} protected onBeforeRespawn() {} @@ -120,8 +113,6 @@ export abstract class PlayerBase extends CommandReceiver { return vec2.clone(Random.choose(safe.length ? safe : candidates)!.center); } - // Seed a reconnecting player with the score it held before the drop, so a - // network blip costs you your character but not your match. public restoreScore(kills: number, deaths: number) { this.sumKills = kills; this.sumDeaths = deaths; @@ -141,7 +132,10 @@ export abstract class PlayerBase extends CommandReceiver { const playerBoundingBox = getBoundingBoxOfCircle(playerBoundingCircle); const possibleIntersectors = this.objectContainer.findIntersecting(playerBoundingBox); - if (!isCircleIntersecting(playerBoundingCircle, possibleIntersectors)) { + if ( + evaluateSdf(playerBoundingCircle.center, possibleIntersectors) >= + playerBoundingCircle.radius + ) { return playerPosition; } diff --git a/backend/src/players/player-container.ts b/backend/src/players/player-container.ts index c5412e9..0fce4a3 100644 --- a/backend/src/players/player-container.ts +++ b/backend/src/players/player-container.ts @@ -7,8 +7,8 @@ import { PlayerBase } from './player-base'; import { ServerFullError } from './server-full-error'; import { randomUUID } from 'node:crypto'; -// Score held for a player whose socket dropped, so a reconnect inside the grace -// window resumes the match instead of starting from zero. +// Score held for a dropped player so a reconnect inside the grace window +// resumes the match instead of starting from zero. interface ReservedScore { team: CharacterTeam; kills: number; @@ -69,8 +69,8 @@ export class PlayerContainer { } /** - * A fresh token for a joining player. Handed to the client immediately; it - * only becomes redeemable once the player actually drops (see reserveScore). + * A fresh token for a joining player; only redeemable once the player + * actually drops (see reserveScore). */ public issueToken(): string { return randomUUID(); @@ -78,7 +78,7 @@ export class PlayerContainer { /** * Hold a dropped player's team and score against its token for the grace - * window, so a client whose transport blipped rejoins as itself. + * window, so a reconnecting client rejoins as itself. */ public reserveScore( token: string, @@ -128,16 +128,14 @@ export class PlayerContainer { return this._players.length >= this.playerMaxCount; } - // Measured round-trip times (ms) of the real connected players, for - // server-side latency stats. NPCs have no socket and are excluded. + // Real connected players only — NPCs have no socket and are excluded. public get connectedPlayerRttsMs(): Array { return this._players.map((p) => p.rttMs); } public step(deltaTimeInSeconds: number) { - // Iterates the two backing arrays rather than the merged `players` getter: - // this runs once per 200 Hz physics substep, and the getter builds a new - // array every call. + // Iterate the backing arrays directly: this runs per 200 Hz substep and the + // `players` getter allocates a new array every call. this._players.forEach((p) => p.step(deltaTimeInSeconds)); this._npcs.forEach((p) => p.step(deltaTimeInSeconds)); } @@ -173,8 +171,8 @@ export class PlayerContainer { public deletePlayer(player: Player) { const had = this._players.includes(player); this._players = this._players.filter((p) => p !== player); - // Only refill bots if this player was actually ours. A socket left over from - // a previous round reports its disconnect against the new container, and + // Only refill bots if this player was actually ours: a stale socket from a + // previous round reports its disconnect against the new container, and // topping up on that would over-fill the roster. if (had) { this.createNPCs(); diff --git a/backend/src/players/player.ts b/backend/src/players/player.ts index 22cd9a3..ed3cfb5 100644 --- a/backend/src/players/player.ts +++ b/backend/src/players/player.ts @@ -37,51 +37,38 @@ import { PlayerContainer } from './player-container'; import { PlayerBase } from './player-base'; import { CharacterPhysical } from '../objects/character-physical'; -// How often the server pings each client to measure round-trip time. const pingIntervalSeconds = 1; -// Aspect ratio is used to size the streamed view area; a zero, negative or -// non-finite value collapses it and the player stops receiving the world. const minimumAspectRatio = 0.2; const maximumAspectRatio = 8; -// How much unflushed data on the socket counts as "this client is not keeping -// up". See sendQueuedCommandsToClient. const maximumBufferedBytes = settings.maxBufferedBytesPerClient; export class Player extends PlayerBase { - // default, until the clients sends its real value private aspectRatio: number = 16 / 9; private timeSinceLastMessage = 0; - // What this client has already been told about, in the order it was told. - // A Set because the per-snapshot diff below is membership-heavy; iteration - // order is insertion order, so the streamed object order is unchanged. + // A Set: the per-snapshot diff is membership-heavy, and insertion-order + // iteration keeps the streamed object order unchanged. private objectsInViewArea = new Set(); private lastInputClientTimeMs = 0; private lastInputReceiptMs = 0; private lastLeapClientTimeMs = 0; - // Token this session was issued at join. On a drop it is what the held score - // is filed under, and what the returning client presents to reclaim it. public reconnectToken = ''; - // Measured round-trip time to this client (ms) — drives projectile lag - // compensation as well as the server-side latency stats. + // Drives projectile lag compensation as well as the server-side latency stats. public rttMs = 0; private timeSinceLastPing = 0; private lastPingSentMs = 0; - // Nonce of the ping currently awaiting a reply, cleared once answered. Without - // it a Pong was just a bare event: any client could send a stream of them, or - // answer a ping it had sat on for a minute, and the RTT it produced feeds - // projectile lag compensation. + // Nonce of the ping awaiting a reply: prevents a client forging Pongs or + // answering a stale ping to corrupt the RTT that feeds lag compensation. private pendingPingNonce = 0; private nextPingNonce = 1; - // Nothing below trusts a field it was handed. Every one of these arrives - // straight from `deserialize`, which is a bare JSON.parse — and JSON carries - // Infinity happily as `1e999`. A single non-finite number reaching the - // simulation is unrecoverable: it spreads through the shared SDF and leaves a - // character that never dies and never respawns. + // Input below is untrusted: every command arrives from `deserialize` + // (a bare JSON.parse, which accepts Infinity as `1e999`). A single + // non-finite value reaching the simulation is unrecoverable — it spreads + // through the shared SDF and leaves a character that never dies or respawns. protected commandExecutors: CommandExecutors = { [SetAspectRatioActionCommand.type]: (v: SetAspectRatioActionCommand) => { this.aspectRatio = finiteInRange( @@ -115,26 +102,22 @@ export class Player extends PlayerBase { if (!isFiniteNumber(c.clientTimeMs)) { return; } - // Record receipt (whether or not leap() accepts it): either way its effect - // on bodyVelocity is now reflected in the streamed launch momentum, so the - // predictor must stop replaying this leap. + // Record receipt whether or not leap() accepts it: the predictor must + // stop replaying this leap either way. this.lastLeapClientTimeMs = c.clientTimeMs; this.observeClientTime(c.clientTimeMs); this.character?.leap(); }, - // Closes every batch. Movement is only sent when it changes, so a held key - // used to freeze the acknowledged input time — and with it the window the - // client's predictor is allowed to replay. This advances the acknowledgement - // at the client's send rate regardless of whether the input changed. + // Advances the acknowledgement at the client's send rate regardless of + // whether input changed — otherwise a held key freezes the predictor's + // replay window. [ClientHeartbeatCommand.type]: (c: ClientHeartbeatCommand) => this.observeClientTime(c.clientTimeMs), }; - // The newest client-clock instant whose commands have all been applied, and - // the server-clock instant it arrived at. The client's predictor needs both: - // the first tells it which of its inputs are already reflected in the - // snapshot, the second how stale that answer was by the time the snapshot was - // taken (see InputAcknowledgement.ackAgeMs). + // Tracks both the newest applied client-clock instant and the server-clock + // instant it arrived. The predictor needs both: which inputs are already in + // the snapshot, and how stale that answer was (see InputAcknowledgement). private observeClientTime(clientTimeMs: number) { if (isFiniteNumber(clientTimeMs) && clientTimeMs > this.lastInputClientTimeMs) { this.lastInputClientTimeMs = clientTimeMs; @@ -142,10 +125,8 @@ export class Player extends PlayerBase { } } - // How long a command spent reaching us, for projectile lag compensation. - // Deliberately derived from the measured RTT and NOT from the client's own - // timestamp, so a forged stamp cannot buy extra compensation — which is why it - // takes no argument. ProjectilePhysical.fastForward caps the result. + // Derived from the measured RTT, NOT the client's timestamp, so a forged + // stamp cannot buy extra lag compensation. fastForward caps the result. private get catchUpSeconds(): number { return Math.max(0, this.rttMs / 2 / 1000); } @@ -161,16 +142,13 @@ export class Player extends PlayerBase { this.createCharacter(); this.step(0); - // The client already echoes a Pong for every Ping (see game.ts). Only one - // ping is ever in flight, so RTT is simply now − send-time; no payload - // needed and no client change required. + // Only one ping is ever in flight, so RTT is simply now − send-time. this.socket.on(TransportEvents.Pong, this.onPong); } - // Only the outstanding ping counts, and only once: a stale, duplicated or - // invented reply is ignored. A client can still sit on the live ping to - // inflate its measured RTT, so the result is clamped — and the next ping - // retires the nonce, bounding the stall at one ping interval. + // Only the outstanding ping's nonce counts, once. A client can sit on the + // live ping to inflate measured RTT, so the result is clamped; the next ping + // retires the nonce, bounding any stall at one ping interval. private readonly onPong = (nonce: unknown) => { if (this.pendingPingNonce === 0 || nonce !== this.pendingPingNonce) { return; @@ -182,9 +160,8 @@ export class Player extends PlayerBase { ); }; - // Fix 7a: the Pong listener outlives the round it belongs to unless it is - // taken off explicitly — the socket survives a restart, so a retired Player - // would stay reachable (and keep updating) through it. + // The Pong listener outlives the round: the socket survives a restart, so a + // retired Player would stay reachable through it unless removed explicitly. public detachFromSocket() { this.socket.off(TransportEvents.Pong, this.onPong); } @@ -201,9 +178,8 @@ export class Player extends PlayerBase { this.winnerTeam = winnerTeam; } - // The corpse a projectile may still credit a kill to. Kept so a shot that was - // already in flight when we died is not thrown away: killCount is re-read at - // respawn instead of being snapshotted the instant the body died. + // Retained so an in-flight shot can still credit a kill to this corpse: + // killCount is re-read at respawn, not snapshotted when the body died. private dyingCharacter?: CharacterPhysical | null; public step(deltaTimeInSeconds: number) { @@ -231,17 +207,14 @@ export class Player extends PlayerBase { this.objectContainer.findIntersecting(bb).map((o) => o.gameObject), ); - // The owning character must always be in its own snapshot, regardless of the - // view-area query, so the client predictor never loses its authoritative - // anchor (the body can ride a fast spinner to the very edge of the box). + // The owning character must always be in its own snapshot so the client + // predictor never loses its authoritative anchor. if (this.character) { inViewArea.add(this.character); } // Set membership rather than Array.includes: this diff runs per player per - // snapshot over everything on their screen, and was quadratic in that. - // Set membership rather than Array.includes: this diff runs per player per - // snapshot over everything on their screen, and was quadratic in that. + // snapshot over everything on screen and was quadratic in that. const newlyIntersecting = [...inViewArea].filter( (o) => !this.objectsInViewArea.has(o), ); @@ -275,9 +248,8 @@ export class Player extends PlayerBase { new PropertyUpdatesForObjects(propertyUpdates, performance.now() / 1000), ); - // Tell the client how much of its own input is reflected in the snapshot it - // just received, so its predictor can replay the rest. Only while alive — - // a dead player isn't predicting. + // Tells the client how much of its own input is reflected in the snapshot + // so its predictor can replay the rest. Only while alive. if (this.character) { this.queueCommandSend( new InputAcknowledgement( @@ -292,8 +264,8 @@ export class Player extends PlayerBase { } } - // Every living player except this one, reported by absolute world position so - // the client can plot the whole circular arena on its minimap. + // Every living player except this one, by absolute world position for the + // minimap. private getMinimapPlayers(): Array { return this.playerContainer.players .filter((p) => p !== this && p.character?.isAlive) @@ -309,8 +281,7 @@ export class Player extends PlayerBase { } public stepCommunications(deltaTime: number) { - // Runs at the physics rate, and on most ticks nothing has fired — so test - // first and only build the command when there is something to send. + // Test first and only build the command when there is something to send. let remoteCalls: Array | undefined; for (const object of this.objectsInViewArea) { const calls = object.getRemoteCalls(); @@ -338,11 +309,8 @@ export class Player extends PlayerBase { } } - // Bytes written to this client's socket but not yet flushed to the network. - // engine.io's own Socket exposes no such number — it lives on the underlying - // ws socket, and only the websocket transport has one — so reading - // `socket.conn.bufferedAmount` (as this did) always produced undefined and the - // backpressure check below never once ran. + // engine.io's Socket exposes no bufferedAmount — it lives on the underlying + // ws socket, and only the websocket transport has one. private get bufferedBytes(): number { const conn = this.socket.conn as | { transport?: { socket?: { bufferedAmount?: unknown } } } @@ -356,27 +324,18 @@ export class Player extends PlayerBase { return; } - // A client that is not draining gets the cheap half of the snapshot shed: - // piling more state behind it only grows a backlog it will never catch up - // on, and the freshest property update is the only one that matters. - // - // Only the state that next tick regenerates in full may be dropped. Create - // and delete are one-shot: handleViewAreaUpdate has ALREADY advanced - // objectsPreviouslyInViewArea by the time this runs, so dropping them leaves - // this client permanently missing objects it was never told about and - // holding ghosts it was never told to remove. Announcements and the end-game - // card are one-shot for the same reason. They are small; they go out even - // while backed up. + // Only state next tick regenerates in full may be dropped. Create/delete + // are one-shot: handleViewAreaUpdate has ALREADY advanced + // objectsPreviouslyInViewArea, so dropping them leaves this client + // permanently missing objects and holding ghosts. They go out even backed up. if (this.bufferedBytes > maximumBufferedBytes) { - // Matched by command type rather than `instanceof`, the same way every - // other dispatch in the codebase does it — the identity of a class from a + // Matched by command type, not `instanceof`: a class identity from a // bundled shared module is not something to depend on. const sheddable: ReadonlyArray = [ PropertyUpdatesForObjects.type, UpdateMinimap.type, - // Only meaningful together with the pose it describes, and regenerated - // in full next tick — so it is shed with it rather than advancing the - // client's replay anchor past a snapshot it never received. + // Regenerated in full next tick — shed with the pose, never advance the + // replay anchor past a snapshot the client never received. InputAcknowledgement.type, ]; this.commandsToBeSent = this.commandsToBeSent.filter( @@ -391,7 +350,6 @@ export class Player extends PlayerBase { this.commandsToBeSent = []; } - /** Score to hold for this player if it reconnects inside the grace window. */ public get scoreSnapshot(): { kills: number; deaths: number } { return { kills: Math.max(this.sumKills, this.character?.killCount ?? 0), diff --git a/backend/tsconfig.json b/backend/tsconfig.json index e9e0884..1bd6a6c 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -10,8 +10,7 @@ "moduleResolution": "node", "ignoreDeprecations": "6.0", "module": "commonjs", - "lib": ["dom", "es2017"], - "typeRoots": ["./types", "./node_modules/@types"] + "lib": ["es2017"] }, "include": ["src/**/*.ts"], "exclude": ["node_modules", "typings"] diff --git a/backend/types/socket.io-msgpack-parser/index.d.ts b/backend/types/socket.io-msgpack-parser/index.d.ts deleted file mode 100644 index 4065a1e..0000000 --- a/backend/types/socket.io-msgpack-parser/index.d.ts +++ /dev/null @@ -1 +0,0 @@ -declare module 'socket.io-msgpack-parser'; diff --git a/backend/webpack.config.js b/backend/webpack.config.js index 20e8a57..87a462a 100644 --- a/backend/webpack.config.js +++ b/backend/webpack.config.js @@ -43,10 +43,7 @@ module.exports = (env, argv) => ({ }, plugins: [ new webpack.BannerPlugin({ banner: '#!/usr/bin/env node', raw: true }), - new CleanWebpackPlugin({ - protectWebpackAssets: false, - cleanAfterEveryBuildPatterns: [], - }), + new CleanWebpackPlugin(), ], module: { rules: [ diff --git a/frontend/src/custom.d.ts b/frontend/src/custom.d.ts index f053a20..5c3fbac 100644 --- a/frontend/src/custom.d.ts +++ b/frontend/src/custom.d.ts @@ -3,13 +3,11 @@ declare module '*.mp3' { export default content; } -// Asset imports handled by webpack loaders. TypeScript 5.x+ requires ambient -// declarations for side-effect imports of these, so declare them here. +// TypeScript 5.x+ requires ambient declarations for side-effect asset imports. declare module '*.png'; declare module '*.ico'; declare module '*.svg'; declare module '*.scss'; -// webpack's `mode` inlines `process.env.NODE_ENV` via DefinePlugin. Declare just -// that one global so browser code can read it without pulling in all of @types/node. +// Declare just NODE_ENV (DefinePlugin inlines it) so browser code need not pull in all of @types/node. declare const process: { env: { NODE_ENV?: string } }; diff --git a/frontend/src/index.ts b/frontend/src/index.ts index 272c3b9..893a816 100644 --- a/frontend/src/index.ts +++ b/frontend/src/index.ts @@ -20,8 +20,14 @@ import { handleFullScreen } from './scripts/helper/handle-full-screen'; import { Game } from './scripts/game'; import ResizeObserver from 'resize-observer-polyfill'; import { OptionsHandler } from './scripts/options-handler'; -import { hide } from './scripts/helper/hide'; -import { show } from './scripts/helper/show'; +const hide = (e: HTMLElement, d?: boolean) => { + if (d) e.style.display = 'none'; + else e.style.visibility = 'hidden'; +}; +const show = (e: HTMLElement, d?: boolean, v?: string) => { + if (d) e.style.display = v!; + else e.style.visibility = 'inherit'; +}; import { SoundHandler, Sounds } from './scripts/sound-handler'; import { VibrationHandler } from './scripts/vibration-handler'; import { CharacterView } from './scripts/objects/types/character-view'; @@ -84,91 +90,86 @@ const applyServerContainerShadows = () => { }; const main = async () => { - try { - let game: Game; + let game: Game; - const storedUserName = localStorage?.getItem('userName'); - if (storedUserName) { - nameInput.value = JSON.parse(storedUserName); + const storedUserName = localStorage.getItem('userName'); + if (storedUserName) { + nameInput.value = JSON.parse(storedUserName); + } + + const firstClickListener = () => { + SoundHandler.initialize( + () => { + enableMusic.checked = true; + enableMusic.dispatchEvent(new Event('change')); + }, + () => { + enableMusic.checked = false; + enableMusic.dispatchEvent(new Event('change')); + }, + ); + document.removeEventListener('click', firstClickListener); + }; + document.addEventListener('click', firstClickListener); + + if (!VibrationHandler.isVibrationEnabledHeuristics) { + hide(document.querySelector("label[for='enable-vibration']") as HTMLElement, true); + } + + handleFullScreen(minimize, maximize); + toggleSettingsButton.addEventListener('click', toggleSettings); + + new ResizeObserver(applyServerContainerShadows).observe(serverContainer); + serverContainer.addEventListener('scroll', applyServerContainerShadows); + + OptionsHandler.initialize({ + soundsEnabled: enableSounds, + vibrationEnabled: enableVibration, + musicEnabled: enableMusic, + }); + + logoutButton.addEventListener('click', () => { + game.destroy(); + toggleSettings(); + }); + window.onpopstate = () => game.destroy(); + + for (;;) { + show(spinner); + hide(logoutButton, true); + show(landingUI, true, 'flex'); + + const background = new LandingPageBackground(canvas); + const joinHandler = new JoinFormHandler(joinGameForm, serverContainer); + + await background.renderer; + hide(spinner); + + const playerDecision = await joinHandler.getPlayerDecision(); + + localStorage.setItem('userName', JSON.stringify(playerDecision.name)); + + if (!history.state) { + history.pushState(true, ''); } - const firstClickListener = () => { - SoundHandler.initialize( - () => { - enableMusic.checked = true; - enableMusic.dispatchEvent(new Event('change')); - }, - () => { - enableMusic.checked = false; - enableMusic.dispatchEvent(new Event('change')); - }, - ); - document.removeEventListener('click', firstClickListener); - }; - document.addEventListener('click', firstClickListener); + hide(landingUI, true); + show(spinner); + background.destroy(); + game = new Game(playerDecision, canvas, overlay); + const gameOver = game.start(); + await game.started; + hide(spinner); + show(logoutButton, true, 'block'); + await gameOver; - if (!VibrationHandler.isVibrationEnabledHeuristics) { - hide(document.querySelector("label[for='enable-vibration']") as HTMLElement, true); + const reason = game.lastRejectionReason; + if (reason) { + joinNotice.innerText = Game.rejectionText(reason); + joinNotice.style.display = 'block'; + } else { + joinNotice.style.display = 'none'; } - - handleFullScreen(minimize, maximize); - toggleSettingsButton.addEventListener('click', toggleSettings); - - new ResizeObserver(applyServerContainerShadows).observe(serverContainer); - serverContainer.addEventListener('scroll', applyServerContainerShadows); - - OptionsHandler.initialize({ - soundsEnabled: enableSounds, - vibrationEnabled: enableVibration, - musicEnabled: enableMusic, - }); - - logoutButton.addEventListener('click', () => { - game.destroy(); - toggleSettings(); - }); - window.onpopstate = () => game.destroy(); - - for (;;) { - show(spinner); - hide(logoutButton, true); - show(landingUI, true, 'flex'); - - const background = new LandingPageBackground(canvas); - const joinHandler = new JoinFormHandler(joinGameForm, serverContainer); - - await background.renderer; - hide(spinner); - - const playerDecision = await joinHandler.getPlayerDecision(); - - localStorage?.setItem('userName', JSON.stringify(playerDecision.name)); - - if (!history.state) { - history.pushState(true, ''); - } - - hide(landingUI, true); - show(spinner); - background.destroy(); - game = new Game(playerDecision, canvas, overlay); - const gameOver = game.start(); - await game.started; - hide(spinner); - show(logoutButton, true, 'block'); - await gameOver; - - const reason = game.lastRejectionReason; - if (reason) { - joinNotice.innerText = Game.rejectionText(reason); - joinNotice.style.display = 'block'; - } else { - joinNotice.style.display = 'none'; - } - } - } catch (e) { - console.error(e); - alert(e); } }; diff --git a/frontend/src/scripts/analytics.ts b/frontend/src/scripts/analytics.ts index e732cc4..76e258f 100644 --- a/frontend/src/scripts/analytics.ts +++ b/frontend/src/scripts/analytics.ts @@ -1,16 +1,9 @@ -import { init as plausibleInit } from '@plausible-analytics/tracker'; - -const ANALYTICS_AUTO_CAPTURE_PAGEVIEWS = true; -const ANALYTICS_DOMAIN = 'doppler.schmelczer.dev'; -const ANALYTICS_ENDPOINT = 'https://stats.schmelczer.dev/status'; -const ANALYTICS_LOGGING = process.env.NODE_ENV !== 'production'; +import { init } from '@plausible-analytics/tracker'; try { - plausibleInit({ - domain: ANALYTICS_DOMAIN, - endpoint: ANALYTICS_ENDPOINT, - autoCapturePageviews: ANALYTICS_AUTO_CAPTURE_PAGEVIEWS, - logging: ANALYTICS_LOGGING, + init({ + domain: 'doppler.schmelczer.dev', + endpoint: 'https://stats.schmelczer.dev/status', }); } catch (error) { console.warn('Could not initialize analytics.', error); diff --git a/frontend/src/scripts/charge-indicator.ts b/frontend/src/scripts/charge-indicator.ts index 239e33b..670711b 100644 --- a/frontend/src/scripts/charge-indicator.ts +++ b/frontend/src/scripts/charge-indicator.ts @@ -1,58 +1,58 @@ import { holdDurationToCharge } from 'shared'; -import { Pointer } from './helper/pointer'; +import { pointer } from './helper/pointer'; -export abstract class ChargeIndicator { - private static element?: HTMLElement; - private static heldSince = 0; - private static raf = 0; - private static followPointer = false; +let element: HTMLElement | undefined; +let heldSince = 0; +let raf = 0; +let _followPointer = false; - public static begin(x: number, y: number, followPointer = false) { - this.end(); +export function beginChargeIndicator(x: number, y: number, followPointer = false) { + endChargeIndicator(); - const element = document.createElement('div'); - element.className = 'charge-ring'; - element.style.left = `${x}px`; - element.style.top = `${y}px`; - document.body.appendChild(element); + const el = document.createElement('div'); + el.className = 'charge-ring'; + el.style.left = `${x}px`; + el.style.top = `${y}px`; + document.body.appendChild(el); - this.element = element; - this.heldSince = performance.now(); - this.followPointer = followPointer; - this.raf = requestAnimationFrame(this.update); - } - - public static end() { - if (this.element) { - cancelAnimationFrame(this.raf); - this.element.parentElement?.removeChild(this.element); - this.element = undefined; - } - } - - private static update = () => { - const element = ChargeIndicator.element; - if (!element) { - return; - } - - const charge = holdDurationToCharge( - (performance.now() - ChargeIndicator.heldSince) / 1000, - ); - element.style.opacity = charge < 0.12 ? '0' : '1'; - element.style.background = `conic-gradient(rgba(255, 255, 255, 0.85) ${ - charge * 360 - }deg, rgba(255, 255, 255, 0.15) 0deg)`; - element.classList.toggle('full', charge >= 1); - - if (ChargeIndicator.followPointer) { - const cursor = Pointer.getDisplayPosition(); - if (cursor) { - element.style.left = `${cursor.x}px`; - element.style.top = `${cursor.y}px`; - } - } - - ChargeIndicator.raf = requestAnimationFrame(ChargeIndicator.update); - }; + element = el; + heldSince = performance.now(); + _followPointer = followPointer; + raf = requestAnimationFrame(updateChargeIndicator); } + +export function endChargeIndicator() { + if (element) { + cancelAnimationFrame(raf); + element.parentElement?.removeChild(element); + element = undefined; + } +} + +function updateChargeIndicator() { + if (!element) { + return; + } + + const charge = holdDurationToCharge((performance.now() - heldSince) / 1000); + element.style.opacity = charge < 0.12 ? '0' : '1'; + element.style.background = `conic-gradient(rgba(255, 255, 255, 0.85) ${ + charge * 360 + }deg, rgba(255, 255, 255, 0.15) 0deg)`; + element.classList.toggle('full', charge >= 1); + + if (_followPointer) { + const cursor = pointer.displayPosition; + if (cursor) { + element.style.left = `${cursor.x}px`; + element.style.top = `${cursor.y}px`; + } + } + + raf = requestAnimationFrame(updateChargeIndicator); +} + +export const ChargeIndicator = { + begin: beginChargeIndicator, + end: endChargeIndicator, +}; diff --git a/frontend/src/scripts/commands/command-socket.ts b/frontend/src/scripts/commands/command-socket.ts index b7d1129..f7f04d5 100644 --- a/frontend/src/scripts/commands/command-socket.ts +++ b/frontend/src/scripts/commands/command-socket.ts @@ -32,16 +32,7 @@ export class CommandSocket extends CommandReceiver { public sendQueuedCommands() { const nowMs = predictorNowMs(); - // Real input goes out on the frame it happens — that is the whole latency - // budget the player feels. An idle client still has to keep the server's - // input acknowledgement moving (a held key generates no fresh command), but - // that heartbeat is paced by the clock rather than by the frame rate. - // - // Sending once per rendered frame instead put every display above - // settings.maxInboundMessagesPerSecond permanently over the server's inbound - // allowance; once the burst was spent it silently discarded whole batches, - // and since movement commands are edge-triggered and never re-sent, a lost - // batch meant the direction change simply never happened. + // Real input goes out on the frame it happens — that is the latency budget the player feels. An idle client still keeps the server's input acknowledgement moving (a held key generates no fresh command), paced by the clock not the frame rate; sending every rendered frame would exceed settings.maxInboundMessagesPerSecond, and since movement commands are edge-triggered and never re-sent, a discarded batch would drop the direction change for good. if ( this.commandQueue.length === 0 && nowMs - this.lastSendMs < heartbeatIntervalMs - dueToleranceMs diff --git a/frontend/src/scripts/commands/mouse-listener.ts b/frontend/src/scripts/commands/mouse-listener.ts index cff0be2..fdd06e7 100644 --- a/frontend/src/scripts/commands/mouse-listener.ts +++ b/frontend/src/scripts/commands/mouse-listener.ts @@ -3,11 +3,9 @@ import { CommandGenerator, PrimaryActionCommand, holdDurationToCharge } from 'sh import { Game } from '../game'; import { ChargeIndicator } from '../charge-indicator'; import { predictorNowMs } from '../helper/prediction/local-character-predictor'; -import { Pointer } from '../helper/pointer'; +import { pointer } from '../helper/pointer'; export class MouseListener extends CommandGenerator { - // Timestamp (ms) of the primary press, or null when not held. On release the - // held duration is mapped to the charge scalar; a quick tap reads as ~0. private primaryDownAt: number | null = null; constructor( @@ -19,25 +17,19 @@ export class MouseListener extends CommandGenerator { target.addEventListener('mousedown', this.mouseDownListener); target.addEventListener('mousemove', this.mouseMoveListener); target.addEventListener('contextmenu', this.contextMenuListener); - // Release is watched on the window, not the canvas: a charge released over - // the settings gear, the fullscreen icon, or outside the window otherwise - // never fired the shot and left the charge ring up until the next press. + // Release watched on the window, not the canvas — a release over UI or outside the window must still fire. window.addEventListener('mouseup', this.mouseUpListener); window.addEventListener('blur', this.cancelPrimary); } - // Only the screen position is stored; it is reprojected to world space each - // frame so the gaze stays correct even while the camera pans under a still - // cursor. + // Store screen position; reproject to world each frame so gaze stays correct while the camera pans. private mouseMoveListener = (event: MouseEvent) => { - Pointer.setDisplayPosition(event.clientX, event.clientY); + pointer.displayPosition = vec2.fromValues(event.clientX, event.clientY); }; private mouseDownListener = (event: MouseEvent) => { if (event.button === 0) { this.primaryDownAt = performance.now(); - // The ring follows the cursor and only fades in once this press has - // clearly become a hold. ChargeIndicator.begin(event.clientX, event.clientY, true); } }; @@ -59,8 +51,6 @@ export class MouseListener extends CommandGenerator { ); }; - // Losing the window mid-charge cannot produce a meaningful aim point, so drop - // the charge rather than leaving the indicator running. private cancelPrimary = () => { if (this.primaryDownAt !== null) { this.primaryDownAt = null; @@ -68,7 +58,6 @@ export class MouseListener extends CommandGenerator { } }; - // Suppress the browser context menu on the canvas; right-click has no action. private contextMenuListener = (event: MouseEvent) => { event.preventDefault(); }; diff --git a/frontend/src/scripts/commands/touch-listener.ts b/frontend/src/scripts/commands/touch-listener.ts index 25c6463..a73bf5d 100644 --- a/frontend/src/scripts/commands/touch-listener.ts +++ b/frontend/src/scripts/commands/touch-listener.ts @@ -2,7 +2,6 @@ import { vec2 } from 'gl-matrix'; import { CommandGenerator, MoveActionCommand, - last, PrimaryActionCommand, LeapActionCommand, holdDurationToCharge, @@ -18,8 +17,6 @@ import { export class TouchListener extends CommandGenerator { private static readonly deadZone = 8; private static readonly deltaScaling = 0.4; - // Min screen drag (px) from the fire button before a shot is aimed by the - // drag direction instead of firing straight ahead. private static readonly aimDeadZone = 18; private joystick: HTMLElement; @@ -76,7 +73,6 @@ export class TouchListener extends CommandGenerator { target.addEventListener('touchcancel', this.touchCancelListener); } - // The contact that started this gesture, or undefined if it is not in `list`. private findGestureTouch(list: TouchList): Touch | undefined { if (this.gestureTouchId === null) { return undefined; @@ -93,8 +89,8 @@ export class TouchListener extends CommandGenerator { event.preventDefault(); if (this.isJoystickActive) { const center = vec2.fromValues( - last(event.changedTouches)!.clientX, - last(event.changedTouches)!.clientY, + event.changedTouches[event.changedTouches.length - 1].clientX, + event.changedTouches[event.changedTouches.length - 1].clientY, ); this.sendCommandToSubscribers( new PrimaryActionCommand( @@ -159,8 +155,6 @@ export class TouchListener extends CommandGenerator { private touchEndListener = (event: TouchEvent) => { event.preventDefault(); - // Only the contact that owns the gesture ends it — not whichever finger - // happens to lift, and not "the screen is now empty". const touch = this.findGestureTouch(event.changedTouches); if (!touch) { return; @@ -187,8 +181,7 @@ export class TouchListener extends CommandGenerator { } }; - // Also reached from touchcancel: a system gesture, an incoming call or a - // palm rejection would otherwise leave the stick latched at full tilt. + // Also reached from touchcancel — otherwise a system gesture leaves the stick latched. private touchCancelListener = (event: TouchEvent) => { if (!this.findGestureTouch(event.changedTouches)) { return; @@ -230,9 +223,6 @@ export class TouchListener extends CommandGenerator { ChargeIndicator.begin(this.fireButtonCenter[0], this.fireButtonCenter[1]); }; - // Dragging from the fire button aims the shot: the drag vector sets the - // direction, decoupling aim from movement so a touch player can fire one way - // while walking another. A tap with no meaningful drag fires straight ahead. private fireButtonMoveListener = (event: TouchEvent) => { this.swallowTouch(event); if (this.fireDownAt === null || !this.fireButtonCenter) { @@ -271,8 +261,7 @@ export class TouchListener extends CommandGenerator { return; } - // Screen drag → world aim direction (flip Y: screen +y is down). Below the - // dead-zone it's a tap, so fall back to firing along the facing direction. + // Screen drag → world aim direction (flip Y: screen +y is down). let direction = character.facingDirection; if (this.fireButtonCenter && this.fireAimScreen) { const dx = this.fireAimScreen[0] - this.fireButtonCenter[0]; diff --git a/frontend/src/scripts/configuration.ts b/frontend/src/scripts/configuration.ts index e254dcb..f41d8f9 100644 --- a/frontend/src/scripts/configuration.ts +++ b/frontend/src/scripts/configuration.ts @@ -1,20 +1,12 @@ /** - * Hardcoded list of game servers the landing page offers to players. - * - * Each entry is the public origin of a dockerized `doppler-server` instance. - * The join screen polls `/state` (see `serverInformationEndpoint`) and - * only shows a server once it responds, so listing an offline origin here is - * harmless. Add or remove origins as you deploy more server containers. + * Game server origins. The join screen polls each and only shows those that + * respond, so listing an offline origin here is harmless. */ -// This origin predates the rebrand; update it once the game server is -// redeployed under a doppler subdomain. const productionServers: Array = ['https://declared.schmelczer.dev']; /** - * When the page is served from localhost (i.e. the webpack-dev-server), also - * offer the local backend so a `npm start` server can be joined during - * development. The backend's default port is 3000 (see backend/src/options.ts). - * In production the page is served from its own hostname, so this never leaks. + * On localhost (webpack-dev-server) also offer the local backend on its default + * port; in production the page is served from its own hostname, so this never leaks. */ const isDevelopment = typeof location !== 'undefined' && diff --git a/frontend/src/scripts/feedback-hud.ts b/frontend/src/scripts/feedback-hud.ts index 553a5a9..939b99f 100644 --- a/frontend/src/scripts/feedback-hud.ts +++ b/frontend/src/scripts/feedback-hud.ts @@ -1,132 +1,128 @@ import { settings } from 'shared'; -import { Pointer } from './helper/pointer'; +import { pointer } from './helper/pointer'; -export abstract class FeedbackHud { - private static root?: HTMLElement; - private static killfeed?: HTMLElement; - private static elimination?: HTMLElement; +let root: HTMLElement | undefined; +let killfeed: HTMLElement | undefined; +let elimination: HTMLElement | undefined; - private static ensureRoot(): { root: HTMLElement; killfeed: HTMLElement } { - if (!this.root || !this.killfeed) { - this.root = document.createElement('div'); - this.root.className = 'feedback-hud'; +function ensureRoot(): { root: HTMLElement; killfeed: HTMLElement } { + if (!root || !killfeed) { + root = document.createElement('div'); + root.className = 'feedback-hud'; - this.killfeed = document.createElement('div'); - this.killfeed.className = 'killfeed'; - this.root.appendChild(this.killfeed); + killfeed = document.createElement('div'); + killfeed.className = 'killfeed'; + root.appendChild(killfeed); - document.body.appendChild(this.root); - } - return { root: this.root, killfeed: this.killfeed }; + document.body.appendChild(root); } + return { root, killfeed }; +} - private static focusPoint(): { x: number; y: number } { - const cursor = Pointer.getDisplayPosition(); - if (cursor) { - return { x: cursor.x, y: cursor.y }; - } - return { x: window.innerWidth / 2, y: window.innerHeight / 2 }; +function focusPoint(): { x: number; y: number } { + const cursor = pointer.displayPosition; + if (cursor) { + return { x: cursor.x, y: cursor.y }; } + return { x: window.innerWidth / 2, y: window.innerHeight / 2 }; +} - private static addTransient(element: HTMLElement, lifetimeMs: number) { - const { root } = this.ensureRoot(); - root.appendChild(element); - setTimeout(() => element.parentElement?.removeChild(element), lifetimeMs); - } +function addTransient(el: HTMLElement, lifetimeMs: number) { + const { root: r } = ensureRoot(); + r.appendChild(el); + setTimeout(() => el.parentElement?.removeChild(el), lifetimeMs); +} - public static hitMarker(charge = 0) { - const { x, y } = this.focusPoint(); - const marker = document.createElement('div'); - marker.className = - 'hitmarker' + (charge >= settings.chargedHitThreshold ? ' charged' : ''); - marker.style.left = `${x}px`; - marker.style.top = `${y}px`; - this.addTransient(marker, 250); - } +export function hitMarker(charge = 0) { + const { x, y } = focusPoint(); + const marker = document.createElement('div'); + marker.className = + 'hitmarker' + (charge >= settings.chargedHitThreshold ? ' charged' : ''); + marker.style.left = `${x}px`; + marker.style.top = `${y}px`; + addTransient(marker, 250); +} - public static killConfirmed(victimName?: string, streak = 1, charge = 0) { - const { killfeed } = this.ensureRoot(); - const charged = charge >= settings.chargedHitThreshold; +export function killConfirmed(victimName?: string, streak = 1, charge = 0) { + const { killfeed: kf } = ensureRoot(); + const charged = charge >= settings.chargedHitThreshold; - // A quick crimson vignette pulse around the whole frame to punctuate the kill. - const flash = document.createElement('div'); - flash.className = 'kill-flash' + (charged ? ' charged' : ''); - this.addTransient(flash, 420); + const flash = document.createElement('div'); + flash.className = 'kill-flash' + (charged ? ' charged' : ''); + addTransient(flash, 420); - const entry = document.createElement('div'); - entry.className = 'kill-entry'; - entry.innerHTML = `Eliminated ${this.escape(victimName ?? 'enemy')}`; - killfeed.insertBefore(entry, killfeed.firstChild); - setTimeout(() => entry.parentElement?.removeChild(entry), 4500); + const entry = document.createElement('div'); + entry.className = 'kill-entry'; + entry.innerHTML = `Eliminated ${escapeHtml(victimName ?? 'enemy')}`; + kf.insertBefore(entry, kf.firstChild); + setTimeout(() => entry.parentElement?.removeChild(entry), 4500); - const { x, y } = this.focusPoint(); - const popup = document.createElement('div'); - popup.className = 'kill-popup' + (charged ? ' charged' : ''); - popup.innerHTML = `+${settings.playerKillPoint} +${settings.playerKillHealthReward}❤`; - popup.style.left = `${x}px`; - popup.style.top = `${y}px`; - this.addTransient(popup, 1200); + const { x, y } = focusPoint(); + const popup = document.createElement('div'); + popup.className = 'kill-popup' + (charged ? ' charged' : ''); + popup.innerHTML = `+${settings.playerKillPoint} +${settings.playerKillHealthReward}❤`; + popup.style.left = `${x}px`; + popup.style.top = `${y}px`; + addTransient(popup, 1200); - const callout = this.streakName(streak) ?? (charged ? 'Charged Kill!' : undefined); - if (callout) { - const el = document.createElement('div'); - el.className = 'streak-callout'; - el.innerText = callout; - this.addTransient(el, 1400); - } - } - - // Persistent centred overlay shown while the local player is dead and waiting - // to respawn. The countdown itself is the server-driven "Reviving in N…" - // announcement; this makes the death state unmistakable and stays up until - // hideElimination() is called on respawn. - public static showElimination(): void { - if (this.elimination) { - return; - } - const { root } = this.ensureRoot(); + const callout = streakName(streak) ?? (charged ? 'Charged Kill!' : undefined); + if (callout) { const el = document.createElement('div'); - el.className = 'elimination'; - el.innerHTML = - '
Eliminated
' + - '
Respawning…
'; - root.appendChild(el); - this.elimination = el; - } - - public static hideElimination(): void { - this.elimination?.parentElement?.removeChild(this.elimination); - this.elimination = undefined; - } - - /** - * Tear the HUD down entirely. Its root lives on document.body rather than the - * game overlay, so leaving a match while dead used to leave "Eliminated / - * Respawning…" painted over the landing page. - */ - public static reset(): void { - this.hideElimination(); - this.root?.parentElement?.removeChild(this.root); - this.root = undefined; - this.killfeed = undefined; - } - - private static streakName(streak: number): string | undefined { - switch (streak) { - case 2: - return 'Double Kill!'; - case 3: - return 'Triple Kill!'; - case 4: - return 'Quad Kill!'; - default: - return streak >= 5 ? 'Rampage!' : undefined; - } - } - - private static escape(text: string): string { - const div = document.createElement('div'); - div.innerText = text; - return div.innerHTML; + el.className = 'streak-callout'; + el.innerText = callout; + addTransient(el, 1400); } } + +export function showElimination(): void { + if (elimination) { + return; + } + const { root: r } = ensureRoot(); + const el = document.createElement('div'); + el.className = 'elimination'; + el.innerHTML = + '
Eliminated
' + + '
Respawning…
'; + r.appendChild(el); + elimination = el; +} + +export function hideElimination(): void { + elimination?.parentElement?.removeChild(elimination); + elimination = undefined; +} + +export function resetFeedbackHud(): void { + hideElimination(); + root?.parentElement?.removeChild(root); + root = undefined; + killfeed = undefined; +} + +function streakName(streak: number): string | undefined { + switch (streak) { + case 2: + return 'Double Kill!'; + case 3: + return 'Triple Kill!'; + case 4: + return 'Quad Kill!'; + default: + return streak >= 5 ? 'Rampage!' : undefined; + } +} + +function escapeHtml(text: string): string { + const div = document.createElement('div'); + div.innerText = text; + return div.innerHTML; +} + +export const FeedbackHud = { + hitMarker, + killConfirmed, + showElimination, + hideElimination, + reset: resetFeedbackHud, +}; diff --git a/frontend/src/scripts/game.ts b/frontend/src/scripts/game.ts index ca08a83..25f684c 100644 --- a/frontend/src/scripts/game.ts +++ b/frontend/src/scripts/game.ts @@ -65,8 +65,6 @@ export class Game extends CommandReceiver { private keystoneArrow?: HTMLElement; private socketReceiver!: CommandSocket; private tutorial!: Tutorial; - // Issued by the server on join; presented on reconnect to reclaim this - // player's team and score instead of coming back as a blank slate. private reconnectToken?: string; private connectionBanner?: HTMLElement; private rejectionReason?: JoinRejectionReason; @@ -91,8 +89,6 @@ export class Game extends CommandReceiver { this.socket?.close(); serverTimeline.reset(); localCharacterPredictor.reset(); - // Clear any leftover shake/zoom so a kill at the end of one match can't bleed - // its camera impact into the next. ScreenShake.reset(); this.gameObjects = new GameObjectContainer(this); this.overlay.innerHTML = ''; @@ -114,16 +110,12 @@ export class Game extends CommandReceiver { parser, } as any); - // In socket.io-client v4 reconnection events are emitted by the Manager - // (`socket.io`), not the Socket itself. + // In socket.io-client v4 reconnection events are emitted by the Manager (`socket.io`), not the Socket itself. this.socket.io.on('reconnect_attempt', () => { this.socket.io.opts.transports = ['polling', 'websocket']; }); - // A transport drop is not the end of the match. Previously this tore the - // game down on the first `disconnect`, which stopped the render loop, which - // closed the socket — cancelling the reconnection the client is configured - // for before it could ever run, and dumping the player on the server list. + // A transport drop is not the end of the match — tearing the game down would cancel the reconnection the client is configured for. this.socket.on('disconnect', () => { if (this.isBetweenGames) { return; @@ -135,15 +127,11 @@ export class Game extends CommandReceiver { if (this.isBetweenGames) { return; } - // A reconnect is a brand-new server-side connection that has never seen a - // join, so the join has to be re-sent or the client sits connected and - // invisible forever. + // A reconnect is a brand-new server-side connection that has never seen a join, so the join must be re-sent or the client sits connected and invisible forever. this.hideConnectionBanner(); serverTimeline.reset(); localCharacterPredictor.reset(); - // ...and it is a brand-new server-side *player*, whose view-area - // bookkeeping starts empty. Anything the dropped session was told about - // will never be retracted, so the old world has to go here. + // A brand-new server-side player whose view-area bookkeeping starts empty: the dropped session's objects will never be retracted, so reset here. this.gameObjects.reset(); this.socket.emit(TransportEvents.PlayerJoining, { ...this.playerDecision, @@ -167,17 +155,13 @@ export class Game extends CommandReceiver { this.destroy(); }); - // Echo the nonce: the server only accepts a reply that matches the ping - // still outstanding, so a stale or duplicated Pong cannot move its RTT. + // Echo the nonce: the server only accepts a reply matching the ping still outstanding, so a stale or duplicated Pong cannot move its RTT. this.socket.on(TransportEvents.Ping, (nonce: unknown) => { this.socket.emit(TransportEvents.Pong, nonce); }); this.socket.on(TransportEvents.ServerToPlayer, (serializedCommands: string) => { - // One malformed object must not take down the message pump. deserialize - // revives classes by name from the payload, so a hostile or corrupt field - // can throw inside JSON.parse's reviver — and every later batch would be - // lost with it. + // deserialize revives classes by name from the payload, so a hostile or corrupt field can throw inside JSON.parse's reviver — one malformed object must not take down the message pump or every later batch is lost with it. try { const commands: Array = deserialize(serializedCommands); commands.forEach((c) => { @@ -193,8 +177,6 @@ export class Game extends CommandReceiver { }); this.socketReceiver = new CommandSocket(this.socket); - // The tutorial listens to the same input streams as the socket, so its - // stages clear off the player's own commands without any server involvement. this.keyboardListener.clearSubscribers(); this.keyboardListener.subscribe(this.socketReceiver); this.keyboardListener.subscribe(this.tutorial); @@ -205,9 +187,7 @@ export class Game extends CommandReceiver { this.touchListener.subscribe(this.socketReceiver); this.touchListener.subscribe(this.tutorial); - // The join is emitted from the socket's `connect` handler above, which fires - // for the first connection and for every reconnection alike — so one code - // path covers both, and a reconnect can never be left unjoined. + // Join is emitted from the `connect` handler above, which fires for the first connection and every reconnection alike — so one code path covers both and a reconnect can never be left unjoined. this.isBetweenGames = false; } @@ -278,15 +258,13 @@ export class Game extends CommandReceiver { this.socket.close(); this.overlay.innerHTML = ''; this.hideConnectionBanner(); - // The HUD root lives on document.body, not the overlay, so it has to be torn - // down explicitly or it stays painted over the landing page. + // The HUD root lives on document.body, not the overlay, so it must be torn down explicitly or it stays painted over the landing page. FeedbackHud.reset(); this.keyboardListener.destroy(); this.mouseListener.destroy(); this.touchListener.destroy(); } - /** Why the player was sent back to the server list, if they were. */ public get lastRejectionReason(): JoinRejectionReason | undefined { return this.rejectionReason; } @@ -341,22 +319,14 @@ export class Game extends CommandReceiver { deltaTime: DOMHighResTimeStamp, ): boolean { this.resolveStarted(); - // The client's one clock, for input stamps, the outgoing send cadence and - // the prediction replay window alike. It has to be the frame's timestamp - // rather than performance.now(): the two differ by however long the browser - // took to dispatch this callback, and that difference would land straight - // in the replay window and jitter the predicted body. + // Must use the frame's timestamp, not performance.now(): the dispatch delay would land in the prediction replay window and jitter the predicted body. setFrameTimeMs(currentTime); deltaTime /= 1000; - // Decay the camera impact effects on raw wall-clock time, before any of the - // end-game slow-motion scaling below. These only adjust the rendered view, - // never the simulation, so they stay decoupled from prediction and netcode. + // Camera impact decays on raw wall-clock time, before the end-game slow-motion scaling below — view-only, never the simulation, so it stays decoupled from prediction and netcode. ScreenShake.step(deltaTime); - // Stepped before the end-game time scaling on purpose: the slow motion is - // already baked into the snapshots the server sends, so the playback - // cursor itself must keep running on wall-clock time. + // Stepped before the end-game time scaling on purpose: the slow motion is already baked into the server's snapshots, so the playback cursor must keep running on wall-clock time. serverTimeline.step(deltaTime); let shouldChangeLayout = false; @@ -395,7 +365,6 @@ export class Game extends CommandReceiver { private draw() { if (this.lastGameState) { - // The local player's team is read off the main character once it exists. this.scoreboard.update(this.lastGameState, this.gameObjects.player?.team); } @@ -411,8 +380,6 @@ export class Game extends CommandReceiver { } } - // Points an off-screen chevron toward the keystone "Heart" planet, tinted by - // who currently holds it, so the match's focal objective is always findable. private handleKeystoneArrow() { if (!this.renderer) { return; diff --git a/frontend/src/scripts/helper/hide.ts b/frontend/src/scripts/helper/hide.ts deleted file mode 100644 index 8283913..0000000 --- a/frontend/src/scripts/helper/hide.ts +++ /dev/null @@ -1,7 +0,0 @@ -export const hide = (e: HTMLElement, useDisplay = false) => { - if (useDisplay) { - e.style.display = 'none'; - } else { - e.style.visibility = 'hidden'; - } -}; diff --git a/frontend/src/scripts/helper/interpolators/circle-interpolator.ts b/frontend/src/scripts/helper/interpolators/circle-interpolator.ts deleted file mode 100644 index 62816e5..0000000 --- a/frontend/src/scripts/helper/interpolators/circle-interpolator.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Circle } from 'shared'; -import { LinearInterpolator } from './linear-interpolator'; -import { Vec2Interpolator } from './vec2-interpolator'; - -export class CircleInterpolator { - private center: Vec2Interpolator; - private radius: LinearInterpolator; - - constructor(currentValue: Circle) { - this.center = new Vec2Interpolator(currentValue.center); - this.radius = new LinearInterpolator(currentValue.radius); - } - - public addFrame(value: Circle, rateOfChange: Circle) { - this.center.addFrame(value.center, rateOfChange.center); - this.radius.addFrame(value.radius, rateOfChange.radius); - } - - public getValue(deltaTime: number): Circle { - return new Circle(this.center.getValue(deltaTime), this.radius.getValue(deltaTime)); - } -} diff --git a/frontend/src/scripts/helper/interpolators/linear-interpolator.ts b/frontend/src/scripts/helper/interpolators/linear-interpolator.ts index bcc23b5..fdd8a4b 100644 --- a/frontend/src/scripts/helper/interpolators/linear-interpolator.ts +++ b/frontend/src/scripts/helper/interpolators/linear-interpolator.ts @@ -1,4 +1,4 @@ -import { last, mix } from 'shared'; +import { mix } from 'shared'; import { serverTimeline } from '../server-timeline'; interface Frame { @@ -7,25 +7,16 @@ interface Frame { velocity: number; } -// How far past the newest snapshot the value may coast on its last known -// velocity (network hiccup) before freezing in place. const maxCoastSeconds = 0.06; -// When fresh snapshots arrive after a coast, they usually disagree with where -// the coast ended up; the difference decays away with this time constant -// instead of being shown as a jump. const blendSeconds = 0.12; -// At 25 snapshots per second this spans over a second of state, far more than -// rendering ever needs; it only bounds memory while the tab is hidden and -// snapshots keep arriving without being consumed. const maxFrames = 32; /** * Replays a server-streamed scalar by interpolating between timestamped - * snapshots at the shared timeline's render time, which trails the newest - * snapshot. The streamed rate of change is only used to coast briefly when - * the buffer runs dry. + * snapshots at the render time; coasts briefly on the streamed rate of change + * when the buffer runs dry. */ export class LinearInterpolator { private frames: Array = []; @@ -39,7 +30,7 @@ export class LinearInterpolator { public addFrame(value: number, rateOfChange: number) { const time = serverTimeline.snapshotTime; - const newest = last(this.frames); + const newest = this.frames.length > 0 ? this.frames[this.frames.length - 1] : null; const wasCoasting = this.isCoasting; if (newest && time <= newest.time) { @@ -56,8 +47,6 @@ export class LinearInterpolator { } if (wasCoasting) { - // Continue from where the coast left off and let the offset decay, - // instead of jumping onto the freshly revealed trajectory. this.blendOffset = this.lastValue - this.sample(serverTimeline.renderTime); } } diff --git a/frontend/src/scripts/helper/interpolators/vec2-interpolator.ts b/frontend/src/scripts/helper/interpolators/vec2-interpolator.ts deleted file mode 100644 index ae6fb52..0000000 --- a/frontend/src/scripts/helper/interpolators/vec2-interpolator.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { vec2 } from 'gl-matrix'; -import { LinearInterpolator } from './linear-interpolator'; - -export class Vec2Interpolator { - private x: LinearInterpolator; - private y: LinearInterpolator; - - constructor(currentValue: vec2) { - this.x = new LinearInterpolator(currentValue.x); - this.y = new LinearInterpolator(currentValue.y); - } - - public addFrame(value: vec2, rateOfChange: vec2) { - this.x.addFrame(value.x, rateOfChange.x); - this.y.addFrame(value.y, rateOfChange.y); - } - - public getValue(deltaTime: number): vec2 { - return vec2.fromValues(this.x.getValue(deltaTime), this.y.getValue(deltaTime)); - } -} diff --git a/frontend/src/scripts/helper/pointer.ts b/frontend/src/scripts/helper/pointer.ts index ab8886d..cda73ee 100644 --- a/frontend/src/scripts/helper/pointer.ts +++ b/frontend/src/scripts/helper/pointer.ts @@ -1,13 +1,3 @@ import { vec2 } from 'gl-matrix'; -export class Pointer { - private static displayPosition: vec2 | null = null; - - public static setDisplayPosition(x: number, y: number): void { - Pointer.displayPosition = vec2.fromValues(x, y); - } - - public static getDisplayPosition(): vec2 | null { - return Pointer.displayPosition; - } -} +export const pointer = { displayPosition: null as vec2 | null }; diff --git a/frontend/src/scripts/helper/prediction/client-character-world.ts b/frontend/src/scripts/helper/prediction/client-character-world.ts index b09390d..f3b5df1 100644 --- a/frontend/src/scripts/helper/prediction/client-character-world.ts +++ b/frontend/src/scripts/helper/prediction/client-character-world.ts @@ -9,8 +9,7 @@ import { resolveCircleMovement, } from 'shared'; -// What the predictor needs to know about a planet to collide and be pulled by -// it. The client reads this off its PlanetViews. +// What the predictor needs to know about a planet to collide and be pulled by it. export interface PredictablePlanet { id: Id; vertices: Array; @@ -20,9 +19,7 @@ export interface PredictablePlanet { rotationSpeed: number; } -// A planet collision/gravity surface whose rotation can be advanced during -// replay, so its outline turns in lockstep with the body the carry term moves — -// exactly as the server steps the planet before the character each tick. +// A planet surface whose rotation advances during replay, in lockstep with the body — exactly as the server steps the planet before the character each tick. class PlanetSurface implements GroundSurface { public readonly canCollide = true; public readonly isGround = true; @@ -71,18 +68,12 @@ class PlanetSurface implements GroundSurface { } } -// The planets-only collision world the local predictor runs against. It holds -// persistent surfaces keyed by planet id (so a `currentPlanet` reference stays -// valid across frames) and never dispatches collision reactions — damage, -// scoring and the like are server-authoritative. +// Planets-only collision world for the predictor. Persistent surfaces keyed by id (currentPlanet stays valid across frames); never dispatches collision reactions — those are server-authoritative. export class ClientCharacterWorld implements CharacterWorld { private surfaces = new Map(); private ordered: Array = []; - // Refresh from the current PlanetViews. Far planets contribute zero gravity - // (the falloff clamps to 0 past maxGravityDistance) and never collide, so the - // whole set can be handed to every query without a range filter. Ordered by - // id so the (rare) two-surface contact picks a stable surface. + // Far planets contribute zero gravity (the falloff clamps to 0 past maxGravityDistance) and never collide, so the whole set can be handed to every query without a range filter. Ordered by id so the (rare) two-surface contact picks a stable surface. public sync(planets: Array) { const seen = new Set(); for (const planet of planets) { @@ -104,10 +95,7 @@ export class ClientCharacterWorld implements CharacterWorld { .map((e) => e[1]); } - // Advance every planet's collision frame by one replay substep, so surfaces - // and the carried body rotate together. The surfaces are re-synced to the - // newest snapshot rotation each frame (see sync), so the replay only ever - // steps forward from there. + // Advance every planet's collision frame by one replay substep, so surfaces and the carried body rotate together. Surfaces are re-synced to the newest snapshot rotation each frame (see sync), so the replay only ever steps forward from there. public advance(deltaTimeInSeconds: number) { for (const surface of this.ordered) { surface.advance(deltaTimeInSeconds); diff --git a/frontend/src/scripts/helper/prediction/input-history.ts b/frontend/src/scripts/helper/prediction/input-history.ts index 3ec2604..be372fb 100644 --- a/frontend/src/scripts/helper/prediction/input-history.ts +++ b/frontend/src/scripts/helper/prediction/input-history.ts @@ -5,16 +5,9 @@ interface InputSample { direction: vec2; } -// Keep a little over a second of input — far more than any sane reconciliation -// window — so a brief stall (or a backgrounded tab catching up) can still be -// replayed, while old samples are pruned to bound memory. const retainMs = 1500; -// A timeline of the local player's movement directions in client-clock time. -// Each generated MoveActionCommand is stamped with the time this hands out, and -// the same sample is recorded here so the predictor replays exactly the input -// the server will eventually receive. Direction is piecewise-constant: the -// active direction at any instant is the most recent sample at or before it. +// Timeline of local movement directions; the predictor replays exactly the input the server receives. export class InputHistory { private samples: Array = []; @@ -27,8 +20,6 @@ export class InputHistory { } } - // The held direction at `timeMs`: the latest sample at or before it, or zero - // before any input exists. public directionAt(timeMs: number): vec2 { let direction = vec2.create(); for (const sample of this.samples) { diff --git a/frontend/src/scripts/helper/prediction/local-character-predictor.ts b/frontend/src/scripts/helper/prediction/local-character-predictor.ts index e5e53c2..e7b3407 100644 --- a/frontend/src/scripts/helper/prediction/local-character-predictor.ts +++ b/frontend/src/scripts/helper/prediction/local-character-predictor.ts @@ -14,49 +14,26 @@ import { import { ClientCharacterWorld, PredictablePlanet } from './client-character-world'; import { InputHistory } from './input-history'; -// The server's fixed tick, read from the same place the server reads it — the -// replay integrates over this dt, so a comment promising they match is not -// enough. +// Must match the server's tick source — the replay integrates over this dt. const stepSeconds = settings.targetPhysicsDeltaTimeInSeconds; const stepMs = stepSeconds * 1000; -// The client's clock: the timestamp of the frame currently being rendered, set -// once per frame by the game loop (requestAnimationFrame hands it to us -// already). Everything time-related on the client reads it from here, so input -// stamps, the outgoing send cadence and the replay window all sit on one -// timeline. -// -// Sampling performance.now() ad hoc instead — which is what this used to do — -// reads the frame's *dispatch* time, and the few ms of scheduling jitter -// between the frame's timestamp and the callback actually running went straight -// into the replay window. The body then advanced by a slightly different amount -// than the frame it was drawn in. +// Frame timestamp, not performance.now() — dispatch jitter would land in the replay window. let frameTimeMs = 0; export const setFrameTimeMs = (timeMs: number): void => { frameTimeMs = timeMs; }; export const predictorNowMs = (): number => frameTimeMs; -// Don't replay more than this far back: if the last acknowledged input is older -// (a stall, or a backgrounded tab catching up) fall back to a shorter window -// instead of grinding through hundreds of steps. Comfortably covers the replay -// a 300 ms round trip needs; beyond that the body simply trails a little. const maxReplayMs = 400; -// Render-side easing of the correction the reconciliation produces each frame, -// so a snapshot that disagrees with the prediction is smoothed out instead of -// popping. Short, so the local player still feels immediate. const smoothSeconds = 0.06; -// A correction bigger than this isn't prediction error — it's a respawn, -// teleport, or a server-side impulse (leap / slingshot / recoil / death throw) -// the predictor doesn't model. Snap to it rather than gliding across the gap. const snapDistance = 250; const upwards = vec2.fromValues(0, 1); -// Velocity is deliberately zero: the movement simulation rebuilds it from -// scratch every tick and zeroes it again at the end, so it is not carried state. +// Velocity starts at zero — the sim rebuilds it each tick, so it is not carried state. const makeBody = (center: vec2, radius: number, normal: vec2): PhysicsBody => ({ center: vec2.clone(center), radius, @@ -65,68 +42,22 @@ const makeBody = (center: vec2, radius: number, normal: vec2): PhysicsBody => ({ restitution: 0, }); -// Predicts the local player's character so it responds to input immediately, -// instead of lagging ~100 ms + RTT behind like the interpolated remote objects. -// Each frame it resets to the latest authoritative snapshot and replays the -// player's own un-acknowledged input through the SAME movement simulation the -// server runs (shared/stepCharacterMovement), then eases the rendered pose -// toward the result. Discrete server-side impulses it can't model show up as a -// large correction and snap rather than rubber-band. export class LocalCharacterPredictor { private readonly inputHistory = new InputHistory(); private readonly world = new ClientCharacterWorld(); private authoritative?: { head: Circle; leftFoot: Circle; rightFoot: Circle }; - // Client-clock instant the authoritative pose below belongs to, less one - // one-way trip — which is exactly the lead the local body should have, since - // the input the player is giving right now will not reach the server for that - // long. Replaying from here reproduces every input the server has not applied - // yet, so the body responds immediately at any latency. - // - // It is built from BOTH halves of the acknowledgement: the client-clock time - // of the newest input the server had applied, plus how long after that the - // snapshot was actually taken. Both halves are needed. - // - // Anchoring on the acknowledged input time ALONE beats against the send - // cadence: at snapshot time the newest input the server holds is 0..1 send - // interval old depending on where the client's sends fell, and that age lands - // directly in the replay window. Measured on localhost it walked a sawtooth — - // 17, 10, 6, 1, 11, 6, 2, 12 ms — so the window jumped by up to a frame of - // travel every snapshot and the body stuttered at 25 Hz. ackAgeMs cancels it - // exactly. - // - // Anchoring on the snapshot's ARRIVAL is free of that beat, but it under- - // advances by a one-way trip, so the local body trails its own input at any - // real latency. This anchor degenerates to arrival on a zero-latency link and - // grows into full compensation as latency rises. - // - // A held key sends no fresh movement, which would freeze the acknowledgement - // altogether; that is fixed at the source, by closing every outgoing batch - // with a ClientHeartbeatCommand. + // Replay anchor: built from both halves of the ack (input time + ackAge), advances forward only. private replayAnchorMs?: number; - // The rest of the server's movement state at that same instant: facing - // direction, launch momentum, foot contact normals and the latched planet. - // Together with the pose above it is everything stepCharacterMovement carries - // between ticks, so a replay continues the server's simulation instead of - // approximating it. private movement?: CharacterMovementSnapshot; - // Frame times the player issued a leap, replayed (with the impulse applied - // locally) so the launch is felt immediately, not after a round trip. private leapHistory: Array = []; - // clientTimeMs of the last leap the server has folded into the streamed - // momentum. Leaps at or before this are already in movement.bodyVelocity; - // only newer ones are replayed, so a leap is never applied twice. + // Last leap the server folded into streamed momentum; newer ones only, so a leap is never applied twice. private lastLeapAckMs = -Infinity; - // Latest streamed shooting-strength, to gate predicted leaps as the server does. private currentStrength = settings.playerMaxStrength; - // Whether the local body is alive on the server. While dead (awaiting respawn) - // prediction is suppressed so the corpse/ghost can't keep walking in response - // to input — the server ignores a dead player's movement, so a predicted body - // that still moved would be a pure client-side desync. + // Dead players don't predict — the server ignores their input, so moving would desync. private alive = true; - // The eased, rendered pose handed to the view. private renderHead = new Circle(vec2.create(), headRadius); private renderLeftFoot = new Circle(vec2.create(), feetRadius); private renderRightFoot = new Circle(vec2.create(), feetRadius); @@ -142,8 +73,6 @@ export class LocalCharacterPredictor { return this.renderRightFoot; } - // Stamp a movement command and record it for replay. Returns the client-clock - // time the command should carry so the server can echo it back. public recordInput(direction: vec2): number { const timeMs = Math.round(frameTimeMs); this.inputHistory.record(direction, timeMs); @@ -156,8 +85,7 @@ export class LocalCharacterPredictor { lastLeapClientTimeMs: number, ackAgeMs = 0, ): void { - // Inputs only advance the acknowledgement forward; the movement state and - // leap boundary always adopt the latest authoritative values. + // The anchor only advances forward; movement state and leap boundary always adopt the latest authoritative values. const anchor = clientTimeMs + Math.max(0, ackAgeMs); if (this.replayAnchorMs === undefined || anchor > this.replayAnchorMs) { this.replayAnchorMs = anchor; @@ -170,10 +98,7 @@ export class LocalCharacterPredictor { this.authoritative = { head, leftFoot, rightFoot }; } - // The player pressed leap; remember when, so the replay applies the same - // impulse the server will. Recorded regardless of whether the server accepts - // it — a rejected leap (no strength/cooldown) self-corrects via the streamed - // authoritative momentum. + // Recorded regardless of acceptance — a rejected leap self-corrects via streamed momentum. public recordLeap(): number { const timeMs = Math.round(frameTimeMs); this.leapHistory.push(timeMs); @@ -212,10 +137,7 @@ export class LocalCharacterPredictor { ); } - // During spawn-in and death the server freezes walking and only scales the - // body (CharacterPhysical.step returns early), so its head radius is below - // nominal. Predicting then would walk the body off a position the server is - // holding still — let interpolation show the animation instead. + // While spawn-in/death the server freezes walking (head radius < nominal) — don't predict, let interpolation show it. private get isAnimatingInOrOut(): boolean { return ( this.authoritative !== undefined && @@ -223,13 +145,8 @@ export class LocalCharacterPredictor { ); } - // Run one frame of prediction. Returns true if it produced a rendered pose the - // caller should use (otherwise fall back to interpolation). `planets` is the - // current collision world; `frameSeconds` is the render delta. public update(planets: Array, frameSeconds: number): boolean { if (!this.alive || !this.canPredict || this.isAnimatingInOrOut) { - // Resume from the authoritative pose with a snap rather than gliding from - // a stale rendered one. this.hasRender = false; return false; } @@ -250,21 +167,14 @@ export class LocalCharacterPredictor { const auth = this.authoritative!; const movement = this.movement!; const now = Math.round(frameTimeMs); - // Replay every input the server has not confirmed yet: from the anchor up to - // now, clamped so a stall (or a backgrounded tab catching up) cannot grind - // through hundreds of steps. + // Replay unconfirmed input from anchor to now, clamped so a stalled tab can't grind hundreds of steps. const startMs = Math.min(now, Math.max(this.replayAnchorMs!, now - maxReplayMs)); const windowMs = Math.max(0, now - startMs); const steps = Math.floor(windowMs / stepMs); const remainderSeconds = (windowMs - steps * stepMs) / 1000; - // The full authoritative state: the pose from the property updates, the - // rest from the acknowledgement. Nothing here is inferred or carried over - // from the previous frame, so a replay is a pure function of (snapshot, - // input history, window) and cannot drift. + // Full authoritative state — replay is a pure function of (snapshot, input, window) and cannot drift. const state: CharacterMovementState = { - // The head's contact normal is written by the collision but never read by - // the movement, so it does not need to travel. head: makeBody(auth.head.center, auth.head.radius, upwards), leftFoot: makeBody( auth.leftFoot.center, @@ -279,22 +189,14 @@ export class LocalCharacterPredictor { direction: movement.direction, currentPlanet: this.world.surfaceById(movement.groundPlanetId), secondsSinceOnSurface: movement.secondsSinceOnSurface, - // Leaps before the replay window are already baked into this; leaps inside - // the window are re-applied below, so neither is double-counted. + // Leaps before the window are baked in; inside it they're re-applied — neither double-counted. bodyVelocity: vec2.fromValues(movement.bodyVelocity[0], movement.bodyVelocity[1]), }; - // The planet collision frames were synced (in update(), just before this) - // to the NEWEST snapshot's rotation — the same instant the authoritative - // body pose is from — so the body and the surface start the replay at the - // same phase. The loop below advances the surfaces FORWARD in lockstep with - // the body's carry from that shared phase, so no rewind is needed (and the - // persistent surfaces are re-synced next frame, so this never accumulates). + // Planet frames are synced to the newest snapshot's rotation, then advanced forward in lockstep with the body — no rewind needed. const cooldownMs = settings.leapCooldownSeconds * 1000; - // Mirror the server: each accepted leap spends leapStrengthCost, so a burst - // of leaps in one replay window is gated by the running strength rather than - // a single up-front affordability check. + // Mirror the server: each accepted leap spends strength, so a burst is gated by running strength. let availableStrength = this.currentStrength; let lastLeapMs = -Infinity; @@ -305,15 +207,11 @@ export class LocalCharacterPredictor { stepCharacterMovement(state, this.world, input, stepSeconds); this.world.advance(stepSeconds); - // A leap issued during this step launches now (the next step injects the - // momentum), gated like the server: on a surface, off cooldown, with - // strength. applyLeapImpulse is a no-op off-surface. for (const leapMs of this.leapHistory) { if ( leapMs >= t && leapMs < t + stepMs && - // Only leaps the server hasn't yet folded into the seed, so a leap - // is never both seeded and replayed. + // Only leaps the server hasn't seeded — never both seeded and replayed. leapMs > this.lastLeapAckMs && state.currentPlanet && leapMs - lastLeapMs >= cooldownMs && @@ -328,10 +226,7 @@ export class LocalCharacterPredictor { t += stepMs; } - // Final sub-tick of the leftover (< stepMs) so the predicted pose is a - // continuous function of the window length rather than advancing in 5 ms - // quanta — the floor() above would otherwise surface that as a per-frame - // wobble on top of the ~16.7 ms render cadence. + // Sub-tick the remainder so the pose is continuous in the window length, not quantized to stepMs. if (remainderSeconds > 0) { tickPlanetDetachment(state, remainderSeconds); stepCharacterMovement( diff --git a/frontend/src/scripts/helper/server-timeline.ts b/frontend/src/scripts/helper/server-timeline.ts index 28aab75..7121da1 100644 --- a/frontend/src/scripts/helper/server-timeline.ts +++ b/frontend/src/scripts/helper/server-timeline.ts @@ -1,27 +1,15 @@ import { clamp, settings } from 'shared'; -// Convergence rate of the playback cursor towards its target; a deviation -// decays with a time constant of 1 / rateGain seconds. const rateGain = 2; -// Playback speed stays within [0.75, 1.25]× so corrections are invisible. const maxRateAdjustment = 0.25; -// Beyond this divergence (tab was in the background, server changed) chasing -// the target is pointless: jump straight to it. const resyncSeconds = 0.3; /** - * The playback clock for state streamed from the server. - * - * Snapshot timestamps estimate the server's clock: the newest received - * timestamp plus the time elapsed since it arrived. Rendering happens - * settings.interpolationDelaySeconds behind that estimate, so there is - * normally a newer snapshot to interpolate towards and network jitter is - * absorbed by the buffer instead of being shown. - * - * The cursor never jumps under normal operation: it runs at a gently adjusted - * rate to stay on target and only snaps after a long divergence. + * Playback clock for streamed server state — renders interpolationDelaySeconds + * behind the server clock so a newer snapshot is usually available; the cursor + * adjusts its rate gently and only snaps after a long divergence. */ class ServerTimeline { private cursor?: number; @@ -29,12 +17,10 @@ class ServerTimeline { private sinceNewestSnapshot = 0; private _snapshotTime = 0; - /** Timestamp of the update batch currently being applied. */ public get snapshotTime(): number { return this._snapshotTime; } - /** The point on the server's clock that should be rendered this frame. */ public get renderTime(): number { return this.cursor ?? 0; } diff --git a/frontend/src/scripts/helper/show.ts b/frontend/src/scripts/helper/show.ts deleted file mode 100644 index cb43718..0000000 --- a/frontend/src/scripts/helper/show.ts +++ /dev/null @@ -1,7 +0,0 @@ -export const show = (e: HTMLElement, useDisplay = false, normalDisplayValue?: string) => { - if (useDisplay) { - e.style.display = normalDisplayValue!; - } else { - e.style.visibility = 'inherit'; - } -}; diff --git a/frontend/src/scripts/join-form-handler.ts b/frontend/src/scripts/join-form-handler.ts index f18a746..ebc4d6b 100644 --- a/frontend/src/scripts/join-form-handler.ts +++ b/frontend/src/scripts/join-form-handler.ts @@ -16,10 +16,7 @@ export class JoinFormHandler { private resolvePlayerDecision!: (d: PlayerDecision) => void; private pollServersTimer: any; private keyUpListener = (e: KeyboardEvent) => { - // KeyboardEvent.key for Return is 'Enter' (capital E); the old lowercase - // comparison never matched, so pressing Enter silently did nothing. - // requestSubmit() (unlike submit()) fires the form's onsubmit handler and - // runs HTML5 validation, so Enter behaves exactly like clicking Join. + // requestSubmit() (not submit()) fires onsubmit and runs HTML5 validation, so Enter matches clicking Join. if (e.key === 'Enter' && !this.joinButton.disabled) { this.form.requestSubmit(); } @@ -79,9 +76,7 @@ export class JoinFormHandler { let response: Response | undefined; try { response = await fetch(url + serverInformationEndpoint, { signal }); - } catch { - // it's okay - } + } catch {} if (response?.ok) { const content: ServerInformation = await response.json(); diff --git a/frontend/src/scripts/landing-page-background.ts b/frontend/src/scripts/landing-page-background.ts index bafe559..f6565f0 100644 --- a/frontend/src/scripts/landing-page-background.ts +++ b/frontend/src/scripts/landing-page-background.ts @@ -1,4 +1,4 @@ -import { vec2 } from 'gl-matrix'; +import { vec2, vec3 } from 'gl-matrix'; import { CircleLight, FilteringOptions, @@ -8,12 +8,9 @@ import { runAnimation, WrapOptions, } from 'sdf-2d'; -import { settings, rgb, PlanetBase, Random } from 'shared'; +import { settings, PlanetBase, Random } from 'shared'; import { PlanetShape } from './shapes/planet-shape'; -// PlanetShape colours by mixing blue (0) -> red (1). The two backdrop planets -// read as the two in-game teams; the red planet is pulled a little off the -// pure-red end so it shows as a more muted, less saturated red. const bluePlanet = 0; const redPlanet = 0.85; @@ -46,7 +43,7 @@ export class LandingPageBackground { { shadowTraceCount: 16, paletteSize: 1, - ambientLight: rgb(0, 0, 0), + ambientLight: vec3.fromValues(0, 0, 0), lightCutoffDistance: settings.lightCutoffDistance, textures: { noiseTexture: { @@ -84,11 +81,6 @@ export class LandingPageBackground { redPlanet, ); - // Fixed terrain phase (no longer animated -> no pulsing); the planet spins - // instead, the same way in-game planets do: PlanetShape's rotation uniform - // turns the whole body, terrain and outline together, in its own frame. - // Speeds sit in the game's per-planet range (~0.05-0.12 rad/s) and - // counter-rotate so the two planets don't drift in lockstep. topPlanet.randomOffset = Random.getRandom(); topPlanet.rotation = (time / 1000) * 0.09; diff --git a/frontend/src/scripts/minimap.ts b/frontend/src/scripts/minimap.ts index 64267e8..92fc0c2 100644 --- a/frontend/src/scripts/minimap.ts +++ b/frontend/src/scripts/minimap.ts @@ -7,19 +7,12 @@ export interface MinimapBlip { team: CharacterTeam; } -// Top-down radar of the whole circular arena, pinned to the top-left. The map's -// circular border is the world boundary (its radius maps to worldRadius), so the -// local player's bright dot reads as a true position in the arena and every other -// living player shows as a team-coloured dot. Owns its own , so the Game -// just appends `element` to the overlay and feeds it `update()` each frame. -// Replaces the off-screen chevrons that used to point at other players. export class Minimap { public readonly element = document.createElement('canvas'); private readonly ctx: CanvasRenderingContext2D; private readonly colors: Record; - // World-space positions, smoothed per player so the 25 Hz snapshots glide - // rather than step between frames. + // Smoothed per player so the 25 Hz snapshots glide rather than step between frames. private readonly smoothed = new Map(); private bufferSize = 0; @@ -40,7 +33,7 @@ export class Minimap { public update(localPosition: vec2 | undefined, players: Array) { const size = this.element.clientWidth; if (size === 0) { - return; // not laid out yet + return; } this.syncBufferSize(size); @@ -62,7 +55,6 @@ export class Minimap { return { x: center + dx, y: center + dy }; }; - // Faint marker at the world's centre to aid orientation. ctx.fillStyle = 'rgba(255, 255, 255, 0.12)'; ctx.beginPath(); ctx.arc(center, center, 1.5, 0, Math.PI * 2); @@ -87,8 +79,6 @@ export class Minimap { } } - // The local player rides on top, drawn in white with a ring so "you" is - // unmistakable amongst the team-coloured dots. if (localPosition) { const { x, y } = toMap(localPosition); this.drawDot(x, y, 3.5, '#ffffff'); diff --git a/frontend/src/scripts/objects/game-object-container.ts b/frontend/src/scripts/objects/game-object-container.ts index 2ee5507..82dc34f 100644 --- a/frontend/src/scripts/objects/game-object-container.ts +++ b/frontend/src/scripts/objects/game-object-container.ts @@ -32,14 +32,7 @@ export class GameObjectContainer extends CommandReceiver { public camera: Camera = new Camera(this.game); private wasLocalPlayerAlive = false; - // Create and delete arrive on the wire, but positions are drawn - // interpolationDelaySeconds in the past — so applying them the instant a - // packet lands made every projectile pop into existence and hang at its muzzle - // for 100 ms, then disappear a few hundred units before its rendered impact. - // Objects are therefore admitted and retired on the render timeline instead: - // they exist (and accumulate interpolation frames) from the moment they - // arrive, but only draw once the cursor reaches the snapshot that introduced - // them, and keep drawing until it reaches the one that removed them. + // Create/delete are admitted/retired on the render timeline (not the wire) so interpolated objects don't pop at the muzzle. private visibleFrom = new Map(); private deleteAt = new Map(); private awaitingCreateStamp: Array = []; @@ -50,10 +43,7 @@ export class GameObjectContainer extends CommandReceiver { this.player = c.character as CharacterView; this.player.isMainCharacter = true; this.addObject(this.player); - // Fresh character (first spawn or respawn at a far planet): drop any - // prediction state so it snaps to the new body instead of gliding across. localCharacterPredictor.reset(); - // Respawned — clear the elimination overlay. FeedbackHud.hideElimination(); this.wasLocalPlayerAlive = true; }, @@ -65,32 +55,22 @@ export class GameObjectContainer extends CommandReceiver { }), [StepCommand.type]: (c: StepCommand) => { - // A batch carrying no property updates (a bare announcement, say) leaves - // entries unstamped; fall back to the newest known snapshot so nothing is - // stranded invisible. + // Fall back to newest snapshot if a batch had no property updates, so nothing is stranded. this.stampPending(serverTimeline.snapshotTime); this.retireDueObjects(); this.defaultCommandExecutor(c); - // The local body is alive only while its object still exists (the server - // deletes it on death) and its health is above zero — `player` keeps - // pointing at the now-stale view after death, so both checks are needed. + // Alive = object exists AND health > 0 (player pointer outlives the body). const bodyPresent = !!this.player && this.objects.has(this.player.id); const alive = bodyPresent && this.player.health > 0; - // Show the elimination overlay on the alive→dead edge; CreatePlayerCommand - // clears it on respawn. if (this.wasLocalPlayerAlive && !alive) { FeedbackHud.showElimination(); } this.wasLocalPlayerAlive = alive; if (bodyPresent) { - // Override the interpolated pose of the local player with the predicted - // one so it responds to input immediately. Suppressed while dead so the - // corpse can't be walked around (the server ignores a dead player's - // input — a moving predicted body would be a pure client-side desync). - // A large correction (respawn / death) snaps inside the predictor. + // Override the local player's interpolated pose with the predicted one; suppressed while dead (server ignores dead input). localCharacterPredictor.setAlive(alive); localCharacterPredictor.setStrength( this.player.strengthFraction * settings.playerMaxStrength, @@ -113,8 +93,7 @@ export class GameObjectContainer extends CommandReceiver { [PropertyUpdatesForObjects.type]: (c: PropertyUpdatesForObjects) => { serverTimeline.onSnapshot(c.timestamp); - // Create/delete precede the property updates inside a batch, so this is - // where the batch's server timestamp becomes known to them. + // Create/delete precede property updates in a batch — timestamp is known here. this.stampPending(c.timestamp); c.updates.forEach((u) => { u.updates.forEach((au) => this.objects.get(u.id)?.handleCommand(au)); @@ -133,17 +112,10 @@ export class GameObjectContainer extends CommandReceiver { } /** - * Forget everything the previous connection was told about. - * - * A reconnect is a brand-new server-side player whose view-area bookkeeping - * starts empty, so it re-announces what is in view NOW and never sends a - * delete for anything the dropped session held. Without this, every object - * that has since moved out of view — the old character included — stays in the - * map and on screen forever, as a frozen ghost. - * - * `player` is deliberately left pointing at the stale view: every read of it - * is gated on the object still being in `objects`, so clearing the map is what - * makes it inert, and the next CreatePlayerCommand replaces it. + * A reconnect is a new server-side player that never sends deletes for the + * old session's objects — clear them or they stay as frozen ghosts. `player` + * is left stale: reads are gated on `objects`, and the next CreatePlayerCommand + * replaces it. */ public reset() { this.objects.forEach((o) => o.handleCommand(new BeforeDestroyCommand())); @@ -174,8 +146,7 @@ export class GameObjectContainer extends CommandReceiver { protected defaultCommandExecutor(c: Command) { const isRender = c.type === RenderCommand.type; this.objects.forEach((o) => { - // Stepping always runs, so an object that has not appeared yet is still - // accumulating interpolation frames and enters the scene already moving. + // Stepping runs even for not-yet-visible objects so they enter the scene already moving. if (isRender && !this.isVisible(o.id)) { return; } @@ -214,9 +185,7 @@ export class GameObjectContainer extends CommandReceiver { } } - // Hand the local player's raw authoritative pose to the predictor (the - // interpolated pose would already be ~100 ms stale). The three body parts - // arrive together in one snapshot. + // Feed the predictor the raw (not interpolated) authoritative pose. private feedPredictor(updates: Array) { let head: Circle | undefined; let leftFoot: Circle | undefined; diff --git a/frontend/src/scripts/objects/types/camera.ts b/frontend/src/scripts/objects/types/camera.ts index c607c38..e0434b3 100644 --- a/frontend/src/scripts/objects/types/camera.ts +++ b/frontend/src/scripts/objects/types/camera.ts @@ -1,9 +1,10 @@ -import { vec2 } from 'gl-matrix'; +import { vec2, vec3 } from 'gl-matrix'; import { calculateViewArea, + clamp01, CommandExecutors, CommandReceiver, - mixRgb, + mix, settings, } from 'shared'; import { RenderCommand } from '../../commands/types/render'; @@ -60,11 +61,15 @@ export class Camera extends CommandReceiver { renderer.setViewArea(viewArea.topLeft, viewArea.size); renderer.setRuntimeSettings({ - ambientLight: mixRgb( - settings.backgroundGradient[0], - settings.backgroundGradient[1], - vec2.length(this.center) / settings.worldRadius, - ), + ambientLight: (() => { + const q = clamp01(vec2.length(this.center) / settings.worldRadius); + const [a, b] = settings.backgroundGradient; + return vec3.fromValues( + mix(a[0], b[0], q), + mix(a[1], b[1], q), + mix(a[2], b[2], q), + ); + })(), }); } } diff --git a/frontend/src/scripts/objects/types/character-view.ts b/frontend/src/scripts/objects/types/character-view.ts index d23e420..d806d21 100644 --- a/frontend/src/scripts/objects/types/character-view.ts +++ b/frontend/src/scripts/objects/types/character-view.ts @@ -18,9 +18,8 @@ import { import { BeforeDestroyCommand } from '../../commands/types/before-destroy'; import { RenderCommand } from '../../commands/types/render'; import { StepCommand } from '../../commands/types/step'; -import { CircleInterpolator } from '../../helper/interpolators/circle-interpolator'; import { LinearInterpolator } from '../../helper/interpolators/linear-interpolator'; -import { Pointer } from '../../helper/pointer'; +import { pointer } from '../../helper/pointer'; import { CharacterShape } from '../../shapes/character-shape'; import { SoundHandler, Sounds } from '../../sound-handler'; import { VibrationHandler } from '../../vibration-handler'; @@ -30,9 +29,7 @@ import { ScreenShake } from '../../screen-shake'; const muzzleFlashDecaySeconds = 0.12; const hitFlashDecaySeconds = 0.15; -// A white-hot pop of light thrown at the spot a character dies, seen by everyone -// who can see the body. No radius knob on a CircleLight, so the burst is sold by -// a bright (HDR) colour with a fast-decaying intensity envelope. +// No radius knob on CircleLight, so the death burst is sold by a bright (HDR) colour with a fast-decaying intensity. const deathBurstDecaySeconds = 0.42; const deathBurstMaxIntensity = 1.7; const deathBurstColor = vec3.fromValues(2.5, 2.3, 2.1); @@ -62,9 +59,15 @@ export class CharacterView extends CharacterBase { public isMainCharacter = false; - private leftFootInterpolator: CircleInterpolator; - private rightFootInterpolator: CircleInterpolator; - private headInterpolator: CircleInterpolator; + private lfx = new LinearInterpolator(0); + private lfy = new LinearInterpolator(0); + private lfr = new LinearInterpolator(0); + private rfx = new LinearInterpolator(0); + private rfy = new LinearInterpolator(0); + private rfr = new LinearInterpolator(0); + private hdx = new LinearInterpolator(0); + private hdy = new LinearInterpolator(0); + private hdr = new LinearInterpolator(0); protected commandExecutors: CommandExecutors = { [RenderCommand.type]: this.draw.bind(this), @@ -93,9 +96,18 @@ export class CharacterView extends CharacterBase { ); this.deathBurst = new CircleLight(vec2.clone(this.head!.center), deathBurstColor, 0); - this.leftFootInterpolator = new CircleInterpolator(this.leftFoot!); - this.rightFootInterpolator = new CircleInterpolator(this.rightFoot!); - this.headInterpolator = new CircleInterpolator(this.head!); + const lf = this.leftFoot!; + this.lfx = new LinearInterpolator(lf.center[0]); + this.lfy = new LinearInterpolator(lf.center[1]); + this.lfr = new LinearInterpolator(lf.radius); + const rf = this.rightFoot!; + this.rfx = new LinearInterpolator(rf.center[0]); + this.rfy = new LinearInterpolator(rf.center[1]); + this.rfr = new LinearInterpolator(rf.radius); + const hd = this.head!; + this.hdx = new LinearInterpolator(hd.center[0]); + this.hdy = new LinearInterpolator(hd.center[1]); + this.hdr = new LinearInterpolator(hd.radius); this.nameElement.className = 'player-tag ' + this.team; this.nameElement.innerText = this.name; @@ -151,13 +163,19 @@ export class CharacterView extends CharacterBase { rateOfChange, }: UpdatePropertyCommand) { if (propertyKey === 'head') { - this.headInterpolator.addFrame(propertyValue, rateOfChange); + this.hdx.addFrame(propertyValue.center[0], rateOfChange.center[0]); + this.hdy.addFrame(propertyValue.center[1], rateOfChange.center[1]); + this.hdr.addFrame(propertyValue.radius, rateOfChange.radius); } if (propertyKey === 'leftFoot') { - this.leftFootInterpolator.addFrame(propertyValue, rateOfChange); + this.lfx.addFrame(propertyValue.center[0], rateOfChange.center[0]); + this.lfy.addFrame(propertyValue.center[1], rateOfChange.center[1]); + this.lfr.addFrame(propertyValue.radius, rateOfChange.radius); } if (propertyKey === 'rightFoot') { - this.rightFootInterpolator.addFrame(propertyValue, rateOfChange); + this.rfx.addFrame(propertyValue.center[0], rateOfChange.center[0]); + this.rfy.addFrame(propertyValue.center[1], rateOfChange.center[1]); + this.rfr.addFrame(propertyValue.radius, rateOfChange.radius); } if (propertyKey === 'strength') { this.strengthInterpolator.addFrame(propertyValue, rateOfChange); @@ -177,8 +195,6 @@ export class CharacterView extends CharacterBase { if (this.isMainCharacter) { VibrationHandler.vibrate(Math.min(200, damage * 4)); - // Getting hit jolts the frame too, so taking fire has weight, not just - // dealing it. ScreenShake.add(clamp01(0.12 + (0.5 * damage) / settings.playerMaxStrength)); } } @@ -188,9 +204,7 @@ export class CharacterView extends CharacterBase { if (this.isMainCharacter) { VibrationHandler.vibrate(150); } - // Visible to everyone who can see the body: a white-hot flash plus a - // full-body whiteout, so a kill reads as a violent burst rather than the - // character quietly blinking out. + // Visible to everyone who can see the body: a white-hot flash plus a full-body whiteout. this.deathBurstIntensity = 1; this.hitFlashIntensity = 1; } @@ -199,8 +213,7 @@ export class CharacterView extends CharacterBase { if (!this.isMainCharacter) { return; } - // Layer a meaty thud under the crisp confirmation tick; a charged hit lands - // lower and harder than a panic tap. + // Layer a meaty thud under the crisp confirmation tick; a charged hit lands lower and harder. SoundHandler.play(Sounds.hit, mix(0.35, 0.7, charge), mix(1.3, 0.95, charge)); SoundHandler.play(Sounds.click, mix(0.4, 0.75, charge), mix(1.7, 1.1, charge)); ScreenShake.add(mix(0.22, 0.5, charge)); @@ -212,9 +225,7 @@ export class CharacterView extends CharacterBase { if (!this.isMainCharacter) { return; } - // A heavy low thud for the kill with a brighter confirmation over the top, - // a hard frame jolt, a zoom-punch toward the action for weight, and a - // double-thump rumble. + // A heavy low thud for the kill with a brighter confirmation over the top, a hard frame jolt, a zoom-punch, and a double-thump rumble. SoundHandler.play(Sounds.hit, 1, mix(0.62, 0.5, charge)); SoundHandler.play(Sounds.click, 0.9, mix(0.6, 0.45, charge)); ScreenShake.add(mix(0.75, 1, charge)); @@ -231,9 +242,27 @@ export class CharacterView extends CharacterBase { } private step({ deltaTimeInSeconds }: StepCommand): void { - this.head! = this.headInterpolator.getValue(deltaTimeInSeconds); - this.leftFoot! = this.leftFootInterpolator.getValue(deltaTimeInSeconds); - this.rightFoot! = this.rightFootInterpolator.getValue(deltaTimeInSeconds); + this.head! = new Circle( + vec2.fromValues( + this.hdx.getValue(deltaTimeInSeconds), + this.hdy.getValue(deltaTimeInSeconds), + ), + this.hdr.getValue(deltaTimeInSeconds), + ); + this.leftFoot! = new Circle( + vec2.fromValues( + this.lfx.getValue(deltaTimeInSeconds), + this.lfy.getValue(deltaTimeInSeconds), + ), + this.lfr.getValue(deltaTimeInSeconds), + ); + this.rightFoot! = new Circle( + vec2.fromValues( + this.rfx.getValue(deltaTimeInSeconds), + this.rfy.getValue(deltaTimeInSeconds), + ), + this.rfr.getValue(deltaTimeInSeconds), + ); this.strength = clamp( this.strengthInterpolator.getValue(deltaTimeInSeconds), @@ -315,7 +344,7 @@ export class CharacterView extends CharacterBase { } private calculateGazeTarget(renderer: Renderer): vec2 { - const cursor = Pointer.getDisplayPosition(); + const cursor = pointer.displayPosition; if (this.isMainCharacter && cursor) { return renderer.displayToWorldCoordinates(cursor); } diff --git a/frontend/src/scripts/objects/types/lamp-view.ts b/frontend/src/scripts/objects/types/lamp-view.ts index e4db4f6..f6f0d3f 100644 --- a/frontend/src/scripts/objects/types/lamp-view.ts +++ b/frontend/src/scripts/objects/types/lamp-view.ts @@ -1,6 +1,6 @@ import { vec2, vec3 } from 'gl-matrix'; import { CircleLight } from 'sdf-2d'; -import { CommandExecutors, Id, LampBase, mixRgb, settings } from 'shared'; +import { clamp01, CommandExecutors, Id, LampBase, mix, settings } from 'shared'; import { RenderCommand } from '../../commands/types/render'; import { StepCommand } from '../../commands/types/step'; @@ -29,7 +29,12 @@ export class LampView extends LampBase { private step({ deltaTimeInSeconds }: StepCommand): void { const t = 1 - Math.exp(-deltaTimeInSeconds / settings.lampLerpSeconds); - this.light.color = mixRgb(this.light.color, this.targetColor, t); + const q = clamp01(t); + this.light.color = vec3.fromValues( + mix(this.light.color[0], this.targetColor[0], q), + mix(this.light.color[1], this.targetColor[1], q), + mix(this.light.color[2], this.targetColor[2], q), + ); this.light.intensity += (this.targetLightness - this.light.intensity) * t; } diff --git a/frontend/src/scripts/objects/types/planet-view.ts b/frontend/src/scripts/objects/types/planet-view.ts index 03b6582..7c4df7b 100644 --- a/frontend/src/scripts/objects/types/planet-view.ts +++ b/frontend/src/scripts/objects/types/planet-view.ts @@ -17,10 +17,8 @@ import { PlanetShape } from '../../shapes/planet-shape'; const fallingPointLifetimeMs = 2000; -// Global budget for simultaneously-lit capture flares, so a clustered wave of -// captures can never white out the SDF exposure — excess flips still pulse the -// ring and toast, they just skip the extra light. The acquire/release pair keeps -// the count in one place instead of being hand-maintained at every call site. +// Global budget for simultaneously-lit capture flares, so a clustered wave can't +// white out the SDF exposure; excess flips still pulse the ring/toast but skip the light. abstract class FlareBudget { private static active = 0; @@ -40,9 +38,7 @@ abstract class FlareBudget { export class PlanetView extends PlanetBase { private shape: PlanetShape; private ownershipProgress: HTMLElement; - // Rotation is owned by the backend (it drives the collision polygon too) and - // streamed in; the interpolator replays the angle on the shared snapshot - // timeline, in sync with the characters standing on the surface. + // Rotation is owned by the backend and streamed in; the interpolator replays it on the snapshot timeline, in sync with characters on the surface. private readonly rotationInterpolator = new LinearInterpolator(0); private flareLight?: CircleLight; @@ -70,13 +66,9 @@ export class PlanetView extends PlanetBase { } private rotationSpeed = 0; - // Newest streamed rotation VALUE — the server-current angle at the latest - // snapshot — as opposed to renderedRotation, which the interpolator holds - // ~interpolationDelaySeconds in the PAST for drawing. The predictor seeds the - // local body from the same snapshot's pose, so it must collide against the - // planet at THIS (newest) phase and advance forward from it; using the drawn - // lagged angle biases the body off the surface by omega*delay*radius and - // wobbles it whenever the spin or the interpolator's rate cursor varies. + // Newest streamed rotation (server-current at the latest snapshot), NOT the lagged + // rendered one. The predictor seeds the body from the same snapshot's pose, so it + // must collide at THIS phase — using the drawn (lagged) angle biases the body off the surface. private latestRotation = 0; public get predictionRotation(): number { return this.latestRotation; @@ -165,9 +157,7 @@ export class PlanetView extends PlanetBase { const screenPosition = renderer.worldToDisplayCoordinates(this.center); this.ownershipProgress.style.transform = `translateX(${screenPosition.x}px) translateY(${screenPosition.y}px) translateX(-50%) translateY(-50%)`; - // Re-parsing a conic-gradient for every planet on screen every layout - // frame is not free, and ownership moves far more slowly than the ring - // can show. + // Re-parsing a conic-gradient per planet per layout frame is not free; ownership moves slower than the ring shows. const gradient = this.getGradient(); if (gradient !== this.lastGradient) { this.lastGradient = gradient; diff --git a/frontend/src/scripts/objects/types/projectile-view.ts b/frontend/src/scripts/objects/types/projectile-view.ts index 5843e59..ba1a255 100644 --- a/frontend/src/scripts/objects/types/projectile-view.ts +++ b/frontend/src/scripts/objects/types/projectile-view.ts @@ -10,12 +10,13 @@ import { } from 'shared'; import { RenderCommand } from '../../commands/types/render'; import { StepCommand } from '../../commands/types/step'; -import { Vec2Interpolator } from '../../helper/interpolators/vec2-interpolator'; +import { LinearInterpolator } from '../../helper/interpolators/linear-interpolator'; export class ProjectileView extends ProjectileBase { private light: CircleLight; - private centerInterpolator: Vec2Interpolator; + private centerX = new LinearInterpolator(0); + private centerY = new LinearInterpolator(0); protected commandExecutors: CommandExecutors = { [RenderCommand.type]: this.draw.bind(this), @@ -36,17 +37,22 @@ export class ProjectileView extends ProjectileBase { settings.paletteDim[settings.colorIndices[team]], 0, ); - this.centerInterpolator = new Vec2Interpolator(center); + this.centerX = new LinearInterpolator(center[0]); + this.centerY = new LinearInterpolator(center[1]); } private updateProperty({ propertyValue, rateOfChange }: UpdatePropertyCommand): void { - this.centerInterpolator.addFrame(propertyValue, rateOfChange); + this.centerX.addFrame(propertyValue[0], rateOfChange[0]); + this.centerY.addFrame(propertyValue[1], rateOfChange[1]); } private handleStep({ deltaTimeInSeconds }: StepCommand): void { this.step(deltaTimeInSeconds); - this.center = this.centerInterpolator.getValue(deltaTimeInSeconds); + this.center = vec2.fromValues( + this.centerX.getValue(deltaTimeInSeconds), + this.centerY.getValue(deltaTimeInSeconds), + ); this.light.center = this.center; this.light.intensity = Math.min( 0.1, diff --git a/frontend/src/scripts/scoreboard.ts b/frontend/src/scripts/scoreboard.ts index 41b02ba..3b2190f 100644 --- a/frontend/src/scripts/scoreboard.ts +++ b/frontend/src/scripts/scoreboard.ts @@ -1,10 +1,5 @@ import { CharacterTeam, UpdateGameState, clamp, settings } from 'shared'; -// Centred tug-of-war territory bar. The two fills meet at the bar's centre and -// grow outward by each team's share of the score limit, so the meeting point -// reads as the lead at a glance. Numeric scores are overlaid (the leader's is -// emphasised) and a "YOU" tick marks the local team's half. Owns its own DOM so -// the Game just appends `element` to the overlay and feeds it `update()`. export class Scoreboard { public readonly element = document.createElement('div'); diff --git a/frontend/src/scripts/screen-shake.ts b/frontend/src/scripts/screen-shake.ts index 75c5bce..bc7a6d7 100644 --- a/frontend/src/scripts/screen-shake.ts +++ b/frontend/src/scripts/screen-shake.ts @@ -1,84 +1,75 @@ -// Camera impact effects fed by combat hits, sampled by the camera at render -// time. Kept as a process-wide singleton so any view can feed it without -// threading a camera reference through the deserialized object graph. Both -// effects touch only the *rendered* view (centre offset + view-area zoom), never -// the followed position or the simulation clock — so they can't drift the camera -// off the player, and they're completely decoupled from prediction/netcode. -// -// Two effects: -// - shake: a trauma model (Eiserloh) — the felt shake is trauma SQUARED so -// small taps stay subtle while a kill punches hard; it bleeds off linearly. -// - punch: a brief zoom-in on a kill that snaps back fast, for "weight". -// -// Honours prefers-reduced-motion: callers can fire freely and it simply no-ops -// for players who have asked the OS to minimise motion. -export abstract class ScreenShake { - private static trauma = 0; - private static punch = 0; +let trauma = 0; +let punch = 0; - // Peak translation, in world units, at full trauma. The visible world is - // ~3800 units wide, so this tops out at a few percent of the screen. - private static readonly maxTranslation = 150; - // Fraction the view zooms in at full punch (smaller view area = zoomed in). - private static readonly maxZoom = 0.07; - private static readonly traumaDecayPerSecond = 1.7; - // Snaps back quickly so the punch reads as a sharp bite, not a slow drift. - private static readonly punchDecayPerSecond = 6; +const maxTranslation = 150; +const maxZoom = 0.07; +const traumaDecayPerSecond = 1.7; +const punchDecayPerSecond = 6; - private static offsetXValue = 0; - private static offsetYValue = 0; +let offsetXValue = 0; +let offsetYValue = 0; - private static get reducedMotion(): boolean { - return ( - typeof window !== 'undefined' && - typeof window.matchMedia === 'function' && - window.matchMedia('(prefers-reduced-motion: reduce)').matches - ); - } - - public static add(trauma: number): void { - if (this.reducedMotion) { - return; - } - this.trauma = Math.min(1, this.trauma + trauma); - } - - public static addPunch(amount: number): void { - if (this.reducedMotion) { - return; - } - this.punch = Math.min(1, this.punch + amount); - } - - public static step(deltaTimeInSeconds: number): void { - this.trauma = Math.max( - 0, - this.trauma - this.traumaDecayPerSecond * deltaTimeInSeconds, - ); - this.punch = Math.max(0, this.punch - this.punchDecayPerSecond * deltaTimeInSeconds); - const shake = this.trauma * this.trauma; - this.offsetXValue = this.maxTranslation * shake * (Math.random() * 2 - 1); - this.offsetYValue = this.maxTranslation * shake * (Math.random() * 2 - 1); - } - - public static get offsetX(): number { - return this.offsetXValue; - } - - public static get offsetY(): number { - return this.offsetYValue; - } - - // Multiplier for the view-area size: <1 zooms in. Squared so the punch bites - // sharply near its peak rather than ramping linearly. - public static get viewScale(): number { - return 1 - this.maxZoom * this.punch * this.punch; - } - - public static reset(): void { - this.trauma = 0; - this.punch = 0; - this.offsetXValue = 0; - this.offsetYValue = 0; - } +function reducedMotion(): boolean { + return ( + typeof window !== 'undefined' && + typeof window.matchMedia === 'function' && + window.matchMedia('(prefers-reduced-motion: reduce)').matches + ); } + +export function addTrauma(t: number): void { + if (reducedMotion()) { + return; + } + trauma = Math.min(1, trauma + t); +} + +export function addPunch(amount: number): void { + if (reducedMotion()) { + return; + } + punch = Math.min(1, punch + amount); +} + +export function stepScreenShake(deltaTimeInSeconds: number): void { + trauma = Math.max(0, trauma - traumaDecayPerSecond * deltaTimeInSeconds); + punch = Math.max(0, punch - punchDecayPerSecond * deltaTimeInSeconds); + const shake = trauma * trauma; + offsetXValue = maxTranslation * shake * (Math.random() * 2 - 1); + offsetYValue = maxTranslation * shake * (Math.random() * 2 - 1); +} + +function getOffsetX(): number { + return offsetXValue; +} + +function getOffsetY(): number { + return offsetYValue; +} + +function getViewScale(): number { + return 1 - maxZoom * punch * punch; +} + +export function resetScreenShake(): void { + trauma = 0; + punch = 0; + offsetXValue = 0; + offsetYValue = 0; +} + +export const ScreenShake = { + add: addTrauma, + addPunch, + step: stepScreenShake, + get offsetX() { + return getOffsetX(); + }, + get offsetY() { + return getOffsetY(); + }, + get viewScale() { + return getViewScale(); + }, + reset: resetScreenShake, +}; diff --git a/frontend/src/scripts/shapes/character-shape.ts b/frontend/src/scripts/shapes/character-shape.ts index 711f70a..0fe1840 100644 --- a/frontend/src/scripts/shapes/character-shape.ts +++ b/frontend/src/scripts/shapes/character-shape.ts @@ -2,9 +2,6 @@ import { mat2d, vec2 } from 'gl-matrix'; import { Drawable, DrawableDescriptor } from 'sdf-2d'; import { Circle } from 'shared'; -// A single character silhouette shared by every team (teams differ only by -// colour): a circular head sitting on two thin line-legs that run down to the -// two feet. export class CharacterShape extends Drawable { public static descriptor: DrawableDescriptor = { sdf: { @@ -22,8 +19,7 @@ export class CharacterShape extends Drawable { return distance(target, circleCenter) - radius; } - // Distance to the segment a->b; subtract a radius to get a capsule (a - // rounded thick line). + // Distance to the segment a->b; subtract a radius to get a capsule (a rounded thick line). float segmentDistance(vec2 a, vec2 b, vec2 target) { vec2 pa = target - a; vec2 ba = b - a; @@ -41,29 +37,20 @@ export class CharacterShape extends Drawable { float headRadius = characterHeadRadii[i]; float footRadius = characterFootRadii[i]; - // The server rotates the whole posture toward travel, so the head - // leads: forward is the facing direction, perp its 90deg rotation, so - // the face sits on the leading side and reads at any rotation. + // The server rotates the whole posture toward travel, so the head leads: forward is the facing direction, perp its 90deg rotation, so the face sits on the leading side at any rotation. vec2 footAverage = (leftFoot + rightFoot) * 0.5; vec2 toHead = head - footAverage; float toHeadLength = length(toHead); vec2 forward = toHeadLength > 0.001 ? toHead / toHeadLength : vec2(0.0, 1.0); vec2 perp = vec2(-forward.y, forward.x); - // A circular head, drawn a bit smaller than the head->feet gap so the - // legs below it read as two distinct LINES rather than being swallowed - // by the head. float renderRadius = headRadius * 0.75; float headDistance = circleDistance(head, renderRadius, target); - // Legs as thin line-segments (capsules) from the head down to each - // foot, hard-min'd onto the head so the head stays a crisp circle. float legThickness = footRadius * 0.35; float leftLeg = segmentDistance(head, leftFoot, target) - legThickness; float rightLeg = segmentDistance(head, rightFoot, target) - legThickness; - // Rounded feet at the ends of the lines, kept large enough to stay - // visible even when the leg is short and the head nearly reaches them. float footRender = footRadius * 0.7; float leftFootDistance = circleDistance(leftFoot, footRender, target); float rightFootDistance = circleDistance(rightFoot, footRender, target); @@ -73,23 +60,13 @@ export class CharacterShape extends Drawable { min(min(leftLeg, rightLeg), min(leftFootDistance, rightFootDistance)) ); - // Real eyes painted on the leading face — a bright white sclera with a - // dark pupil — rather than carved holes, so the character reads as - // alive instead of hollow-socketed. - // Big white sclera, small pupil: the eye must read as mostly white so - // it looks like an eye, not a dark socket. (An 8-bit albedo caps the - // sclera at pure white, so its area — not its colour — is what makes - // the eye read brighter against the body.) vec2 eyeBase = head + forward * (renderRadius * 0.26); vec2 leftEyeCenter = eyeBase + perp * (renderRadius * 0.38); vec2 rightEyeCenter = eyeBase - perp * (renderRadius * 0.38); float scleraRadius = renderRadius * 0.26; float pupilRadius = renderRadius * 0.11; - // The pupil slides toward the gaze target (the cursor for the local - // player, otherwise the travel direction), kept well inside the rim so - // a generous ring of white always frames it. The normalize is guarded - // so a cursor resting exactly on an eye can't produce NaNs. + // The normalize is guarded so a cursor resting exactly on an eye can't produce NaNs. vec2 gazeTarget = characterGazeTargets[i]; float pupilReach = (scleraRadius - pupilRadius) * 0.6; vec2 toLeftGaze = gazeTarget - leftEyeCenter; @@ -106,14 +83,7 @@ export class CharacterShape extends Drawable { circleDistance(rightEyeCenter + rightGaze * pupilReach, pupilRadius, target) ); - // Soften each eye edge by at least one screen texel so the colour - // boundary antialiases instead of staircasing when the character is - // small on screen. The fixed world-space term sets a floor on the - // softness when zoomed in; fwidth widens the band to a texel as the - // head shrinks. Computed here in uniform control flow — NOT inside the - // body branch below — so the screen-space derivatives stay defined. - // fwidth needs WebGL2 derivatives (core in GLSL ES 3.00), so the - // WebGL1 fallback keeps the plain world-space band. + // Computed here in uniform control flow — NOT inside the body branch below — so the screen-space derivatives fwidth needs stay defined. WebGL1 fallback keeps the plain world-space band (no fwidth in GLSL ES 1.00). float eyeAaBase = renderRadius * 0.025; #ifdef WEBGL2_IS_AVAILABLE float scleraAa = max(eyeAaBase, fwidth(sclera)); @@ -125,21 +95,13 @@ export class CharacterShape extends Drawable { if (body < minDistance) { minDistance = body; - // Brief white punch when taking a hit. color = mix( readFromPalette(characterColors[i]), vec4(1.0), clamp(characterFlash[i], 0.0, 1.0) ); - // Paint the eyes over the body colour. The sclera albedo is HDR - // (>> 1): the shading pass multiplies it by the (often dim, reddish) - // scene light before clamping to the screen, so an over-bright white - // reads as a clean white eye everywhere instead of dimming into a - // dark socket. Needs the float colour buffer in sdf-2d; on 8-bit - // fallback it simply clamps back to plain white. The smoothstep - // widths (scleraAa / pupilAa, computed above) antialias the colour - // boundary in a zoom-aware way. + // The sclera albedo is HDR (>> 1) so the shading pass — which multiplies by dim scene light before clamping — still renders a clean white eye instead of dimming to a dark socket. Needs sdf-2d's float colour buffer; on 8-bit fallback it clamps to plain white. color = mix(color, vec4(10.0, 10.0, 10.0, 1.0), 1.0 - smoothstep(-scleraAa, scleraAa, sclera)); color = mix(color, vec4(0.04, 0.04, 0.07, 1.0), 1.0 - smoothstep(-pupilAa, pupilAa, pupil)); } @@ -169,10 +131,7 @@ export class CharacterShape extends Drawable { protected leftFoot!: Circle; protected rightFoot!: Circle; - // 0..1 transient white flash on taking a hit. Set per frame. public hitFlash = 0; - // World point the eyes look at; the pupils slide toward it. Set per frame - // (the cursor for the local player, the travel direction for everyone else). public gazeTarget = vec2.create(); public constructor(private readonly color: number) { diff --git a/frontend/src/scripts/shapes/planet-shape.ts b/frontend/src/scripts/shapes/planet-shape.ts index ceb2e35..7d8ac96 100644 --- a/frontend/src/scripts/shapes/planet-shape.ts +++ b/frontend/src/scripts/shapes/planet-shape.ts @@ -58,9 +58,7 @@ export class PlanetShape extends PolygonFactory(settings.planetEdgeCount, 0) { float cr = cos(rotation); float sr = sin(rotation); - // Spin the whole planet: evaluate the SDF in the planet's own - // rotating frame so the polygon outline turns together with its - // terrain, instead of the terrain sliding over a fixed outline. + // Evaluate the SDF in the planet's own rotating frame so the outline turns with its terrain. vec2 targetCenterDelta = target - center; float targetDistance = length(targetCenterDelta); vec2 localTarget = center + vec2( @@ -129,9 +127,7 @@ export class PlanetShape extends PolygonFactory(settings.planetEdgeCount, 0) { public randomOffset = 0; public rotation = 0; - // Circle about the rotation centre (the vertex centroid, which is what the - // shader spins around — see planetMinDistance above). The vertices never - // change after construction, so cache it once. + // Circle about the vertex centroid — the point the shader spins around (planetMinDistance above). Vertices are immutable after construction, so it is cached once. private readonly cullCenter: vec2; private readonly cullRadius: number; diff --git a/frontend/src/scripts/sound-handler.ts b/frontend/src/scripts/sound-handler.ts index 7346f64..c36445f 100644 --- a/frontend/src/scripts/sound-handler.ts +++ b/frontend/src/scripts/sound-handler.ts @@ -1,89 +1,85 @@ import hitSound from '../../static/hit.mp3'; import shootSound from '../../static/shoot.mp3'; import clickSound from '../../static/click.mp3'; -import ambientSound from '../../static/ambient.mp3'; +import ambientAudio from '../../static/ambient.mp3'; import { OptionsHandler } from './options-handler'; -export enum Sounds { - hit = 'hit', - shoot = 'shoot', - click = 'click', +export const Sounds = { hit: 'hit', shoot: 'shoot', click: 'click' } as const; +export type Sound = (typeof Sounds)[keyof typeof Sounds]; + +let sounds: Record; +let isAmbientPlaying = false; +const ambientSnd = new Audio(ambientAudio); +let initialized = false; + +async function initializeSound(src: string): Promise { + const snd = new Audio(src); + snd.muted = true; + await snd.play(); + snd.pause(); + snd.muted = false; + snd.currentTime = 0; + return snd; } -export abstract class SoundHandler { - private static sounds: { [key in Sounds]: HTMLAudioElement }; - private static isAmbientPlaying = false; +async function initialize( + onPlayKeypress: () => unknown = () => null, + onPauseKeypress: () => unknown = () => null, +) { + sounds = { + hit: await initializeSound(hitSound), + shoot: await initializeSound(shootSound), + click: await initializeSound(clickSound), + }; - private static ambientSound = new Audio(ambientSound); + await ambientSnd.play(); + ambientSnd.muted = true; + initialized = true; + ambientSnd.onpause = onPauseKeypress; + ambientSnd.onplay = onPlayKeypress; - private static initialized = false; - public static async initialize( - onPlayKeypress: () => unknown = () => null, - onPauseKeypress: () => unknown = () => null, - ) { - this.sounds = { - [Sounds.hit]: await this.initializeSound(hitSound), - [Sounds.shoot]: await this.initializeSound(shootSound), - [Sounds.click]: await this.initializeSound(clickSound), - }; + ambientSnd.muted = false; + ambientSnd.volume = 0.5; + ambientSnd.loop = true; - await this.ambientSound.play(); - this.ambientSound.muted = true; - this.initialized = true; - this.ambientSound.onpause = onPauseKeypress; - this.ambientSound.onplay = onPlayKeypress; - - this.ambientSound.muted = false; - this.ambientSound.volume = 0.5; - this.ambientSound.loop = true; - - if (!this.isAmbientPlaying) { - this.ambientSound.pause(); - } - } - - private static async initializeSound(hitSound: string): Promise { - const sound = new Audio(hitSound); - sound.muted = true; - await sound.play(); - sound.pause(); - sound.muted = false; - sound.currentTime = 0; - return sound; - } - - public static play(sound: Sounds, volume = 1, playbackRate = 1) { - if (!this.initialized || !OptionsHandler.options.soundsEnabled) { - return; - } - - // Reuse the pooled element unless it is still mid-playback; only overlapping - // sounds need a clone. Testing `currentTime > 0` instead stayed true forever - // after the first play, so every later effect allocated a fresh element. - const pooled = this.sounds[sound]; - const isBusy = !pooled.paused && !pooled.ended; - const audio = isBusy ? (pooled.cloneNode(true) as HTMLAudioElement) : pooled; - if (!isBusy) { - audio.currentTime = 0; - } - audio.volume = Math.max(0, Math.min(1, volume)); - audio.playbackRate = playbackRate; - // A rejected play() (autoplay policy, or a clone GC'd mid-play) is expected - // and must not surface as an unhandled rejection. - void audio.play().catch(() => undefined); - } - - public static playAmbient() { - this.isAmbientPlaying = true; - if (this.initialized) { - this.ambientSound.play(); - } - } - - public static stopAmbient() { - this.isAmbientPlaying = false; - if (this.initialized) { - this.ambientSound.pause(); - } + if (!isAmbientPlaying) { + ambientSnd.pause(); } } + +function play(snd: Sound, volume = 1, playbackRate = 1) { + if (!initialized || !OptionsHandler.options.soundsEnabled) { + return; + } + + const pooled = sounds[snd]; + const isBusy = !pooled.paused && !pooled.ended; + const audio = isBusy ? (pooled.cloneNode(true) as HTMLAudioElement) : pooled; + if (!isBusy) { + audio.currentTime = 0; + } + audio.volume = Math.max(0, Math.min(1, volume)); + audio.playbackRate = playbackRate; + void audio.play().catch(() => undefined); +} + +function playAmbient() { + isAmbientPlaying = true; + if (initialized) { + ambientSnd.play(); + } +} + +function stopAmbient() { + isAmbientPlaying = false; + if (initialized) { + ambientSnd.pause(); + } +} + +export const SoundHandler = { + initialize, + play, + playAmbient, + stopAmbient, +}; diff --git a/frontend/src/scripts/vibration-handler.ts b/frontend/src/scripts/vibration-handler.ts index 3ae8420..0ca5e76 100644 --- a/frontend/src/scripts/vibration-handler.ts +++ b/frontend/src/scripts/vibration-handler.ts @@ -1,19 +1,15 @@ import { OptionsHandler } from './options-handler'; -export abstract class VibrationHandler { - // Accepts either a single duration or an on/off pattern (e.g. a double-thump - // [40, 30, 90] for a kill). - public static vibrate(pattern: number | number[]): void { - if (OptionsHandler.options.vibrationEnabled && this.isVibrationEnabled) { +export const VibrationHandler = { + vibrate(pattern: number | number[]) { + if (OptionsHandler.options.vibrationEnabled && 'vibrate' in navigator) { navigator.vibrate(pattern); } - } - - public static get isVibrationEnabled(): boolean { + }, + get isVibrationEnabled() { return 'vibrate' in navigator; - } - - public static get isVibrationEnabledHeuristics(): boolean { - return this.isVibrationEnabled && 'ontouchstart' in window; - } -} + }, + get isVibrationEnabledHeuristics() { + return 'vibrate' in navigator && 'ontouchstart' in window; + }, +}; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index b12fbd2..07a4028 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -5,14 +5,12 @@ "target": "es6", "esModuleInterop": true, "strict": true, - "experimentalDecorators": true, "moduleResolution": "node", "ignoreDeprecations": "6.0", "skipLibCheck": true, "module": "commonjs", - "lib": ["dom", "es2017"], - "typeRoots": ["./types", "./node_modules/@types"] + "lib": ["dom", "es2017"] }, - "include": ["src/**/*.ts", "src/*.ts"], + "include": ["src/**/*.ts"], "exclude": ["node_modules", "typings"] } diff --git a/frontend/types/socket.io-msgpack-parser/index.d.ts b/frontend/types/socket.io-msgpack-parser/index.d.ts deleted file mode 100644 index 4065a1e..0000000 --- a/frontend/types/socket.io-msgpack-parser/index.d.ts +++ /dev/null @@ -1 +0,0 @@ -declare module 'socket.io-msgpack-parser'; diff --git a/frontend/webpack.config.js b/frontend/webpack.config.js index 97f6f6b..f729fa2 100644 --- a/frontend/webpack.config.js +++ b/frontend/webpack.config.js @@ -5,7 +5,6 @@ const MiniCssExtractPlugin = require('mini-css-extract-plugin'); const TerserJSPlugin = require('terser-webpack-plugin'); const CopyWebpackPlugin = require('copy-webpack-plugin'); const HTMLInlineCSSWebpackPlugin = require('html-inline-css-webpack-plugin').default; -// const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin; const Sass = require('sass'); module.exports = (env, argv) => { @@ -31,7 +30,6 @@ module.exports = (env, argv) => { plugins: [ new CleanWebpackPlugin(), new MiniCssExtractPlugin(), - //new BundleAnalyzerPlugin(), new HtmlWebpackPlugin({ template: './src/index.html', }), diff --git a/shared/src/commands/command-executors.ts b/shared/src/commands/command-executors.ts deleted file mode 100644 index a5271f5..0000000 --- a/shared/src/commands/command-executors.ts +++ /dev/null @@ -1,3 +0,0 @@ -export type CommandExecutors = { - [type: string]: (command: any) => unknown; -}; diff --git a/shared/src/commands/command-receiver.ts b/shared/src/commands/command-receiver.ts index 0eff7c4..51f8600 100644 --- a/shared/src/commands/command-receiver.ts +++ b/shared/src/commands/command-receiver.ts @@ -1,6 +1,9 @@ -import { CommandExecutors } from './command-executors'; import { Command } from './command'; +export type CommandExecutors = { + [type: string]: (command: any) => unknown; +}; + export abstract class CommandReceiver { protected commandExecutors: CommandExecutors = {}; diff --git a/shared/src/commands/types/actions/client-heartbeat.ts b/shared/src/commands/types/actions/client-heartbeat.ts index 0724817..013485d 100644 --- a/shared/src/commands/types/actions/client-heartbeat.ts +++ b/shared/src/commands/types/actions/client-heartbeat.ts @@ -1,12 +1,10 @@ import { serializable } from '../../../serialization/serializable'; import { Command } from '../../command'; -// Appended to every client -> server batch. Movement is only transmitted when it -// changes, so on its own the server's "newest input I have applied" marker -// freezes whenever a key is simply held down — which pins the client predictor's -// replay window and stops reconciliation from covering the un-acknowledged -// input. This carries the client's wall-clock for the batch, so the marker keeps -// advancing at the send rate whether or not the input changed. +// Appended to every client -> server batch. Movement is only sent on change, +// so without this the server's "newest input applied" marker freezes while a +// key is held, pinning the predictor's replay window. This carries the client's +// wall-clock so the marker advances at the send rate regardless of input change. @serializable export class ClientHeartbeatCommand extends Command { public constructor(public readonly clientTimeMs: number = 0) { diff --git a/shared/src/commands/types/actions/leap-action.ts b/shared/src/commands/types/actions/leap-action.ts index 0115e73..a7ca5c8 100644 --- a/shared/src/commands/types/actions/leap-action.ts +++ b/shared/src/commands/types/actions/leap-action.ts @@ -1,12 +1,9 @@ import { serializable } from '../../../serialization/serializable'; import { Command } from '../../command'; -// Sent client -> server when the player triggers a leap (Space / leap button). -// The launch direction is derived server-side from the character's surface -// normal and current movement input. clientTimeMs is the client's wall-clock at -// the press, echoed back via InputAcknowledgement so the predictor knows which -// leaps the server has already folded into the streamed launch momentum and -// must not replay again. +// clientTimeMs is echoed back via InputAcknowledgement so the predictor knows +// which leaps the server has already folded into the streamed launch momentum +// and must not replay again. @serializable export class LeapActionCommand extends Command { public constructor(public readonly clientTimeMs: number = 0) { diff --git a/shared/src/commands/types/actions/move-action.ts b/shared/src/commands/types/actions/move-action.ts index 117d3e3..2cecb1c 100644 --- a/shared/src/commands/types/actions/move-action.ts +++ b/shared/src/commands/types/actions/move-action.ts @@ -4,11 +4,10 @@ import { Command } from '../../command'; @serializable export class MoveActionCommand extends Command { - // clientTimeMs is the client's wall-clock (integer ms, survives the - // serializer's toFixed(3)) when the input was generated. The server echoes - // the latest one back via InputAcknowledgement so the client knows how much - // of its input timeline is already baked into a snapshot and can replay the - // rest for prediction. Defaults to 0 for inputs the server itself synthesises. + // clientTimeMs (integer ms, survives the serializer's toFixed(3)) is echoed + // back via InputAcknowledgement so the client knows how much of its input + // timeline is baked into a snapshot and can replay the rest. Defaults to 0 + // for inputs the server itself synthesises. public constructor( public readonly direction: vec2, public readonly clientTimeMs: number = 0, diff --git a/shared/src/commands/types/actions/primary-action.ts b/shared/src/commands/types/actions/primary-action.ts index 32ec1b8..e0365e3 100644 --- a/shared/src/commands/types/actions/primary-action.ts +++ b/shared/src/commands/types/actions/primary-action.ts @@ -7,11 +7,9 @@ export class PrimaryActionCommand extends Command { public constructor( public readonly position: vec2, public readonly charge: number = 0, - // Client wall-clock at the moment the shot was released. Appended last so - // the existing wire order is unchanged. The server uses it (together with - // the measured RTT) to fast-forward the projectile by the time the command - // spent in flight, so the shooter does not have to lead by their own - // latency on top of the projectile's travel time. + // Client wall-clock at shot release. Appended last so the existing wire + // order is unchanged. The server fast-forwards the projectile by its + // in-flight time so the shooter need not lead by their own latency. public readonly clientTimeMs: number = 0, ) { super(); diff --git a/shared/src/commands/types/delete-objects.ts b/shared/src/commands/types/delete-objects.ts index 6bf9ecf..698b59c 100644 --- a/shared/src/commands/types/delete-objects.ts +++ b/shared/src/commands/types/delete-objects.ts @@ -1,4 +1,4 @@ -import { Id } from '../../communication/id'; +import { Id } from '../../communication/communication'; import { serializable } from '../../serialization/serializable'; import { Command } from '../command'; diff --git a/shared/src/commands/types/game-end.ts b/shared/src/commands/types/game-end.ts index c209942..a4551e4 100644 --- a/shared/src/commands/types/game-end.ts +++ b/shared/src/commands/types/game-end.ts @@ -1,9 +1,6 @@ import { serializable } from '../../serialization/serializable'; import { Command } from '../command'; -// "The round is over" — a bare signal, like GameStartCommand. The winner is -// announced through ServerAnnouncement and the end-card length is the server's -// own restart timer, so neither has to travel here. @serializable export class GameEndCommand extends Command { public toArray(): Array { diff --git a/shared/src/commands/types/input-acknowledgement.ts b/shared/src/commands/types/input-acknowledgement.ts index 077ac4d..2359288 100644 --- a/shared/src/commands/types/input-acknowledgement.ts +++ b/shared/src/commands/types/input-acknowledgement.ts @@ -1,35 +1,25 @@ import { vec2 } from 'gl-matrix'; -import { Id } from '../../communication/id'; +import { Id } from '../../communication/communication'; import { serializable } from '../../serialization/serializable'; import { Command } from '../command'; /** - * Everything the character's movement simulation carries from one tick to the - * next that is *not* in the pose. + * Movement state carried tick-to-tick that is NOT in the pose. Together with + * the pose (which travels as ordinary property updates) this is the complete + * state `stepCharacterMovement` needs, so the predictor resumes the server's + * simulation rather than approximating it. * - * The pose (head/feet circles) travels as ordinary property updates because - * every viewer needs it; this travels to the owning client only. Together they - * are the complete state `stepCharacterMovement` needs, so the predictor can - * resume the server's simulation rather than approximate it. - * - * It all has to be here. `direction` in particular is not recoverable from the - * pose: the body is sprung *towards* the posture that direction implies and - * lags it while walking, so deriving the angle back out of a walking pose is - * off by tens of degrees — and every replay then spent its first ticks - * twisting the body back, by an amount that varied with the replay length. + * `direction` is not recoverable from the pose: the body is sprung *towards* + * the posture that direction implies and lags it while walking, so deriving the + * angle back out is off by tens of degrees. */ @serializable export class CharacterMovementSnapshot { public constructor( public readonly direction: number, - // Persistent launch momentum (leap / slingshot / recoil), so the predictor - // reproduces a flight rather than only snapping to it. public readonly bodyVelocity: vec2, - // Foot contact normals: the on-surface branch projects gravity onto these, - // so a body standing on a slope needs the real ones. public readonly leftFootNormal: vec2, public readonly rightFootNormal: vec2, - // The planet being stood on, or null while airborne. public readonly groundPlanetId: Id, public readonly secondsSinceOnSurface: number, ) {} @@ -46,28 +36,21 @@ export class CharacterMovementSnapshot { } } -// Sent server -> owning client only, alongside that client's own character -// snapshot. Carries the clientTimeMs of the most recent input the server had -// received when the snapshot was taken (so the client's predictor can reset to -// the snapshot and replay just the inputs the server hasn't seen yet) and the -// authoritative movement state the replay resumes from. See -// LocalCharacterPredictor. +// Sent server -> owning client only. Carries the clientTimeMs of the most +// recent input the server had received when the snapshot was taken (so the +// predictor replays only the inputs the server hasn't seen) and the movement +// state the replay resumes from. See LocalCharacterPredictor. @serializable export class InputAcknowledgement extends Command { public constructor( public readonly clientTimeMs: number, public readonly movement: CharacterMovementSnapshot, // clientTimeMs of the most recent leap the server has received: any leap at - // or before it is already reflected in movement.bodyVelocity, so the - // predictor must not replay it. + // or before it is already in movement.bodyVelocity and must not be replayed. public readonly lastLeapClientTimeMs: number, - // How long before this snapshot the acknowledged input arrived. The client - // sends at a fixed cadence, so by the time a snapshot is taken the newest - // input it has is already 0..1 send interval old — and that age varies with - // where the client's sends happened to fall between two snapshots. Adding - // it back places the replay anchor at the snapshot instant itself instead - // of at whichever input happened to land last, which is what keeps the - // predicted pose free of the send-cadence beat. See LocalCharacterPredictor. + // Age of the acknowledged input at snapshot time. Adding it back places the + // replay anchor at the snapshot instant instead of whichever input landed + // last, keeping the predicted pose free of the send-cadence beat. public readonly ackAgeMs: number = 0, ) { super(); diff --git a/shared/src/commands/types/property-updates-for-objects.ts b/shared/src/commands/types/property-updates-for-objects.ts index bef2d6b..1ed4b1e 100644 --- a/shared/src/commands/types/property-updates-for-objects.ts +++ b/shared/src/commands/types/property-updates-for-objects.ts @@ -6,8 +6,7 @@ import { Command } from '../command'; export class PropertyUpdatesForObjects extends Command { constructor( public readonly updates: Array, - // Seconds on the server's monotonic clock at the moment this state was - // captured. Only differences between timestamps are meaningful. + // Only differences between timestamps are meaningful. public readonly timestamp: number, ) { super(); diff --git a/shared/src/commands/types/update-minimap.ts b/shared/src/commands/types/update-minimap.ts index a24e371..7650d0e 100644 --- a/shared/src/commands/types/update-minimap.ts +++ b/shared/src/commands/types/update-minimap.ts @@ -1,5 +1,5 @@ import { vec2 } from 'gl-matrix'; -import { Id } from '../../communication/id'; +import { Id } from '../../communication/communication'; import { CharacterTeam } from '../../objects/types/character-base'; import { serializable } from '../../serialization/serializable'; import { Command } from '../command'; diff --git a/shared/src/communication/communication.ts b/shared/src/communication/communication.ts new file mode 100644 index 0000000..c23f7c4 --- /dev/null +++ b/shared/src/communication/communication.ts @@ -0,0 +1,20 @@ +export type Id = number | null; + +let currentId = 0; +export const id = (): number => { + return currentId++; +}; + +export interface PlayerInformation { + name: string; + reconnectToken?: string; +} + +export interface ServerInformation { + playerLimit: number; + playerCount: number; + serverName: string; + gameStatePercent: number; +} + +export const serverInformationEndpoint = '/state'; diff --git a/shared/src/communication/id.ts b/shared/src/communication/id.ts deleted file mode 100644 index c6c1e4d..0000000 --- a/shared/src/communication/id.ts +++ /dev/null @@ -1,7 +0,0 @@ -export type Id = number | null; - -let currentId = 0; - -export const id = (): number => { - return currentId++; -}; diff --git a/shared/src/communication/player-information.ts b/shared/src/communication/player-information.ts deleted file mode 100644 index 3180ecd..0000000 --- a/shared/src/communication/player-information.ts +++ /dev/null @@ -1,6 +0,0 @@ -export interface PlayerInformation { - name: string; - // Issued by the server on a previous join. Presented on reconnect to reclaim - // the team and score held during the grace window; absent on a first join. - reconnectToken?: string; -} diff --git a/shared/src/communication/server-information.ts b/shared/src/communication/server-information.ts deleted file mode 100644 index 425ef2a..0000000 --- a/shared/src/communication/server-information.ts +++ /dev/null @@ -1,8 +0,0 @@ -export interface ServerInformation { - playerLimit: number; - playerCount: number; - serverName: string; - gameStatePercent: number; -} - -export const serverInformationEndpoint = '/state'; diff --git a/shared/src/helper/array.ts b/shared/src/helper/array.ts index 8c86ffa..a7f4554 100644 --- a/shared/src/helper/array.ts +++ b/shared/src/helper/array.ts @@ -1,6 +1,5 @@ declare global { - // `T` must stay to merge with the global `Array` (otherwise TS2428), but it - // is unused by the x/y aliases below. + // `T` must stay to merge with the global `Array` (otherwise TS2428). // eslint-disable-next-line @typescript-eslint/no-unused-vars interface Array { x: number; diff --git a/shared/src/helper/calculate-view-area.ts b/shared/src/helper/calculate-view-area.ts index 00ebd50..e23f886 100644 --- a/shared/src/helper/calculate-view-area.ts +++ b/shared/src/helper/calculate-view-area.ts @@ -1,22 +1,17 @@ import { vec2 } from 'gl-matrix'; -import { Rectangle, settings } from '../main'; +import { settings } from '../main'; export const calculateViewArea = ( center: vec2, aspectRatio: number, oversizeRatio = 1, -): Rectangle => { - const viewArea = new Rectangle(); - - viewArea.size = vec2.fromValues( +) => { + const size = vec2.fromValues( Math.sqrt(settings.inViewAreaSize * oversizeRatio * aspectRatio), Math.sqrt((settings.inViewAreaSize * oversizeRatio) / aspectRatio), ); - viewArea.topLeft = vec2.fromValues( - center[0] - viewArea.size[0] / 2, - center[1] + viewArea.size[1] / 2, - ); + const topLeft = vec2.fromValues(center[0] - size[0] / 2, center[1] + size[1] / 2); - return viewArea; + return { topLeft, size }; }; diff --git a/shared/src/helper/charge.ts b/shared/src/helper/charge.ts index 145de12..be2a67a 100644 --- a/shared/src/helper/charge.ts +++ b/shared/src/helper/charge.ts @@ -4,10 +4,8 @@ import { settings } from '../settings'; export const holdDurationToCharge = (heldSeconds: number): number => clamp01(heldSeconds / settings.chargeShotFullHoldSeconds); -// The charge a shot of this strength represents — the inverse of the mix() the -// server uses to size a projectile. The server needs it to scale radius, speed -// and recoil; the client needs the same number to pitch the sound and the -// muzzle flash, so it lives here rather than being derived twice. +// Inverse of the server's projectile mix(); both sides need this number, so it +// lives here rather than being derived twice. export const strengthToCharge = (strength: number): number => clamp01( (strength - settings.chargeShotStrengthMin) / diff --git a/shared/src/helper/finite.ts b/shared/src/helper/finite.ts index 00d0880..ddfb6c4 100644 --- a/shared/src/helper/finite.ts +++ b/shared/src/helper/finite.ts @@ -48,10 +48,8 @@ export const finiteVec2 = (value: unknown, maxMagnitude: number): vec2 | undefin }; /** - * A display name that is safe to store and re-serialize. Anything that is not a - * string is coerced, because a non-string survives `.slice()` — an array name - * round-trips through the serializer and is revived as a class on every peer - * that receives it, throwing inside the deserializer's reviver. + * Coerces non-strings: a non-string survives `.slice()` and round-trips through + * the serializer revived as a class on every peer, throwing in the reviver. */ export const sanitizeName = (value: unknown, maxLength: number): string => { const text = typeof value === 'string' ? value : String(value ?? ''); diff --git a/shared/src/helper/hsl.ts b/shared/src/helper/hsl.ts index 5b4aaf0..4b29cda 100644 --- a/shared/src/helper/hsl.ts +++ b/shared/src/helper/hsl.ts @@ -1,7 +1,5 @@ import { vec3 } from 'gl-matrix'; -import { rgb } from './rgb'; -// source: https://stackoverflow.com/questions/2353211/hsl-to-rgb-color-conversion export const hsl = (hue: number, saturation: number, lightness: number): vec3 => { hue /= 360; saturation /= 100; @@ -31,5 +29,5 @@ export const hsl = (hue: number, saturation: number, lightness: number): vec3 => b = hue2rgb(p, q, hue - 1 / 3); } - return rgb(r, g, b); + return vec3.fromValues(r, g, b); }; diff --git a/shared/src/helper/last.ts b/shared/src/helper/last.ts deleted file mode 100644 index 39a5c1f..0000000 --- a/shared/src/helper/last.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function last(a: ArrayLike): T | null { - return a.length > 0 ? a[a.length - 1] : null; -} diff --git a/shared/src/helper/mix-rgb.ts b/shared/src/helper/mix-rgb.ts deleted file mode 100644 index 17195af..0000000 --- a/shared/src/helper/mix-rgb.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { vec3 } from 'gl-matrix'; -import { clamp01 } from './clamp'; -import { mix } from './mix'; - -export const mixRgb = (a: vec3, b: vec3, q: number): vec3 => { - const clampedQ = clamp01(q); - return vec3.fromValues( - mix(a[0], b[0], clampedQ), - mix(a[1], b[1], clampedQ), - mix(a[2], b[2], clampedQ), - ); -}; diff --git a/shared/src/helper/random.ts b/shared/src/helper/random.ts index d7a0de3..76c904a 100644 --- a/shared/src/helper/random.ts +++ b/shared/src/helper/random.ts @@ -1,31 +1,36 @@ -// source -// https://stackoverflow.com/questions/521295/seeding-the-random-number-generator-in-javascript -// Mulberry32 +let _seed = Math.random(); -export abstract class Random { - private static _seed = Math.random(); - - public static set seed(value: number) { - Random._seed = value; - } - - public static choose(values: Array): T | undefined { - const to = values.length; - if (to === 0) { - return undefined; - } - - return values[Math.floor(this.getRandomInRange(0, to))]; - } - - public static getRandomInRange(from: number, to: number): number { - return from + this.getRandom() * (to - from); - } - - public static getRandom(): number { - let t = (Random._seed += 0x6d2b79f5); - t = Math.imul(t ^ (t >>> 15), t | 1); - t ^= t + Math.imul(t ^ (t >>> 7), t | 61); - return ((t ^ (t >>> 14)) >>> 0) / 4294967296; - } +export function setRandomSeed(value: number) { + _seed = value; } + +export function chooseRandom(values: Array): T | undefined { + const to = values.length; + if (to === 0) { + return undefined; + } + return values[Math.floor(getRandomInRange(0, to))]; +} + +export function getRandomInRange(from: number, to: number): number { + return from + getRandom() * (to - from); +} + +export function getRandom(): number { + let t = (_seed += 0x6d2b79f5); + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; +} + +export const Random = { + get seed() { + return _seed; + }, + set seed(v: number) { + _seed = v; + }, + choose: chooseRandom, + getRandomInRange, + getRandom, +}; diff --git a/shared/src/helper/rectangle.ts b/shared/src/helper/rectangle.ts deleted file mode 100644 index f1aa8f3..0000000 --- a/shared/src/helper/rectangle.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { vec2 } from 'gl-matrix'; -import { serializable } from '../serialization/serializable'; - -@serializable -export class Rectangle { - constructor( - public topLeft = vec2.create(), - public size = vec2.create(), - ) {} - - public toArray(): Array { - return [this.topLeft, this.size]; - } -} diff --git a/shared/src/helper/rgb.ts b/shared/src/helper/rgb.ts deleted file mode 100644 index a6ef20a..0000000 --- a/shared/src/helper/rgb.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { vec3 } from 'gl-matrix'; - -export const rgb = (r: number, g: number, b: number): vec3 => vec3.fromValues(r, g, b); diff --git a/shared/src/helper/rotate-90-deg.ts b/shared/src/helper/rotate-90-deg.ts deleted file mode 100644 index 2196fa8..0000000 --- a/shared/src/helper/rotate-90-deg.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { vec2 } from 'gl-matrix'; - -export const rotate90Deg = (vec: vec2): vec2 => vec2.fromValues(-vec[1], vec[0]); diff --git a/shared/src/main.ts b/shared/src/main.ts index ba7ee41..5776194 100644 --- a/shared/src/main.ts +++ b/shared/src/main.ts @@ -10,7 +10,6 @@ export * from './commands/types/game-start'; export * from './commands/types/update-minimap'; export * from './commands/types/update-game-state'; export * from './commands/command-receiver'; -export * from './commands/command-executors'; export * from './commands/command-generator'; export * from './commands/types/actions/move-action'; export * from './commands/types/actions/primary-action'; @@ -18,23 +17,16 @@ export * from './commands/types/actions/leap-action'; export * from './commands/types/actions/client-heartbeat'; export * from './commands/types/actions/set-aspect-ratio-action'; export * from './helper/array'; -export * from './helper/last'; -export * from './helper/rgb'; export * from './helper/hsl'; export * from './helper/rgb255'; -export * from './helper/mix-rgb'; export * from './helper/clamp'; export * from './helper/finite'; export * from './helper/charge'; export * from './helper/calculate-view-area'; export * from './helper/circle'; -export * from './helper/rectangle'; export * from './helper/mix'; export * from './helper/random'; -export * from './communication/id'; -export * from './communication/server-information'; -export * from './communication/player-information'; -export * from './helper/rotate-90-deg'; +export * from './communication/communication'; export * from './objects/game-object'; export * from './serialization/deserialize'; export * from './serialization/serialize'; diff --git a/shared/src/objects/game-object.ts b/shared/src/objects/game-object.ts index 1372c99..fbcd26f 100644 --- a/shared/src/objects/game-object.ts +++ b/shared/src/objects/game-object.ts @@ -1,6 +1,6 @@ import { Command } from '../commands/command'; import { CommandReceiver } from '../commands/command-receiver'; -import { Id } from '../communication/id'; +import { Id } from '../communication/communication'; import { serializable } from '../serialization/serializable'; @serializable @@ -15,11 +15,8 @@ export class RemoteCall { } } -// Every object property streamed via UpdatePropertyCommand. A single union means -// a typo or rename on the producing (server *-physical) or consuming (client -// *-view) side is a compile error instead of a silently dropped update that -// just stops a body interpolating. The wire format is unchanged — these remain -// the same strings, only now compiler-checked at both ends. +// Wire property keys — compiler-checked at both the producing (server) and +// consuming (client) ends; wire strings are unchanged. export type SyncPropertyKey = | 'head' | 'leftFoot' @@ -66,11 +63,8 @@ export abstract class GameObject extends CommandReceiver { private updateGeneration = -1; private cachedPropertyUpdates?: PropertyUpdatesForObject; - // The only methods a peer may invoke over the wire. processRemoteCalls - // dispatches by a raw string taken straight off the network, so without this - // gate a malformed or hostile packet could call ANY method on the object - // (toArray, resetRemoteCalls, even prototype methods). Keep in sync with the - // remoteCall() emitters on the *-physical classes. + // Security: processRemoteCalls dispatches by a raw string off the network. + // Keep this allow-list in sync with the remoteCall() emitters on *-physical. private static readonly allowedRemoteCalls: ReadonlySet = new Set([ 'onShoot', 'onLeap', @@ -102,12 +96,14 @@ export abstract class GameObject extends CommandReceiver { }); } - public getPropertyUpdates(): PropertyUpdatesForObject | void {} + public getPropertyUpdates(): PropertyUpdatesForObject | undefined { + return undefined; + } public getPropertyUpdatesForFrame(): PropertyUpdatesForObject | undefined { if (this.updateGeneration !== currentUpdateGeneration) { this.updateGeneration = currentUpdateGeneration; - this.cachedPropertyUpdates = this.getPropertyUpdates() ?? undefined; + this.cachedPropertyUpdates = this.getPropertyUpdates(); } return this.cachedPropertyUpdates; } diff --git a/shared/src/objects/types/character-base.ts b/shared/src/objects/types/character-base.ts index f6cad5c..631d91e 100644 --- a/shared/src/objects/types/character-base.ts +++ b/shared/src/objects/types/character-base.ts @@ -1,4 +1,4 @@ -import { Id } from '../../communication/id'; +import { Id } from '../../communication/communication'; import { Circle } from '../../helper/circle'; import { serializable } from '../../serialization/serializable'; import { toArrayFromFields } from '../../serialization/serialized-fields'; diff --git a/shared/src/objects/types/lamp-base.ts b/shared/src/objects/types/lamp-base.ts index 9f86ca6..00c20bb 100644 --- a/shared/src/objects/types/lamp-base.ts +++ b/shared/src/objects/types/lamp-base.ts @@ -21,8 +21,6 @@ export class LampBase extends GameObject { super(id); } - // Overridden by LampView (which lerps toward the new colour/brightness); a - // no-op on the wire base. // eslint-disable-next-line @typescript-eslint/no-unused-vars public setLight(color: vec3, lightness: number) {} diff --git a/shared/src/objects/types/planet-base.ts b/shared/src/objects/types/planet-base.ts index f28ca4e..bd3cabf 100644 --- a/shared/src/objects/types/planet-base.ts +++ b/shared/src/objects/types/planet-base.ts @@ -4,13 +4,12 @@ import { settings } from '../../settings'; import { serializable } from '../../serialization/serializable'; import { toArrayFromFields } from '../../serialization/serialized-fields'; import { GameObject } from '../game-object'; -import { Id } from '../../communication/id'; +import { Id } from '../../communication/communication'; import { CharacterTeam } from './character-base'; @serializable export class PlanetBase extends GameObject { - // centre/radius are derived from vertices in the constructor, so they are not - // serialized — only the constructor parameters are. + // centre/radius are derived from vertices, so not serialized. private static readonly serializedFields = [ 'id', 'vertices', @@ -35,9 +34,8 @@ export class PlanetBase extends GameObject { this.vertices.length; } - // Which team controls this planet, with the neutral dead-band around 50% - // applied. Lives here so scoring (server) and every tint the player sees - // (client) read one rule rather than three copies of the same comparison. + // Single rule for the dead-band: scoring (server) and every tint (client) + // read this rather than duplicating the comparison. public get team(): CharacterTeam { return Math.abs(this.ownership - 0.5) < settings.planetControlThreshold ? CharacterTeam.neutral diff --git a/shared/src/objects/types/projectile-base.ts b/shared/src/objects/types/projectile-base.ts index f96fd94..0d68e4d 100644 --- a/shared/src/objects/types/projectile-base.ts +++ b/shared/src/objects/types/projectile-base.ts @@ -3,7 +3,7 @@ import { settings } from '../../settings'; import { serializable } from '../../serialization/serializable'; import { toArrayFromFields } from '../../serialization/serialized-fields'; import { GameObject } from '../game-object'; -import { Id } from '../../communication/id'; +import { Id } from '../../communication/communication'; import { CharacterTeam } from './character-base'; @serializable diff --git a/shared/src/physics/character-movement.ts b/shared/src/physics/character-movement.ts index d447cb5..acd99ee 100644 --- a/shared/src/physics/character-movement.ts +++ b/shared/src/physics/character-movement.ts @@ -3,9 +3,8 @@ import { settings } from '../settings'; import { GroundSurface, PhysicsBody } from './sdf'; import { interpolateAngles } from './interpolate-angles'; -// Body layout, copied verbatim from CharacterPhysical so the predicted body -// matches the authoritative one to the bit. The head sits this far above the -// feet; offsets are measured from the centre of mass. +// Body layout — must match CharacterPhysical verbatim so the predicted body +// equals the authoritative one to the bit. export const headRadius = 50; export const feetRadius = 20; @@ -34,9 +33,7 @@ export const rightFootOffset = vec2.subtract( ); export const boundRadius = (headRadius + feetRadius * 2) * 2; -// The character's movement state: the three body parts plus the small amount of -// carried state the step reads and writes. Backend CharacterPhysical and the -// client predictor both expose this shape. +// Shared by backend CharacterPhysical and the client predictor. export interface CharacterMovementState { readonly head: PhysicsBody; readonly leftFoot: PhysicsBody; @@ -44,29 +41,21 @@ export interface CharacterMovementState { direction: number; currentPlanet: GroundSurface | undefined; secondsSinceOnSurface: number; - // Persistent launch momentum (leap / slingshot / recoil / death throw). - // Walking rebuilds and zeroes each body part's velocity every tick, - // so anything that should carry accumulates here, is injected into the parts - // before they step, and decays by friction. Stays zero for ordinary walking. - // The client predictor leaves this zero and relies on the reconciliation snap - // to follow server-side impulses, but the field is here so the server can - // delegate its full movement to stepCharacterMovement unchanged. + // Persistent launch momentum; walking zeroes per-part velocity each tick, so + // anything that must carry lives here. The client predictor leaves it zero and + // snaps to the server's impulses; the server uses it for full movement. bodyVelocity: vec2; } -// The collision/gravity world the movement queries. The server backs this with -// its spatial container (planets + dynamics, dispatching collision reactions); -// the client backs it with the planets it knows about, dispatching nothing. +// The server backs this with its spatial container (dispatching collision +// reactions); the client backs it with planets only, dispatching nothing. export interface CharacterWorld { - // Planets within `radius` of `center` that exert gravity / can be stood on. groundsNear(center: vec2, radius: number): Array; - // Resolve one body part's motion this tick; returns the ground it landed on. stepBody(body: PhysicsBody, deltaTimeInSeconds: number): GroundSurface | undefined; } -// Integrate a force into a body's velocity for one tick. Exported so the -// backend's CirclePhysical accumulates forces through the exact same -// expression, f32 intermediate included. +// Exported so the backend's CirclePhysical accumulates forces through the exact +// same expression, f32 intermediate included. export const applyForce = ( body: PhysicsBody, force: vec2, @@ -79,9 +68,8 @@ export const applyForce = ( ); }; -// ((head + leftFoot) + rightFoot) / 3, in the exact association every reader of -// the character centre uses — do not reassociate. Takes the three circles -// rather than a whole movement state so the client's view can call it too. +// ((head + leftFoot) + rightFoot) / 3 — do not reassociate; every reader of the +// character centre uses this exact association. export const characterCenter = ( head: { center: vec2 }, leftFoot: { center: vec2 }, @@ -110,10 +98,7 @@ const springMove = ( const desiredPosition = vec2.add(vec2.create(), center, offset); vec2.rotate(desiredPosition, desiredPosition, center, state.direction); const positionDelta = vec2.subtract(vec2.create(), desiredPosition, body.center); - // First-order velocity relaxation toward the desired posture position, added - // to the gravity/movement velocity already accumulated this tick. The dt - // arrives later when the body integrates velocity, so the per-tick - // displacement is positionDelta * stiffness * dt. + // dt arrives later at integration, so per-tick displacement is positionDelta * stiffness * dt. vec2.scaleAndAdd(body.velocity, body.velocity, positionDelta, stiffness); }; @@ -136,10 +121,8 @@ const keepPosture = (state: CharacterMovementState) => { springMove(state, state.head, center, headOffset, settings.postureHeadStiffness); }; -// While standing on a planet, ride its spin: rigidly rotate the whole body -// about the planet centre by the same per-tick angle the collision SDF turns -// by (negative, matching R(-rotation)), so the player is carried with the -// surface instead of sliding across it. +// Ride a planet's spin: rotate the body about the planet centre by the same +// per-tick angle the collision SDF turns by (negative, matching R(-rotation)). const carryWithRotatingPlanet = ( state: CharacterMovementState, deltaTimeInSeconds: number, @@ -165,12 +148,8 @@ const carryWithRotatingPlanet = ( ); }; -// Launch off the current surface: directed by the foot contact normals plus -// the movement input, slingshotted by the planet's spin. Mutates bodyVelocity -// (which the next tick injects into the body) and detaches. Shared so the -// server's leap() and the client's prediction apply the exact same impulse. -// The caller does the gating (strength, cooldown, alive); this is a no-op when -// not on a surface. +// Shared so the server's leap() and the client's prediction apply the exact +// same impulse. Caller does gating; no-op when not on a surface. export const applyLeapImpulse = (state: CharacterMovementState, moveDirection: vec2) => { const planet = state.currentPlanet; if (!planet) { @@ -200,8 +179,7 @@ export const applyLeapImpulse = (state: CharacterMovementState, moveDirection: v vec2.normalize(launch, launch); vec2.scaleAndAdd(state.bodyVelocity, state.bodyVelocity, launch, settings.leapSpeed); - // Slingshot: add the tangential velocity of the spinning surface under the - // body (the same motion carryWithRotatingPlanet imparts). + // Slingshot: tangential velocity of the spinning surface (same motion carryWithRotatingPlanet imparts). const center = characterCenter(state.head, state.leftFoot, state.rightFoot); const omega = planet.angularVelocity; const surfaceVelocity = vec2.fromValues( @@ -219,9 +197,8 @@ export const applyLeapImpulse = (state: CharacterMovementState, moveDirection: v state.secondsSinceOnSurface = settings.planetDetachmentSeconds; }; -// Time-based detachment: a grounded body that hasn't touched a surface for a -// while floats free. Kept as its own step because on the server it runs before -// the ownership/scoring blocks; the client calls it at the head of each tick. +// Kept as its own step: on the server it runs before the ownership/scoring +// blocks; the client calls it at the head of each tick. export const tickPlanetDetachment = ( state: CharacterMovementState, deltaTimeInSeconds: number, @@ -233,9 +210,8 @@ export const tickPlanetDetachment = ( } }; -// Inject the persistent launch momentum onto every body part right before they -// step, so the whole body translates rigidly without disturbing the posture -// springs (which only set up relative offsets). No-op while walking. +// Inject body momentum onto every part right before they step, so the whole +// body translates rigidly without disturbing the posture springs. No-op while walking. const applyBodyMomentum = (state: CharacterMovementState) => { if (vec2.squaredLength(state.bodyVelocity) === 0) { return; @@ -245,12 +221,6 @@ const applyBodyMomentum = (state: CharacterMovementState) => { vec2.add(state.head.velocity, state.head.velocity, state.bodyVelocity); }; -// Decay one launch-momentum vector in place by one tick. Stiff on the ground -// (skid to a stop), gentle in the air so a leap or slingshot still carries -// across the gaps. The gentle exponential only asymptotes, though, so on top of -// it a constant deceleration brakes the momentum to a definite stop in a couple -// of seconds instead of leaving a 15+ second drift, and a hard cap stops stacked -// impulses (rapid recoil, a leap into a slingshot) from building without bound. // Shared so the living body, the client predictor, and the ragdoll corpse all // brake identically. export const decayMomentum = ( @@ -297,12 +267,9 @@ const latchGround = ( } }; -// One tick of character movement. This is the exact movement block of the -// authoritative CharacterPhysical.step (gravity gather → movement force → -// on/off-planet branch → posture → step the three body parts), with all -// server-only concerns (scoring, health, shooting, spawn/death animation, -// ownership) left to the caller. `inputDirection` is the already-averaged, -// already-normalized movement direction for this tick and is consumed in place. +// The exact movement block of CharacterPhysical.step (gravity → movement force +// → on/off-planet branch → posture → step the parts); server-only concerns are +// left to the caller. `inputDirection` is the already-normalized movement direction. export const stepCharacterMovement = ( state: CharacterMovementState, world: CharacterWorld, @@ -318,10 +285,8 @@ export const stepCharacterMovement = ( applyForce(state.rightFoot, movementForce, deltaTimeInSeconds); if (!state.currentPlanet) { - // Only the airborne branch needs the surrounding planets, and this is the - // widest query the character makes — running it while grounded (the common - // case) walked the server's spatial tree once per character per tick for a - // result nothing read. + // Widest query the character makes — only run while airborne; grounded is + // the common case. const center = characterCenter(state.head, state.leftFoot, state.rightFoot); const grounds = world.groundsNear(center, boundRadius + settings.maxGravityDistance); const leftFootGravity = sumGravity(grounds, state.leftFoot.center); diff --git a/shared/src/physics/depenetrate-circle.ts b/shared/src/physics/depenetrate-circle.ts index 9a0a356..e39572e 100644 --- a/shared/src/physics/depenetrate-circle.ts +++ b/shared/src/physics/depenetrate-circle.ts @@ -3,13 +3,9 @@ import { PhysicsBody, Sdf } from './sdf'; import { evaluateSdf } from './evaluate-sdf'; import { sdfNormal } from './sdf-normal'; -// Planet collision outlines rotate (see PlanetPhysical.distance), so a surface -// can sweep into a circle that hasn't itself moved. marchCircle assumes an -// overlap-free start — beginning inside, it registers a zero-distance hit and -// never moves again — so any overlap must be resolved here, before marching. -// Iterating handles concave spots, where leaving one face pushes into another; -// if no overlap-free position exists nearby (a crevice narrower than the -// circle), it gives up and leaves the rest to a later tick. +// marchCircle assumes an overlap-free start (inside, it registers a zero- +// distance hit and never moves), so any overlap from rotating surfaces must be +// resolved here before marching. export const depenetrateCircle = ( body: PhysicsBody, possibleIntersectors: Array, diff --git a/shared/src/physics/evaluate-sdf.ts b/shared/src/physics/evaluate-sdf.ts index af0dc29..0f0cac7 100644 --- a/shared/src/physics/evaluate-sdf.ts +++ b/shared/src/physics/evaluate-sdf.ts @@ -1,10 +1,8 @@ import { vec2 } from 'gl-matrix'; import { Sdf } from './sdf'; -// Distance to the nearest collidable surface. This is the innermost leaf of the -// whole physics stack — every raymarch step, every depenetration pass and four -// times per surface normal — so it is a plain indexed loop rather than -// filter().reduce(), which allocated an intermediate array per call. +// Innermost leaf of the physics stack — plain loop, not filter().reduce(), +// to avoid the per-call intermediate array. export const evaluateSdf = (target: vec2, objects: Array): number => { let minimum = 1000000; for (let i = 0; i < objects.length; i++) { diff --git a/shared/src/physics/march-circle.ts b/shared/src/physics/march-circle.ts index 0c90b3c..425bec6 100644 --- a/shared/src/physics/march-circle.ts +++ b/shared/src/physics/march-circle.ts @@ -13,14 +13,9 @@ const minimumStep = 0.5; const maximumSteps = 256; // Raymarch a circle by `delta`, stopping at the first surface it would overlap. -// Shared so server and client resolve motion identically. The collision -// *reaction* is not dispatched here: on a real (non-ignored) hit, -// `onHit(intersecting)` is invoked at the point the backend used to dispatch its -// ReactToCollisionCommands, and the backend wrapper supplies that callback. The -// client passes none. -// -// The march advances by the *free gap* (distance minus the circle's radius), -// which is what sphere-tracing a circle of radius r requires. +// Shared so server and client resolve motion identically; the collision reaction +// is not dispatched here — on a real hit `onHit(intersecting)` is invoked by the +// caller. Advances by the free gap (distance minus radius), as sphere-tracing requires. export const marchCircle = ( body: PhysicsBody, delta: vec2, @@ -30,8 +25,8 @@ export const marchCircle = ( ): MarchResult => { const deltaLength = vec2.length(delta); - // A zero-length move cannot resolve a contact by advancing, so report no hit - // rather than letting a caller that loops on `hitSurface` spin forever. + // Zero-length move cannot resolve a contact by advancing; report no hit so a + // caller looping on `hitSurface` doesn't spin forever. if (!(deltaLength > 0)) { return { hitSurface: false }; } @@ -54,8 +49,7 @@ export const marchCircle = ( )!; if (ignoreCollision) { - // Pass straight through, but still report the contact so callers that - // march repeatedly to escape geometry keep making progress. + // Pass through, but still report the contact so repeated marches keep progressing. vec2.scaleAndAdd(body.center, body.center, direction, deltaLength); return { hitSurface: true, hitObject: intersecting }; } diff --git a/shared/src/physics/planet-sdf.ts b/shared/src/physics/planet-sdf.ts index bbe1364..be61212 100644 --- a/shared/src/physics/planet-sdf.ts +++ b/shared/src/physics/planet-sdf.ts @@ -2,10 +2,8 @@ import { vec2 } from 'gl-matrix'; import { clamp, clamp01 } from '../helper/clamp'; import { settings } from '../settings'; -// Rotate a world point into the planet's own frame: R(-rotation) about the -// centre, matching the shader's localTarget = center + R(rotation)*(target-center). -// cos/sin are passed in so callers can memoise them per angle. `out` is written -// in place, so the hot path can hand in a scratch vector instead of allocating. +// R(-rotation) about the centre, matching the shader's localTarget = center + R(rotation)*(target-center). +// cos/sin passed in for memoisation; `out` written in place for the hot path. export const toPlanetLocalFrame = ( out: vec2, target: vec2, @@ -18,24 +16,18 @@ export const toPlanetLocalFrame = ( return vec2.set(out, center[0] + cos * dx - sin * dy, center[1] + sin * dx + cos * dy); }; -// Scratch vectors for planetDistance. It is the innermost leaf of the physics -// stack — called for every planet on every SDF sample — and allocating four -// vectors per polygon edge dominated its cost. Nothing re-enters it (callers -// evaluate planets one after another), and none of these outlive the call. +// Innermost leaf — module-level scratch to avoid per-call allocation. Nothing +// re-enters planetDistance and none of these outlive the call. const localPoint = vec2.create(); const targetFromDelta = vec2.create(); const toFromDelta = vec2.create(); const closestOnEdge = vec2.create(); -// [distance to the edge, which side of it]. These two stay in a vec2 rather -// than in plain numbers because a vec2 is a Float32Array: rounding them to f32 -// is part of the outline the server and the client both have to agree on. +// Stays a vec2 (Float32Array) because rounding to f32 is part of the outline +// the server and the client both have to agree on. const edgeSample = vec2.create(); -// Signed distance to the (smooth, noise-free) planet polygon, evaluated in the -// planet's rotating frame. This is the one and only planet outline: the -// authoritative server and the client predictor both collide against it, which -// is what lets prediction reconcile at all. Indexed vector access keeps it -// independent of the .x/.y prototype plugin. +// The one and only planet outline: the authoritative server and the client +// predictor both collide against it, which is what lets prediction reconcile. export const planetDistance = ( target: vec2, vertices: Array, @@ -79,7 +71,6 @@ export const planetDistance = ( return sign * d; }; -// Gravity a planet exerts at a world position. export const planetGravity = (center: vec2, radius: number, position: vec2): vec2 => { const diff = vec2.subtract(vec2.create(), center, position); const dist = Math.max(settings.minGravityDistance, vec2.length(diff) - radius); diff --git a/shared/src/physics/resolve-circle-movement.ts b/shared/src/physics/resolve-circle-movement.ts index 47f0c1c..9d728cf 100644 --- a/shared/src/physics/resolve-circle-movement.ts +++ b/shared/src/physics/resolve-circle-movement.ts @@ -3,17 +3,13 @@ import { PhysicsBody, Sdf } from './sdf'; import { depenetrateCircle } from './depenetrate-circle'; import { marchCircle } from './march-circle'; -// The position-resolution half of the backend's CirclePhysical.stepManually, -// extracted so server and client integrate a body identically. The caller is -// responsible for the broadphase (gathering `possibleIntersectors`, including -// the swept-radius bump) because that depends on each side's spatial structure; -// everything from depenetration onward lives here. -// -// `onHit` is threaded through to marchCircle so the backend can dispatch its -// collision reactions at the same points as before (including the second, -// post-bounce slide march); the client passes none. Velocity is reset to zero -// at the end — the character re-applies all forces from zero every tick, so a -// body that retained velocity would double-integrate and diverge. +// Position-resolution half of CirclePhysical.stepManually, extracted so server +// and client integrate a body identically. The caller owns the broadphase +// (gathering `possibleIntersectors`, incl. the swept-radius bump); everything +// from depenetration onward lives here. `onHit` is threaded to marchCircle so +// the backend dispatches collision reactions at the same points; the client +// passes none. Velocity is reset at the end — the character re-applies all +// forces from zero every tick, so retained velocity would double-integrate and diverge. export const resolveCircleMovement = ( body: PhysicsBody, deltaTimeInSeconds: number, diff --git a/shared/src/physics/sdf-normal.ts b/shared/src/physics/sdf-normal.ts index 56ad90b..028d16a 100644 --- a/shared/src/physics/sdf-normal.ts +++ b/shared/src/physics/sdf-normal.ts @@ -2,10 +2,8 @@ import { vec2 } from 'gl-matrix'; import { Sdf } from './sdf'; import { evaluateSdf } from './evaluate-sdf'; -// Reused probe point. sdfNormal is called on every depenetration pass and every -// raymarch hit, so allocating four sample vectors per call was a meaningful -// share of the physics step's garbage. evaluateSdf reads the probe and returns -// before the next vec2.set, so one is enough. +// Reused probe point — sdfNormal is called on every depenetration pass and +// raymarch hit. const probe = vec2.create(); const sampleAt = (x: number, y: number, objects: Array): number => { @@ -13,10 +11,9 @@ const sampleAt = (x: number, y: number, objects: Array): number => { return evaluateSdf(probe, objects); }; -// Central-difference gradient of the combined SDF. Can be zero where the -// samples cancel out (e.g. on the medial axis of a shape) — callers must -// handle that case. Uses indexed access so it never depends on the vec2 .x/.y -// prototype plugin (identical numerically: .x === [0]). +// Central-difference gradient. Can be zero where samples cancel (e.g. on the +// medial axis) — callers must handle that. Indexed access keeps it independent +// of the vec2 .x/.y prototype plugin. export const sdfNormal = (target: vec2, objects: Array): vec2 => { const dx = sampleAt(target[0] + 0.01, target[1], objects) - diff --git a/shared/src/physics/sdf.ts b/shared/src/physics/sdf.ts index 38bb74c..e33e81e 100644 --- a/shared/src/physics/sdf.ts +++ b/shared/src/physics/sdf.ts @@ -1,18 +1,15 @@ import { vec2 } from 'gl-matrix'; -// Minimal signed-distance collider the geometry functions need. Backend -// Physicals and the client's planet surfaces both satisfy this structurally. +// Minimal signed-distance collider. Backend Physicals and the client's planet +// surfaces both satisfy this. export interface Sdf { readonly canCollide: boolean; - // Planets set this true so a body landing on one can latch it as ground; - // everything else leaves it falsy. Replaces the server's - // `instanceof PlanetPhysical` check with a structural flag both sides share. + // Planets set this so a body landing on one can latch it as ground; replaces + // the server's `instanceof PlanetPhysical` with a structural flag both share. readonly isGround?: boolean; distance(target: vec2): number; } -// A movable circle the geometry resolves in place. Backend CirclePhysical and -// the client predictor's plain bodies both satisfy this. export interface PhysicsBody { center: vec2; radius: number; @@ -21,8 +18,6 @@ export interface PhysicsBody { readonly restitution: number; } -// A planet-like surface: collidable, exerts gravity, and spins. Drives the -// character's on-surface movement branch. export interface GroundSurface extends Sdf { readonly isGround: true; readonly center: vec2; diff --git a/shared/src/serialization/deserializable-class.ts b/shared/src/serialization/deserializable-class.ts deleted file mode 100644 index 11d0a4f..0000000 --- a/shared/src/serialization/deserializable-class.ts +++ /dev/null @@ -1 +0,0 @@ -export type DeserializableClass = { new (...args: Array): unknown; name: string }; diff --git a/shared/src/serialization/deserialize.ts b/shared/src/serialization/deserialize.ts index 6ba3a67..d5d2418 100644 --- a/shared/src/serialization/deserialize.ts +++ b/shared/src/serialization/deserialize.ts @@ -3,10 +3,9 @@ import { serializableMapping } from './serializable-mapping'; export const deserialize = (json: string): any => { return JSON.parse(json, (_, v) => { if (v !== null && Object.prototype.hasOwnProperty.call(v, '0')) { - const possibleType = v[0]; - const overridableConstructor = serializableMapping.get(possibleType); - if (overridableConstructor) { - return new overridableConstructor.constructor(...v.slice(1)); + const Ctor = serializableMapping.get(v[0]); + if (Ctor) { + return new Ctor(...v.slice(1)); } return v; } diff --git a/shared/src/serialization/mangled-type-key.ts b/shared/src/serialization/mangled-type-key.ts deleted file mode 100644 index 2460725..0000000 --- a/shared/src/serialization/mangled-type-key.ts +++ /dev/null @@ -1 +0,0 @@ -export const mangledTypeKey = '__serializable_type'; diff --git a/shared/src/serialization/override-deserialization.ts b/shared/src/serialization/override-deserialization.ts index 7b8d6d9..0802f54 100644 --- a/shared/src/serialization/override-deserialization.ts +++ b/shared/src/serialization/override-deserialization.ts @@ -1,13 +1,8 @@ -import { DeserializableClass } from './deserializable-class'; -import { SerializableClass } from './serializable-class'; -import { serializableMapping } from './serializable-mapping'; +import { serializableMapping, SerializableClass } from './serializable-mapping'; export const overrideDeserialization = ( source: SerializableClass, - target: DeserializableClass, + target: SerializableClass, ) => { - serializableMapping.set(source.name, { - constructor: target, - overridden: true, - }); + serializableMapping.set(source.name, target); }; diff --git a/shared/src/serialization/serializable-class.ts b/shared/src/serialization/serializable-class.ts deleted file mode 100644 index 88eb2d4..0000000 --- a/shared/src/serialization/serializable-class.ts +++ /dev/null @@ -1,4 +0,0 @@ -export type SerializableClass = { - new (...args: Array): { toArray(): Array }; - name: string; -}; diff --git a/shared/src/serialization/serializable-mapping.ts b/shared/src/serialization/serializable-mapping.ts index 5c9d7cb..a287fe5 100644 --- a/shared/src/serialization/serializable-mapping.ts +++ b/shared/src/serialization/serializable-mapping.ts @@ -1,9 +1,3 @@ -import { DeserializableClass } from './deserializable-class'; +export type SerializableClass = { new (...args: Array): unknown; name: string }; -export const serializableMapping = new Map< - string, - { - constructor: DeserializableClass; - overridden: boolean; - } ->(); +export const serializableMapping = new Map(); diff --git a/shared/src/serialization/serializable.ts b/shared/src/serialization/serializable.ts index 4605f65..b2a0309 100644 --- a/shared/src/serialization/serializable.ts +++ b/shared/src/serialization/serializable.ts @@ -1,17 +1,14 @@ -import { mangledTypeKey } from './mangled-type-key'; -import { SerializableClass } from './serializable-class'; -import { serializableMapping } from './serializable-mapping'; +import { SerializableClass, serializableMapping } from './serializable-mapping'; + +const MANGLE = '__serializable_type'; export const serializable = (type: SerializableClass): any => { - if (!serializableMapping.get(type.name)) { - serializableMapping.set(type.name, { - constructor: type, - overridden: false, - }); + if (!serializableMapping.has(type.name)) { + serializableMapping.set(type.name, type); } - Object.defineProperty(type, mangledTypeKey, { value: type.name }); - Object.defineProperty(type.prototype, mangledTypeKey, { value: type.name }); + Object.defineProperty(type, MANGLE, { value: type.name }); + Object.defineProperty(type.prototype, MANGLE, { value: type.name }); return type; }; diff --git a/shared/src/serialization/serialize.ts b/shared/src/serialization/serialize.ts index bd72218..c5b1529 100644 --- a/shared/src/serialization/serialize.ts +++ b/shared/src/serialization/serialize.ts @@ -1,10 +1,10 @@ -import { mangledTypeKey } from './mangled-type-key'; +const MANGLE = '__serializable_type'; export const serialize = (object: any): string => { return JSON.stringify(object, (_, value) => { - if (value && value[mangledTypeKey]) { + if (value && value[MANGLE]) { const props = value.toArray() as Array; - props.unshift(value[mangledTypeKey]); + props.unshift(value[MANGLE]); return props; } return value?.toFixed ? Number(value.toFixed(3)) : value; diff --git a/shared/src/serialization/serialized-fields.ts b/shared/src/serialization/serialized-fields.ts index 48733e8..dd32dc0 100644 --- a/shared/src/serialization/serialized-fields.ts +++ b/shared/src/serialization/serialized-fields.ts @@ -1,12 +1,6 @@ -// Derive a serializable object's positional wire array from a single declared -// list of field names, so toArray() and the constructor share ONE source of -// truth for field order instead of a hand-written array that can silently drift -// out of step with the parameters. -// // deserialize reconstructs via `new Ctor(...array.slice(1))`, so the field list -// MUST name the constructor's parameters in order. Fields a constructor -// recomputes from others (e.g. a planet's centre/radius from its vertices) are -// derived, not serialized, and so are deliberately absent from the list. +// MUST name the constructor's parameters in order. Derived (non-serialized) +// fields are deliberately absent. export const toArrayFromFields = ( object: any, fields: ReadonlyArray, diff --git a/shared/src/serialization/serializes-to.ts b/shared/src/serialization/serializes-to.ts index 47030de..ed1296e 100644 --- a/shared/src/serialization/serializes-to.ts +++ b/shared/src/serialization/serializes-to.ts @@ -1,20 +1,15 @@ -import { mangledTypeKey } from './mangled-type-key'; -import { SerializableClass } from './serializable-class'; -import { serializableMapping } from './serializable-mapping'; +import { SerializableClass, serializableMapping } from './serializable-mapping'; + +const MANGLE = '__serializable_type'; export const serializesTo = (target: SerializableClass) => { return (actual: SerializableClass): any => { - if (!serializableMapping.get(target.name)) { - serializableMapping.set(target.name, { - constructor: target, - overridden: false, - }); + if (!serializableMapping.has(target.name)) { + serializableMapping.set(target.name, target); } - Object.defineProperty(actual, mangledTypeKey, { value: target.name }); - Object.defineProperty(actual.prototype, mangledTypeKey, { - value: target.name, - }); + Object.defineProperty(actual, MANGLE, { value: target.name }); + Object.defineProperty(actual.prototype, MANGLE, { value: target.name }); return actual; }; diff --git a/shared/src/settings.ts b/shared/src/settings.ts index 23811b3..a904913 100644 --- a/shared/src/settings.ts +++ b/shared/src/settings.ts @@ -18,9 +18,6 @@ export const settings = { worldRadius: 4000, objectsOnCircleLength: 0.002, updateMessageInterval: 1 / 25, - // How far behind the estimated server time remote state is rendered: ~2.5 - // update intervals, so a late packet rarely leaves the client without a - // newer snapshot to interpolate towards. interpolationDelaySeconds: 0.1, planetEdgeCount: 7, playerKillPoint: 25, @@ -33,15 +30,11 @@ export const settings = { maxGravityDistance: 800, minGravityDistance: 1, maxGravityQ: 5000, - // Half-width of the neutral dead-band around 50% ownership. A planet only - // counts as captured (team(), point generation, flip) once |ownership-0.5| - // exceeds this, and the rendered ownership ring stays neutral until the same - // point — so what you see matches what scores. + // Neutral dead-band around 50% ownership; the rendered ring uses the same + // threshold as scoring so visuals match state. planetControlThreshold: 0.12, - // Extra margin a planet must clear *beyond* planetControlThreshold before a - // capture flip pays out again (a Schmitt trigger). Without it, ownership - // hovering on the dead-band edge re-triggers the reward, the announcement and - // the flare every time it grazes the boundary. + // Schmitt trigger: extra margin beyond planetControlThreshold before a flip + // pays out again, so boundary-hovering doesn't re-trigger the reward. planetFlipHysteresis: 0.06, playerMaxHealth: 100, maxGravityStrength: 50000, @@ -75,7 +68,6 @@ export const settings = { chargeShotRadiusMax: 32, chargeShotSpeedMin: 2500, chargeShotSpeedMax: 3400, - playerColorIndexOffset: 3, backgroundGradient: [rgb255(90, 38, 43), rgb255(43, 39, 73)], blueColor, bluePlanetColor, @@ -153,45 +145,21 @@ export const settings = { chargedHitThreshold: 0.6, - // Projectiles fall through planetary gravity like a free-falling character, - // so slower (charged) shots arc. Scale kept tiny: near a surface gravity is - // maxGravityStrength=50000, which at full strength would corkscrew a shot into - // the planet — 0.04 gives a readable bend instead. projectileGravityScale: 0.04, - // Speed the corpse is flung at along the killing shot's direction, lerped by - // that shot's charge. Added to whatever momentum the victim already carried. deathImpulseMin: 280, deathImpulseMax: 1300, - // A planet's net team head-count drives a single capture step per tick; the - // lead multiplier is capped so a zerg can't flip instantly. Equal head-counts - // freeze the planet (contested) instead of silently cancelling. maxContestLeadMultiplier: 2, - // Persistent body momentum decays per second by these exponents. Airborne is - // near-frictionless so leaps and slingshots carry across the gaps; grounded is - // stiff so you skid to a stop on landing rather than sliding forever. airMomentumFriction: 0.4, groundMomentumFriction: 7, - // On top of the exponential frictions above, a constant deceleration (u/s^2) - // applied to body momentum. The exponential alone only asymptotes toward zero, - // so a fast launch keeps a slow tail for 15+ seconds — it reads as drifting - // forever with nothing slowing you. This constant brake brings the momentum to - // a definite stop in a couple of seconds, while the gentle exponential still - // lets the launch cover its distance first. - momentumStopDeceleration: 400, - // Hard ceiling (u/s) on body momentum, so stacked impulses — rapid charged-shot - // recoil, or a leap chained into a spin slingshot — can't build speed without - // bound. Kept above the overcharge fling (1700) so single launches survive. maxBodyMomentum: 2000, - // Leap: a charged-cost launch off a surface, paid from the shared shooting - // strength pool so it trades against firepower. leapStrengthCost: 32, leapSpeed: 1350, @@ -199,62 +167,34 @@ export const settings = { leapMoveBias: 0.65, leapCooldownSeconds: 0.35, - // Fraction of the planet's tangential surface velocity you keep when you leave - // it (slingshot). Leap off a fast spinner to be flung far. slingshotScale: 1, - // Recoil speed imparted opposite a shot, scaled by its charge (0 for taps). chargeShotRecoilMax: 650, - // Lag compensation for projectiles. A shot is fast-forwarded by the time its - // command spent reaching the server, so the shooter does not have to lead by - // their own latency on top of the projectile's travel time. Capped so a very - // bad (or forged) timestamp cannot spawn a shot arbitrarily far downrange. + // Capped so a forged timestamp cannot spawn a shot arbitrarily far downrange. maxProjectileCatchUpSeconds: 0.2, - // How long a dropped player's score and team are held so a reconnect within - // the window rejoins as the same player instead of a blank slate. reconnectGraceSeconds: 30, - // Hard ceiling on one inbound client message. socket.io's 1 MB default is far - // more than any legitimate command batch, and parsing is synchronous with the - // physics loop — a single oversized message stalls the tick for everyone. + // DoS guard: parsing is synchronous with the physics loop, so one oversized + // message stalls the tick for everyone. maxInboundMessageBytes: 16 * 1024, - // How often an idle client flushes its command queue. Input is flushed on the - // frame it happens, so this only paces the heartbeat that keeps the server's - // input acknowledgement moving while a key is simply held. - // - // It must NOT be the frame rate. Sending once per rendered frame put a 144 Hz - // display permanently over the inbound allowance below, and once the burst was - // spent the server silently discarded whole batches — including the - // edge-triggered movement commands, which are never re-sent, so the player - // kept walking the old way until they pressed something else. + // Must NOT be the frame rate: per-render-frame sends exceed the inbound + // allowance on high-Hz displays, and dropped batches lose edge-triggered + // movement commands that are never re-sent. clientSendInterval: 1 / 30, - // Inbound allowance per socket: a token bucket sized far above anything a - // legitimate client produces (a 30 Hz heartbeat plus whatever discrete input - // events a human generates), so it only ever trips on a flood. Dropping a - // batch loses real input, so the headroom is deliberate. maxInboundMessagesPerSecond: 240, maxInboundMessageBurst: 480, - // Ceiling on a measured round-trip time. The Pong that carries it comes from - // the client, which can always answer late; the nonce check makes the reply - // unforgeable and single-use, and this bounds what stalling one can buy. + // The nonce check makes the Pong reply unforgeable/single-use; this bounds + // what stalling one can buy. maxMeasuredRttMs: 1000, - // Skip the bulky part of a snapshot when the socket already has this much - // unflushed. A slow or stalled client otherwise accumulates a backlog it will - // never catch up on, and the newest snapshot is the only one that matters. maxBufferedBytesPerClient: 256 * 1024, - // Sanity bound on a client-supplied world position (the arena is - // worldRadius across, so anything past this is nonsense). maxClientPositionMagnitude: 1e6, - // The central giant is a named, always-contested focus. Its neutral decay is - // slowed so control lingers and teams keep fighting over it; flips are - // announced to everyone and an off-screen arrow points the way. keystoneLoseControlScale: 2.5, }; diff --git a/shared/tsconfig.json b/shared/tsconfig.json index 91326be..4bae3bb 100644 --- a/shared/tsconfig.json +++ b/shared/tsconfig.json @@ -7,7 +7,6 @@ "target": "es6", "esModuleInterop": true, "strict": true, - "experimentalDecorators": true, "moduleResolution": "node", "ignoreDeprecations": "6.0", "module": "commonjs", @@ -15,5 +14,5 @@ "composite": true, "lib": ["dom", "es2017"] }, - "include": ["src/**/*.ts", "src/*.ts"] + "include": ["src/**/*.ts"] } diff --git a/shared/webpack.config.js b/shared/webpack.config.js index 2eebc06..f811bdc 100644 --- a/shared/webpack.config.js +++ b/shared/webpack.config.js @@ -27,7 +27,6 @@ module.exports = (env, argv) => ({ optimization: { minimize: false, }, - plugins: [], module: { rules: [ { diff --git a/test/backend-regressions.test.ts b/test/backend-regressions.test.ts index 2231356..bf2b244 100644 --- a/test/backend-regressions.test.ts +++ b/test/backend-regressions.test.ts @@ -1,6 +1,4 @@ -// Regression net for the backend defects found in the audit. These drive the -// real classes rather than re-implementing their logic, so they fail if the -// behaviour regresses rather than if the code is merely reshaped. +// Regression net for backend defects — drives the real classes. import { describe, it, expect, beforeEach } from 'vitest'; import { createRequire } from 'node:module'; @@ -8,8 +6,7 @@ import { PhysicalContainer } from '../backend/src/physics/containers/physical-co import { BoundingBox } from '../backend/src/physics/bounding-boxes/bounding-box'; import { ProjectilePhysical } from '../backend/src/objects/projectile-physical'; import { CharacterPhysical } from '../backend/src/objects/character-physical'; -import { StepCommand } from '../backend/src/commands/step'; -import { GeneratePointsCommand } from '../backend/src/commands/generate-points'; +import { StepCommand, GeneratePointsCommand } from '../backend/src/commands/commands'; const require = createRequire(import.meta.url); const shared = require('../shared/lib/main.js'); @@ -20,7 +17,6 @@ applyArrayPlugins(); const step = settings.targetPhysicsDeltaTimeInSeconds; -// Collects the points a stepped object asks the game to award. class PointsSpy { public blue = 0; public red = 0; @@ -46,11 +42,6 @@ beforeEach(() => { }); describe('projectile broadphase registration', () => { - // The bounding box is only rebuilt by the center/radius setters, but the - // geometry helpers write `center` in place — so a projectile stayed registered - // at its muzzle for its whole flight. View-area streaming is driven by that - // box, so shots fired off-screen were never sent, and shots in view vanished - // as soon as their muzzle scrolled away. it('follows the projectile instead of staying at the muzzle', () => { const container = new PhysicalContainer(); container.initialize(); @@ -135,9 +126,6 @@ describe('scoring', () => { return character; }; - // A disconnect and an NPC being retired on join both run through onDie(), and - // used to award the opposing team a full kill, so ordinary connection churn - // moved the match score with nobody playing. it('awards nothing for an administrative death', () => { const container = new PhysicalContainer(); container.initialize(); @@ -166,8 +154,6 @@ describe('scoring', () => { }); describe('charged shots', () => { - // Capping only the COST meant a max-charge request on a nearly empty pool paid - // a tap's price while still getting full-charge radius, speed and recoil. it('scales the shot by the strength actually paid, not the requested charge', () => { const container = new PhysicalContainer(); container.initialize(); @@ -181,7 +167,6 @@ describe('charged shots', () => { ); container.addObject(character); - // Drain the pool down to roughly a tap's worth. (character as unknown as { projectileStrength: number }).projectileStrength = settings.chargeShotStrengthMin; diff --git a/test/backpressure.test.ts b/test/backpressure.test.ts index 0fc2dea..b3cccac 100644 --- a/test/backpressure.test.ts +++ b/test/backpressure.test.ts @@ -1,7 +1,4 @@ -// What a Player sheds when its socket stops draining. The guard used to read -// `socket.conn.bufferedAmount`, which engine.io's Socket does not have, so it -// never once ran — and had it run, it dropped the whole batch, including the -// create/delete that handleViewAreaUpdate had already committed to having sent. +// Slow-client backpressure: what a Player sheds when its socket stops draining. import { describe, it, expect, beforeEach } from 'vitest'; import { createRequire } from 'node:module'; @@ -26,8 +23,7 @@ const { applyArrayPlugins(); -// A socket whose write buffer we control, shaped the way engine.io really nests -// it: the byte count lives on the ws socket under the transport. +// Shaped like engine.io: the byte count lives on the ws socket under the transport. class FakeSocket { public readonly sent: Array<{ event: string; payload: unknown }> = []; public readonly conn = { transport: { socket: { bufferedAmount: 0 } } }; @@ -58,15 +54,11 @@ const makePlayer = () => { const players = new PlayerContainer(objects, 4, 0); const socket = new FakeSocket(); const player = players.createPlayer({ name: 'player' }, socket as never); - // The constructor already queues this player's own CreatePlayerCommand; flush - // it so each case starts from an empty queue. player.sendQueuedCommandsToClient(); socket.sent.length = 0; return { player, socket }; }; -// One snapshot's worth of queued traffic: the bulky repeated state, and the -// one-shot bookkeeping that is never regenerated. const queueOneSnapshot = (player: ReturnType['player']) => { player.queueCommandSend(new CreateObjectsCommand([])); player.queueCommandSend(new DeleteObjectsCommand([1234])); @@ -102,8 +94,6 @@ describe('slow-client backpressure', () => { queueOneSnapshot(player); player.sendQueuedCommandsToClient(); - // If the guard cannot see the buffer it degrades to sending everything, so - // anything shed at all proves it is reading the right property. expect(typesIn(socket.lastBatch)).not.toContain(PropertyUpdatesForObjects.name); }); @@ -114,12 +104,8 @@ describe('slow-client backpressure', () => { player.sendQueuedCommandsToClient(); const types = typesIn(socket.lastBatch); - // Repeated in full every tick — safe to drop. expect(types).not.toContain(PropertyUpdatesForObjects.name); expect(types).not.toContain(UpdateMinimap.name); - // One-shot. handleViewAreaUpdate has already advanced its record of what - // this client knows, so these are never re-derived: dropping them leaves the - // client permanently missing objects and holding ghosts. expect(types).toContain(CreateObjectsCommand.name); expect(types).toContain(DeleteObjectsCommand.name); expect(types).toContain(ServerAnnouncement.name); diff --git a/test/client-send-cadence.test.ts b/test/client-send-cadence.test.ts index 61fa4f4..9e4e8e1 100644 --- a/test/client-send-cadence.test.ts +++ b/test/client-send-cadence.test.ts @@ -1,8 +1,4 @@ -// The client's outbound cadence, which is what the server's inbound allowance -// has to be sized against. Sending once per rendered frame put every display -// above the allowance permanently over it; once the burst was spent the server -// silently discarded whole batches, and movement commands are edge-triggered -// and never re-sent, so a lost batch meant the direction change never happened. +// Client outbound send cadence vs. server inbound allowance. import { describe, it, expect } from 'vitest'; import { createRequire } from 'node:module'; @@ -16,7 +12,6 @@ const { applyArrayPlugins, settings, MoveActionCommand, ClientHeartbeatCommand } applyArrayPlugins(); -// Collects what would go on the wire. const drive = (frameRate: number, seconds: number, onFrame?: (socket: any) => void) => { const batches: Array = []; const socket = { emit: (_event: string, payload: string) => batches.push(payload) }; @@ -38,9 +33,6 @@ describe('client send cadence', () => { for (const frameRate of [60, 144, 240]) { const batches = drive(frameRate, seconds); - // One heartbeat per interval, give or take where the frames fall. The - // point is that the count barely moves across a 4x range of frame rates — - // sending once per frame gave 180, 432 and 720 here. expect(batches.length).toBeLessThanOrEqual(expected * 1.1); expect(batches.length).toBeGreaterThanOrEqual(expected * 0.85); } @@ -54,8 +46,6 @@ describe('client send cadence', () => { }); it('still flushes real input on the frame it happens', () => { - // Input on frame 1 of a 144 Hz client: far inside the heartbeat interval, so - // pacing must not hold it back. Latency the player feels is one frame. let sentOn = -1; const batches = drive(144, 0.1, (commandSocket) => { if (sentOn === -1) { diff --git a/test/connection-lifecycle.test.ts b/test/connection-lifecycle.test.ts index cfb91f2..e9f3220 100644 --- a/test/connection-lifecycle.test.ts +++ b/test/connection-lifecycle.test.ts @@ -1,12 +1,9 @@ -// Drives the real GameServer through a fake socket.io to cover the connection -// lifecycle: the join guard, refusal reasons, and reconnect. None of this is -// reachable from a unit test of any single class, and it is where the audit -// found the most severe defects. +// Connection lifecycle: join guard, refusal reasons, reconnect — driven through a fake socket.io. import { describe, it, expect, beforeEach, vi } from 'vitest'; import { createRequire } from 'node:module'; import { GameServer } from '../backend/src/game-server'; -import { defaultOptions } from '../backend/src/default-options'; +import { defaultOptions } from '../backend/src/options'; const require = createRequire(import.meta.url); const shared = require('../shared/lib/main.js'); @@ -91,9 +88,6 @@ beforeEach(() => { }); describe('join guard', () => { - // One socket used to be able to emit PlayerJoining as often as it liked, each - // pass spawning another Player and stacking another set of listeners, until it - // held every slot on the server. it('accepts exactly one join per connection', () => { const { server, attach } = startServer({ playerLimit: 4, npcCount: 1 }); const socket = attach(); @@ -145,7 +139,6 @@ describe('hostile payloads', () => { attach().fire(TransportEvents.PlayerJoining, { name: ['CharacterBase'] }); } - // The joins are accepted (the name is coerced) and no bot is stranded. expect(server.serverInfo.playerCount).toBe(4); }); @@ -154,7 +147,6 @@ describe('hostile payloads', () => { const socket = attach(); socket.fire(TransportEvents.PlayerJoining, { name: 'sender' }); - // Would previously be handed straight to JSON.parse on the physics thread. expect(() => socket.fire(TransportEvents.PlayerToServer, 'x'.repeat(2 * 1024 * 1024)), ).not.toThrow(); @@ -162,8 +154,6 @@ describe('hostile payloads', () => { }); describe('reconnect', () => { - // A transport blip used to end the match: the client tore itself down and the - // server deleted the character, with no way back to the same player. it('hands out a token on join and honours it on the way back', () => { const { server, attach } = startServer({ playerLimit: 4, npcCount: 0 }); @@ -181,7 +171,7 @@ describe('reconnect', () => { expect(server.serverInfo.playerCount).toBe(1); expect(second.disconnected).toBe(false); expect(second.lastPayloadFor(TransportEvents.JoinRejected)).toBeUndefined(); - // A fresh token for the new session, so the old one cannot be replayed. + // Fresh token: the old one cannot be replayed. expect(second.lastPayloadFor(TransportEvents.PlayerJoined)).not.toBe(token); }); @@ -199,15 +189,6 @@ describe('reconnect', () => { }); describe('inbound rate limiting', () => { - // The bucket exists to survive a flood, but a dropped batch is LOST input, - // not delayed input: movement is edge-triggered and nothing is ever re-sent, - // so a discarded batch means the direction change simply never happened. The - // ceiling used to be 120/s on the assumption of "one batch per frame", which - // any display above 120 Hz exceeds outright — after the burst was spent, a - // 144 Hz client had roughly one batch in six silently swallowed. - // - // Each message that gets past the limiter is unparseable and so logs exactly - // once, which counts admissions without reaching into the server. const admitted = (socket: { fire: (e: string, p: string) => void }, count: number) => { const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined); for (let i = 0; i < count; i++) { @@ -218,10 +199,6 @@ describe('inbound rate limiting', () => { return seen; }; - // The allowance has to sit well clear of what a well-behaved client actually - // sends, or the limiter eats gameplay input instead of floods. It used to be - // 120/s against a client that sent once per rendered frame — a ratio below 1 - // for anyone on a 144 Hz display. it('allows several times what a well-behaved client sends', () => { const clientMessagesPerSecond = 1 / settings.clientSendInterval; @@ -238,7 +215,6 @@ describe('inbound rate limiting', () => { const socket = attach(); socket.fire(TransportEvents.PlayerJoining, { name: 'player' }); - // The paced heartbeat plus a generous allowance for discrete input events. const oneSecond = Math.ceil(1 / settings.clientSendInterval) + 100; expect(admitted(socket, oneSecond)).toBe(oneSecond); @@ -256,10 +232,6 @@ describe('inbound rate limiting', () => { }); describe('reconnect tokens', () => { - // They used to be minted from `Random` — the seeded Mulberry32 stream shared - // with world generation, NPC decisions and planet spin, the last of which is - // streamed to every client. That made them guessable, and made every draw - // after a join shift, so a seed no longer reproduced a match. const tokenFromFreshServer = () => { Random.seed = 1; const { attach } = startServer({ playerLimit: 4, npcCount: 0, seed: 1 }); @@ -272,12 +244,6 @@ describe('reconnect tokens', () => { expect(tokenFromFreshServer()).not.toBe(tokenFromFreshServer()); }); - // Shape, because that is what distinguishes the two sources: 122 random bits - // from the platform CSPRNG, rather than a counter and one 32-bit draw off the - // gameplay stream. Minting from `Random` also advanced the stream the world - // generator runs on, so the same seed stopped replaying the same match — but - // that is not assertable here: this harness seeds a different instance of the - // shared bundle than the server source resolves. it('mints them from the platform CSPRNG, not the gameplay stream', () => { const { attach } = startServer({ playerLimit: 4, npcCount: 0 }); const socket = attach(); @@ -290,9 +256,6 @@ describe('reconnect tokens', () => { }); describe('socket listeners', () => { - // The Player registers its own Pong listener, so nothing else knew to take it - // off. A socket outlives the round it joined, so the retired Player stayed - // reachable — and kept updating its RTT — through a listener nobody removed. it('takes every listener back off when the player drops', () => { const { attach } = startServer({ playerLimit: 4, npcCount: 0 }); const socket = attach(); diff --git a/test/physics-determinism.test.mjs b/test/physics-determinism.test.mjs index d2ca282..2a4ad65 100644 --- a/test/physics-determinism.test.mjs +++ b/test/physics-determinism.test.mjs @@ -1,9 +1,4 @@ -// Determinism guard for the shared character simulation. The client predictor -// and the authoritative server run the EXACT same stepCharacterMovement; if it -// were non-deterministic (a stray Math.random / Date, or an order-dependent -// reduce) prediction would rubber-band and could never reconcile. This is also -// the regression net for the upcoming GC/scratch-pool perf rewrites: the pinned -// hash must not change unless physics behaviour is intentionally changed. +// Determinism guard: client predictor and server run the same stepCharacterMovement. import { describe, it, expect } from 'vitest'; import { createRequire } from 'node:module'; @@ -34,9 +29,7 @@ const makeBody = (center, radius) => ({ restitution: 0, }); -// A free-space world (no planets): the body is driven purely by the movement -// input force, posture springs and momentum decay — enough to exercise the -// deterministic core without coupling the test to planet SDF geometry. +// Free-space world (no planets): exercises the deterministic core without planet SDF geometry. const emptyWorld = { groundsNear: () => [], stepBody: (body, dt) => { @@ -47,11 +40,7 @@ const emptyWorld = { const stepSeconds = 1 / 200; -// A spinning planet, built exactly the way create-world builds one, wired up as -// the GroundSurface the shared movement collides against and is pulled by. The -// free-space run above deliberately avoids planet geometry, so without this the -// SDF, the gravity falloff and the on-surface branch — the parts most likely to -// be touched by a perf rewrite — have no pin at all. +// A spinning planet, built the way create-world builds one, so the SDF/gravity/on-surface branches have a pin too. const makeSpinningPlanetWorld = () => { Random.seed = 20; const vertices = PlanetBase.createPlanetVertices( @@ -85,8 +74,6 @@ const makeSpinningPlanetWorld = () => { }; }; -// Walk a body across a spinning planet: gravity, the SDF outline, collision -// resolution, surface latching and the rotation carry all participate. const runPlanetSimulation = () => { const { surface, world } = makeSpinningPlanetWorld(); const start = vec2.fromValues(0, 250); @@ -120,8 +107,6 @@ const runPlanetSimulation = () => { }); }; -// Run a fixed, scripted input sequence through the shared sim and return a -// stable string snapshot of the final pose + carried momentum. const runSimulation = () => { const start = vec2.fromValues(100, 100); const state = { @@ -164,8 +149,6 @@ describe('shared character simulation determinism', () => { }); it('matches the pinned reference pose on a spinning planet', () => { - // Covers the planet SDF, the gravity falloff, collision resolution and the - // rotation carry, none of which the free-space run above touches. expect(runPlanetSimulation()).toMatchSnapshot(); }); }); diff --git a/test/prediction-reconciliation.test.ts b/test/prediction-reconciliation.test.ts index f155de6..efd883a 100644 --- a/test/prediction-reconciliation.test.ts +++ b/test/prediction-reconciliation.test.ts @@ -1,12 +1,4 @@ -// Reconciliation guard for the REAL client predictor. It exercises -// LocalCharacterPredictor end-to-end with a driven frame clock, verifying the -// properties reconciliation depends on: -// 1. a fully-acknowledged snapshot reproduces the authoritative pose exactly -// (no spurious drift on top of server truth), -// 2. un-acknowledged input is replayed forward deterministically, and -// 3. the replayed body advances smoothly as the window grows. -// This is the net for prediction-touching changes (the death-while-dead fix, -// future netcode work) — a regression shows up as drift or non-determinism. +// Reconciliation guard for the client predictor: acknowledged pose, deterministic replay, replay-window behaviour. import { describe, it, expect } from 'vitest'; import { LocalCharacterPredictor, @@ -16,17 +8,12 @@ import { const HEAD_RADIUS = 50; const FEET_RADIUS = 20; -// A Circle-shaped pose; the predictor only reads .center / .radius, so plain -// objects with array centres are sufficient (and avoid importing gl-matrix). const poseAt = (cx: number, cy: number) => ({ head: { center: [cx, cy + 37], radius: HEAD_RADIUS }, leftFoot: { center: [cx - 33, cy - 18], radius: FEET_RADIUS }, rightFoot: { center: [cx + 33, cy - 18], radius: FEET_RADIUS }, }); -// The authoritative movement state that rides along with every snapshot: the -// pose is only half of what the simulation carries between ticks. Upright, -// airborne and with no launch momentum unless a test says otherwise. const movementState = (direction = 0) => ({ direction, bodyVelocity: [0, 0], @@ -44,7 +31,7 @@ describe('local prediction reconciliation', () => { const auth = poseAt(500, 500); const t = predictor.recordInput([1, 0]); - predictor.acknowledge(t, movementState() as never, -Infinity); // server has consumed this input + predictor.acknowledge(t, movementState() as never, -Infinity); predictor.setStrength(80); predictor.setAuthoritative( auth.head as never, @@ -52,7 +39,6 @@ describe('local prediction reconciliation', () => { auth.rightFoot as never, ); - // No clock advance → zero replay window → predicted pose == authoritative. const used = predictor.update([], 1 / 60); expect(used).toBe(true); @@ -66,7 +52,7 @@ describe('local prediction reconciliation', () => { const drive = () => { const predictor = new LocalCharacterPredictor(); setFrameTimeMs(1000); - predictor.acknowledge(900, movementState() as never, -Infinity); // baseline ack (older than the input below) + predictor.acknowledge(900, movementState() as never, -Infinity); predictor.setStrength(80); const auth = poseAt(0, 0); predictor.setAuthoritative( @@ -74,9 +60,9 @@ describe('local prediction reconciliation', () => { auth.leftFoot as never, auth.rightFoot as never, ); - predictor.recordInput([1, 0]); // unacked rightward input at t=1000 + predictor.recordInput([1, 0]); - setFrameTimeMs(1100); // replay ~100 ms forward + setFrameTimeMs(1100); predictor.update([], 1 / 60); return [ predictor.head.center[0], @@ -88,9 +74,9 @@ describe('local prediction reconciliation', () => { const a = drive(); const b = drive(); - expect(a).toEqual(b); // deterministic replay + expect(a).toEqual(b); expect(a.every((n) => Number.isFinite(n))).toBe(true); - expect(a[0]).toBeGreaterThan(0.5); // rightward input actually moved the body + expect(a[0]).toBeGreaterThan(0.5); }); it('suppresses prediction while the local player is dead', () => { @@ -106,32 +92,22 @@ describe('local prediction reconciliation', () => { auth.leftFoot as never, auth.rightFoot as never, ); - predictor.setAlive(false); // dead, awaiting respawn + predictor.setAlive(false); - setFrameTimeMs(5200); // input + elapsed time that would otherwise be replayed forward - // Even with a valid authoritative pose and pending input, a dead body must - // not be predicted/moved — that was the "move while dead" bug. + setFrameTimeMs(5200); + // Regression: a dead body must not be predicted/moved. expect(predictor.update([], 1 / 60)).toBe(false); }); - // The replay window spans everything the server has not acknowledged, not just - // the snapshot's age. Anchoring on the snapshot's arrival made the window just - // that age, so prediction cancelled the interpolation buffer and nothing else - // and the local body sat a full one-way trip behind the server. it('replays the whole un-acknowledged span, so a longer trip predicts further', () => { const driveWithUnackedSpan = (ackAgeMs: number) => { const predictor = new LocalCharacterPredictor(); predictor.setStrength(80); - // The key went down well before the acknowledged span and is still held — - // the case that used to freeze the acknowledgement, because movement is - // only transmitted when it changes. setFrameTimeMs(9_000); predictor.recordInput([1, 0]); setFrameTimeMs(10_000); const auth = poseAt(0, 0); - // The server has confirmed input only up to `ackAgeMs` ago; the snapshot - // carrying that acknowledgement arrives now. predictor.acknowledge(10_000 - ackAgeMs, movementState() as never, -Infinity); predictor.setAuthoritative( auth.head as never, @@ -147,29 +123,16 @@ describe('local prediction reconciliation', () => { const longTrip = driveWithUnackedSpan(200); expect(shortTrip).toBeGreaterThan(0); - // A 200 ms un-acknowledged span must predict meaningfully further ahead than - // a 40 ms one; when the window was pinned to the snapshot they were equal. expect(longTrip).toBeGreaterThan(shortTrip * 2); }); - // ...but the anchor must not also inherit the AGE of whichever input happened - // to be the newest one the server had when it took the snapshot. Input is sent - // at a fixed cadence, so that age is 0..1 send interval depending on where the - // client's sends fell between two snapshots, and it walks a sawtooth: measured - // against a real server on localhost it ran 5, 16, 12, 8, 3, 14, 9, 1 ms and so - // on. Left in the window, it jumped the predicted body by up to a frame of - // travel 25 times a second — the local body was choppy on a link with no - // latency at all to hide it. The server reports the age with the - // acknowledgement so the anchor lands on the snapshot instant instead. it('cancels the age of the acknowledged input, so the send cadence cannot beat', () => { - // Both runs describe the SAME snapshot: taken 20 ms ago, of the same pose. - // They differ only in how long before it the server's newest input arrived. const snapshotAgeMs = 20; const drive = (inputAgeMs: number, serverReportsAge: boolean) => { const predictor = new LocalCharacterPredictor(); predictor.setStrength(80); setFrameTimeMs(9_000); - predictor.recordInput([1, 0]); // held down throughout + predictor.recordInput([1, 0]); setFrameTimeMs(10_000); predictor.acknowledge( @@ -190,30 +153,17 @@ describe('local prediction reconciliation', () => { }; expect(drive(16, true)).toBeCloseTo(drive(0, true), 6); - // And the difference being cancelled is a real one: uncancelled, the same - // 16 ms moves the body, so the assertion above does not hold vacuously. expect(Math.abs(drive(16, false) - drive(0, false))).toBeGreaterThan(0.5); }); - // The jitter this file exists to catch. A walking body's posture springs lag, - // so its head trails the upright position — and the facing direction is NOT - // recoverable from such a pose: the angle you get back is tens of degrees off. - // The predictor used to derive it that way rather than take the server's, so - // every replay opened by twisting the body towards the pose-implied posture, - // by an amount that depended on how many ticks the window happened to contain. - // The rendered body then advanced a different distance each frame. it('replays the streamed facing direction, not one guessed from the pose', () => { - // Head pulled 35 units behind the upright position: the steady-state posture - // lag of a body walking to the right. atan2 on this pose gives ~0.57 rad; - // the server's own direction is 0. + // Head trails 35 units behind: atan2 gives ~0.57 rad, server direction is 0. const walkingPose = () => ({ head: { center: [-35, 37], radius: HEAD_RADIUS }, leftFoot: { center: [-33, -18], radius: FEET_RADIUS }, rightFoot: { center: [33, -18], radius: FEET_RADIUS }, }); - // One snapshot interval of replay, which is as much as reconciliation ever - // asks for on a healthy link. const predictWithDirection = (direction: number) => { const predictor = new LocalCharacterPredictor(); predictor.setStrength(80); @@ -227,8 +177,6 @@ describe('local prediction reconciliation', () => { auth.rightFoot as never, ); - // A fresh predictor renders its first pose by snapping, so this is the raw - // prediction with no easing laid over it. setFrameTimeMs(40); predictor.update([], 1 / 60); return [predictor.head.center[0], predictor.head.center[1]]; @@ -241,10 +189,6 @@ describe('local prediction reconciliation', () => { upright[1] - asThePoseImplies[1], ); - // The guess moved the body by a visible amount every 40 ms — and by a - // different amount each frame, because the correction it triggers decays - // over the replay. If the streamed direction were ignored again the two - // would come out identical (this was exactly 0 before the fix). expect(apart).toBeGreaterThan(1); }); }); diff --git a/test/serialization.test.mjs b/test/serialization.test.mjs index 9c7bc11..1260fdb 100644 --- a/test/serialization.test.mjs +++ b/test/serialization.test.mjs @@ -1,8 +1,4 @@ -// Round-trip tests for the custom wire serializer — the single most fragile, -// highest-blast-radius mechanism in the codebase (every networked message goes -// through it, and dispatch is keyed on class name). We exercise the BUILT shared -// bundle (shared/lib/main.js) rather than the TS source, so the decorators are -// already applied exactly as they ship and there is no transform/ESM ambiguity. +// Round-trip tests for the wire serializer, exercising the BUILT shared bundle. import { describe, it, expect } from 'vitest'; import { createRequire } from 'node:module'; @@ -68,8 +64,7 @@ describe('serialization round-trip (built shared lib)', () => { }); it('preserves per-item class identity across a mixed batch', () => { - // A clobbered name→constructor mapping (two classes sharing a name) would - // surface here as an item deserializing to the wrong class. + // A clobbered name→constructor mapping surfaces as the wrong class. const batch = [ new ServerAnnouncement('a'), new MoveActionCommand([0, 1], 5), diff --git a/tsconfig.json b/tsconfig.json index 4b59984..62eedff 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,11 +3,8 @@ "target": "es6", "esModuleInterop": true, "strict": true, - "experimentalDecorators": true, - "downlevelIteration": true, "moduleResolution": "node", "module": "commonjs", - "composite": true, - "lib": ["dom", "es2017"] + "lib": ["es2017"] } } -- 2.54.0 From 98c4d6375603ba602cfc3a940fb44442b0f9c484 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sat, 22 Aug 2026 20:59:46 +0100 Subject: [PATCH 05/15] more --- backend/src/game-server.ts | 95 +++++++++++-------- backend/src/objects/character-physical.ts | 3 +- backend/src/objects/planet-physical.ts | 18 +--- backend/src/objects/projectile-physical.ts | 19 ---- .../physics/containers/bounding-box-list.ts | 8 +- backend/src/players/player-base.ts | 2 +- backend/src/players/player-container.ts | 81 ++++------------ backend/src/players/player.ts | 12 +-- frontend/src/index.ts | 7 +- frontend/src/scripts/game.ts | 1 + frontend/tsconfig.json | 1 + shared/src/helper/random.ts | 38 +++----- shared/src/objects/types/planet-base.ts | 4 +- shared/src/physics/march-circle.ts | 4 +- shared/src/settings.ts | 12 +-- shared/tsconfig.json | 1 + test/backend-regressions.test.ts | 31 +----- test/connection-lifecycle.test.ts | 41 +++++++- 18 files changed, 155 insertions(+), 223 deletions(-) diff --git a/backend/src/game-server.ts b/backend/src/game-server.ts index 3531be8..0c076e5 100644 --- a/backend/src/game-server.ts +++ b/backend/src/game-server.ts @@ -1,5 +1,6 @@ import { PhysicalContainer } from './physics/containers/physical-container'; import { Server, Socket } from 'socket.io'; +import { randomUUID } from 'node:crypto'; import { TransportEvents, deserialize, @@ -19,7 +20,7 @@ import { } from 'shared'; import { createWorld } from './create-world'; import { Options } from './options'; -import { PlayerContainer } from './players/player-container'; +import { CarriedScore, PlayerContainer } from './players/player-container'; import { ServerFullError } from './players/server-full-error'; import { Player } from './players/player'; import { StepCommand, GeneratePointsCommand, AnnounceCommand } from './commands/commands'; @@ -29,7 +30,7 @@ const gameStateSubscribedRoom = 'gameStateSubscribedRoom'; export class GameServer extends CommandReceiver { private objects!: PhysicalContainer; private players!: PlayerContainer; - private lastPhysicsBase: [number, number] = process.hrtime(); + private lastPhysicsBase = process.hrtime.bigint(); private bluePoints = 0; private redPoints = 0; @@ -50,7 +51,7 @@ export class GameServer extends CommandReceiver { this.options.playerLimit, this.options.npcCount, ); - this.lastPhysicsBase = process.hrtime(); + this.lastPhysicsBase = process.hrtime.bigint(); this.bluePoints = 0; this.redPoints = 0; this.matchPointAnnounced = {}; @@ -85,7 +86,10 @@ export class GameServer extends CommandReceiver { }); } - private readonly joinedSockets = new Map(); + private readonly joinedSockets = new Map< + Socket, + { player: Player; release: () => void } + >(); private handleJoin(socket: Socket, playerInfo: PlayerInformation) { if (this.joinedSockets.has(socket)) { @@ -98,6 +102,11 @@ export class GameServer extends CommandReceiver { return; } + // A reconnect arrives seconds after the drop, long before engine.io times + // the dead socket out. Retire that ghost first so its slot, team and score + // go back to the returning client. + const carried = this.retireGhost(playerInfo.reconnectToken); + if (this.players.isFull) { socket.emit(TransportEvents.JoinRejected, JoinRejectionReason.ServerFull); return; @@ -105,7 +114,7 @@ export class GameServer extends CommandReceiver { let player: Player; try { - player = this.players.createPlayer(playerInfo, socket); + player = this.players.createPlayer(playerInfo, socket, carried); } catch (e) { console.error('Failed to register joining player', e); socket.emit( @@ -134,31 +143,22 @@ export class GameServer extends CommandReceiver { }; const onDisconnect = () => { - const p = this.joinedSockets.get(socket); - this.joinedSockets.delete(socket); - this.inboundBudget.delete(socket); - if (!p) { - return; - } - p.detachFromSocket(); - const { kills, deaths } = player.scoreSnapshot; - this.players.reserveScore( - player.reconnectToken, - player.team, - kills, - deaths, - Date.now(), - ); - player.destroy(); - this.players.deletePlayer(player); + this.removeJoined(socket); this.sendServerStateUpdate(); }; socket.on(TransportEvents.PlayerToServer, onPlayerToServer); socket.on('disconnect', onDisconnect); - this.joinedSockets.set(socket, player); + this.joinedSockets.set(socket, { + player, + release: () => { + socket.off(TransportEvents.PlayerToServer, onPlayerToServer); + socket.off('disconnect', onDisconnect); + player.detachFromSocket(); + }, + }); - player.reconnectToken = this.players.issueToken(); + player.reconnectToken = randomUUID(); socket.emit(TransportEvents.PlayerJoined, player.reconnectToken); this.sendServerStateUpdate(); @@ -188,11 +188,36 @@ export class GameServer extends CommandReceiver { return true; } - private releaseJoinedSockets() { - for (const [socket, player] of this.joinedSockets) { - socket.removeAllListeners(); - player.detachFromSocket(); + private retireGhost(token?: string): CarriedScore | undefined { + if (!token) { + return undefined; } + for (const [socket, { player }] of this.joinedSockets) { + if (player.reconnectToken === token) { + const carried = { team: player.team, ...player.scoreSnapshot }; + this.removeJoined(socket); + return carried; + } + } + return undefined; + } + + private removeJoined(socket: Socket) { + const joined = this.joinedSockets.get(socket); + if (!joined) { + return; + } + this.joinedSockets.delete(socket); + this.inboundBudget.delete(socket); + // Only the listeners this join added: socket.io keeps its own internal + // 'error' guard and the connection-scoped PlayerJoining handler here. + joined.release(); + joined.player.destroy(); + this.players.deletePlayer(joined.player); + } + + private releaseJoinedSockets() { + this.joinedSockets.forEach(({ release }) => release()); this.joinedSockets.clear(); this.inboundBudget.clear(); } @@ -255,9 +280,9 @@ export class GameServer extends CommandReceiver { private saturatedFrames = 0; private handlePhysics() { - const now = process.hrtime(this.lastPhysicsBase); - const delta = now[0] + now[1] / 1e9; - this.lastPhysicsBase = process.hrtime(); + const frameStart = process.hrtime.bigint(); + const delta = Number(frameStart - this.lastPhysicsBase) / 1e9; + this.lastPhysicsBase = frameStart; if (Date.now() - this.statReportMs > 30000) { this.statReportMs = Date.now(); @@ -317,13 +342,9 @@ export class GameServer extends CommandReceiver { this.players.stepCommunication(delta); this.objects.resetRemoteCalls(); - const elapsed = process.hrtime(now); - const physicsDelta = elapsed[0] + elapsed[1] / 1e9; + const elapsed = Number(process.hrtime.bigint() - frameStart) / 1e9; - setTimeout( - this.handlePhysics.bind(this), - Math.max(0, fixedDelta - physicsDelta) * 1000, - ); + setTimeout(this.handlePhysics.bind(this), Math.max(0, fixedDelta - elapsed) * 1000); } public get serverInfo(): ServerInformation { diff --git a/backend/src/objects/character-physical.ts b/backend/src/objects/character-physical.ts index 6c65b7f..5fba09a 100644 --- a/backend/src/objects/character-physical.ts +++ b/backend/src/objects/character-physical.ts @@ -249,7 +249,7 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical } } - public shootTowards(position: vec2, charge = 0, catchUpSeconds = 0) { + public shootTowards(position: vec2, charge = 0) { if ( !this.isAlive || this.timeSinceLastShot < settings.projectileCreationInterval || @@ -295,7 +295,6 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical c, ); this.container.addObject(projectile); - projectile.fastForward(catchUpSeconds); if (c > 0) { vec2.scaleAndAdd( diff --git a/backend/src/objects/planet-physical.ts b/backend/src/objects/planet-physical.ts index b951e11..db54c33 100644 --- a/backend/src/objects/planet-physical.ts +++ b/backend/src/objects/planet-physical.ts @@ -175,24 +175,8 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical { } } - private get flipTeam(): CharacterTeam { - const control = this.ownership - 0.5; - const enter = settings.planetControlThreshold + settings.planetFlipHysteresis; - - if (control > enter) { - return CharacterTeam.red; - } - if (control < -enter) { - return CharacterTeam.blue; - } - if (Math.abs(control) < settings.planetControlThreshold) { - return CharacterTeam.neutral; - } - return this.lastTeam; - } - private detectFlip(game: CommandReceiver) { - const currentTeam = this.flipTeam; + const currentTeam = this.team; if (currentTeam === this.lastTeam) { return; } diff --git a/backend/src/objects/projectile-physical.ts b/backend/src/objects/projectile-physical.ts index 3fe0075..2437d0c 100644 --- a/backend/src/objects/projectile-physical.ts +++ b/backend/src/objects/projectile-physical.ts @@ -82,25 +82,6 @@ export class ProjectilePhysical extends ProjectileBase implements DynamicPhysica wasCollision = hitSurface; this.object.syncBoundingBox(); } - vec2.add(this.center, this.center, delta); - vec2.add(this.center, this.center, delta); - this.object.syncBoundingBox(); - } - - /** - * Lag compensation: runs the projectile forward by the time its command spent - * travelling to the server. Capped by settings.maxProjectileCatchUpSeconds so - * a forged timestamp cannot spawn a shot arbitrarily far downrange. - */ - public fastForward(seconds: number) { - const step = settings.targetPhysicsDeltaTimeInSeconds; - let remaining = Math.min(Math.max(seconds, 0), settings.maxProjectileCatchUpSeconds); - - while (remaining > 0 && this.isAlive) { - const delta = Math.min(step, remaining); - this.advance(delta); - remaining -= delta; - } } // Live view of the circle's box, not a snapshot — the circle keeps it in sync diff --git a/backend/src/physics/containers/bounding-box-list.ts b/backend/src/physics/containers/bounding-box-list.ts index 4311f62..1c85f99 100644 --- a/backend/src/physics/containers/bounding-box-list.ts +++ b/backend/src/physics/containers/bounding-box-list.ts @@ -9,10 +9,10 @@ export class BoundingBoxList { } public remove(object: DynamicPhysical) { - this.objects.splice( - this.objects.findIndex((i) => i === object), - 1, - ); + const index = this.objects.indexOf(object); + if (index >= 0) { + this.objects.splice(index, 1); + } } public forEach(func: (object: DynamicPhysical) => unknown) { diff --git a/backend/src/players/player-base.ts b/backend/src/players/player-base.ts index af2b2a3..22e6163 100644 --- a/backend/src/players/player-base.ts +++ b/backend/src/players/player-base.ts @@ -8,10 +8,10 @@ import { settings, sanitizeName, boundRadius, + evaluateSdf, } from 'shared'; import { PhysicalContainer } from '../physics/containers/physical-container'; import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle'; -import { evaluateSdf } from 'shared'; import { CharacterPhysical } from '../objects/character-physical'; import { PlanetPhysical } from '../objects/planet-physical'; import { PlayerContainer } from './player-container'; diff --git a/backend/src/players/player-container.ts b/backend/src/players/player-container.ts index 0fce4a3..a430220 100644 --- a/backend/src/players/player-container.ts +++ b/backend/src/players/player-container.ts @@ -5,21 +5,18 @@ import { NPC } from './npc'; import { Player } from './player'; import { PlayerBase } from './player-base'; import { ServerFullError } from './server-full-error'; -import { randomUUID } from 'node:crypto'; -// Score held for a dropped player so a reconnect inside the grace window -// resumes the match instead of starting from zero. -interface ReservedScore { +// Team and score carried over from the connection a reconnecting client is +// replacing, so it resumes the match instead of starting from zero. +export interface CarriedScore { team: CharacterTeam; kills: number; deaths: number; - expiresAtMs: number; } export class PlayerContainer { private _players: Array = []; private _npcs: Array = []; - private reservedScores = new Map(); constructor( private readonly objects: PhysicalContainer, @@ -42,80 +39,34 @@ export class PlayerContainer { } } - public createPlayer(playerInfo: PlayerInformation, socket: Socket): Player { + public createPlayer( + playerInfo: PlayerInformation, + socket: Socket, + carried?: CarriedScore, + ): Player { if (this._players.length >= this.playerMaxCount) { throw new ServerFullError(); } - const reserved = this.claimReservedScore(playerInfo.reconnectToken); - const team = reserved ? reserved.team : this.getTeamOfNextPlayer(); + const team = carried ? carried.team : this.getTeamOfNextPlayer(); - const player = new Player(playerInfo, this, this.objects, team, socket); - - let npcToReplace = this._npcs.find((n) => n.team === team); - if (!npcToReplace) { - npcToReplace = this._npcs.find((n) => n.team !== team); - } + // Retire the bot before the player spawns: its body would otherwise push + // the spawn search away from the position it picked. + const npcToReplace = + this._npcs.find((n) => n.team === team) ?? this._npcs.find((n) => n.team !== team); npcToReplace?.destroy(); this._npcs = this._npcs.filter((n) => n !== npcToReplace); + const player = new Player(playerInfo, this, this.objects, team, socket); this._players.push(player); - if (reserved) { - player.restoreScore(reserved.kills, reserved.deaths); + if (carried) { + player.restoreScore(carried.kills, carried.deaths); } return player; } - /** - * A fresh token for a joining player; only redeemable once the player - * actually drops (see reserveScore). - */ - public issueToken(): string { - return randomUUID(); - } - - /** - * Hold a dropped player's team and score against its token for the grace - * window, so a reconnecting client rejoins as itself. - */ - public reserveScore( - token: string, - team: CharacterTeam, - kills: number, - deaths: number, - nowMs: number, - ) { - this.expireReservations(nowMs); - this.reservedScores.set(token, { - team, - kills, - deaths, - expiresAtMs: nowMs + settings.reconnectGraceSeconds * 1000, - }); - } - - private claimReservedScore(token?: string): ReservedScore | undefined { - if (!token || typeof token !== 'string') { - return undefined; - } - const reserved = this.reservedScores.get(token); - if (!reserved) { - return undefined; - } - this.reservedScores.delete(token); - return reserved; - } - - private expireReservations(nowMs: number) { - for (const [token, reserved] of this.reservedScores) { - if (reserved.expiresAtMs <= nowMs) { - this.reservedScores.delete(token); - } - } - } - public get players(): Array { return [...this._players, ...this._npcs]; } diff --git a/backend/src/players/player.ts b/backend/src/players/player.ts index ed3cfb5..4238441 100644 --- a/backend/src/players/player.ts +++ b/backend/src/players/player.ts @@ -92,11 +92,7 @@ export class Player extends PlayerBase { return; } this.observeClientTime(c.clientTimeMs); - this.character?.shootTowards( - position, - finiteInRange(c.charge, 0, 1, 0), - this.catchUpSeconds, - ); + this.character?.shootTowards(position, finiteInRange(c.charge, 0, 1, 0)); }, [LeapActionCommand.type]: (c: LeapActionCommand) => { if (!isFiniteNumber(c.clientTimeMs)) { @@ -125,12 +121,6 @@ export class Player extends PlayerBase { } } - // Derived from the measured RTT, NOT the client's timestamp, so a forged - // stamp cannot buy extra lag compensation. fastForward caps the result. - private get catchUpSeconds(): number { - return Math.max(0, this.rttMs / 2 / 1000); - } - constructor( playerInfo: PlayerInformation, playerContainer: PlayerContainer, diff --git a/frontend/src/index.ts b/frontend/src/index.ts index 893a816..99d95f2 100644 --- a/frontend/src/index.ts +++ b/frontend/src/index.ts @@ -173,4 +173,9 @@ const main = async () => { } }; -main(); +main().catch((error) => { + console.error(error); + hide(spinner); + joinNotice.innerText = 'Something went wrong. Please reload the page.'; + joinNotice.style.display = 'block'; +}); diff --git a/frontend/src/scripts/game.ts b/frontend/src/scripts/game.ts index 25f684c..aa88ce8 100644 --- a/frontend/src/scripts/game.ts +++ b/frontend/src/scripts/game.ts @@ -93,6 +93,7 @@ export class Game extends CommandReceiver { this.gameObjects = new GameObjectContainer(this); this.overlay.innerHTML = ''; this.keystoneArrow = undefined; + this.connectionBanner = undefined; this.lastMinimap = undefined; this.isEnding = false; this.lastAnnouncementText = ''; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 07a4028..e404baf 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -5,6 +5,7 @@ "target": "es6", "esModuleInterop": true, "strict": true, + "experimentalDecorators": true, "moduleResolution": "node", "ignoreDeprecations": "6.0", "skipLibCheck": true, diff --git a/shared/src/helper/random.ts b/shared/src/helper/random.ts index 76c904a..7e49865 100644 --- a/shared/src/helper/random.ts +++ b/shared/src/helper/random.ts @@ -1,23 +1,7 @@ -let _seed = Math.random(); +let seed = Math.random(); -export function setRandomSeed(value: number) { - _seed = value; -} - -export function chooseRandom(values: Array): T | undefined { - const to = values.length; - if (to === 0) { - return undefined; - } - return values[Math.floor(getRandomInRange(0, to))]; -} - -export function getRandomInRange(from: number, to: number): number { - return from + getRandom() * (to - from); -} - -export function getRandom(): number { - let t = (_seed += 0x6d2b79f5); +function getRandom(): number { + let t = (seed += 0x6d2b79f5); t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; @@ -25,12 +9,18 @@ export function getRandom(): number { export const Random = { get seed() { - return _seed; + return seed; }, - set seed(v: number) { - _seed = v; + set seed(value: number) { + seed = value; }, - choose: chooseRandom, - getRandomInRange, getRandom, + getRandomInRange(from: number, to: number): number { + return from + getRandom() * (to - from); + }, + choose(values: Array): T | undefined { + return values.length === 0 + ? undefined + : values[Math.floor(getRandom() * values.length)]; + }, }; diff --git a/shared/src/objects/types/planet-base.ts b/shared/src/objects/types/planet-base.ts index bd3cabf..84a8e6d 100644 --- a/shared/src/objects/types/planet-base.ts +++ b/shared/src/objects/types/planet-base.ts @@ -34,8 +34,8 @@ export class PlanetBase extends GameObject { this.vertices.length; } - // Single rule for the dead-band: scoring (server) and every tint (client) - // read this rather than duplicating the comparison. + // The one capture rule: server flips and scoring, client tints and the + // keystone arrow all read this. public get team(): CharacterTeam { return Math.abs(this.ownership - 0.5) < settings.planetControlThreshold ? CharacterTeam.neutral diff --git a/shared/src/physics/march-circle.ts b/shared/src/physics/march-circle.ts index 425bec6..5f7836b 100644 --- a/shared/src/physics/march-circle.ts +++ b/shared/src/physics/march-circle.ts @@ -78,7 +78,9 @@ export const marchCircle = ( ); } - vec2.scaleAndAdd(body.center, body.center, direction, deltaLength); + // lastFreeDistance, not deltaLength: on a completed march they are equal, and + // if the step budget ran out it is the furthest point actually checked. + vec2.scaleAndAdd(body.center, body.center, direction, lastFreeDistance); return { hitSurface: false, diff --git a/shared/src/settings.ts b/shared/src/settings.ts index a904913..67f4aeb 100644 --- a/shared/src/settings.ts +++ b/shared/src/settings.ts @@ -30,12 +30,9 @@ export const settings = { maxGravityDistance: 800, minGravityDistance: 1, maxGravityQ: 5000, - // Neutral dead-band around 50% ownership; the rendered ring uses the same - // threshold as scoring so visuals match state. + // Neutral dead-band around 50% ownership. The single capture rule: flips, + // scoring and every client-side tint all read PlanetBase.team. planetControlThreshold: 0.12, - // Schmitt trigger: extra margin beyond planetControlThreshold before a flip - // pays out again, so boundary-hovering doesn't re-trigger the reward. - planetFlipHysteresis: 0.06, playerMaxHealth: 100, maxGravityStrength: 50000, planetMinReferenceRadius: 150, @@ -171,11 +168,6 @@ export const settings = { chargeShotRecoilMax: 650, - // Capped so a forged timestamp cannot spawn a shot arbitrarily far downrange. - maxProjectileCatchUpSeconds: 0.2, - - reconnectGraceSeconds: 30, - // DoS guard: parsing is synchronous with the physics loop, so one oversized // message stalls the tick for everyone. maxInboundMessageBytes: 16 * 1024, diff --git a/shared/tsconfig.json b/shared/tsconfig.json index 4bae3bb..ca43c33 100644 --- a/shared/tsconfig.json +++ b/shared/tsconfig.json @@ -7,6 +7,7 @@ "target": "es6", "esModuleInterop": true, "strict": true, + "experimentalDecorators": true, "moduleResolution": "node", "ignoreDeprecations": "6.0", "module": "commonjs", diff --git a/test/backend-regressions.test.ts b/test/backend-regressions.test.ts index bf2b244..e3b9640 100644 --- a/test/backend-regressions.test.ts +++ b/test/backend-regressions.test.ts @@ -69,31 +69,7 @@ describe('projectile broadphase registration', () => { expect(container.findIntersecting(boxAround([0, 0], 100))).not.toContain(projectile); }); - it('fast-forwards a shot by the sender latency', () => { - const container = new PhysicalContainer(); - container.initialize(); - const spawn = vec2.fromValues(0, 0); - - const make = () => - new ProjectilePhysical( - vec2.clone(spawn), - 20, - 40, - CharacterTeam.blue, - vec2.fromValues(3000, 0), - { team: CharacterTeam.blue } as never, - container, - 0, - ); - - const plain = make(); - const compensated = make(); - compensated.fastForward(0.1); - - expect(compensated.center[0]).toBeGreaterThan(plain.center[0] + 200); - }); - - it('never fast-forwards further than the cap', () => { + it('spawns a shot just clear of the shooter, not a jump downrange', () => { const container = new PhysicalContainer(); container.initialize(); const projectile = new ProjectilePhysical( @@ -106,9 +82,8 @@ describe('projectile broadphase registration', () => { container, 0, ); - projectile.fastForward(10); - const cap = settings.chargeShotSpeedMax * settings.maxProjectileCatchUpSeconds; - expect(projectile.center[0]).toBeLessThanOrEqual(cap + 50); + + expect(projectile.center[0]).toBeLessThanOrEqual(15); }); }); diff --git a/test/connection-lifecycle.test.ts b/test/connection-lifecycle.test.ts index e9f3220..730c893 100644 --- a/test/connection-lifecycle.test.ts +++ b/test/connection-lifecycle.test.ts @@ -255,6 +255,44 @@ describe('reconnect tokens', () => { }); }); +describe('reconnect while the dropped socket is still live', () => { + // engine.io needs ~45 s to notice a transport drop; the client is back in ~1 s. + it('retires the ghost so the returning player keeps its slot, team and score', () => { + const { server, attach } = startServer({ playerLimit: 1, npcCount: 0 }); + + const first = attach(); + first.fire(TransportEvents.PlayerJoining, { name: 'flaky' }); + const token = first.lastPayloadFor(TransportEvents.PlayerJoined) as string; + expect(server.serverInfo.playerCount).toBe(1); + + // No 'disconnect' fired: the server still holds the dead socket's player. + const second = attach(); + second.fire(TransportEvents.PlayerJoining, { name: 'flaky', reconnectToken: token }); + + expect(second.lastPayloadFor(TransportEvents.JoinRejected)).toBeUndefined(); + expect(second.lastPayloadFor(TransportEvents.PlayerJoined)).toBeTruthy(); + expect(server.serverInfo.playerCount).toBe(1); + expect(first.listenerCount(TransportEvents.PlayerToServer)).toBe(0); + }); + + it('does not hand a slot to an unknown token', () => { + const { server, attach } = startServer({ playerLimit: 1, npcCount: 0 }); + + attach().fire(TransportEvents.PlayerJoining, { name: 'holder' }); + + const stranger = attach(); + stranger.fire(TransportEvents.PlayerJoining, { + name: 'stranger', + reconnectToken: 'not-a-real-token', + }); + + expect(stranger.lastPayloadFor(TransportEvents.JoinRejected)).toBe( + JoinRejectionReason.ServerFull, + ); + expect(server.serverInfo.playerCount).toBe(1); + }); +}); + describe('socket listeners', () => { it('takes every listener back off when the player drops', () => { const { attach } = startServer({ playerLimit: 4, npcCount: 0 }); @@ -266,7 +304,8 @@ describe('socket listeners', () => { socket.fire('disconnect'); expect(socket.listenerCount(TransportEvents.Pong)).toBe(0); - expect(socket.listenerCount(TransportEvents.PlayerToServer)).toBe(1); + expect(socket.listenerCount(TransportEvents.PlayerToServer)).toBe(0); + expect(socket.listenerCount('disconnect')).toBe(0); }); it('does not stack a listener per reconnect', () => { -- 2.54.0 From 82e092ba550304f71ee7afc88fdbed7445c0c1df Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sun, 23 Aug 2026 10:27:53 +0100 Subject: [PATCH 06/15] more --- backend/src/commands/commands.ts | 31 ----- backend/src/game-server.ts | 69 ++++++++-- .../{bounding-boxes => }/bounding-box.ts | 0 .../bounding-boxes/bounding-box-base.ts | 51 ------- .../bounding-boxes/immutable-bounding-box.ts | 3 - .../physics/containers/bounding-box-list.ts | 25 ---- .../physics/containers/bounding-box-tree.ts | 126 ------------------ .../physics/functions/force-at-position.ts | 12 -- .../functions/get-bounding-box-of-circle.ts | 10 -- .../{containers => }/physical-container.ts | 0 .../src/physics/{physicals => }/physical.ts | 0 .../src/physics/physicals/dynamic-physical.ts | 5 - .../src/physics/physicals/physical-base.ts | 12 -- .../src/physics/physicals/static-physical.ts | 7 - backend/src/players/player-base.ts | 18 ++- backend/src/players/player.ts | 11 +- backend/src/players/server-full-error.ts | 5 - frontend/src/scripts/charge-indicator.ts | 11 +- .../src/scripts/commands/command-socket.ts | 11 ++ .../scripts/commands/types/before-destroy.ts | 7 - frontend/src/scripts/commands/types/render.ts | 12 -- frontend/src/scripts/commands/types/step.ts | 7 - frontend/src/scripts/feedback-hud.ts | 11 +- frontend/src/scripts/game.ts | 18 +++ .../interpolators/circle-interpolator.ts | 22 +++ .../helper/interpolators/vec2-interpolator.ts | 22 +++ frontend/src/scripts/objects/types/camera.ts | 16 +-- .../scripts/objects/types/character-view.ts | 64 ++------- .../src/scripts/objects/types/lamp-view.ts | 9 +- .../scripts/objects/types/projectile-view.ts | 16 +-- frontend/src/scripts/screen-shake.ts | 28 ++-- shared/src/commands/command-generator.ts | 18 --- .../commands/types/actions/primary-action.ts | 6 +- shared/src/helper/array.ts | 33 ----- shared/src/helper/mix-rgb.ts | 14 ++ shared/src/main.ts | 1 + shared/src/physics/character-movement.ts | 4 + shared/src/physics/march-circle.ts | 11 +- shared/src/physics/planet-sdf.ts | 18 ++- shared/src/physics/sdf-normal.ts | 6 +- .../src/serialization/serializable-mapping.ts | 6 + shared/src/serialization/serializable.ts | 4 +- shared/src/serialization/serialize.ts | 2 +- shared/src/serialization/serialized-fields.ts | 7 - shared/src/serialization/serializes-to.ts | 16 --- .../physics-determinism.test.mjs.snap | 2 +- test/client-send-cadence.test.ts | 27 +++- test/connection-lifecycle.test.ts | 56 ++++++++ test/physics-determinism.test.mjs | 13 +- test/setup.mjs | 20 +++ vitest.config.ts | 9 ++ 51 files changed, 373 insertions(+), 539 deletions(-) delete mode 100644 backend/src/commands/commands.ts rename backend/src/physics/{bounding-boxes => }/bounding-box.ts (100%) delete mode 100644 backend/src/physics/bounding-boxes/bounding-box-base.ts delete mode 100644 backend/src/physics/bounding-boxes/immutable-bounding-box.ts delete mode 100644 backend/src/physics/containers/bounding-box-list.ts delete mode 100644 backend/src/physics/containers/bounding-box-tree.ts delete mode 100644 backend/src/physics/functions/force-at-position.ts delete mode 100644 backend/src/physics/functions/get-bounding-box-of-circle.ts rename backend/src/physics/{containers => }/physical-container.ts (100%) rename backend/src/physics/{physicals => }/physical.ts (100%) delete mode 100644 backend/src/physics/physicals/dynamic-physical.ts delete mode 100644 backend/src/physics/physicals/physical-base.ts delete mode 100644 backend/src/physics/physicals/static-physical.ts delete mode 100644 backend/src/players/server-full-error.ts delete mode 100644 frontend/src/scripts/commands/types/before-destroy.ts delete mode 100644 frontend/src/scripts/commands/types/render.ts delete mode 100644 frontend/src/scripts/commands/types/step.ts create mode 100644 frontend/src/scripts/helper/interpolators/circle-interpolator.ts create mode 100644 frontend/src/scripts/helper/interpolators/vec2-interpolator.ts delete mode 100644 shared/src/commands/command-generator.ts delete mode 100644 shared/src/helper/array.ts create mode 100644 shared/src/helper/mix-rgb.ts delete mode 100644 shared/src/serialization/serialized-fields.ts delete mode 100644 shared/src/serialization/serializes-to.ts create mode 100644 test/setup.mjs create mode 100644 vitest.config.ts diff --git a/backend/src/commands/commands.ts b/backend/src/commands/commands.ts deleted file mode 100644 index 981f1fd..0000000 --- a/backend/src/commands/commands.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { Command, CommandReceiver, GameObject } from 'shared'; - -export class StepCommand extends Command { - constructor( - public readonly deltaTimeInSeconds: number, - public readonly game: CommandReceiver, - ) { - super(); - } -} - -export class GeneratePointsCommand extends Command { - constructor( - public readonly blue: number, - public readonly red: number, - ) { - super(); - } -} - -export class AnnounceCommand extends Command { - constructor(public readonly text: string) { - super(); - } -} - -export class ReactToCollisionCommand extends Command { - constructor(public readonly other: GameObject) { - super(); - } -} diff --git a/backend/src/game-server.ts b/backend/src/game-server.ts index 0c076e5..9d5ec8f 100644 --- a/backend/src/game-server.ts +++ b/backend/src/game-server.ts @@ -1,6 +1,7 @@ import { PhysicalContainer } from './physics/containers/physical-container'; import { Server, Socket } from 'socket.io'; import { randomUUID } from 'node:crypto'; +import { performance } from 'node:perf_hooks'; import { TransportEvents, deserialize, @@ -27,6 +28,23 @@ import { StepCommand, GeneratePointsCommand, AnnounceCommand } from './commands/ const gameStateSubscribedRoom = 'gameStateSubscribedRoom'; +// The payload is whatever the client sent: `socket.emit('PlayerJoining')` with +// no argument arrives as undefined. socket.io dispatches listeners inside a +// `process.nextTick` with no try/catch of its own, so a dereference of an +// unchecked payload here would surface as an uncaught exception and take the +// whole match down with the process. +const asPlayerInformation = (payload: unknown): PlayerInformation | undefined => { + if (!payload || typeof payload !== 'object') { + return undefined; + } + const { name, reconnectToken } = payload as Record; + return { + // Coerced and length-capped downstream, in PlayerBase.createCharacter. + name: name as string, + reconnectToken: typeof reconnectToken === 'string' ? reconnectToken : undefined, + }; +}; + export class GameServer extends CommandReceiver { private objects!: PhysicalContainer; private players!: PlayerContainer; @@ -75,12 +93,27 @@ export class GameServer extends CommandReceiver { this.initialize(); + // Both listeners are metered: they exist before any join, so the limiter + // inside onPlayerToServer cannot see them, and each one costs a reply or an + // allocation an unauthenticated client could otherwise ask for at line rate. io.on('connection', (socket: Socket) => { - socket.on(TransportEvents.PlayerJoining, (playerInfo: PlayerInformation) => - this.handleJoin(socket, playerInfo), - ); + socket.on(TransportEvents.PlayerJoining, (payload: unknown) => { + if (!this.allowInboundMessage(socket)) { + return; + } + try { + this.handleJoin(socket, payload); + } catch (e) { + console.error('Failed to handle a join request', e); + socket.emit(TransportEvents.JoinRejected, JoinRejectionReason.InvalidRequest); + socket.disconnect(); + } + }); socket.on(TransportEvents.SubscribeForServerInfoUpdates, () => { + if (!this.allowInboundMessage(socket)) { + return; + } socket.join(gameStateSubscribedRoom); }); }); @@ -91,7 +124,13 @@ export class GameServer extends CommandReceiver { { player: Player; release: () => void } >(); - private handleJoin(socket: Socket, playerInfo: PlayerInformation) { + private handleJoin(socket: Socket, payload: unknown) { + const playerInfo = asPlayerInformation(payload); + if (!playerInfo) { + socket.emit(TransportEvents.JoinRejected, JoinRejectionReason.InvalidRequest); + return; + } + if (this.joinedSockets.has(socket)) { socket.emit(TransportEvents.JoinRejected, JoinRejectionReason.AlreadyJoined); return; @@ -164,17 +203,22 @@ export class GameServer extends CommandReceiver { this.sendServerStateUpdate(); } - private readonly inboundBudget = new Map(); + // A WeakMap, not a Map: sockets that never join are metered too, and they + // have no join-scoped teardown to remove their entry. + private inboundBudget = new WeakMap(); private droppedInboundMessages = 0; private allowInboundMessage(socket: Socket): boolean { - const nowMs = Date.now(); + // performance.now(), never Date.now(): a backwards wall-clock step (an NTP + // correction, a VM resync) would make the refill negative and lock the + // client out of sending input for as long as the step was. + const nowMs = performance.now(); const burst = settings.maxInboundMessageBurst; const perSecond = settings.maxInboundMessagesPerSecond; const budget = this.inboundBudget.get(socket) ?? { tokens: burst, lastMs: nowMs }; budget.tokens = Math.min( burst, - budget.tokens + ((nowMs - budget.lastMs) / 1000) * perSecond, + budget.tokens + (Math.max(0, nowMs - budget.lastMs) / 1000) * perSecond, ); budget.lastMs = nowMs; this.inboundBudget.set(socket, budget); @@ -219,7 +263,7 @@ export class GameServer extends CommandReceiver { private releaseJoinedSockets() { this.joinedSockets.forEach(({ release }) => release()); this.joinedSockets.clear(); - this.inboundBudget.clear(); + this.inboundBudget = new WeakMap(); } private timeSinceLastServerStateUpdate = 0; @@ -289,6 +333,15 @@ export class GameServer extends CommandReceiver { const mem = `${(process.memoryUsage().rss / 1024 / 1024).toFixed(2)} MB`; console.info(`Memory: ${mem}, Players: ${this.players.count}`); + const rtts = this.players.connectedPlayerRttsMs.filter((r) => r > 0); + if (rtts.length > 0) { + rtts.sort((a, b) => a - b); + console.info( + `RTT median ${rtts[Math.floor(rtts.length / 2)].toFixed(0)} ms ` + + `(min ${rtts[0].toFixed(0)}, max ${rtts[rtts.length - 1].toFixed(0)}, n=${rtts.length})`, + ); + } + if (this.droppedInboundMessages > 0) { console.warn(`Rate limited ${this.droppedInboundMessages} inbound msg(s)`); this.droppedInboundMessages = 0; diff --git a/backend/src/physics/bounding-boxes/bounding-box.ts b/backend/src/physics/bounding-box.ts similarity index 100% rename from backend/src/physics/bounding-boxes/bounding-box.ts rename to backend/src/physics/bounding-box.ts diff --git a/backend/src/physics/bounding-boxes/bounding-box-base.ts b/backend/src/physics/bounding-boxes/bounding-box-base.ts deleted file mode 100644 index 6e107ed..0000000 --- a/backend/src/physics/bounding-boxes/bounding-box-base.ts +++ /dev/null @@ -1,51 +0,0 @@ -export abstract class BoundingBoxBase { - constructor( - protected _xMin: number = 0, - protected _xMax: number = 0, - protected _yMin: number = 0, - protected _yMax: number = 0, - ) {} - - [key: number]: number | undefined; - - public get 0(): number { - return this._xMin; - } - - public get 1(): number { - return this._xMax; - } - - public get 2(): number { - return this._yMin; - } - - public get 3(): number { - return this._yMax; - } - - public get xMin(): number { - return this._xMin; - } - - public get xMax(): number { - return this._xMax; - } - - public get yMin(): number { - return this._yMin; - } - - public get yMax(): number { - return this._yMax; - } - - public intersects(other: BoundingBoxBase): boolean { - return ( - this._xMin < other._xMax && - this._xMax > other._xMin && - this._yMin < other._yMax && - this._yMax > other._yMin - ); - } -} diff --git a/backend/src/physics/bounding-boxes/immutable-bounding-box.ts b/backend/src/physics/bounding-boxes/immutable-bounding-box.ts deleted file mode 100644 index 43b87e1..0000000 --- a/backend/src/physics/bounding-boxes/immutable-bounding-box.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { BoundingBoxBase } from './bounding-box-base'; - -export class ImmutableBoundingBox extends BoundingBoxBase {} diff --git a/backend/src/physics/containers/bounding-box-list.ts b/backend/src/physics/containers/bounding-box-list.ts deleted file mode 100644 index 1c85f99..0000000 --- a/backend/src/physics/containers/bounding-box-list.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { DynamicPhysical } from '../physicals/dynamic-physical'; -import { BoundingBoxBase } from '../bounding-boxes/bounding-box-base'; - -export class BoundingBoxList { - constructor(private objects: Array = []) {} - - public insert(object: DynamicPhysical) { - this.objects.push(object); - } - - public remove(object: DynamicPhysical) { - const index = this.objects.indexOf(object); - if (index >= 0) { - this.objects.splice(index, 1); - } - } - - public forEach(func: (object: DynamicPhysical) => unknown) { - this.objects.forEach(func); - } - - public findIntersecting(boundingBox: BoundingBoxBase): Array { - return this.objects.filter((b) => b.boundingBox.intersects(boundingBox)); - } -} diff --git a/backend/src/physics/containers/bounding-box-tree.ts b/backend/src/physics/containers/bounding-box-tree.ts deleted file mode 100644 index 39981ff..0000000 --- a/backend/src/physics/containers/bounding-box-tree.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { BoundingBoxBase } from '../bounding-boxes/bounding-box-base'; -import { StaticPhysical } from '../physicals/static-physical'; - -// source: https://github.com/ubilabs/kd-tree-javascript/blob/master/kdTree.js -class Node { - public left: Node | null = null; - public right: Node | null = null; - constructor( - public object: StaticPhysical, - public parent: Node | null, - ) {} -} - -export class BoundingBoxTree { - root?: Node | null; - - constructor(objects: Array = []) { - this.build(objects); - } - - public build(objects: Array) { - this.root = this.buildRecursive(objects, 0, null); - } - - private buildRecursive( - objects: Array, - depth: number, - parent: Node | null, - ): Node | null { - if (objects.length === 0) { - return null; - } - - if (objects.length === 1) { - return new Node(objects[0], parent); - } - - const dimension = depth % 4; - - objects.sort((a, b) => a.boundingBox[dimension]! - b.boundingBox[dimension]!); - - const median = Math.floor(objects.length / 2); - - const node = new Node(objects[median], parent); - node.left = this.buildRecursive(objects.slice(0, median), depth + 1, node); - node.right = this.buildRecursive(objects.slice(median + 1), depth + 1, node); - - return node; - } - - public insert(object: StaticPhysical) { - const [insertPosition, depth] = this.findParent(object, this.root, 0, null); - - if (insertPosition === null) { - this.root = new Node(object, null); - } else { - const node = new Node(object, insertPosition); - const dimension = depth % 4; - - if ( - object.boundingBox[dimension]! < insertPosition.object.boundingBox[dimension]! - ) { - insertPosition.left = node; - } else { - insertPosition.right = node; - } - } - } - - public findIntersecting(boundingBox: BoundingBoxBase): Array { - const maybeResults = this.findMaybeIntersecting(boundingBox, this.root!, 0); - const results = maybeResults.filter((b) => b.boundingBox.intersects(boundingBox)); - return results; - } - - private findMaybeIntersecting( - boundingBox: BoundingBoxBase, - node: Node | null, - depth: number, - ): Array { - if (node === null) { - return []; - } - - if (depth % 4 == 0 && boundingBox.xMax < node.object.boundingBox.xMin) { - return this.findMaybeIntersecting(boundingBox, node.left, depth + 1); - } - - if (depth % 4 == 1 && boundingBox.xMin > node.object.boundingBox.xMax) { - return this.findMaybeIntersecting(boundingBox, node.right, depth + 1); - } - - if (depth % 4 == 2 && boundingBox.yMax < node.object.boundingBox.yMin) { - return this.findMaybeIntersecting(boundingBox, node.left, depth + 1); - } - - if (depth % 4 == 3 && boundingBox.yMin > node.object.boundingBox.yMax) { - return this.findMaybeIntersecting(boundingBox, node.right, depth + 1); - } - - return [ - node.object, - ...this.findMaybeIntersecting(boundingBox, node.left, depth + 1), - ...this.findMaybeIntersecting(boundingBox, node.right, depth + 1), - ]; - } - - private findParent( - object: StaticPhysical, - node: Node | null | undefined, - depth: number, - parent: Node | null, - ): [Node | null, number] { - if (!node) { - return [parent, depth - 1]; - } - - const dimension = depth % 4; - - if (object.boundingBox[dimension]! < node.object.boundingBox[dimension]!) { - return this.findParent(object, node.left, depth + 1, node); - } - - return this.findParent(object, node.right, depth + 1, node); - } -} diff --git a/backend/src/physics/functions/force-at-position.ts b/backend/src/physics/functions/force-at-position.ts deleted file mode 100644 index 20b066e..0000000 --- a/backend/src/physics/functions/force-at-position.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { vec2 } from 'gl-matrix'; -import { PlanetPhysical } from '../../objects/planet-physical'; -import { Physical } from '../physicals/physical'; - -export const forceAtPosition = (position: vec2, objects: Array) => - objects - .filter((o) => o instanceof PlanetPhysical) - .reduce( - (sum: vec2, o: Physical) => - vec2.add(sum, sum, (o as PlanetPhysical).gravityAt(position)), - vec2.create(), - ); diff --git a/backend/src/physics/functions/get-bounding-box-of-circle.ts b/backend/src/physics/functions/get-bounding-box-of-circle.ts deleted file mode 100644 index 43ef937..0000000 --- a/backend/src/physics/functions/get-bounding-box-of-circle.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Circle } from 'shared'; -import { ImmutableBoundingBox } from '../bounding-boxes/immutable-bounding-box'; - -export const getBoundingBoxOfCircle = (circle: Circle): ImmutableBoundingBox => - new ImmutableBoundingBox( - circle.center.x - circle.radius, - circle.center.x + circle.radius, - circle.center.y - circle.radius, - circle.center.y + circle.radius, - ); diff --git a/backend/src/physics/containers/physical-container.ts b/backend/src/physics/physical-container.ts similarity index 100% rename from backend/src/physics/containers/physical-container.ts rename to backend/src/physics/physical-container.ts diff --git a/backend/src/physics/physicals/physical.ts b/backend/src/physics/physical.ts similarity index 100% rename from backend/src/physics/physicals/physical.ts rename to backend/src/physics/physical.ts diff --git a/backend/src/physics/physicals/dynamic-physical.ts b/backend/src/physics/physicals/dynamic-physical.ts deleted file mode 100644 index d72cd9d..0000000 --- a/backend/src/physics/physicals/dynamic-physical.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { PhysicalBase } from './physical-base'; - -export interface DynamicPhysical extends PhysicalBase { - readonly canMove: true; -} diff --git a/backend/src/physics/physicals/physical-base.ts b/backend/src/physics/physicals/physical-base.ts deleted file mode 100644 index ab41eac..0000000 --- a/backend/src/physics/physicals/physical-base.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { vec2 } from 'gl-matrix'; -import { CommandReceiver, GameObject } from 'shared'; -import { BoundingBoxBase } from '../bounding-boxes/bounding-box-base'; - -export interface PhysicalBase extends CommandReceiver { - readonly canCollide: boolean; - readonly canMove: boolean; - readonly boundingBox: BoundingBoxBase; - readonly gameObject: GameObject; - - distance(target: vec2): number; -} diff --git a/backend/src/physics/physicals/static-physical.ts b/backend/src/physics/physicals/static-physical.ts deleted file mode 100644 index eef168b..0000000 --- a/backend/src/physics/physicals/static-physical.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { PhysicalBase } from './physical-base'; -import { ImmutableBoundingBox } from '../bounding-boxes/immutable-bounding-box'; - -export interface StaticPhysical extends PhysicalBase { - readonly canMove: false; - readonly boundingBox: ImmutableBoundingBox; -} diff --git a/backend/src/players/player-base.ts b/backend/src/players/player-base.ts index 22e6163..5ce7552 100644 --- a/backend/src/players/player-base.ts +++ b/backend/src/players/player-base.ts @@ -121,6 +121,12 @@ export abstract class PlayerBase extends CommandReceiver { protected findEmptyPositionForPlayer(preferredCenter: vec2): vec2 { let rotation = 0; let radius = 0; + // preferredCenter is a planet centre, the single worst place to give up on: + // depenetrateCircle only runs four passes, and a body left that deep inside + // the rock registers a zero-distance hit every march and never moves again. + // So the roomiest point the spiral saw is kept as the fallback instead. + let roomiestPosition = vec2.clone(preferredCenter); + let roomiestClearance = -Infinity; for (let attempt = 0; attempt < 512; attempt++) { const playerPosition = vec2.fromValues( radius * Math.cos(rotation) + preferredCenter.x, @@ -132,18 +138,20 @@ export abstract class PlayerBase extends CommandReceiver { const playerBoundingBox = getBoundingBoxOfCircle(playerBoundingCircle); const possibleIntersectors = this.objectContainer.findIntersecting(playerBoundingBox); - if ( - evaluateSdf(playerBoundingCircle.center, possibleIntersectors) >= - playerBoundingCircle.radius - ) { + const clearance = evaluateSdf(playerBoundingCircle.center, possibleIntersectors); + if (clearance >= playerBoundingCircle.radius) { return playerPosition; } + if (clearance > roomiestClearance) { + roomiestClearance = clearance; + roomiestPosition = playerPosition; + } rotation += Math.PI / 8; radius += 30; } - return vec2.clone(preferredCenter); + return roomiestPosition; } public destroy() { diff --git a/backend/src/players/player.ts b/backend/src/players/player.ts index 4238441..8787332 100644 --- a/backend/src/players/player.ts +++ b/backend/src/players/player.ts @@ -56,12 +56,12 @@ export class Player extends PlayerBase { public reconnectToken = ''; - // Drives projectile lag compensation as well as the server-side latency stats. + // Server-side latency stat, reported in GameServer's periodic log. public rttMs = 0; private timeSinceLastPing = 0; private lastPingSentMs = 0; // Nonce of the ping awaiting a reply: prevents a client forging Pongs or - // answering a stale ping to corrupt the RTT that feeds lag compensation. + // answering a stale ping to corrupt the measured RTT. private pendingPingNonce = 0; private nextPingNonce = 1; @@ -136,9 +136,10 @@ export class Player extends PlayerBase { this.socket.on(TransportEvents.Pong, this.onPong); } - // Only the outstanding ping's nonce counts, once. A client can sit on the - // live ping to inflate measured RTT, so the result is clamped; the next ping - // retires the nonce, bounding any stall at one ping interval. + // Only the outstanding ping's nonce counts, once: a client cannot forge a + // Pong or answer a stale ping to skew the stat. Sitting on the live ping to + // inflate the measurement is bounded by the clamp, and the next ping retires + // the nonce, so any stall costs at most one ping interval. private readonly onPong = (nonce: unknown) => { if (this.pendingPingNonce === 0 || nonce !== this.pendingPingNonce) { return; diff --git a/backend/src/players/server-full-error.ts b/backend/src/players/server-full-error.ts deleted file mode 100644 index 720e5b3..0000000 --- a/backend/src/players/server-full-error.ts +++ /dev/null @@ -1,5 +0,0 @@ -export class ServerFullError extends Error { - constructor() { - super('Too many players'); - } -} diff --git a/frontend/src/scripts/charge-indicator.ts b/frontend/src/scripts/charge-indicator.ts index 670711b..1a3d01d 100644 --- a/frontend/src/scripts/charge-indicator.ts +++ b/frontend/src/scripts/charge-indicator.ts @@ -4,9 +4,9 @@ import { pointer } from './helper/pointer'; let element: HTMLElement | undefined; let heldSince = 0; let raf = 0; -let _followPointer = false; +let isFollowingPointer = false; -export function beginChargeIndicator(x: number, y: number, followPointer = false) { +function beginChargeIndicator(x: number, y: number, followPointer = false) { endChargeIndicator(); const el = document.createElement('div'); @@ -17,11 +17,11 @@ export function beginChargeIndicator(x: number, y: number, followPointer = false element = el; heldSince = performance.now(); - _followPointer = followPointer; + isFollowingPointer = followPointer; raf = requestAnimationFrame(updateChargeIndicator); } -export function endChargeIndicator() { +function endChargeIndicator() { if (element) { cancelAnimationFrame(raf); element.parentElement?.removeChild(element); @@ -41,7 +41,7 @@ function updateChargeIndicator() { }deg, rgba(255, 255, 255, 0.15) 0deg)`; element.classList.toggle('full', charge >= 1); - if (_followPointer) { + if (isFollowingPointer) { const cursor = pointer.displayPosition; if (cursor) { element.style.left = `${cursor.x}px`; @@ -52,6 +52,7 @@ function updateChargeIndicator() { raf = requestAnimationFrame(updateChargeIndicator); } +// The one public surface; the functions above stay module-private. export const ChargeIndicator = { begin: beginChargeIndicator, end: endChargeIndicator, diff --git a/frontend/src/scripts/commands/command-socket.ts b/frontend/src/scripts/commands/command-socket.ts index f7f04d5..da06121 100644 --- a/frontend/src/scripts/commands/command-socket.ts +++ b/frontend/src/scripts/commands/command-socket.ts @@ -30,6 +30,17 @@ export class CommandSocket extends CommandReceiver { } public sendQueuedCommands() { + // Nothing goes out while the transport is down. socket.io-client would + // otherwise buffer every emit in an uncapped sendBuffer and flush it on + // reconnect BEFORE the `connect` handler re-sends PlayerJoining — the + // server has no PlayerToServer listener attached yet and drops the lot. + // The local queue is dropped with it: these are timestamped inputs, and + // replaying a minute-old shot after the gap would be worse than losing it. + if (!this.socket.connected) { + this.commandQueue = []; + return; + } + const nowMs = predictorNowMs(); // Real input goes out on the frame it happens — that is the latency budget the player feels. An idle client still keeps the server's input acknowledgement moving (a held key generates no fresh command), paced by the clock not the frame rate; sending every rendered frame would exceed settings.maxInboundMessagesPerSecond, and since movement commands are edge-triggered and never re-sent, a discarded batch would drop the direction change for good. diff --git a/frontend/src/scripts/commands/types/before-destroy.ts b/frontend/src/scripts/commands/types/before-destroy.ts deleted file mode 100644 index 3020623..0000000 --- a/frontend/src/scripts/commands/types/before-destroy.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Command } from 'shared'; - -export class BeforeDestroyCommand extends Command { - public constructor() { - super(); - } -} diff --git a/frontend/src/scripts/commands/types/render.ts b/frontend/src/scripts/commands/types/render.ts deleted file mode 100644 index c83f5a1..0000000 --- a/frontend/src/scripts/commands/types/render.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Renderer } from 'sdf-2d'; -import { Command } from 'shared'; - -export class RenderCommand extends Command { - public constructor( - public readonly renderer: Renderer, - public readonly overlay: HTMLElement, - public readonly shouldChangeLayout: boolean, - ) { - super(); - } -} diff --git a/frontend/src/scripts/commands/types/step.ts b/frontend/src/scripts/commands/types/step.ts deleted file mode 100644 index ff0d3cc..0000000 --- a/frontend/src/scripts/commands/types/step.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Command } from 'shared'; - -export class StepCommand extends Command { - public constructor(public readonly deltaTimeInSeconds: number) { - super(); - } -} diff --git a/frontend/src/scripts/feedback-hud.ts b/frontend/src/scripts/feedback-hud.ts index 939b99f..ccc4636 100644 --- a/frontend/src/scripts/feedback-hud.ts +++ b/frontend/src/scripts/feedback-hud.ts @@ -33,7 +33,7 @@ function addTransient(el: HTMLElement, lifetimeMs: number) { setTimeout(() => el.parentElement?.removeChild(el), lifetimeMs); } -export function hitMarker(charge = 0) { +function hitMarker(charge = 0) { const { x, y } = focusPoint(); const marker = document.createElement('div'); marker.className = @@ -43,7 +43,7 @@ export function hitMarker(charge = 0) { addTransient(marker, 250); } -export function killConfirmed(victimName?: string, streak = 1, charge = 0) { +function killConfirmed(victimName?: string, streak = 1, charge = 0) { const { killfeed: kf } = ensureRoot(); const charged = charge >= settings.chargedHitThreshold; @@ -74,7 +74,7 @@ export function killConfirmed(victimName?: string, streak = 1, charge = 0) { } } -export function showElimination(): void { +function showElimination(): void { if (elimination) { return; } @@ -88,12 +88,12 @@ export function showElimination(): void { elimination = el; } -export function hideElimination(): void { +function hideElimination(): void { elimination?.parentElement?.removeChild(elimination); elimination = undefined; } -export function resetFeedbackHud(): void { +function resetFeedbackHud(): void { hideElimination(); root?.parentElement?.removeChild(root); root = undefined; @@ -119,6 +119,7 @@ function escapeHtml(text: string): string { return div.innerHTML; } +// The one public surface; the functions above stay module-private. export const FeedbackHud = { hitMarker, killConfirmed, diff --git a/frontend/src/scripts/game.ts b/frontend/src/scripts/game.ts index aa88ce8..29ca72e 100644 --- a/frontend/src/scripts/game.ts +++ b/frontend/src/scripts/game.ts @@ -46,6 +46,10 @@ import { Minimap } from './minimap'; import { ScreenShake } from './screen-shake'; import { FeedbackHud } from './feedback-hud'; +// Ten attempts at the configured backoff is on the order of a minute of +// retrying before the client gives up and returns to the landing page. +const maximumReconnectionAttempts = 10; + export class Game extends CommandReceiver { public gameObjects = new GameObjectContainer(this); public renderer?: Renderer; @@ -106,6 +110,10 @@ export class Game extends CommandReceiver { this.socket = io(this.playerDecision.server, { reconnectionDelayMax: 10000, + // Must be finite. The default is Infinity, and with it the manager never + // gives up, so `reconnect_failed` below never fires and a client whose + // server is gone sits on a frozen world behind the banner forever. + reconnectionAttempts: maximumReconnectionAttempts, transports: ['websocket'], forceNew: true, parser, @@ -138,6 +146,14 @@ export class Game extends CommandReceiver { ...this.playerDecision, reconnectToken: this.reconnectToken, }); + // A reconnect gets a brand-new server-side Player, back on the default + // aspect ratio; the camera only reports a ratio when it changes, so + // without this re-send it would stay wrong for the rest of the match. + if (this.lastAspectRatio !== undefined) { + this.socketReceiver.handleCommand( + new SetAspectRatioActionCommand(this.lastAspectRatio), + ); + } }); this.socket.io.on('reconnect_failed', () => { @@ -303,7 +319,9 @@ export class Game extends CommandReceiver { return this.renderer?.displayToWorldCoordinates(p) ?? vec2.create(); } + private lastAspectRatio?: number; public aspectRatioChanged(aspectRatio: number) { + this.lastAspectRatio = aspectRatio; this.socketReceiver.handleCommand(new SetAspectRatioActionCommand(aspectRatio)); } diff --git a/frontend/src/scripts/helper/interpolators/circle-interpolator.ts b/frontend/src/scripts/helper/interpolators/circle-interpolator.ts new file mode 100644 index 0000000..62816e5 --- /dev/null +++ b/frontend/src/scripts/helper/interpolators/circle-interpolator.ts @@ -0,0 +1,22 @@ +import { Circle } from 'shared'; +import { LinearInterpolator } from './linear-interpolator'; +import { Vec2Interpolator } from './vec2-interpolator'; + +export class CircleInterpolator { + private center: Vec2Interpolator; + private radius: LinearInterpolator; + + constructor(currentValue: Circle) { + this.center = new Vec2Interpolator(currentValue.center); + this.radius = new LinearInterpolator(currentValue.radius); + } + + public addFrame(value: Circle, rateOfChange: Circle) { + this.center.addFrame(value.center, rateOfChange.center); + this.radius.addFrame(value.radius, rateOfChange.radius); + } + + public getValue(deltaTime: number): Circle { + return new Circle(this.center.getValue(deltaTime), this.radius.getValue(deltaTime)); + } +} diff --git a/frontend/src/scripts/helper/interpolators/vec2-interpolator.ts b/frontend/src/scripts/helper/interpolators/vec2-interpolator.ts new file mode 100644 index 0000000..5a4544e --- /dev/null +++ b/frontend/src/scripts/helper/interpolators/vec2-interpolator.ts @@ -0,0 +1,22 @@ +import { vec2 } from 'gl-matrix'; +import { LinearInterpolator } from './linear-interpolator'; + +// Indexed access, not .x/.y, so it never depends on the vec2 prototype plugin. +export class Vec2Interpolator { + private x: LinearInterpolator; + private y: LinearInterpolator; + + constructor(currentValue: vec2) { + this.x = new LinearInterpolator(currentValue[0]); + this.y = new LinearInterpolator(currentValue[1]); + } + + public addFrame(value: vec2, rateOfChange: vec2) { + this.x.addFrame(value[0], rateOfChange[0]); + this.y.addFrame(value[1], rateOfChange[1]); + } + + public getValue(deltaTime: number): vec2 { + return vec2.fromValues(this.x.getValue(deltaTime), this.y.getValue(deltaTime)); + } +} diff --git a/frontend/src/scripts/objects/types/camera.ts b/frontend/src/scripts/objects/types/camera.ts index e0434b3..3b9b004 100644 --- a/frontend/src/scripts/objects/types/camera.ts +++ b/frontend/src/scripts/objects/types/camera.ts @@ -1,10 +1,9 @@ -import { vec2, vec3 } from 'gl-matrix'; +import { vec2 } from 'gl-matrix'; import { calculateViewArea, - clamp01, CommandExecutors, CommandReceiver, - mix, + mixRgb, settings, } from 'shared'; import { RenderCommand } from '../../commands/types/render'; @@ -60,16 +59,9 @@ export class Camera extends CommandReceiver { const viewArea = calculateViewArea(shakenCenter, canvasAspectRatio, scale * scale); renderer.setViewArea(viewArea.topLeft, viewArea.size); + const [near, far] = settings.backgroundGradient; renderer.setRuntimeSettings({ - ambientLight: (() => { - const q = clamp01(vec2.length(this.center) / settings.worldRadius); - const [a, b] = settings.backgroundGradient; - return vec3.fromValues( - mix(a[0], b[0], q), - mix(a[1], b[1], q), - mix(a[2], b[2], q), - ); - })(), + ambientLight: mixRgb(near, far, vec2.length(this.center) / settings.worldRadius), }); } } diff --git a/frontend/src/scripts/objects/types/character-view.ts b/frontend/src/scripts/objects/types/character-view.ts index d806d21..6fe951b 100644 --- a/frontend/src/scripts/objects/types/character-view.ts +++ b/frontend/src/scripts/objects/types/character-view.ts @@ -18,6 +18,7 @@ import { import { BeforeDestroyCommand } from '../../commands/types/before-destroy'; import { RenderCommand } from '../../commands/types/render'; import { StepCommand } from '../../commands/types/step'; +import { CircleInterpolator } from '../../helper/interpolators/circle-interpolator'; import { LinearInterpolator } from '../../helper/interpolators/linear-interpolator'; import { pointer } from '../../helper/pointer'; import { CharacterShape } from '../../shapes/character-shape'; @@ -59,15 +60,9 @@ export class CharacterView extends CharacterBase { public isMainCharacter = false; - private lfx = new LinearInterpolator(0); - private lfy = new LinearInterpolator(0); - private lfr = new LinearInterpolator(0); - private rfx = new LinearInterpolator(0); - private rfy = new LinearInterpolator(0); - private rfr = new LinearInterpolator(0); - private hdx = new LinearInterpolator(0); - private hdy = new LinearInterpolator(0); - private hdr = new LinearInterpolator(0); + private headInterpolator: CircleInterpolator; + private leftFootInterpolator: CircleInterpolator; + private rightFootInterpolator: CircleInterpolator; protected commandExecutors: CommandExecutors = { [RenderCommand.type]: this.draw.bind(this), @@ -96,18 +91,9 @@ export class CharacterView extends CharacterBase { ); this.deathBurst = new CircleLight(vec2.clone(this.head!.center), deathBurstColor, 0); - const lf = this.leftFoot!; - this.lfx = new LinearInterpolator(lf.center[0]); - this.lfy = new LinearInterpolator(lf.center[1]); - this.lfr = new LinearInterpolator(lf.radius); - const rf = this.rightFoot!; - this.rfx = new LinearInterpolator(rf.center[0]); - this.rfy = new LinearInterpolator(rf.center[1]); - this.rfr = new LinearInterpolator(rf.radius); - const hd = this.head!; - this.hdx = new LinearInterpolator(hd.center[0]); - this.hdy = new LinearInterpolator(hd.center[1]); - this.hdr = new LinearInterpolator(hd.radius); + this.headInterpolator = new CircleInterpolator(this.head!); + this.leftFootInterpolator = new CircleInterpolator(this.leftFoot!); + this.rightFootInterpolator = new CircleInterpolator(this.rightFoot!); this.nameElement.className = 'player-tag ' + this.team; this.nameElement.innerText = this.name; @@ -163,19 +149,13 @@ export class CharacterView extends CharacterBase { rateOfChange, }: UpdatePropertyCommand) { if (propertyKey === 'head') { - this.hdx.addFrame(propertyValue.center[0], rateOfChange.center[0]); - this.hdy.addFrame(propertyValue.center[1], rateOfChange.center[1]); - this.hdr.addFrame(propertyValue.radius, rateOfChange.radius); + this.headInterpolator.addFrame(propertyValue, rateOfChange); } if (propertyKey === 'leftFoot') { - this.lfx.addFrame(propertyValue.center[0], rateOfChange.center[0]); - this.lfy.addFrame(propertyValue.center[1], rateOfChange.center[1]); - this.lfr.addFrame(propertyValue.radius, rateOfChange.radius); + this.leftFootInterpolator.addFrame(propertyValue, rateOfChange); } if (propertyKey === 'rightFoot') { - this.rfx.addFrame(propertyValue.center[0], rateOfChange.center[0]); - this.rfy.addFrame(propertyValue.center[1], rateOfChange.center[1]); - this.rfr.addFrame(propertyValue.radius, rateOfChange.radius); + this.rightFootInterpolator.addFrame(propertyValue, rateOfChange); } if (propertyKey === 'strength') { this.strengthInterpolator.addFrame(propertyValue, rateOfChange); @@ -242,27 +222,9 @@ export class CharacterView extends CharacterBase { } private step({ deltaTimeInSeconds }: StepCommand): void { - this.head! = new Circle( - vec2.fromValues( - this.hdx.getValue(deltaTimeInSeconds), - this.hdy.getValue(deltaTimeInSeconds), - ), - this.hdr.getValue(deltaTimeInSeconds), - ); - this.leftFoot! = new Circle( - vec2.fromValues( - this.lfx.getValue(deltaTimeInSeconds), - this.lfy.getValue(deltaTimeInSeconds), - ), - this.lfr.getValue(deltaTimeInSeconds), - ); - this.rightFoot! = new Circle( - vec2.fromValues( - this.rfx.getValue(deltaTimeInSeconds), - this.rfy.getValue(deltaTimeInSeconds), - ), - this.rfr.getValue(deltaTimeInSeconds), - ); + this.head! = this.headInterpolator.getValue(deltaTimeInSeconds); + this.leftFoot! = this.leftFootInterpolator.getValue(deltaTimeInSeconds); + this.rightFoot! = this.rightFootInterpolator.getValue(deltaTimeInSeconds); this.strength = clamp( this.strengthInterpolator.getValue(deltaTimeInSeconds), diff --git a/frontend/src/scripts/objects/types/lamp-view.ts b/frontend/src/scripts/objects/types/lamp-view.ts index f6f0d3f..e4db4f6 100644 --- a/frontend/src/scripts/objects/types/lamp-view.ts +++ b/frontend/src/scripts/objects/types/lamp-view.ts @@ -1,6 +1,6 @@ import { vec2, vec3 } from 'gl-matrix'; import { CircleLight } from 'sdf-2d'; -import { clamp01, CommandExecutors, Id, LampBase, mix, settings } from 'shared'; +import { CommandExecutors, Id, LampBase, mixRgb, settings } from 'shared'; import { RenderCommand } from '../../commands/types/render'; import { StepCommand } from '../../commands/types/step'; @@ -29,12 +29,7 @@ export class LampView extends LampBase { private step({ deltaTimeInSeconds }: StepCommand): void { const t = 1 - Math.exp(-deltaTimeInSeconds / settings.lampLerpSeconds); - const q = clamp01(t); - this.light.color = vec3.fromValues( - mix(this.light.color[0], this.targetColor[0], q), - mix(this.light.color[1], this.targetColor[1], q), - mix(this.light.color[2], this.targetColor[2], q), - ); + this.light.color = mixRgb(this.light.color, this.targetColor, t); this.light.intensity += (this.targetLightness - this.light.intensity) * t; } diff --git a/frontend/src/scripts/objects/types/projectile-view.ts b/frontend/src/scripts/objects/types/projectile-view.ts index ba1a255..5843e59 100644 --- a/frontend/src/scripts/objects/types/projectile-view.ts +++ b/frontend/src/scripts/objects/types/projectile-view.ts @@ -10,13 +10,12 @@ import { } from 'shared'; import { RenderCommand } from '../../commands/types/render'; import { StepCommand } from '../../commands/types/step'; -import { LinearInterpolator } from '../../helper/interpolators/linear-interpolator'; +import { Vec2Interpolator } from '../../helper/interpolators/vec2-interpolator'; export class ProjectileView extends ProjectileBase { private light: CircleLight; - private centerX = new LinearInterpolator(0); - private centerY = new LinearInterpolator(0); + private centerInterpolator: Vec2Interpolator; protected commandExecutors: CommandExecutors = { [RenderCommand.type]: this.draw.bind(this), @@ -37,22 +36,17 @@ export class ProjectileView extends ProjectileBase { settings.paletteDim[settings.colorIndices[team]], 0, ); - this.centerX = new LinearInterpolator(center[0]); - this.centerY = new LinearInterpolator(center[1]); + this.centerInterpolator = new Vec2Interpolator(center); } private updateProperty({ propertyValue, rateOfChange }: UpdatePropertyCommand): void { - this.centerX.addFrame(propertyValue[0], rateOfChange[0]); - this.centerY.addFrame(propertyValue[1], rateOfChange[1]); + this.centerInterpolator.addFrame(propertyValue, rateOfChange); } private handleStep({ deltaTimeInSeconds }: StepCommand): void { this.step(deltaTimeInSeconds); - this.center = vec2.fromValues( - this.centerX.getValue(deltaTimeInSeconds), - this.centerY.getValue(deltaTimeInSeconds), - ); + this.center = this.centerInterpolator.getValue(deltaTimeInSeconds); this.light.center = this.center; this.light.intensity = Math.min( 0.1, diff --git a/frontend/src/scripts/screen-shake.ts b/frontend/src/scripts/screen-shake.ts index bc7a6d7..98b84e6 100644 --- a/frontend/src/scripts/screen-shake.ts +++ b/frontend/src/scripts/screen-shake.ts @@ -17,21 +17,21 @@ function reducedMotion(): boolean { ); } -export function addTrauma(t: number): void { +function addTrauma(t: number): void { if (reducedMotion()) { return; } trauma = Math.min(1, trauma + t); } -export function addPunch(amount: number): void { +function addPunch(amount: number): void { if (reducedMotion()) { return; } punch = Math.min(1, punch + amount); } -export function stepScreenShake(deltaTimeInSeconds: number): void { +function stepScreenShake(deltaTimeInSeconds: number): void { trauma = Math.max(0, trauma - traumaDecayPerSecond * deltaTimeInSeconds); punch = Math.max(0, punch - punchDecayPerSecond * deltaTimeInSeconds); const shake = trauma * trauma; @@ -39,37 +39,27 @@ export function stepScreenShake(deltaTimeInSeconds: number): void { offsetYValue = maxTranslation * shake * (Math.random() * 2 - 1); } -function getOffsetX(): number { - return offsetXValue; -} - -function getOffsetY(): number { - return offsetYValue; -} - -function getViewScale(): number { - return 1 - maxZoom * punch * punch; -} - -export function resetScreenShake(): void { +function resetScreenShake(): void { trauma = 0; punch = 0; offsetXValue = 0; offsetYValue = 0; } +// The one public surface. The functions above stay module-private so each +// operation has exactly one name. export const ScreenShake = { add: addTrauma, addPunch, step: stepScreenShake, get offsetX() { - return getOffsetX(); + return offsetXValue; }, get offsetY() { - return getOffsetY(); + return offsetYValue; }, get viewScale() { - return getViewScale(); + return 1 - maxZoom * punch * punch; }, reset: resetScreenShake, }; diff --git a/shared/src/commands/command-generator.ts b/shared/src/commands/command-generator.ts deleted file mode 100644 index 27db5f3..0000000 --- a/shared/src/commands/command-generator.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { CommandReceiver } from './command-receiver'; -import { Command } from './command'; - -export abstract class CommandGenerator { - private subscribers: Array = []; - - public subscribe(subscriber: CommandReceiver): void { - this.subscribers.push(subscriber); - } - - public clearSubscribers(): void { - this.subscribers = []; - } - - protected sendCommandToSubscribers(command: Command): void { - this.subscribers.forEach((s) => s.handleCommand(command)); - } -} diff --git a/shared/src/commands/types/actions/primary-action.ts b/shared/src/commands/types/actions/primary-action.ts index e0365e3..762feeb 100644 --- a/shared/src/commands/types/actions/primary-action.ts +++ b/shared/src/commands/types/actions/primary-action.ts @@ -7,9 +7,9 @@ export class PrimaryActionCommand extends Command { public constructor( public readonly position: vec2, public readonly charge: number = 0, - // Client wall-clock at shot release. Appended last so the existing wire - // order is unchanged. The server fast-forwards the projectile by its - // in-flight time so the shooter need not lead by their own latency. + // Client wall-clock at shot release, echoed back via InputAcknowledgement + // like the other actions' — it advances the predictor's replay anchor past + // this shot. Appended last so the existing wire order is unchanged. public readonly clientTimeMs: number = 0, ) { super(); diff --git a/shared/src/helper/array.ts b/shared/src/helper/array.ts deleted file mode 100644 index a7f4554..0000000 --- a/shared/src/helper/array.ts +++ /dev/null @@ -1,33 +0,0 @@ -declare global { - // `T` must stay to merge with the global `Array` (otherwise TS2428). - // eslint-disable-next-line @typescript-eslint/no-unused-vars - interface Array { - x: number; - y: number; - } - - interface Float32Array { - x: number; - y: number; - } -} - -const setIndexAlias = (name: string, index: number, type: any) => { - if (!Object.prototype.hasOwnProperty.call(type.prototype, name)) { - Object.defineProperty(type.prototype, name, { - get() { - return this[index]; - }, - set(value) { - this[index] = value; - }, - }); - } -}; - -export const applyArrayPlugins = () => { - setIndexAlias('x', 0, Array); - setIndexAlias('y', 1, Array); - setIndexAlias('x', 0, Float32Array); - setIndexAlias('y', 1, Float32Array); -}; diff --git a/shared/src/helper/mix-rgb.ts b/shared/src/helper/mix-rgb.ts new file mode 100644 index 0000000..b3959b4 --- /dev/null +++ b/shared/src/helper/mix-rgb.ts @@ -0,0 +1,14 @@ +import { vec3 } from 'gl-matrix'; +import { clamp01 } from './clamp'; +import { mix } from './mix'; + +// Component-wise lerp of two colours. Kept as a helper because the alternative +// is the same clamp-plus-three-mixes expansion repeated at every call site. +export const mixRgb = (a: vec3, b: vec3, q: number): vec3 => { + const clampedQ = clamp01(q); + return vec3.fromValues( + mix(a[0], b[0], clampedQ), + mix(a[1], b[1], clampedQ), + mix(a[2], b[2], clampedQ), + ); +}; diff --git a/shared/src/main.ts b/shared/src/main.ts index 5776194..8501417 100644 --- a/shared/src/main.ts +++ b/shared/src/main.ts @@ -25,6 +25,7 @@ export * from './helper/charge'; export * from './helper/calculate-view-area'; export * from './helper/circle'; export * from './helper/mix'; +export * from './helper/mix-rgb'; export * from './helper/random'; export * from './communication/communication'; export * from './objects/game-object'; diff --git a/shared/src/physics/character-movement.ts b/shared/src/physics/character-movement.ts index acd99ee..49cac9a 100644 --- a/shared/src/physics/character-movement.ts +++ b/shared/src/physics/character-movement.ts @@ -341,6 +341,10 @@ export const stepCharacterMovement = ( applyBodyMomentum(state); + // Only the feet latch. The grounded branch above projects gravity onto the + // foot contact normals and leap() launches off them, so latching on a head + // contact would ground the body against normals no part of it is touching — + // a head-first tumble would read as standing. latchGround(state, world.stepBody(state.leftFoot, deltaTimeInSeconds)); latchGround(state, world.stepBody(state.rightFoot, deltaTimeInSeconds)); world.stepBody(state.head, deltaTimeInSeconds); diff --git a/shared/src/physics/march-circle.ts b/shared/src/physics/march-circle.ts index 5f7836b..7d72ac1 100644 --- a/shared/src/physics/march-circle.ts +++ b/shared/src/physics/march-circle.ts @@ -49,9 +49,16 @@ export const marchCircle = ( )!; if (ignoreCollision) { - // Pass through, but still report the contact so repeated marches keep progressing. + // Pass through, but still report the contact so repeated marches keep + // progressing. The normal comes along: `hitSurface: true` is the + // contract callers read it under, and one without it would be a + // TypeError in whichever caller first enables pass-through. vec2.scaleAndAdd(body.center, body.center, direction, deltaLength); - return { hitSurface: true, hitObject: intersecting }; + return { + hitSurface: true, + normal: sdfNormal(rayEnd, [intersecting]), + hitObject: intersecting, + }; } onHit?.(intersecting); diff --git a/shared/src/physics/planet-sdf.ts b/shared/src/physics/planet-sdf.ts index be61212..ea7e72f 100644 --- a/shared/src/physics/planet-sdf.ts +++ b/shared/src/physics/planet-sdf.ts @@ -18,13 +18,17 @@ export const toPlanetLocalFrame = ( // Innermost leaf — module-level scratch to avoid per-call allocation. Nothing // re-enters planetDistance and none of these outlive the call. -const localPoint = vec2.create(); -const targetFromDelta = vec2.create(); -const toFromDelta = vec2.create(); -const closestOnEdge = vec2.create(); -// Stays a vec2 (Float32Array) because rounding to f32 is part of the outline -// the server and the client both have to agree on. -const edgeSample = vec2.create(); +// +// Plain array literals, NOT vec2.create(): imports are evaluated before either +// entry point reaches its glMatrix.setMatrixArrayType(Array) call, so +// vec2.create() here would hand back Float32Array and quietly run the one +// outline the server and the client must agree on at f32 while every other +// vector in the simulation is f64. +const localPoint: vec2 = [0, 0]; +const targetFromDelta: vec2 = [0, 0]; +const toFromDelta: vec2 = [0, 0]; +const closestOnEdge: vec2 = [0, 0]; +const edgeSample: vec2 = [0, 0]; // The one and only planet outline: the authoritative server and the client // predictor both collide against it, which is what lets prediction reconcile. diff --git a/shared/src/physics/sdf-normal.ts b/shared/src/physics/sdf-normal.ts index 028d16a..96ba9cc 100644 --- a/shared/src/physics/sdf-normal.ts +++ b/shared/src/physics/sdf-normal.ts @@ -3,8 +3,10 @@ import { Sdf } from './sdf'; import { evaluateSdf } from './evaluate-sdf'; // Reused probe point — sdfNormal is called on every depenetration pass and -// raymarch hit. -const probe = vec2.create(); +// raymarch hit. A plain array literal for the same reason as planet-sdf's +// scratch: it is allocated before setMatrixArrayType(Array) runs, and the +// gradient must be computed at f64 like the rest of the simulation. +const probe: vec2 = [0, 0]; const sampleAt = (x: number, y: number, objects: Array): number => { vec2.set(probe, x, y); diff --git a/shared/src/serialization/serializable-mapping.ts b/shared/src/serialization/serializable-mapping.ts index a287fe5..2de8ad3 100644 --- a/shared/src/serialization/serializable-mapping.ts +++ b/shared/src/serialization/serializable-mapping.ts @@ -1,3 +1,9 @@ export type SerializableClass = { new (...args: Array): unknown; name: string }; +// Wire format: the property the decorators stamp on and serialize() looks for +// to recognise a decorated class. Defined once on purpose — per-file copies +// would have to agree byte-for-byte, and a typo in one of them compiles and +// type-checks cleanly while every networked object degrades to a raw array. +export const MANGLE = '__serializable_type'; + export const serializableMapping = new Map(); diff --git a/shared/src/serialization/serializable.ts b/shared/src/serialization/serializable.ts index b2a0309..79852f3 100644 --- a/shared/src/serialization/serializable.ts +++ b/shared/src/serialization/serializable.ts @@ -1,6 +1,4 @@ -import { SerializableClass, serializableMapping } from './serializable-mapping'; - -const MANGLE = '__serializable_type'; +import { MANGLE, SerializableClass, serializableMapping } from './serializable-mapping'; export const serializable = (type: SerializableClass): any => { if (!serializableMapping.has(type.name)) { diff --git a/shared/src/serialization/serialize.ts b/shared/src/serialization/serialize.ts index c5b1529..2a91848 100644 --- a/shared/src/serialization/serialize.ts +++ b/shared/src/serialization/serialize.ts @@ -1,4 +1,4 @@ -const MANGLE = '__serializable_type'; +import { MANGLE } from './serializable-mapping'; export const serialize = (object: any): string => { return JSON.stringify(object, (_, value) => { diff --git a/shared/src/serialization/serialized-fields.ts b/shared/src/serialization/serialized-fields.ts deleted file mode 100644 index dd32dc0..0000000 --- a/shared/src/serialization/serialized-fields.ts +++ /dev/null @@ -1,7 +0,0 @@ -// deserialize reconstructs via `new Ctor(...array.slice(1))`, so the field list -// MUST name the constructor's parameters in order. Derived (non-serialized) -// fields are deliberately absent. -export const toArrayFromFields = ( - object: any, - fields: ReadonlyArray, -): Array => fields.map((field) => object[field]); diff --git a/shared/src/serialization/serializes-to.ts b/shared/src/serialization/serializes-to.ts deleted file mode 100644 index ed1296e..0000000 --- a/shared/src/serialization/serializes-to.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { SerializableClass, serializableMapping } from './serializable-mapping'; - -const MANGLE = '__serializable_type'; - -export const serializesTo = (target: SerializableClass) => { - return (actual: SerializableClass): any => { - if (!serializableMapping.has(target.name)) { - serializableMapping.set(target.name, target); - } - - Object.defineProperty(actual, MANGLE, { value: target.name }); - Object.defineProperty(actual.prototype, MANGLE, { value: target.name }); - - return actual; - }; -}; diff --git a/test/__snapshots__/physics-determinism.test.mjs.snap b/test/__snapshots__/physics-determinism.test.mjs.snap index 8c0ab06..ab4977d 100644 --- a/test/__snapshots__/physics-determinism.test.mjs.snap +++ b/test/__snapshots__/physics-determinism.test.mjs.snap @@ -2,4 +2,4 @@ exports[`shared character simulation determinism > matches the pinned reference pose (changes only with intentional physics edits) 1`] = `"{"head":[76.14,123.412],"leftFoot":[83.214,105.036],"rightFoot":[79.974,123.01],"bodyVelocity":[0,0]}"`; -exports[`shared character simulation determinism > matches the pinned reference pose on a spinning planet 1`] = `"{"head":[320.9,-33.986],"leftFoot":[248.603,-50.839],"rightFoot":[281.287,-71.994],"direction":-0.314,"onSurface":true,"secondsSinceOnSurface":0}"`; +exports[`shared character simulation determinism > matches the pinned reference pose on a spinning planet 1`] = `"{"head":[320.826,-34],"leftFoot":[248.634,-50.688],"rightFoot":[281.09,-72.183],"direction":-0.314,"onSurface":true,"secondsSinceOnSurface":0}"`; diff --git a/test/client-send-cadence.test.ts b/test/client-send-cadence.test.ts index 9e4e8e1..4093ff2 100644 --- a/test/client-send-cadence.test.ts +++ b/test/client-send-cadence.test.ts @@ -12,9 +12,17 @@ const { applyArrayPlugins, settings, MoveActionCommand, ClientHeartbeatCommand } applyArrayPlugins(); -const drive = (frameRate: number, seconds: number, onFrame?: (socket: any) => void) => { +const drive = ( + frameRate: number, + seconds: number, + onFrame?: (socket: any) => void, + connected = true, +) => { const batches: Array = []; - const socket = { emit: (_event: string, payload: string) => batches.push(payload) }; + const socket = { + connected, + emit: (_event: string, payload: string) => batches.push(payload), + }; const commandSocket = new CommandSocket(socket as never); const frames = Math.round(frameRate * seconds); @@ -64,4 +72,19 @@ describe('client send cadence', () => { expect(batches.length).toBeGreaterThan(0); expect(batches.every((b) => b.includes(ClientHeartbeatCommand.name))).toBe(true); }); + + // socket.io-client buffers emits made while disconnected in an uncapped + // sendBuffer and flushes them on reconnect, ahead of the re-join — so every + // one of them is both a leak and wasted bandwidth. + it('sends nothing at all while the transport is down', () => { + const batches = drive( + 60, + 30, + (commandSocket) => + commandSocket.handleCommand(new MoveActionCommand(vec2.fromValues(1, 0), 0)), + false, + ); + + expect(batches).toHaveLength(0); + }); }); diff --git a/test/connection-lifecycle.test.ts b/test/connection-lifecycle.test.ts index 730c893..06cb90d 100644 --- a/test/connection-lifecycle.test.ts +++ b/test/connection-lifecycle.test.ts @@ -142,6 +142,25 @@ describe('hostile payloads', () => { expect(server.serverInfo.playerCount).toBe(4); }); + // socket.io dispatches listeners with no try/catch of its own, so anything + // thrown out of a connection-scoped handler is an uncaught exception: the + // process exits and every player in the match is dropped with it. + it.each([ + ['no payload at all', undefined], + ['null', null], + ['a string', 'not-an-object'], + ['a number', 7], + ])('refuses a join payload that is %s instead of throwing', (_label, payload) => { + const { server, attach } = startServer({ playerLimit: 4, npcCount: 0 }); + const socket = attach(); + + expect(() => socket.fire(TransportEvents.PlayerJoining, payload)).not.toThrow(); + expect(socket.lastPayloadFor(TransportEvents.JoinRejected)).toBe( + JoinRejectionReason.InvalidRequest, + ); + expect(server.serverInfo.playerCount).toBe(0); + }); + it('ignores an oversized command batch instead of parsing it', () => { const { attach } = startServer({ playerLimit: 4, npcCount: 0 }); const socket = attach(); @@ -231,6 +250,43 @@ describe('inbound rate limiting', () => { }); }); +describe('pre-join rate limiting', () => { + // These listeners exist before any join, so the limiter inside + // onPlayerToServer never sees them: unmetered, each one is a reply or an + // allocation an unauthenticated client can ask for at line rate. + it('clamps a flood of joins on an already-joined connection', () => { + const { attach } = startServer({ playerLimit: 4, npcCount: 0 }); + const socket = attach(); + socket.fire(TransportEvents.PlayerJoining, { name: 'flooder' }); + + const flood = settings.maxInboundMessageBurst * 4; + for (let i = 0; i < flood; i++) { + socket.fire(TransportEvents.PlayerJoining, { name: 'flooder' }); + } + + const rejections = socket.sent.filter( + (m) => m.event === TransportEvents.JoinRejected, + ); + expect(rejections.length).toBeLessThan(flood); + }); + + it('clamps a flood of server-info subscriptions', () => { + const { attach } = startServer({ playerLimit: 4, npcCount: 0 }); + const socket = attach(); + let joins = 0; + socket.join = () => { + joins++; + }; + + const flood = settings.maxInboundMessageBurst * 4; + for (let i = 0; i < flood; i++) { + socket.fire(TransportEvents.SubscribeForServerInfoUpdates); + } + + expect(joins).toBeLessThan(flood); + }); +}); + describe('reconnect tokens', () => { const tokenFromFreshServer = () => { Random.seed = 1; diff --git a/test/physics-determinism.test.mjs b/test/physics-determinism.test.mjs index 2a4ad65..a4409c9 100644 --- a/test/physics-determinism.test.mjs +++ b/test/physics-determinism.test.mjs @@ -3,6 +3,8 @@ import { describe, it, expect } from 'vitest'; import { createRequire } from 'node:module'; const require = createRequire(import.meta.url); +// Numerics match production because test/setup.mjs has already applied +// glMatrix.setMatrixArrayType(Array) — without it this would pin f32. const shared = require('../shared/lib/main.js'); const { vec2 } = require('../shared/node_modules/gl-matrix'); @@ -58,7 +60,13 @@ const makeSpinningPlanetWorld = () => { angularVelocity: 0.09, advance: (dt) => (rotation += surface.angularVelocity * dt), distance: (target) => - planetDistance(target, vertices, base.center, Math.cos(rotation), Math.sin(rotation)), + planetDistance( + target, + vertices, + base.center, + Math.cos(rotation), + Math.sin(rotation), + ), gravityAt: (target) => planetGravity(base.center, base.radius, target), }; const grounds = [surface]; @@ -89,8 +97,7 @@ const runPlanetSimulation = () => { for (let i = 0; i < 900; i++) { // Fall in, then walk right, then left. - const input = - i < 200 ? vec2.create() : vec2.fromValues(i < 600 ? 1 : -1, 0); + const input = i < 200 ? vec2.create() : vec2.fromValues(i < 600 ? 1 : -1, 0); tickPlanetDetachment(state, stepSeconds); stepCharacterMovement(state, world, input, stepSeconds); surface.advance(stepSeconds); diff --git a/test/setup.mjs b/test/setup.mjs new file mode 100644 index 0000000..fb99774 --- /dev/null +++ b/test/setup.mjs @@ -0,0 +1,20 @@ +// Production calls glMatrix.setMatrixArrayType(Array) before anything else +// (backend/src/main.ts, frontend/src/index.ts), which makes every vector an f64 +// Array instead of the default Float32Array. Without the same call here the +// suite pins the numerics of a build nobody ships — including module-level +// scratch vectors, which are allocated the moment a module is imported. +// +// One call per installed gl-matrix: the prebuilt shared bundle keeps gl-matrix +// external and resolves shared/node_modules, while the backend and frontend +// sources the tests import directly resolve their own copies. +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); + +for (const path of [ + '../shared/node_modules/gl-matrix', + '../backend/node_modules/gl-matrix', + '../frontend/node_modules/gl-matrix', +]) { + require(path).glMatrix.setMatrixArrayType(Array); +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..086f2a0 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + // Runs before any test module is imported, which matters: module-level + // vectors are allocated at import time and cannot be converted afterwards. + setupFiles: ['./test/setup.mjs'], + }, +}); -- 2.54.0 From 8f50ebdb9746c8af125da86cb9ec5f07dd60a95c Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sun, 23 Aug 2026 10:27:55 +0100 Subject: [PATCH 07/15] even more --- backend/src/create-world.ts | 99 ++-- backend/src/game-events.ts | 4 + backend/src/game-server.ts | 205 +++----- backend/src/main.ts | 3 +- backend/src/objects/character-physical.ts | 496 ++++++++---------- backend/src/objects/circle-physical.ts | 122 +---- backend/src/objects/lamp-physical.ts | 26 +- backend/src/objects/planet-physical.ts | 215 +++----- backend/src/objects/projectile-physical.ts | 107 ++-- backend/src/physics/bounding-box.ts | 70 +-- backend/src/physics/physical-container.ts | 51 +- backend/src/physics/physical.ts | 15 +- backend/src/players/npc.ts | 198 +++---- backend/src/players/player-base.ts | 104 ++-- backend/src/players/player-container.ts | 72 ++- backend/src/players/player.ts | 259 ++++----- backend/webpack.config.js | 3 +- eslint.config.js | 2 - frontend/src/index.ts | 112 ++-- frontend/src/scripts/charge-indicator.ts | 11 +- .../src/scripts/commands/command-socket.ts | 32 +- .../src/scripts/commands/input-generator.ts | 29 + .../src/scripts/commands/keyboard-listener.ts | 48 +- .../src/scripts/commands/mouse-listener.ts | 38 +- .../src/scripts/commands/touch-listener.ts | 236 ++++----- frontend/src/scripts/configuration.ts | 8 - frontend/src/scripts/feedback-hud.ts | 11 +- frontend/src/scripts/game.ts | 233 ++++---- .../interpolators/linear-interpolator.ts | 5 - .../helper/interpolators/vec2-interpolator.ts | 1 - .../prediction/client-character-world.ts | 116 +--- .../helper/prediction/input-history.ts | 1 - .../prediction/local-character-predictor.ts | 49 +- .../src/scripts/helper/server-timeline.ts | 17 +- frontend/src/scripts/join-form-handler.ts | 181 +++---- .../src/scripts/landing-page-background.ts | 102 ++-- frontend/src/scripts/minimap.ts | 17 +- .../scripts/objects/game-object-container.ts | 163 +++--- frontend/src/scripts/objects/types/camera.ts | 49 +- .../scripts/objects/types/character-view.ts | 165 +++--- .../src/scripts/objects/types/lamp-view.ts | 25 +- .../src/scripts/objects/types/planet-view.ts | 157 ++---- .../scripts/objects/types/projectile-view.ts | 27 +- frontend/src/scripts/objects/view.ts | 9 + frontend/src/scripts/options-handler.ts | 102 ++-- frontend/src/scripts/screen-shake.ts | 2 - .../src/scripts/shapes/character-shape.ts | 6 +- frontend/src/scripts/shapes/planet-shape.ts | 4 +- frontend/src/scripts/sound-handler.ts | 38 +- frontend/src/scripts/tutorial.ts | 2 +- frontend/src/scripts/vibration-handler.ts | 7 +- frontend/webpack.config.js | 26 +- shared/src/commands/command.ts | 4 +- .../types/actions/client-heartbeat.ts | 4 - .../src/commands/types/actions/leap-action.ts | 3 - .../src/commands/types/actions/move-action.ts | 4 - .../commands/types/actions/primary-action.ts | 3 - shared/src/commands/types/game-start.ts | 4 - .../commands/types/input-acknowledgement.ts | 22 +- .../types/property-updates-for-objects.ts | 1 - .../types/remote-calls-for-objects.ts | 3 +- shared/src/communication/communication.ts | 6 +- shared/src/helper/calculate-view-area.ts | 2 +- shared/src/helper/charge.ts | 5 +- shared/src/helper/ease.ts | 15 + shared/src/helper/finite.ts | 14 +- shared/src/helper/mix-rgb.ts | 2 - shared/src/helper/random.ts | 3 - shared/src/main.ts | 4 +- shared/src/objects/game-object.ts | 71 +-- shared/src/objects/types/character-base.ts | 40 +- shared/src/objects/types/lamp-base.ts | 16 +- shared/src/objects/types/planet-base.ts | 63 ++- shared/src/objects/types/projectile-base.ts | 11 +- shared/src/physics/character-movement.ts | 40 +- shared/src/physics/depenetrate-circle.ts | 3 - shared/src/physics/evaluate-sdf.ts | 2 - shared/src/physics/march-circle.ts | 29 +- shared/src/physics/planet-sdf.ts | 14 +- shared/src/physics/resolve-circle-movement.ts | 17 +- shared/src/physics/sdf-normal.ts | 10 +- shared/src/physics/sdf.ts | 4 - shared/src/serialization/deserialize.ts | 14 +- .../src/serialization/serializable-mapping.ts | 4 - shared/src/serialization/serializable.ts | 7 +- shared/src/serialization/serialize.ts | 11 +- shared/src/settings.ts | 16 +- test/backend-regressions.test.ts | 183 +++---- test/backpressure.test.ts | 10 +- test/client-send-cadence.test.ts | 12 +- test/connection-lifecycle.test.ts | 16 +- test/physics-determinism.test.mjs | 160 ++---- test/prediction-reconciliation.test.ts | 3 - test/serialization.test.mjs | 22 +- test/setup.mjs | 10 +- vitest.config.ts | 2 - 96 files changed, 1858 insertions(+), 3111 deletions(-) create mode 100644 backend/src/game-events.ts create mode 100644 frontend/src/scripts/commands/input-generator.ts create mode 100644 frontend/src/scripts/objects/view.ts create mode 100644 shared/src/helper/ease.ts diff --git a/backend/src/create-world.ts b/backend/src/create-world.ts index a25093c..ace7180 100644 --- a/backend/src/create-world.ts +++ b/backend/src/create-world.ts @@ -2,16 +2,14 @@ import { vec2 } from 'gl-matrix'; import { Random, PlanetBase, hsl, settings, evaluateSdf } from 'shared'; import { LampPhysical } from './objects/lamp-physical'; import { PlanetPhysical } from './objects/planet-physical'; -import { PhysicalContainer } from './physics/containers/physical-container'; -import { Physical } from './physics/physicals/physical'; +import { PhysicalContainer } from './physics/physical-container'; export const createWorld = (objectContainer: PhysicalContainer) => { - const objects: Array = []; - const lights: Array = []; + const planets: Array = []; + const lamps: Array = []; for (let r = 0; r < settings.worldRadius; r += settings.radiusSteps) { - const circumference = 2 * Math.PI * r; - const stepCount = circumference * settings.objectsOnCircleLength; + const stepCount = 2 * Math.PI * r * settings.objectsOnCircleLength; for (let rad = 0; rad < 2 * Math.PI; rad += (2 * Math.PI) / stepCount) { const position = vec2.rotate( vec2.create(), @@ -20,12 +18,12 @@ export const createWorld = (objectContainer: PhysicalContainer) => { rad, ); - if (objects.length !== 0 && Random.getRandom() > 0.5) { + if (planets.length !== 0 && Random.getRandom() > 0.5) { if ( - evaluateSdf(position, objects) > 200 && - !lights.find((l) => l.distance(position) < 2500) + evaluateSdf(position, planets) > 200 && + !lamps.find((l) => l.distance(position) < 2500) ) { - lights.push( + lamps.push( new LampPhysical( position, hsl( @@ -37,56 +35,43 @@ export const createWorld = (objectContainer: PhysicalContainer) => { ), ); } - } else { - if ( - evaluateSdf(position, objects) > 1400 && - !lights.find((l) => l.distance(position) < 1700) - ) { - const planet = - objects.length === 0 - ? new PlanetPhysical( - PlanetBase.createPlanetVertices( - position, - Random.getRandomInRange(1600, 2400), - Random.getRandomInRange(1600, 2400), - Random.getRandomInRange(80, 300), - ), - true, - ) - : new PlanetPhysical( - PlanetBase.createPlanetVertices( - position, - Random.getRandomInRange(300, 1600), - Random.getRandomInRange(300, 1600), - Random.getRandomInRange(20, 100), - ), - ); - - objects.push(planet); - } + } else if ( + evaluateSdf(position, planets) > 1400 && + !lamps.find((l) => l.distance(position) < 1700) + ) { + const isKeystone = planets.length === 0; + const [sizeMin, sizeMax, roughMin, roughMax] = isKeystone + ? [1600, 2400, 80, 300] + : [300, 1600, 20, 100]; + planets.push( + new PlanetPhysical( + PlanetBase.createPlanetVertices( + position, + Random.getRandomInRange(sizeMin, sizeMax), + Random.getRandomInRange(sizeMin, sizeMax), + Random.getRandomInRange(roughMin, roughMax), + ), + isKeystone, + objectContainer.game, + ), + ); } } } - console.info(`Generated ${objects.length} planets`); - console.info(`Generated ${lights.length} light`); + console.info(`Generated ${planets.length} planets and ${lamps.length} lamps`); - // Each lamp is associated with its nearest planet via the planet SDF - // (negative inside), so it advertises that planet's capture. - const planets = objects.filter((o): o is PlanetPhysical => o instanceof PlanetPhysical); - lights - .filter((l): l is LampPhysical => l instanceof LampPhysical) - .forEach((lamp) => { - let nearest: PlanetPhysical | undefined; - let nearestDistance = Infinity; - planets.forEach((planet) => { - const distance = planet.distance(lamp.center); - if (distance < nearestDistance) { - nearestDistance = distance; - nearest = planet; - } - }); - nearest?.addLamp(lamp); - }); + for (const lamp of lamps) { + let nearest: PlanetPhysical | undefined; + let nearestDistance = Infinity; + for (const planet of planets) { + const distance = planet.distance(lamp.center); + if (distance < nearestDistance) { + nearestDistance = distance; + nearest = planet; + } + } + nearest?.addLamp(lamp); + } - [...objects, ...lights].forEach((o) => objectContainer.addObject(o)); + [...planets, ...lamps].forEach((o) => objectContainer.addObject(o)); }; diff --git a/backend/src/game-events.ts b/backend/src/game-events.ts new file mode 100644 index 0000000..58475fc --- /dev/null +++ b/backend/src/game-events.ts @@ -0,0 +1,4 @@ +export interface GameEvents { + addPoints(blue: number, red: number): void; + announce(text: string): void; +} diff --git a/backend/src/game-server.ts b/backend/src/game-server.ts index 9d5ec8f..9bfac97 100644 --- a/backend/src/game-server.ts +++ b/backend/src/game-server.ts @@ -1,4 +1,3 @@ -import { PhysicalContainer } from './physics/containers/physical-container'; import { Server, Socket } from 'socket.io'; import { randomUUID } from 'node:crypto'; import { performance } from 'node:perf_hooks'; @@ -13,39 +12,33 @@ import { GameEndCommand, GameStartCommand, Command, - CommandReceiver, - CommandExecutors, ServerAnnouncement, JoinRejectionReason, - beginPropertyUpdateGeneration, + GameObject, + PropertyUpdatesForObject, } from 'shared'; +import { PhysicalContainer } from './physics/physical-container'; import { createWorld } from './create-world'; import { Options } from './options'; +import { GameEvents } from './game-events'; import { CarriedScore, PlayerContainer } from './players/player-container'; -import { ServerFullError } from './players/server-full-error'; import { Player } from './players/player'; -import { StepCommand, GeneratePointsCommand, AnnounceCommand } from './commands/commands'; const gameStateSubscribedRoom = 'gameStateSubscribedRoom'; +const endTitleSeconds = 6; -// The payload is whatever the client sent: `socket.emit('PlayerJoining')` with -// no argument arrives as undefined. socket.io dispatches listeners inside a -// `process.nextTick` with no try/catch of its own, so a dereference of an -// unchecked payload here would surface as an uncaught exception and take the -// whole match down with the process. const asPlayerInformation = (payload: unknown): PlayerInformation | undefined => { if (!payload || typeof payload !== 'object') { return undefined; } const { name, reconnectToken } = payload as Record; return { - // Coerced and length-capped downstream, in PlayerBase.createCharacter. name: name as string, reconnectToken: typeof reconnectToken === 'string' ? reconnectToken : undefined, }; }; -export class GameServer extends CommandReceiver { +export class GameServer implements GameEvents { private objects!: PhysicalContainer; private players!: PlayerContainer; private lastPhysicsBase = process.hrtime.bigint(); @@ -57,45 +50,24 @@ export class GameServer extends CommandReceiver { private isInEndGame = false; private timeScaling = 1; private statReportMs = Date.now(); + private timeSinceLastServerStateUpdate = 0; + private timeSinceLastPointUpdate = 0; + private physicsAccumulator = 0; + private saturatedFrames = 0; + private droppedInboundMessages = 0; - private initialize() { - const previousPlayers = this.players; - this.releaseJoinedSockets(); - this.objects = new PhysicalContainer(); - createWorld(this.objects); - this.objects.initialize(); - this.players = new PlayerContainer( - this.objects, - this.options.playerLimit, - this.options.npcCount, - ); - this.lastPhysicsBase = process.hrtime.bigint(); - this.bluePoints = 0; - this.redPoints = 0; - this.matchPointAnnounced = {}; - this.isInEndGame = false; - this.timeScaling = 1; - previousPlayers?.queueCommandForEachClient(new GameStartCommand()); - previousPlayers?.sendQueuedCommands(); - } - - protected commandExecutors: CommandExecutors = { - [GeneratePointsCommand.type]: this.addPoints.bind(this), - [AnnounceCommand.type]: ({ text }: AnnounceCommand) => - this.players.queueCommandForEachClient(new ServerAnnouncement(text)), - }; + private readonly joinedSockets = new Map< + Socket, + { player: Player; release: () => void } + >(); + private inboundBudget = new WeakMap(); constructor( private readonly io: Server, private options: Options, ) { - super(); - this.initialize(); - // Both listeners are metered: they exist before any join, so the limiter - // inside onPlayerToServer cannot see them, and each one costs a reply or an - // allocation an unauthenticated client could otherwise ask for at line rate. io.on('connection', (socket: Socket) => { socket.on(TransportEvents.PlayerJoining, (payload: unknown) => { if (!this.allowInboundMessage(socket)) { @@ -111,18 +83,35 @@ export class GameServer extends CommandReceiver { }); socket.on(TransportEvents.SubscribeForServerInfoUpdates, () => { - if (!this.allowInboundMessage(socket)) { - return; + if (this.allowInboundMessage(socket)) { + socket.join(gameStateSubscribedRoom); } - socket.join(gameStateSubscribedRoom); }); }); } - private readonly joinedSockets = new Map< - Socket, - { player: Player; release: () => void } - >(); + private initialize() { + const previousPlayers = this.players; + this.joinedSockets.forEach(({ release }) => release()); + this.joinedSockets.clear(); + this.inboundBudget = new WeakMap(); + + this.objects = new PhysicalContainer(this); + createWorld(this.objects); + this.players = new PlayerContainer( + this.objects, + this.options.playerLimit, + this.options.npcCount, + ); + this.lastPhysicsBase = process.hrtime.bigint(); + this.bluePoints = 0; + this.redPoints = 0; + this.matchPointAnnounced = {}; + this.isInEndGame = false; + this.timeScaling = 1; + previousPlayers?.queueCommandForEachClient(new GameStartCommand()); + previousPlayers?.sendQueuedCommands(); + } private handleJoin(socket: Socket, payload: unknown) { const playerInfo = asPlayerInformation(payload); @@ -141,9 +130,6 @@ export class GameServer extends CommandReceiver { return; } - // A reconnect arrives seconds after the drop, long before engine.io times - // the dead socket out. Retire that ghost first so its slot, team and score - // go back to the returning client. const carried = this.retireGhost(playerInfo.reconnectToken); if (this.players.isFull) { @@ -151,20 +137,7 @@ export class GameServer extends CommandReceiver { return; } - let player: Player; - try { - player = this.players.createPlayer(playerInfo, socket, carried); - } catch (e) { - console.error('Failed to register joining player', e); - socket.emit( - TransportEvents.JoinRejected, - e instanceof ServerFullError - ? JoinRejectionReason.ServerFull - : JoinRejectionReason.InvalidRequest, - ); - socket.disconnect(); - return; - } + const player = this.players.createPlayer(playerInfo, socket, carried); const onPlayerToServer = (json: string) => { try { @@ -203,14 +176,8 @@ export class GameServer extends CommandReceiver { this.sendServerStateUpdate(); } - // A WeakMap, not a Map: sockets that never join are metered too, and they - // have no join-scoped teardown to remove their entry. - private inboundBudget = new WeakMap(); - private droppedInboundMessages = 0; private allowInboundMessage(socket: Socket): boolean { - // performance.now(), never Date.now(): a backwards wall-clock step (an NTP - // correction, a VM resync) would make the refill negative and lock the - // client out of sending input for as long as the step was. + // performance.now(), not Date.now(): a backwards wall-clock step would lock the client out. const nowMs = performance.now(); const burst = settings.maxInboundMessageBurst; const perSecond = settings.maxInboundMessagesPerSecond; @@ -238,7 +205,7 @@ export class GameServer extends CommandReceiver { } for (const [socket, { player }] of this.joinedSockets) { if (player.reconnectToken === token) { - const carried = { team: player.team, ...player.scoreSnapshot }; + const carried = { team: player.team, ...player.score }; this.removeJoined(socket); return carried; } @@ -253,26 +220,17 @@ export class GameServer extends CommandReceiver { } this.joinedSockets.delete(socket); this.inboundBudget.delete(socket); - // Only the listeners this join added: socket.io keeps its own internal - // 'error' guard and the connection-scoped PlayerJoining handler here. joined.release(); joined.player.destroy(); this.players.deletePlayer(joined.player); } - private releaseJoinedSockets() { - this.joinedSockets.forEach(({ release }) => release()); - this.joinedSockets.clear(); - this.inboundBudget = new WeakMap(); - } - - private timeSinceLastServerStateUpdate = 0; public sendServerStateUpdate() { this.io .to(gameStateSubscribedRoom) .emit(TransportEvents.ServerInfoUpdate, [ this.players.count, - (Math.max(this.bluePoints, this.redPoints) / this.options.scoreLimit) * 100, + this.serverInfo.gameStatePercent, ]); } @@ -280,7 +238,7 @@ export class GameServer extends CommandReceiver { this.handlePhysics(); } - private addPoints({ blue, red }: GeneratePointsCommand) { + public addPoints(blue: number, red: number) { if (this.isInEndGame) { return; } @@ -297,31 +255,49 @@ export class GameServer extends CommandReceiver { } } + public announce(text: string) { + this.players.queueCommandForEachClient(new ServerAnnouncement(text)); + } + private announceMatchPointOnce(team: CharacterTeam, points: number) { if ( !this.matchPointAnnounced[team] && points >= this.options.scoreLimit * settings.matchPointScoreRatio ) { this.matchPointAnnounced[team] = true; - this.players.queueCommandForEachClient( - new ServerAnnouncement( - `Match point — team ${team}!`, - ), - ); + this.announce(`Match point — team ${team}!`); } } private endGame(winningTeam: CharacterTeam) { this.isInEndGame = true; - const endTitleLength = 6; this.players.endGame(winningTeam); this.players.queueCommandForEachClient(new GameEndCommand()); - setTimeout(() => this.initialize(), endTitleLength * 1000 * 1.1); + setTimeout(() => this.initialize(), endTitleSeconds * 1000 * 1.1); } - private timeSinceLastPointUpdate = 0; - private physicsAccumulator = 0; - private saturatedFrames = 0; + private reportStats() { + const mem = `${(process.memoryUsage().rss / 1024 / 1024).toFixed(2)} MB`; + console.info(`Memory: ${mem}, Players: ${this.players.count}`); + + const rtts = this.players.connectedPlayerRttsMs.filter((r) => r > 0); + if (rtts.length > 0) { + rtts.sort((a, b) => a - b); + console.info( + `RTT median ${rtts[Math.floor(rtts.length / 2)].toFixed(0)} ms ` + + `(min ${rtts[0].toFixed(0)}, max ${rtts[rtts.length - 1].toFixed(0)}, n=${rtts.length})`, + ); + } + + if (this.droppedInboundMessages > 0) { + console.warn(`Rate limited ${this.droppedInboundMessages} inbound msg(s)`); + this.droppedInboundMessages = 0; + } + if (this.saturatedFrames > 0) { + console.warn(`Physics saturated on ${this.saturatedFrames} frame(s)`); + this.saturatedFrames = 0; + } + } private handlePhysics() { const frameStart = process.hrtime.bigint(); @@ -330,26 +306,7 @@ export class GameServer extends CommandReceiver { if (Date.now() - this.statReportMs > 30000) { this.statReportMs = Date.now(); - const mem = `${(process.memoryUsage().rss / 1024 / 1024).toFixed(2)} MB`; - console.info(`Memory: ${mem}, Players: ${this.players.count}`); - - const rtts = this.players.connectedPlayerRttsMs.filter((r) => r > 0); - if (rtts.length > 0) { - rtts.sort((a, b) => a - b); - console.info( - `RTT median ${rtts[Math.floor(rtts.length / 2)].toFixed(0)} ms ` + - `(min ${rtts[0].toFixed(0)}, max ${rtts[rtts.length - 1].toFixed(0)}, n=${rtts.length})`, - ); - } - - if (this.droppedInboundMessages > 0) { - console.warn(`Rate limited ${this.droppedInboundMessages} inbound msg(s)`); - this.droppedInboundMessages = 0; - } - if (this.saturatedFrames > 0) { - console.warn(`Physics saturated on ${this.saturatedFrames} frame(s)`); - this.saturatedFrames = 0; - } + this.reportStats(); } if ((this.timeSinceLastServerStateUpdate += delta) > 4) { @@ -387,17 +344,21 @@ export class GameServer extends CommandReceiver { this.timeScaling *= Math.pow(settings.endGameDeltaScaling, fixedDelta); scaledDelta /= this.timeScaling; } - this.objects.handleCommand(new StepCommand(scaledDelta, this)); + this.objects.step(scaledDelta); this.players.step(scaledDelta); } - beginPropertyUpdateGeneration(); - this.players.stepCommunication(delta); + const propertyUpdates = new Map(); + this.players.stepCommunication(delta, (object) => { + if (!propertyUpdates.has(object)) { + propertyUpdates.set(object, object.getPropertyUpdates()); + } + return propertyUpdates.get(object); + }); this.objects.resetRemoteCalls(); const elapsed = Number(process.hrtime.bigint() - frameStart) / 1e9; - - setTimeout(this.handlePhysics.bind(this), Math.max(0, fixedDelta - elapsed) * 1000); + setTimeout(() => this.handlePhysics(), Math.max(0, fixedDelta - elapsed) * 1000); } public get serverInfo(): ServerInformation { diff --git a/backend/src/main.ts b/backend/src/main.ts index 6905969..1f7115e 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -2,7 +2,7 @@ import { Server as IoServer } from 'socket.io'; import express from 'express'; import { Server } from 'http'; import cors from 'cors'; -import { applyArrayPlugins, Random, serverInformationEndpoint, settings } from 'shared'; +import { Random, serverInformationEndpoint, settings } from 'shared'; import minimist from 'minimist'; import { glMatrix } from 'gl-matrix'; import { GameServer } from './game-server'; @@ -10,7 +10,6 @@ import { defaultOptions } from './options'; import parser from 'socket.io-msgpack-parser'; glMatrix.setMatrixArrayType(Array); -applyArrayPlugins(); const optionOverrides = minimist(process.argv.slice(2)); const options = { diff --git a/backend/src/objects/character-physical.ts b/backend/src/objects/character-physical.ts index 5fba09a..fd870d0 100644 --- a/backend/src/objects/character-physical.ts +++ b/backend/src/objects/character-physical.ts @@ -2,16 +2,13 @@ import { vec2 } from 'gl-matrix'; import { id, settings, - MoveActionCommand, - serializesTo, Circle, CharacterBase, CharacterTeam, PropertyUpdatesForObject, UpdatePropertyCommand, - CommandExecutors, - CommandReceiver, CharacterMovementSnapshot, + GameObject, mix, clamp01, strengthToCharge, @@ -20,6 +17,7 @@ import { decayMomentum, tickPlanetDetachment, characterCenter, + sumGravity, CharacterWorld, GroundSurface, headRadius, @@ -29,69 +27,45 @@ import { rightFootOffset, boundRadius, } from 'shared'; -import { DynamicPhysical } from '../physics/physicals/dynamic-physical'; +import { Physical } from '../physics/physical'; import { CirclePhysical } from './circle-physical'; -import { PhysicalContainer } from '../physics/containers/physical-container'; -import { BoundingBoxBase } from '../physics/bounding-boxes/bounding-box-base'; +import { PhysicalContainer } from '../physics/physical-container'; +import { BoundingBox } from '../physics/bounding-box'; import { ProjectilePhysical } from './projectile-physical'; -import { forceAtPosition } from '../physics/functions/force-at-position'; -import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle'; -import { PlanetPhysical } from './planet-physical'; -import { - StepCommand, - ReactToCollisionCommand, - GeneratePointsCommand, -} from '../commands/commands'; +import { PlanetPhysical, planetsIn } from './planet-physical'; -interface BodyPose { - head: Circle; - leftFoot: Circle; - rightFoot: Circle; -} +const placeholder = new Circle(vec2.create(), 0); -@serializesTo(CharacterBase) -export class CharacterPhysical extends CharacterBase implements DynamicPhysical { +export class CharacterPhysical extends CharacterBase implements Physical { public readonly canCollide = true; - public readonly canMove = true; - - private projectileStrength = settings.playerMaxStrength; - - private timeSinceDying = 0; - private isDestroyed = false; - private timeSinceBorn = 0; - private hasJustBorn = true; - private timeAlive = 0; - - private timeSinceLastShot = settings.projectileCreationInterval; - private timeSinceLastDamage = settings.playerOutOfCombatDelaySeconds; - private lastSyncedHealth = settings.playerMaxHealth; - - private killStreak = 0; - - // Held in place: the shared simulation reads and writes these directly, so - // the character IS the state it is stepped as (client predictor agrees). - public direction = 0; - public currentPlanet: GroundSurface | undefined; - public secondsSinceOnSurface = settings.planetDetachmentSeconds; - public readonly bodyVelocity = vec2.create(); - - private timeSinceLastLeap = settings.leapCooldownSeconds; public head: CirclePhysical; public leftFoot: CirclePhysical; public rightFoot: CirclePhysical; - private movementActions: Array = []; - private lastMovementAction: MoveActionCommand = new MoveActionCommand(vec2.create()); + public direction = 0; + public currentPlanet: GroundSurface | undefined; + public secondsSinceOnSurface = settings.planetDetachmentSeconds; + public readonly bodyVelocity = vec2.create(); - private headVelocity = new Circle(vec2.create(), 0); - private leftFootVelocity = new Circle(vec2.create(), 0); - private rightFootVelocity = new Circle(vec2.create(), 0); + private projectileStrength = settings.playerMaxStrength; + private timeAlive = 0; + private timeSinceDying = 0; + private isDestroyed = false; + private hasJustBorn = true; + private hasFiredSinceSpawn = false; + private timeSinceLastShot = settings.projectileCreationInterval; + private timeSinceLastLeap = settings.leapCooldownSeconds; + private timeSinceLastDamage = settings.playerOutOfCombatDelaySeconds; + private lastSyncedHealth = settings.playerMaxHealth; + private killStreak = 0; - protected commandExecutors: CommandExecutors = { - [StepCommand.type]: this.step.bind(this), - [ReactToCollisionCommand.type]: this.onCollision.bind(this), - }; + private movementDirections: Array = []; + private lastMovementDirection = vec2.create(); + + private readonly previousPose = [0, 1, 2].map(() => new Circle(vec2.create(), 0)); + private readonly velocities = [0, 1, 2].map(() => new Circle(vec2.create(), 0)); + private readonly box = new BoundingBox(); constructor( name: string, @@ -101,43 +75,42 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical private readonly container: PhysicalContainer, startPosition: vec2, ) { - super(id(), name, killCount, deathCount, team, settings.playerMaxHealth); - this.head = new CirclePhysical( - vec2.add(vec2.create(), startPosition, headOffset), - headRadius, - this, - container, + super( + id(), + name, + killCount, + deathCount, + team, + settings.playerMaxHealth, + placeholder, + placeholder, + placeholder, ); - this.leftFoot = new CirclePhysical( - vec2.add(vec2.create(), startPosition, leftFootOffset), - feetRadius, - this, - container, - ); - this.rightFoot = new CirclePhysical( - vec2.add(vec2.create(), startPosition, rightFootOffset), - feetRadius, - this, - container, - ); - container.addObject(this.head); - container.addObject(this.leftFoot); - container.addObject(this.rightFoot); + const part = (offset: vec2, radius: number) => + new CirclePhysical( + vec2.add(vec2.create(), startPosition, offset), + radius, + this, + container, + ); + this.head = part(headOffset, headRadius); + this.leftFoot = part(leftFootOffset, feetRadius); + this.rightFoot = part(rightFootOffset, feetRadius); } private readonly movementWorld: CharacterWorld = { - // Same set and order forceAtPosition uses, so the f64 gravity sum matches. groundsNear: (center, radius) => - this.container - .findIntersecting(getBoundingBoxOfCircle(new Circle(center, radius))) - .filter((o): o is PlanetPhysical => o instanceof PlanetPhysical), + planetsIn(this.container.findIntersecting(BoundingBox.ofCircle(center, radius))), stepBody: (body, deltaTimeInSeconds) => { - const { hitObject } = (body as CirclePhysical).stepManually(deltaTimeInSeconds); - return hitObject instanceof PlanetPhysical ? hitObject : undefined; + const hit = (body as CirclePhysical).stepManually(deltaTimeInSeconds); + return hit instanceof PlanetPhysical ? hit : undefined; }, }; - private hasFiredSinceSpawn = false; + private get parts(): Array { + return [this.head, this.leftFoot, this.rightFoot]; + } + private get isSpawnProtected(): boolean { return ( !this.hasFiredSinceSpawn && @@ -146,38 +119,14 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical ); } - private hasGeneratedPoints = false; - private wasKilledInCombat = false; - private getPoints(game: CommandReceiver) { - if (!this.isAlive && !this.hasGeneratedPoints) { - this.hasGeneratedPoints = true; - - if (!this.wasKilledInCombat) { - return; - } - - const blue = this.team === CharacterTeam.blue ? 0 : settings.playerKillPoint; - const red = this.team === CharacterTeam.red ? 0 : settings.playerKillPoint; - - game.handleCommand(new GeneratePointsCommand(blue, red)); - } - } - public get isAlive(): boolean { return !this.isDestroyed; } - public handleMovementAction(c: MoveActionCommand) { - this.movementActions.push(c); - } - - // The world only ever hands back planets, so narrowing GroundSurface is safe. public get groundPlanet(): PlanetPhysical | undefined { return this.currentPlanet as PlanetPhysical | undefined; } - // Continuous movement state streamed to the owning client so its predictor - // resumes this simulation instead of guessing. See CharacterMovementSnapshot. public get movementSnapshot(): CharacterMovementSnapshot { return new CharacterMovementSnapshot( this.direction, @@ -189,6 +138,37 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical ); } + public get boundingBox(): BoundingBox { + const center = this.center; + this.box.xMin = center[0] - boundRadius; + this.box.xMax = center[0] + boundRadius; + this.box.yMin = center[1] - boundRadius; + this.box.yMax = center[1] + boundRadius; + return this.box; + } + + public get gameObject(): this { + return this; + } + + public get center(): vec2 { + return characterCenter(this.head, this.leftFoot, this.rightFoot); + } + + public distance(target: vec2): number { + return ( + Math.min( + this.head.distance(target), + this.leftFoot.distance(target), + this.rightFoot.distance(target), + ) - 5 + ); + } + + public setMoveDirection(direction: vec2) { + this.movementDirections.push(direction); + } + public addKill(victimName: string, charge = 0) { this.killCount++; this.killStreak++; @@ -214,38 +194,38 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical } } - public onCollision({ other }: ReactToCollisionCommand) { + public onCollision(other: GameObject) { if ( - // A corpse keeps its collidable circles for the despawn animation; the - // isAlive guard stops a flying corpse from eating shots aimed past it. - this.isAlive && - other instanceof ProjectilePhysical && - other.team !== this.team && - other.isAlive + !this.isAlive || + !(other instanceof ProjectilePhysical) || + other.team === this.team || + !other.isAlive ) { - other.destroy(); + return; + } - if (this.isSpawnProtected) { - return; - } + other.destroy(); - this.timeSinceLastDamage = 0; - this.health -= other.strength; - this.lastSyncedHealth = Math.round(this.health); - this.remoteCall('setHealth', this.health); + if (this.isSpawnProtected) { + return; + } - if (this.health <= 0 && this.isAlive) { - vec2.scaleAndAdd( - this.bodyVelocity, - this.bodyVelocity, - other.direction, - mix(settings.deathImpulseMin, settings.deathImpulseMax, other.charge), - ); - this.onDie(true); - other.originator.addKill(this.name, other.charge); - } else { - other.originator.registerHit(other.charge); - } + this.timeSinceLastDamage = 0; + this.health -= other.strength; + this.lastSyncedHealth = Math.round(this.health); + this.remoteCall('setHealth', this.health); + + if (this.health <= 0) { + vec2.scaleAndAdd( + this.bodyVelocity, + this.bodyVelocity, + other.direction, + mix(settings.deathImpulseMin, settings.deathImpulseMax, other.charge), + ); + this.onDie(true); + other.originator.addKill(this.name, other.charge); + } else { + other.originator.registerHit(other.charge); } } @@ -266,41 +246,37 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical this.timeSinceLastShot = 0; this.hasFiredSinceSpawn = true; - const requestedCharge = clamp01(charge); const desiredStrength = mix( settings.chargeShotStrengthMin, settings.chargeShotStrengthMax, - requestedCharge, + clamp01(charge), ); const strength = Math.min(desiredStrength, this.projectileStrength); this.projectileStrength -= strength; const c = strengthToCharge(strength); - const radius = mix(settings.chargeShotRadiusMin, settings.chargeShotRadiusMax, c); const speed = mix(settings.chargeShotSpeedMin, settings.chargeShotSpeedMax, c); vec2.normalize(direction, direction); - // Keep the unit direction before vec2.scale repurposes it as the velocity. - const shotDirection = vec2.clone(direction); - const velocity = vec2.scale(direction, direction, speed); - const projectile = new ProjectilePhysical( - vec2.clone(this.center), - radius, - strength, - this.team, - velocity, - this, - this.container, - c, + this.container.addObject( + new ProjectilePhysical( + vec2.clone(this.center), + radius, + strength, + this.team, + vec2.scale(vec2.create(), direction, speed), + this, + this.container, + c, + ), ); - this.container.addObject(projectile); if (c > 0) { vec2.scaleAndAdd( this.bodyVelocity, this.bodyVelocity, - shotDirection, + direction, -settings.chargeShotRecoilMax * c, ); } @@ -321,67 +297,32 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical this.timeSinceLastLeap = 0; this.projectileStrength -= settings.leapStrengthCost; - - // Same impulse the client predicts with (shared), so a leap launches - // identically on both sides. - applyLeapImpulse(this, this.lastMovementAction.direction); + applyLeapImpulse(this, this.lastMovementDirection); this.remoteCall('onLeap'); } - public get boundingBox(): BoundingBoxBase { - return getBoundingBoxOfCircle(new Circle(this.center, boundRadius)); - } - - public get gameObject(): this { - return this; - } - - public get center(): vec2 { - return characterCenter(this.head, this.leftFoot, this.rightFoot); - } - - public distance(target: vec2): number { - return ( - Math.min( - this.head.distance(target), - this.leftFoot.distance(target), - this.rightFoot.distance(target), - ) - 5 - ); - } - - private averageAndResetMovementActions(): vec2 { - let direction: vec2; - if (this.movementActions.length === 0) { - direction = vec2.clone(this.lastMovementAction.direction); - } else { - direction = this.movementActions.reduce( - (sum, current) => vec2.add(sum, sum, current.direction), - vec2.create(), - ); - - vec2.scale(direction, direction, 1 / this.movementActions.length); - - const actions = this.movementActions; - this.lastMovementAction = actions[actions.length - 1]!; - this.movementActions = []; + public onDie(killedInCombat = false) { + if (this.isDestroyed) { + return; } + this.isDestroyed = true; + this.remoteCall('onDie'); - return vec2.length(direction) > 0 - ? vec2.normalize(direction, direction) - : vec2.create(); - } - - private animateScaling(q: number) { - this.head.radius = headRadius * q; - this.leftFoot.radius = this.rightFoot.radius = feetRadius * q; + if (killedInCombat) { + const points = settings.playerKillPoint; + this.container.game.addPoints( + this.team === CharacterTeam.blue ? 0 : points, + this.team === CharacterTeam.red ? 0 : points, + ); + } } public getPropertyUpdates(): PropertyUpdatesForObject { + const [headVelocity, leftFootVelocity, rightFootVelocity] = this.velocities; return new PropertyUpdatesForObject(this.id, [ - new UpdatePropertyCommand('head', this.head, this.headVelocity), - new UpdatePropertyCommand('leftFoot', this.leftFoot, this.leftFootVelocity), - new UpdatePropertyCommand('rightFoot', this.rightFoot, this.rightFootVelocity), + new UpdatePropertyCommand('head', this.head, headVelocity), + new UpdatePropertyCommand('leftFoot', this.leftFoot, leftFootVelocity), + new UpdatePropertyCommand('rightFoot', this.rightFoot, rightFootVelocity), new UpdatePropertyCommand( 'strength', this.projectileStrength, @@ -390,107 +331,51 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical ]); } - // `previous` is consumed as scratch so this allocates nothing beyond the - // Circle it returns. - private static rateOfChange(previous: Circle, current: Circle, deltaTime: number) { - return new Circle( - vec2.scale( - previous.center, - vec2.subtract(previous.center, current.center, previous.center), - 1 / deltaTime, - ), - (current.radius - previous.radius) / deltaTime, - ); - } - - private setPropertyUpdates(previous: BodyPose, deltaTime: number) { - const rate = CharacterPhysical.rateOfChange; - this.headVelocity = rate(previous.head, this.head, deltaTime); - this.leftFootVelocity = rate(previous.leftFoot, this.leftFoot, deltaTime); - this.rightFootVelocity = rate(previous.rightFoot, this.rightFoot, deltaTime); - } - - private get pose(): BodyPose { - const snapshot = (circle: Circle) => - new Circle(vec2.clone(circle.center), circle.radius); - return { - head: snapshot(this.head), - leftFoot: snapshot(this.leftFoot), - rightFoot: snapshot(this.rightFoot), - }; - } - - private step({ deltaTimeInSeconds, game }: StepCommand) { - this.getPoints(game); + public step(deltaTimeInSeconds: number) { this.timeAlive += deltaTimeInSeconds; this.timeSinceLastLeap += deltaTimeInSeconds; - const previousPose = this.pose; + this.parts.forEach((part, i) => { + vec2.copy(this.previousPose[i].center, part.center); + this.previousPose[i].radius = part.radius; + }); if (this.isDestroyed) { if ((this.timeSinceDying += deltaTimeInSeconds) > settings.spawnDespawnTime) { - this.destroy(); + this.container.removeObject(this); } else { this.freeFallCorpse(deltaTimeInSeconds); this.animateScaling(1 - this.timeSinceDying / settings.spawnDespawnTime); } - this.setPropertyUpdates(previousPose, deltaTimeInSeconds); - return; - } - - if (this.hasJustBorn) { - if ((this.timeSinceBorn += deltaTimeInSeconds) > settings.spawnDespawnTime) { + } else if (this.hasJustBorn) { + if (this.timeAlive > settings.spawnDespawnTime) { this.hasJustBorn = false; this.animateScaling(1); } else { - this.animateScaling(this.timeSinceBorn / settings.spawnDespawnTime); + this.animateScaling(this.timeAlive / settings.spawnDespawnTime); } - this.setPropertyUpdates(previousPose, deltaTimeInSeconds); - return; + } else { + this.stepAlive(deltaTimeInSeconds); } + this.parts.forEach((part, i) => { + const velocity = this.velocities[i]; + const previous = this.previousPose[i]; + vec2.subtract(velocity.center, part.center, previous.center); + vec2.scale(velocity.center, velocity.center, 1 / deltaTimeInSeconds); + velocity.radius = (part.radius - previous.radius) / deltaTimeInSeconds; + }); + } + + private stepAlive(deltaTimeInSeconds: number) { tickPlanetDetachment(this, deltaTimeInSeconds); this.timeSinceLastShot += deltaTimeInSeconds; - this.projectileStrength = Math.min( settings.playerMaxStrength, this.projectileStrength + settings.playerStrengthRegenerationPerSeconds * deltaTimeInSeconds, ); - this.regenerateHealth(deltaTimeInSeconds); - - this.groundPlanet?.registerPresence(this); - - // stepCharacterMovement is the shared simulation the client predicts with, - // so the two can never drift. - const direction = this.averageAndResetMovementActions(); - stepCharacterMovement(this, this.movementWorld, direction, deltaTimeInSeconds); - - this.setPropertyUpdates(previousPose, deltaTimeInSeconds); - } - - private freeFallCorpse(deltaTime: number) { - const intersecting = this.container.findIntersecting( - getBoundingBoxOfCircle( - new Circle(this.center, boundRadius + settings.maxGravityDistance), - ), - ); - let grounded = false; - for (const part of [this.leftFoot, this.rightFoot, this.head]) { - part.applyForce(forceAtPosition(part.center, intersecting), deltaTime); - vec2.add(part.velocity, part.velocity, this.bodyVelocity); - const { hitObject } = part.stepManually(deltaTime); - if (hitObject instanceof PlanetPhysical) { - grounded = true; - } - } - // Brake with the shared model the living body uses, so a flung corpse - // comes to a definite stop instead of sliding forever. - decayMomentum(this.bodyVelocity, grounded, deltaTime); - } - - private regenerateHealth(deltaTimeInSeconds: number) { this.timeSinceLastDamage += deltaTimeInSeconds; if ( this.timeSinceLastDamage > settings.playerOutOfCombatDelaySeconds && @@ -502,18 +387,51 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical ); this.syncHealth(); } + + this.groundPlanet?.registerPresence(this); + + stepCharacterMovement( + this, + this.movementWorld, + this.averageAndResetMovementDirections(), + deltaTimeInSeconds, + ); } - public onDie(killedInCombat = false) { - this.wasKilledInCombat = killedInCombat; - this.isDestroyed = true; - this.remoteCall('onDie'); + private averageAndResetMovementDirections(): vec2 { + const direction = vec2.create(); + if (this.movementDirections.length === 0) { + vec2.copy(direction, this.lastMovementDirection); + } else { + for (const d of this.movementDirections) { + vec2.add(direction, direction, d); + } + vec2.scale(direction, direction, 1 / this.movementDirections.length); + this.lastMovementDirection = + this.movementDirections[this.movementDirections.length - 1]; + this.movementDirections = []; + } + return vec2.normalize(direction, direction); } - private destroy() { - this.container.removeObject(this); - this.container.removeObject(this.head); - this.container.removeObject(this.leftFoot); - this.container.removeObject(this.rightFoot); + private animateScaling(q: number) { + this.head.radius = headRadius * q; + this.leftFoot.radius = this.rightFoot.radius = feetRadius * q; + } + + private freeFallCorpse(deltaTime: number) { + const planets = this.movementWorld.groundsNear( + this.center, + boundRadius + settings.maxGravityDistance, + ); + let grounded = false; + for (const part of this.parts) { + vec2.copy(part.velocity, this.bodyVelocity); + part.applyForce(sumGravity(planets, part.center), deltaTime); + if (part.stepManually(deltaTime) instanceof PlanetPhysical) { + grounded = true; + } + } + decayMomentum(this.bodyVelocity, grounded, deltaTime); } } diff --git a/backend/src/objects/circle-physical.ts b/backend/src/objects/circle-physical.ts index bf8f716..7cccc47 100644 --- a/backend/src/objects/circle-physical.ts +++ b/backend/src/objects/circle-physical.ts @@ -1,138 +1,72 @@ import { vec2 } from 'gl-matrix'; -import { - applyForce, - Circle, - CommandExecutors, - CommandReceiver, - GameObject, - resolveCircleMovement, - serializesTo, -} from 'shared'; -import { BoundingBox } from '../physics/bounding-boxes/bounding-box'; -import { BoundingBoxBase } from '../physics/bounding-boxes/bounding-box-base'; -import { PhysicalContainer } from '../physics/containers/physical-container'; -import { DynamicPhysical } from '../physics/physicals/dynamic-physical'; -import { Physical } from '../physics/physicals/physical'; -import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle'; -import { ReactToCollisionCommand } from '../commands/commands'; - -@serializesTo(Circle) -export class CirclePhysical extends CommandReceiver implements Circle, DynamicPhysical { - readonly canCollide = true; - readonly canMove = true; +import { applyForce, Circle, GameObject, resolveCircleMovement } from 'shared'; +import { BoundingBox } from '../physics/bounding-box'; +import { PhysicalContainer } from '../physics/physical-container'; +import { Physical } from '../physics/physical'; +export class CirclePhysical extends Circle implements Physical { + public readonly canCollide = true; public velocity = vec2.create(); public lastNormal = vec2.fromValues(0, 1); - private _boundingBox: BoundingBox; - - protected commandExecutors: CommandExecutors = { - [ReactToCollisionCommand.type]: this.onCollision.bind(this), - }; + private readonly box = new BoundingBox(); constructor( - private _center: vec2, - private _radius: number, - public owner: GameObject, + center: vec2, + radius: number, + public readonly owner: Physical, private readonly container: PhysicalContainer, - // Public + readonly so CirclePhysical satisfies the shared PhysicsBody - // interface the movement simulation operates on. public readonly restitution = 0, ) { - super(); - this._boundingBox = new BoundingBox(); - this.syncBoundingBox(); + super(center, radius); } - public get boundingBox(): BoundingBoxBase { - return this._boundingBox; - } - - public get center(): vec2 { - return this._center; - } - - public set center(value: vec2) { - this._center = value; - this.syncBoundingBox(); - } - - public onCollision(c: ReactToCollisionCommand) { - this.owner.handleCommand(c); + public get boundingBox(): BoundingBox { + this.box.xMin = this.center[0] - this.radius; + this.box.xMax = this.center[0] + this.radius; + this.box.yMin = this.center[1] - this.radius; + this.box.yMax = this.center[1] + this.radius; + return this.box; } public get gameObject(): GameObject { - return this.owner; + return this.owner.gameObject; } - public get radius(): number { - return this._radius; - } - - public set radius(value: number) { - this._radius = value; - this.syncBoundingBox(); - } - - public distance(target: vec2): number { - return vec2.distance(target, this.center) - this.radius; - } - - public syncBoundingBox() { - this._boundingBox.xMin = this.center.x - this._radius; - this._boundingBox.xMax = this.center.x + this._radius; - this._boundingBox.yMin = this.center.y - this._radius; - this._boundingBox.yMax = this.center.y + this._radius; + public onCollision(other: GameObject) { + this.owner.onCollision?.(other); } public applyForce(force: vec2, timeInSeconds: number) { applyForce(this, force, timeInSeconds); } - // Delegates to the shared resolveCircleMovement so the server integrates with - // the exact same geometry the client predictor runs — no parallel copy to - // keep in sync. `possibleIntersectors` lets a caller that already broadphased - // avoid a second container query; otherwise it is self-gathered. public stepManually( deltaTimeInSeconds: number, possibleIntersectors?: Array, - ): { - hitObject: GameObject | undefined; - velocity: vec2; - } { + ): GameObject | undefined { const intersecting = ( possibleIntersectors ?? this.sweptBroadphase(deltaTimeInSeconds) ).filter((b) => b.gameObject !== this.gameObject && b.canCollide); - const { hitObject, velocity } = resolveCircleMovement( + const hit = resolveCircleMovement( this, deltaTimeInSeconds, intersecting, (intersected) => { const physical = intersected as Physical; - physical.handleCommand(new ReactToCollisionCommand(this.gameObject)); - this.handleCommand(new ReactToCollisionCommand(physical.gameObject)); + physical.onCollision?.(this.gameObject); + this.onCollision(physical.gameObject); }, - ); + ) as Physical | undefined; - this.syncBoundingBox(); - - return { hitObject: (hitObject as Physical | undefined)?.gameObject, velocity }; + return hit?.gameObject; } private sweptBroadphase(deltaTimeInSeconds: number): Array { - const sweep = vec2.length( - vec2.scale(vec2.create(), this.velocity, deltaTimeInSeconds), - ); - // Grow the query box, not this.radius: mutating the radius would re-register - // the body's extent mid-tick via the setter. + const sweep = vec2.length(this.velocity) * deltaTimeInSeconds; return this.container.findIntersecting( - getBoundingBoxOfCircle(new Circle(this.center, this.radius + sweep)), + BoundingBox.ofCircle(this.center, this.radius + sweep), ); } - - public toArray(): Array { - const { center, radius } = this; - return [center, radius]; - } } diff --git a/backend/src/objects/lamp-physical.ts b/backend/src/objects/lamp-physical.ts index 7438f7d..07d04ef 100644 --- a/backend/src/objects/lamp-physical.ts +++ b/backend/src/objects/lamp-physical.ts @@ -1,29 +1,15 @@ import { vec2, vec3 } from 'gl-matrix'; -import { Circle, LampBase, settings, id, serializesTo } from 'shared'; +import { LampBase, settings, id } from 'shared'; +import { BoundingBox } from '../physics/bounding-box'; +import { Physical } from '../physics/physical'; -import { ImmutableBoundingBox } from '../physics/bounding-boxes/immutable-bounding-box'; -import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle'; -import { StaticPhysical } from '../physics/physicals/static-physical'; - -@serializesTo(LampBase) -export class LampPhysical extends LampBase implements StaticPhysical { +export class LampPhysical extends LampBase implements Physical { public readonly canCollide = false; - public readonly canMove = false; + public readonly boundingBox: BoundingBox; constructor(center: vec2, color: vec3, lightness: number) { super(id(), center, color, lightness); - } - - private _boundingBox?: ImmutableBoundingBox; - - public get boundingBox(): ImmutableBoundingBox { - if (!this._boundingBox) { - this._boundingBox = getBoundingBoxOfCircle( - new Circle(this.center, settings.lightCutoffDistance), - ); - } - - return this._boundingBox; + this.boundingBox = BoundingBox.ofCircle(center, settings.lightCutoffDistance); } public get gameObject(): this { diff --git a/backend/src/objects/planet-physical.ts b/backend/src/objects/planet-physical.ts index db54c33..950a548 100644 --- a/backend/src/objects/planet-physical.ts +++ b/backend/src/objects/planet-physical.ts @@ -1,145 +1,90 @@ import { vec2 } from 'gl-matrix'; - import { - Circle, clamp01, id, mix, Random, - serializesTo, settings, PlanetBase, CharacterTeam, PropertyUpdatesForObject, UpdatePropertyCommand, - planetDistance, - planetGravity, - CommandExecutors, - CommandReceiver, } from 'shared'; -import { - GeneratePointsCommand, - AnnounceCommand, - StepCommand, -} from '../commands/commands'; - -import { ImmutableBoundingBox } from '../physics/bounding-boxes/immutable-bounding-box'; -import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle'; -import { StaticPhysical } from '../physics/physicals/static-physical'; +import { GameEvents } from '../game-events'; +import { BoundingBox } from '../physics/bounding-box'; +import { Physical } from '../physics/physical'; import { LampPhysical } from './lamp-physical'; import type { CharacterPhysical } from './character-physical'; -@serializesTo(PlanetBase) -export class PlanetPhysical extends PlanetBase implements StaticPhysical { - public readonly canCollide = true; - public readonly canMove = false; - // Marks this as standable ground for the shared movement simulation. - public readonly isGround = true; +export const planetsIn = (objects: Array): Array => + objects.filter((o): o is PlanetPhysical => o instanceof PlanetPhysical); +export class PlanetPhysical extends PlanetBase implements Physical { + public readonly boundingBox: BoundingBox; public readonly sizePointMultiplier: number; - // Authoritative spin angle, streamed to the client so the rendered outline - // and this collision polygon turn as one. cos/sin are memoised: distance() - // is called many times per tick by the raymarcher and the angle rarely - // changes between those calls. - private rotation = 0; - private readonly rotationSpeed: number; - private cachedRotation = Number.NaN; - private cosRotation = 1; - private sinRotation = 0; - - private _boundingBox?: ImmutableBoundingBox; - private readonly lamps: Array = []; - private lastTeam: CharacterTeam = CharacterTeam.neutral; - - // Characters standing on the planet this tick. Filled by registerPresence as - // each grounded character steps, drained when the planet resolves capture in - // its own step(). private presentCharacters: Array = []; private isContested = false; + private timeSinceLastPointGeneration = 0; - protected commandExecutors: CommandExecutors = { - [StepCommand.type]: this.step.bind(this), - }; - - public addLamp(lamp: LampPhysical) { - this.lamps.push(lamp); - } - - constructor(vertices: Array, isKeystone = false) { + constructor( + vertices: Array, + isKeystone: boolean, + private readonly game: GameEvents, + ) { super(id(), vertices, 0.5, isKeystone); const sizeClass = clamp01( (this.radius - settings.planetMinReferenceRadius) / (settings.planetMaxReferenceRadius - settings.planetMinReferenceRadius), ); - this.sizePointMultiplier = mix(1, settings.planetSizePointMultiplierMax, sizeClass); - - this.rotationSpeed = + this.angularVelocity = (0.05 + Random.getRandom() * 0.07) * (Random.getRandom() < 0.5 ? -1 : 1); + this.boundingBox = BoundingBox.ofCircle( + this.center, + Math.max(...vertices.map((v) => vec2.distance(this.center, v))), + ); + } + + public get gameObject(): this { + return this; + } + + public addLamp(lamp: LampPhysical) { + this.lamps.push(lamp); } public registerPresence(character: CharacterPhysical) { this.presentCharacters.push(character); } - public distance(target: vec2): number { - // The shared planet outline — the same function the client predictor - // collides against, which is what lets prediction reconcile. - this.syncRotationTrigonometry(); - return planetDistance( - target, - this.vertices, - this.center, - this.cosRotation, - this.sinRotation, - ); - } - - private syncRotationTrigonometry() { - if (this.rotation !== this.cachedRotation) { - this.cachedRotation = this.rotation; - this.cosRotation = Math.cos(this.rotation); - this.sinRotation = Math.sin(this.rotation); - } - } - - // Signed angular velocity in rad/s, exposed so a character can ride the - // planet's spin (see carryWithRotatingPlanet in shared). - public get angularVelocity(): number { - return this.rotationSpeed; - } - - private timeSinceLastPointGeneration = 0; - private getPoints(game: CommandReceiver) { - if (this.timeSinceLastPointGeneration > settings.planetPointGenerationInterval) { - this.timeSinceLastPointGeneration = 0; - - const value = Math.round( - settings.planetPointGenerationValue * this.sizePointMultiplier, - ); - game.handleCommand( - new GeneratePointsCommand( - this.team === CharacterTeam.blue ? value : 0, - this.team === CharacterTeam.red ? value : 0, - ), - ); - } - } - - private step({ deltaTimeInSeconds, game }: StepCommand) { - this.rotation += deltaTimeInSeconds * this.rotationSpeed; + public step(deltaTimeInSeconds: number) { + this.advanceRotation(deltaTimeInSeconds); this.timeSinceLastPointGeneration += deltaTimeInSeconds; - // In reverse order, so that teams can achieve a 100% control. - this.getPoints(game); + this.generatePoints(); this.resolveCapture(deltaTimeInSeconds); - this.detectFlip(game); + this.detectFlip(); - this.presentCharacters = []; + this.presentCharacters.length = 0; + } + + private generatePoints() { + if (this.timeSinceLastPointGeneration > settings.planetPointGenerationInterval) { + this.timeSinceLastPointGeneration = 0; + this.awardToOwner(settings.planetPointGenerationValue); + } + } + + private awardToOwner(base: number) { + const value = Math.round(base * this.sizePointMultiplier); + this.game.addPoints( + this.team === CharacterTeam.blue ? value : 0, + this.team === CharacterTeam.red ? value : 0, + ); } private resolveCapture(deltaTime: number) { @@ -175,7 +120,7 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical { } } - private detectFlip(game: CommandReceiver) { + private detectFlip() { const currentTeam = this.team; if (currentTeam === this.lastTeam) { return; @@ -183,22 +128,15 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical { this.lastTeam = currentTeam; if (currentTeam !== CharacterTeam.neutral) { - const reward = Math.round( - settings.captureFlipPointReward * this.sizePointMultiplier, - ); - this.remoteCall('generatedPoints', reward); - game.handleCommand( - new GeneratePointsCommand( - currentTeam === CharacterTeam.blue ? reward : 0, - currentTeam === CharacterTeam.red ? reward : 0, - ), + this.remoteCall( + 'generatedPoints', + Math.round(settings.captureFlipPointReward * this.sizePointMultiplier), ); + this.awardToOwner(settings.captureFlipPointReward); if (this.isKeystone) { - game.handleCommand( - new AnnounceCommand( - `Team ${currentTeam} captured the Heart`, - ), + this.game.announce( + `Team ${currentTeam} captured the Heart`, ); } } @@ -206,22 +144,12 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical { const control = Math.abs(this.ownership - 0.5) / 0.5; const lightness = mix(settings.lampMinLightness, settings.lampMaxLightness, control); const color = settings.palette[settings.colorIndices[currentTeam]]; - this.lamps.forEach((lamp) => lamp.queueSetLight(color, lightness)); this.remoteCall('onFlipped', currentTeam); } - public getPropertyUpdates(): PropertyUpdatesForObject { - return new PropertyUpdatesForObject(this.id, [ - new UpdatePropertyCommand('ownership', this.ownership, 0), - // Stream the spin rate as the rate-of-change so the client can keep the - // angle moving when snapshots run late (see planet-view.ts). - new UpdatePropertyCommand('rotation', this.rotation, this.rotationSpeed), - ]); - } - - public takeControl(team: CharacterTeam, deltaTime: number) { + private takeControl(team: CharacterTeam, deltaTime: number) { if (team === CharacterTeam.blue) { this.ownership -= (0.5 / settings.takeControlTimeInSeconds) * deltaTime; } else if (team === CharacterTeam.red) { @@ -232,10 +160,7 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical { -Math.sign(this.ownership - 0.5) * (0.5 / settings.loseControlTimeInSeconds) * deltaTime; - if ( - (previous < 0.5 && this.ownership > 0.5) || - (previous > 0.5 && this.ownership < 0.5) - ) { + if ((previous - 0.5) * (this.ownership - 0.5) < 0) { this.ownership = 0.5; } } @@ -243,30 +168,10 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical { this.ownership = clamp01(this.ownership); } - public get boundingBox(): ImmutableBoundingBox { - if (!this._boundingBox) { - // The polygon spins about its centre (see distance), so this static box - // has to cover every orientation, not just the spawn-time one: take the - // circumscribed circle around the rotation centre. - const maxVertexDistance = this.vertices.reduce( - (max, vertex) => Math.max(max, vec2.distance(this.center, vertex)), - 0, - ); - - this._boundingBox = getBoundingBoxOfCircle( - new Circle(this.center, maxVertexDistance), - ); - } - - return this._boundingBox; - } - - // GroundSurface gravity, shared with the client predictor. - public gravityAt(position: vec2): vec2 { - return planetGravity(this.center, this.radius, position); - } - - public get gameObject(): this { - return this; + public getPropertyUpdates(): PropertyUpdatesForObject { + return new PropertyUpdatesForObject(this.id, [ + new UpdatePropertyCommand('ownership', this.ownership, 0), + new UpdatePropertyCommand('rotation', this.rotation, this.angularVelocity), + ]); } } diff --git a/backend/src/objects/projectile-physical.ts b/backend/src/objects/projectile-physical.ts index 2437d0c..fd8496c 100644 --- a/backend/src/objects/projectile-physical.ts +++ b/backend/src/objects/projectile-physical.ts @@ -2,55 +2,42 @@ import { vec2 } from 'gl-matrix'; import { id, settings, - serializesTo, ProjectileBase, CharacterTeam, PropertyUpdatesForObject, UpdatePropertyCommand, - CommandExecutors, - Circle, - marchCircle, + GameObject, + evaluateSdf, + sumGravity, } from 'shared'; -import { BoundingBoxBase } from '../physics/bounding-boxes/bounding-box-base'; +import { BoundingBox } from '../physics/bounding-box'; import { CirclePhysical } from './circle-physical'; -import { DynamicPhysical } from '../physics/physicals/dynamic-physical'; -import { PhysicalContainer } from '../physics/containers/physical-container'; +import { Physical } from '../physics/physical'; +import { PhysicalContainer } from '../physics/physical-container'; import { CharacterPhysical } from './character-physical'; -import { forceAtPosition } from '../physics/functions/force-at-position'; -import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle'; -import { StepCommand, ReactToCollisionCommand } from '../commands/commands'; +import { planetsIn } from './planet-physical'; -@serializesTo(ProjectileBase) -export class ProjectilePhysical extends ProjectileBase implements DynamicPhysical { +export class ProjectilePhysical extends ProjectileBase implements Physical { public readonly canCollide = true; - public readonly canMove = true; + public readonly object: CirclePhysical; private isDestroyed = false; private bounceCount = 0; - public object: CirclePhysical; - - protected commandExecutors: CommandExecutors = { - [StepCommand.type]: this.handleStep.bind(this), - [ReactToCollisionCommand.type]: this.onCollision.bind(this), - }; - constructor( center: vec2, radius: number, public strength: number, team: CharacterTeam, - private velocity: vec2, + velocity: vec2, public readonly originator: CharacterPhysical, - readonly container: PhysicalContainer, - // Normalised charge (0..1) of the shot that fired this; the victim scales - // hit/kill feedback and the death fling by it. + private readonly container: PhysicalContainer, public readonly charge: number = 0, ) { super(id(), center, radius, team, strength); - this.object = new CirclePhysical(center, radius, this, container, 0.9); - - this.moveOutsideOfObject(); + this.object = new CirclePhysical(this.center, radius, this, container, 0.9); + vec2.copy(this.object.velocity, velocity); + this.moveOutsideFriendlyBodies(); } public get isAlive(): boolean { @@ -58,35 +45,13 @@ export class ProjectilePhysical extends ProjectileBase implements DynamicPhysica } public get direction(): vec2 { - const direction = vec2.clone(this.velocity); + const direction = vec2.clone(this.object.velocity); return vec2.length(direction) > 0 ? vec2.normalize(direction, direction) : vec2.fromValues(0, -1); } - private moveOutsideOfObject() { - let wasCollision = true; - const delta = vec2.scale( - vec2.create(), - vec2.normalize(vec2.create(), this.velocity), - 10, - ); - // Bounded: each pass advances by `delta`, but a degenerate direction would - // otherwise spin here forever inside the physics tick. - let passes = 0; - while (wasCollision && passes++ < 32) { - const intersecting = this.container - .findIntersecting(this.boundingBox) - .filter((g) => g instanceof CharacterPhysical && g.team === this.team); - const { hitSurface } = marchCircle(this.object, delta, intersecting, true); - wasCollision = hitSurface; - this.object.syncBoundingBox(); - } - } - - // Live view of the circle's box, not a snapshot — the circle keeps it in sync - // as it moves. - public get boundingBox(): BoundingBoxBase { + public get boundingBox(): BoundingBox { return this.object.boundingBox; } @@ -98,6 +63,19 @@ export class ProjectilePhysical extends ProjectileBase implements DynamicPhysica return this.object.distance(target); } + private moveOutsideFriendlyBodies() { + const step = vec2.scale(vec2.create(), this.direction, 10); + for (let pass = 0; pass < 32; pass++) { + vec2.add(this.center, this.center, step); + const friendly = this.container + .findIntersecting(this.boundingBox) + .filter((o) => o instanceof CharacterPhysical && o.team === this.team); + if (evaluateSdf(this.center, friendly) >= this.radius) { + return; + } + } + } + public destroy() { if (!this.isDestroyed) { this.isDestroyed = true; @@ -105,7 +83,7 @@ export class ProjectilePhysical extends ProjectileBase implements DynamicPhysica } } - public onCollision({ other }: ReactToCollisionCommand) { + public onCollision(other: GameObject) { if ( !(other instanceof CharacterPhysical && other.team === this.team) && this.bounceCount++ === settings.projectileMaxBounceCount @@ -116,15 +94,11 @@ export class ProjectilePhysical extends ProjectileBase implements DynamicPhysica public getPropertyUpdates(): PropertyUpdatesForObject { return new PropertyUpdatesForObject(this.id, [ - new UpdatePropertyCommand('center', this.center, this.velocity), + new UpdatePropertyCommand('center', this.center, this.object.velocity), ]); } - private handleStep({ deltaTimeInSeconds }: StepCommand) { - this.advance(deltaTimeInSeconds); - } - - private advance(deltaTimeInSeconds: number) { + public step(deltaTimeInSeconds: number) { super.step(deltaTimeInSeconds); if (this.strength <= 0) { @@ -132,24 +106,17 @@ export class ProjectilePhysical extends ProjectileBase implements DynamicPhysica return; } - // This single broadphase is reused for the step below: the gravity radius - // (maxGravityDistance) dwarfs one tick's travel, so the set is a superset of - // the swept-collision box and the step needn't query the container again. const intersecting = this.container.findIntersecting( - getBoundingBoxOfCircle( - new Circle(this.center, this.object.radius + settings.maxGravityDistance), - ), + BoundingBox.ofCircle(this.center, this.radius + settings.maxGravityDistance), ); vec2.scaleAndAdd( - this.velocity, - this.velocity, - forceAtPosition(this.center, intersecting), + this.object.velocity, + this.object.velocity, + sumGravity(planetsIn(intersecting), this.center), settings.projectileGravityScale * deltaTimeInSeconds, ); - vec2.copy(this.object.velocity, this.velocity); - const { velocity } = this.object.stepManually(deltaTimeInSeconds, intersecting); - vec2.copy(this.velocity, velocity); + this.object.stepManually(deltaTimeInSeconds, intersecting); } } diff --git a/backend/src/physics/bounding-box.ts b/backend/src/physics/bounding-box.ts index 1a00fb3..0cc6477 100644 --- a/backend/src/physics/bounding-box.ts +++ b/backend/src/physics/bounding-box.ts @@ -1,54 +1,28 @@ import { vec2 } from 'gl-matrix'; -import { BoundingBoxBase } from './bounding-box-base'; -export class BoundingBox extends BoundingBoxBase { - public get xMin(): number { - return this._xMin; +export class BoundingBox { + constructor( + public xMin = 0, + public xMax = 0, + public yMin = 0, + public yMax = 0, + ) {} + + public static ofCircle(center: vec2, radius: number): BoundingBox { + return new BoundingBox( + center[0] - radius, + center[0] + radius, + center[1] - radius, + center[1] + radius, + ); } - public set xMin(value: number) { - this._xMin = value; - } - - public set xMax(value: number) { - this._xMax = value; - } - - public get xMax(): number { - return this._xMax; - } - - public set yMin(value: number) { - this._yMin = value; - } - - public get yMin(): number { - return this._yMin; - } - - public set yMax(value: number) { - this._yMax = value; - } - - public get yMax(): number { - return this._yMax; - } - - public get topLeft(): vec2 { - return vec2.fromValues(this._xMin, this._yMax); - } - - public set topLeft(value: vec2) { - this._xMin = value.x; - this._yMax = value.y; - } - - public set size(value: vec2) { - this._xMax = this.xMin + value.x; - this._yMin = this.yMax - value.y; - } - - public get size(): vec2 { - return vec2.fromValues(this._xMax - this._xMin, this._yMax - this._yMin); + public intersects(other: BoundingBox): boolean { + return ( + this.xMin < other.xMax && + this.xMax > other.xMin && + this.yMin < other.yMax && + this.yMax > other.yMin + ); } } diff --git a/backend/src/physics/physical-container.ts b/backend/src/physics/physical-container.ts index 604672e..a3ac6c2 100644 --- a/backend/src/physics/physical-container.ts +++ b/backend/src/physics/physical-container.ts @@ -1,54 +1,29 @@ -import { BoundingBoxBase } from '../bounding-boxes/bounding-box-base'; -import { BoundingBoxList } from './bounding-box-list'; -import { BoundingBoxTree } from './bounding-box-tree'; -import { Physical } from '../physicals/physical'; -import { StaticPhysical } from '../physicals/static-physical'; -import { DynamicPhysical } from '../physicals/dynamic-physical'; -import { Command, CommandReceiver } from 'shared'; +import { GameEvents } from '../game-events'; +import { BoundingBox } from './bounding-box'; +import { Physical } from './physical'; -export class PhysicalContainer extends CommandReceiver { - private isTreeInitialized = false; - private staticBoundingBoxesWaitList: Array = []; - private staticBoundingBoxes = new BoundingBoxTree(); - private dynamicBoundingBoxes = new BoundingBoxList(); +export class PhysicalContainer { private objects: Array = []; - protected defaultCommandExecutor(c: Command) { - this.objects.forEach((o) => o.handleCommand(c)); - } - - public initialize() { - this.staticBoundingBoxes.build(this.staticBoundingBoxesWaitList); - this.isTreeInitialized = true; - } + constructor(public readonly game: GameEvents) {} public addObject(physical: Physical) { this.objects.push(physical); - - if (physical.canMove) { - this.dynamicBoundingBoxes.insert(physical); - } else { - if (!this.isTreeInitialized) { - this.staticBoundingBoxesWaitList.push(physical); - } else { - this.staticBoundingBoxes.insert(physical); - } - } } - public removeObject(object: DynamicPhysical) { - this.objects = this.objects.filter((p) => p !== object); - this.dynamicBoundingBoxes.remove(object); + public removeObject(physical: Physical) { + this.objects = this.objects.filter((o) => o !== physical); + } + + public step(deltaTimeInSeconds: number) { + this.objects.forEach((o) => o.step?.(deltaTimeInSeconds)); } public resetRemoteCalls() { this.objects.forEach((o) => o.gameObject.resetRemoteCalls()); } - public findIntersecting(box: BoundingBoxBase): Array { - return [ - ...this.staticBoundingBoxes.findIntersecting(box), - ...this.dynamicBoundingBoxes.findIntersecting(box), - ]; + public findIntersecting(box: BoundingBox): Array { + return this.objects.filter((o) => o.boundingBox.intersects(box)); } } diff --git a/backend/src/physics/physical.ts b/backend/src/physics/physical.ts index 2e3189b..4e3fc1a 100644 --- a/backend/src/physics/physical.ts +++ b/backend/src/physics/physical.ts @@ -1,4 +1,13 @@ -import { DynamicPhysical } from './dynamic-physical'; -import { StaticPhysical } from './static-physical'; +import { vec2 } from 'gl-matrix'; +import { GameObject } from 'shared'; +import { BoundingBox } from './bounding-box'; -export type Physical = StaticPhysical | DynamicPhysical; +export interface Physical { + readonly canCollide: boolean; + readonly boundingBox: BoundingBox; + readonly gameObject: GameObject; + + distance(target: vec2): number; + step?(deltaTimeInSeconds: number): void; + onCollision?(other: GameObject): void; +} diff --git a/backend/src/players/npc.ts b/backend/src/players/npc.ts index 6881fc3..3fa8bef 100644 --- a/backend/src/players/npc.ts +++ b/backend/src/players/npc.ts @@ -1,23 +1,15 @@ import { vec2 } from 'gl-matrix'; -import { - PlayerInformation, - settings, - Circle, - Random, - MoveActionCommand, - CharacterTeam, - Id, -} from 'shared'; -import { PhysicalContainer } from '../physics/containers/physical-container'; +import { PlayerInformation, settings, Random, CharacterTeam, Id } from 'shared'; +import { PhysicalContainer } from '../physics/physical-container'; +import { BoundingBox } from '../physics/bounding-box'; import { PlayerContainer } from './player-container'; import { PlayerBase } from './player-base'; -import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle'; import { CharacterPhysical } from '../objects/character-physical'; -import { PlanetPhysical } from '../objects/planet-physical'; +import { PlanetPhysical, planetsIn } from '../objects/planet-physical'; import { ProjectilePhysical } from '../objects/projectile-physical'; -import { Physical } from '../physics/physicals/physical'; +import { Physical } from '../physics/physical'; -const npcTuning = { +const tuning = { planIntervalSeconds: 1, shootIntervalSeconds: 1.5, reactionIntervalSeconds: 1 / 10, @@ -40,7 +32,6 @@ const npcTuning = { dodgeThreatRange: 450, dodgeApproachDot: 0.6, - dodgeBaseChance: 0.25, dodgeAggressionChance: 0.35, dodgeCommitSeconds: 0.35, @@ -64,27 +55,33 @@ const npcTuning = { leapChancePerSecond: 0.35, }; +interface Enemy { + character: CharacterPhysical; + distance: number; +} + export class NPC extends PlayerBase { private direction = vec2.fromValues(Random.getRandom() - 0.5, Random.getRandom() - 0.5); - private timeSinceLastPlan = 10000; - private timeSinceLastShoot = 10000; - private isWandering = false; + private movement = vec2.create(); + private timeSinceLastPlan = Infinity; + private timeSinceLastShoot = Infinity; + private timeSinceObserve = Infinity; private timeSinceLastWanderingConsideration = 0; + private isWandering = false; private isComingBack = false; private readonly aggression = Random.getRandomInRange( - npcTuning.aggressionMin, - npcTuning.aggressionMax, + tuning.aggressionMin, + tuning.aggressionMax, ); - private aimTargetId: Id = null; + private aimTargetId: Id | null = null; private readonly aimTargetLastPosition = vec2.create(); private readonly dodgeDirection = vec2.create(); private dodgeCommitRemaining = 0; private dodgeCooldownRemaining = 0; - private timeSinceObserve = npcTuning.reactionIntervalSeconds; private nearObjects: Array = []; constructor( @@ -95,7 +92,6 @@ export class NPC extends PlayerBase { ) { super(playerInfo, playerContainer, objectContainer, team); this.createCharacter(); - this.step(0); } public step(deltaTimeInSeconds: number) { @@ -106,44 +102,40 @@ export class NPC extends PlayerBase { if ( (this.timeSinceLastWanderingConsideration += deltaTimeInSeconds) > - npcTuning.wanderReconsiderSeconds + tuning.wanderReconsiderSeconds ) { this.timeSinceLastWanderingConsideration = 0; - this.isWandering = Random.getRandom() < npcTuning.wanderProbability; + this.isWandering = Random.getRandom() < tuning.wanderProbability; } - if ((this.timeSinceLastPlan += deltaTimeInSeconds) > npcTuning.planIntervalSeconds) { + if ((this.timeSinceLastPlan += deltaTimeInSeconds) > tuning.planIntervalSeconds) { this.timeSinceLastPlan = 0; this.plan(); } - if ( - (this.timeSinceObserve += deltaTimeInSeconds) > npcTuning.reactionIntervalSeconds - ) { - this.timeSinceObserve = 0; - this.nearObjects = this.observe(npcTuning.reactionObserveRadius); - } - this.dodgeCommitRemaining -= deltaTimeInSeconds; this.dodgeCooldownRemaining -= deltaTimeInSeconds; - const movement = this.decideMovement(this.nearObjects); - character.handleMovementAction(new MoveActionCommand(movement)); + + if ((this.timeSinceObserve += deltaTimeInSeconds) > tuning.reactionIntervalSeconds) { + this.timeSinceObserve = 0; + this.nearObjects = this.observe(tuning.reactionObserveRadius); + this.movement = this.decideMovement(character, this.nearObjects); + character.setMoveDirection(this.movement); + } if ( !this.isComingBack && character.groundPlanet && - vec2.length(movement) > 0 && + vec2.length(this.movement) > 0 && Random.getRandom() < - npcTuning.leapChancePerSecond * this.aggression * deltaTimeInSeconds + tuning.leapChancePerSecond * this.aggression * deltaTimeInSeconds ) { character.leap(); } - if ( - (this.timeSinceLastShoot += deltaTimeInSeconds) > npcTuning.shootIntervalSeconds - ) { + if ((this.timeSinceLastShoot += deltaTimeInSeconds) > tuning.shootIntervalSeconds) { this.timeSinceLastShoot = 0; - this.tryShoot(this.nearObjects); + this.tryShoot(character, this.nearObjects); } } @@ -159,20 +151,18 @@ export class NPC extends PlayerBase { } this.isComingBack = false; - const nearObjects = this.observe(npcTuning.planScanRadius); - const enemies = this.enemiesByDistance(nearObjects); - - const nearest = enemies[0]; + const nearObjects = this.observe(tuning.planScanRadius); + const nearest = this.enemiesByDistance(nearObjects)[0]; if (nearest) { const fleeRange = - npcTuning.fleeBaseRange * (npcTuning.fleeAggressionFalloff - this.aggression); + tuning.fleeBaseRange * (tuning.fleeAggressionFalloff - this.aggression); if (nearest.distance < fleeRange) { vec2.subtract(this.direction, this.center, nearest.character.center); return; } const chaseRange = - npcTuning.chaseBaseRange + npcTuning.chaseAggressionRange * this.aggression; + tuning.chaseBaseRange + tuning.chaseAggressionRange * this.aggression; if (nearest.distance < chaseRange) { vec2.subtract(this.direction, nearest.character.center, this.center); return; @@ -182,7 +172,7 @@ export class NPC extends PlayerBase { if (!this.isWandering) { const planet = this.capturablePlanetsByDistance(nearObjects)[0]; if (planet) { - vec2.subtract(this.direction, planet.planet.center, this.center); + vec2.subtract(this.direction, planet.center, this.center); return; } } @@ -191,11 +181,14 @@ export class NPC extends PlayerBase { this.direction, this.direction, vec2.create(), - Random.getRandomInRange(-npcTuning.wanderTurn, npcTuning.wanderTurn), + Random.getRandomInRange(-tuning.wanderTurn, tuning.wanderTurn), ); } - private decideMovement(nearObjects: Array): vec2 { + private decideMovement( + character: CharacterPhysical, + nearObjects: Array, + ): vec2 { if (this.dodgeCommitRemaining > 0) { return vec2.clone(this.dodgeDirection); } @@ -204,23 +197,20 @@ export class NPC extends PlayerBase { if (dodge) { if ( Random.getRandom() < - npcTuning.dodgeBaseChance + npcTuning.dodgeAggressionChance * this.aggression + tuning.dodgeBaseChance + tuning.dodgeAggressionChance * this.aggression ) { vec2.copy(this.dodgeDirection, dodge); - this.dodgeCommitRemaining = npcTuning.dodgeCommitSeconds; + this.dodgeCommitRemaining = tuning.dodgeCommitSeconds; return vec2.clone(this.dodgeDirection); } - this.dodgeCooldownRemaining = npcTuning.dodgeCooldownSeconds; + this.dodgeCooldownRemaining = tuning.dodgeCooldownSeconds; } } - const planet = this.character!.groundPlanet; + const planet = character.groundPlanet; if (planet && planet.team !== this.team) { const enemies = this.enemiesByDistance(nearObjects); - if ( - enemies.length === 0 || - enemies[0].distance > npcTuning.captureHoldEnemyDistance - ) { + if (enemies.length === 0 || enemies[0].distance > tuning.captureHoldEnemyDistance) { return vec2.create(); } } @@ -239,12 +229,12 @@ export class NPC extends PlayerBase { } const toMe = vec2.subtract(vec2.create(), this.center, p.center); const distance = vec2.length(toMe); - if (distance > npcTuning.dodgeThreatRange || distance === 0) { + if (distance > tuning.dodgeThreatRange || distance === 0) { continue; } vec2.normalize(toMe, toMe); if ( - vec2.dot(p.direction, toMe) > npcTuning.dodgeApproachDot && + vec2.dot(p.direction, toMe) > tuning.dodgeApproachDot && distance < threatDistance ) { threatDistance = distance; @@ -261,14 +251,11 @@ export class NPC extends PlayerBase { if (vec2.dot(perpendicular, toMe) < 0) { vec2.negate(perpendicular, perpendicular); } - vec2.normalize(perpendicular, perpendicular); - - return perpendicular; + return vec2.normalize(perpendicular, perpendicular); } - private tryShoot(nearObjects: Array) { - const enemies = this.enemiesByDistance(nearObjects); - const visible = enemies.find((e) => + private tryShoot(character: CharacterPhysical, nearObjects: Array) { + const visible = this.enemiesByDistance(nearObjects).find((e) => this.hasLineOfSightTo(e.character.center, nearObjects), ); if (!visible) { @@ -276,71 +263,69 @@ export class NPC extends PlayerBase { return; } - const target = visible.character; - const distance = visible.distance; + const { character: target, distance } = visible; const velocity = vec2.create(); if (this.aimTargetId === target.id) { vec2.subtract(velocity, target.center, this.aimTargetLastPosition); - vec2.scale(velocity, velocity, 1 / npcTuning.shootIntervalSeconds); + vec2.scale(velocity, velocity, 1 / tuning.shootIntervalSeconds); } this.aimTargetId = target.id; vec2.copy(this.aimTargetLastPosition, target.center); if ( Random.getRandom() > - npcTuning.fireBaseChance + npcTuning.fireAggressionChance * this.aggression + tuning.fireBaseChance + tuning.fireAggressionChance * this.aggression ) { return; } const charge = - distance > npcTuning.chargeRangeThreshold && + distance > tuning.chargeRangeThreshold && Random.getRandom() < - npcTuning.chargeBaseChance + npcTuning.chargeAggressionChance * this.aggression - ? Random.getRandomInRange(npcTuning.chargeMin, 1) + tuning.chargeBaseChance + tuning.chargeAggressionChance * this.aggression + ? Random.getRandomInRange(tuning.chargeMin, 1) : 0; const projectileSpeed = charge > 0 ? settings.chargeShotSpeedMax : settings.chargeShotSpeedMin; - const leadTime = distance / projectileSpeed; - const aim = vec2.scaleAndAdd(vec2.create(), target.center, velocity, leadTime); + const aim = vec2.scaleAndAdd( + vec2.create(), + target.center, + velocity, + distance / projectileSpeed, + ); const spread = - (npcTuning.spreadBase + distance * npcTuning.spreadPerDistance) * - (npcTuning.spreadAggressionFalloff - this.aggression); - aim.x += Random.getRandomInRange(-spread, spread); - aim.y += Random.getRandomInRange(-spread, spread); + (tuning.spreadBase + distance * tuning.spreadPerDistance) * + (tuning.spreadAggressionFalloff - this.aggression); + aim[0] += Random.getRandomInRange(-spread, spread); + aim[1] += Random.getRandomInRange(-spread, spread); - this.character!.shootTowards(aim, charge); + character.shootTowards(aim, charge); } private hasLineOfSightTo(target: vec2, nearObjects: Array): boolean { - const planets: Array = []; - for (const o of nearObjects) { - if (o.gameObject instanceof PlanetPhysical) { - planets.push(o.gameObject); - } - } + const planets = planetsIn(nearObjects); if (planets.length === 0) { return true; } const direction = vec2.subtract(vec2.create(), target, this.center); const totalDistance = vec2.length(direction); - if (totalDistance <= npcTuning.lineOfSightStartOffset) { + if (totalDistance <= tuning.lineOfSightStartOffset) { return true; } vec2.normalize(direction, direction); - let traveled = npcTuning.lineOfSightStartOffset; + let traveled = tuning.lineOfSightStartOffset; const position = vec2.scaleAndAdd(vec2.create(), this.center, direction, traveled); while (traveled < totalDistance) { let sdf = Infinity; for (const planet of planets) { sdf = Math.min(sdf, planet.distance(position)); } - if (sdf < npcTuning.lineOfSightClearance) { + if (sdf < tuning.lineOfSightClearance) { return false; } traveled += sdf; @@ -351,45 +336,34 @@ export class NPC extends PlayerBase { private observe(radius: number): Array { return this.objectContainer.findIntersecting( - getBoundingBoxOfCircle(new Circle(this.center, radius)), + BoundingBox.ofCircle(this.center, radius), ); } - private enemiesByDistance( - nearObjects: Array, - ): Array<{ character: CharacterPhysical; distance: number }> { - const seen = new Set(); - const enemies: Array<{ character: CharacterPhysical; distance: number }> = []; + private enemiesByDistance(nearObjects: Array): Array { + const enemies: Array = []; for (const o of nearObjects) { const c = o.gameObject; if ( c instanceof CharacterPhysical && c !== this.character && c.isAlive && - c.team !== this.team && - !seen.has(c) + c.team !== this.team ) { - seen.add(c); enemies.push({ character: c, distance: vec2.distance(this.center, c.center) }); } } - enemies.sort((a, b) => a.distance - b.distance); - return enemies; + return enemies.sort((a, b) => a.distance - b.distance); } private capturablePlanetsByDistance( nearObjects: Array, - ): Array<{ planet: PlanetPhysical; distance: number }> { - const planets = nearObjects - .filter( - (o): o is Physical => - o.gameObject instanceof PlanetPhysical && o.gameObject.team !== this.team, - ) - .map((o) => ({ - planet: o.gameObject as PlanetPhysical, - distance: vec2.distance(this.center, (o.gameObject as PlanetPhysical).center), - })); - planets.sort((a, b) => a.distance - b.distance); - return planets; + ): Array { + return planetsIn(nearObjects) + .filter((p) => p.team !== this.team) + .sort( + (a, b) => + vec2.distance(this.center, a.center) - vec2.distance(this.center, b.center), + ); } } diff --git a/backend/src/players/player-base.ts b/backend/src/players/player-base.ts index 5ce7552..25a6751 100644 --- a/backend/src/players/player-base.ts +++ b/backend/src/players/player-base.ts @@ -1,7 +1,6 @@ import { vec2 } from 'gl-matrix'; import { CommandReceiver, - Circle, PlayerInformation, CharacterTeam, Random, @@ -10,53 +9,64 @@ import { boundRadius, evaluateSdf, } from 'shared'; -import { PhysicalContainer } from '../physics/containers/physical-container'; -import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle'; +import { PhysicalContainer } from '../physics/physical-container'; +import { BoundingBox } from '../physics/bounding-box'; import { CharacterPhysical } from '../objects/character-physical'; -import { PlanetPhysical } from '../objects/planet-physical'; +import { PlanetPhysical, planetsIn } from '../objects/planet-physical'; import { PlayerContainer } from './player-container'; const maximumNameLength = 40; +export interface Score { + kills: number; + deaths: number; +} + export abstract class PlayerBase extends CommandReceiver { - public character?: CharacterPhysical | null; + public character: CharacterPhysical | null = null; public center: vec2 = vec2.create(); - protected sumKills = 0; - protected sumDeaths = 0; + protected lastCharacter?: CharacterPhysical; protected timeUntilRespawn = 0; + private kills: number; + private deaths: number; constructor( protected readonly playerInfo: PlayerInformation, protected readonly playerContainer: PlayerContainer, protected readonly objectContainer: PhysicalContainer, public readonly team: CharacterTeam, + score: Score = { kills: 0, deaths: 0 }, ) { super(); + this.kills = score.kills; + this.deaths = score.deaths; } - protected createCharacter() { + public get score(): Score { + return { + kills: (this.character ?? this.lastCharacter)?.killCount ?? this.kills, + deaths: this.deaths, + }; + } + + protected createCharacter(): CharacterPhysical { + this.kills = this.lastCharacter?.killCount ?? this.kills; this.character = new CharacterPhysical( - // Coerce, don't just truncate: input is untrusted (from deserialize), and - // arrays have `.slice()` too — a non-string name would throw inside the - // reviver and kill the recipient's whole message batch. sanitizeName(this.playerInfo.name, maximumNameLength), - this.sumKills, - this.sumDeaths, + this.kills, + this.deaths, this.team, this.objectContainer, this.findEmptyPositionForPlayer(this.findSpawnCenter()), ); - this.objectContainer.addObject(this.character); + this.center = this.character.center; + return this.character; } public abstract step(deltaTimeInSeconds: number): void; - /** - * Shared death/respawn cycle. Returns the living character to act with this - * tick (or undefined), so a subclass can use it directly without re-narrowing. - */ protected stepLifecycle(deltaTimeInSeconds: number): CharacterPhysical | undefined { if (this.character) { this.center = this.character.center; @@ -65,33 +75,25 @@ export abstract class PlayerBase extends CommandReceiver { return this.character; } - this.sumDeaths++; - this.sumKills = this.character.killCount; - this.onCharacterDied(this.character); + this.deaths++; + this.lastCharacter = this.character; this.character = null; this.timeUntilRespawn = settings.playerDiedTimeout; return undefined; } if ((this.timeUntilRespawn -= deltaTimeInSeconds) < 0) { - this.onBeforeRespawn(); this.createCharacter(); - this.center = this.character!.center; } return undefined; } - // Override hooks (no-ops by default). - // eslint-disable-next-line @typescript-eslint/no-unused-vars - protected onCharacterDied(character: CharacterPhysical) {} - protected onBeforeRespawn() {} - private findSpawnCenter(): vec2 { - const planets = this.objectContainer - .findIntersecting( - getBoundingBoxOfCircle(new Circle(vec2.create(), settings.worldRadius * 2)), - ) - .filter((o): o is PlanetPhysical => o instanceof PlanetPhysical); + const planets = planetsIn( + this.objectContainer.findIntersecting( + BoundingBox.ofCircle(vec2.create(), settings.worldRadius * 2), + ), + ); const friendly = planets.filter((p) => p.team === this.team); const neutral = planets.filter((p) => p.team === CharacterTeam.neutral); @@ -109,42 +111,32 @@ export abstract class PlayerBase extends CommandReceiver { ); const safe = candidates.filter((p) => !isContested(p)); - // candidates is non-empty here, so choose() always returns a planet. return vec2.clone(Random.choose(safe.length ? safe : candidates)!.center); } - public restoreScore(kills: number, deaths: number) { - this.sumKills = kills; - this.sumDeaths = deaths; - } - - protected findEmptyPositionForPlayer(preferredCenter: vec2): vec2 { + private findEmptyPositionForPlayer(preferredCenter: vec2): vec2 { let rotation = 0; let radius = 0; - // preferredCenter is a planet centre, the single worst place to give up on: - // depenetrateCircle only runs four passes, and a body left that deep inside - // the rock registers a zero-distance hit every march and never moves again. - // So the roomiest point the spiral saw is kept as the fallback instead. let roomiestPosition = vec2.clone(preferredCenter); let roomiestClearance = -Infinity; for (let attempt = 0; attempt < 512; attempt++) { - const playerPosition = vec2.fromValues( - radius * Math.cos(rotation) + preferredCenter.x, - radius * Math.sin(rotation) + preferredCenter.y, + const position = vec2.fromValues( + radius * Math.cos(rotation) + preferredCenter[0], + radius * Math.sin(rotation) + preferredCenter[1], ); - const playerBoundingCircle = new Circle(playerPosition, boundRadius); - - const playerBoundingBox = getBoundingBoxOfCircle(playerBoundingCircle); - const possibleIntersectors = - this.objectContainer.findIntersecting(playerBoundingBox); - const clearance = evaluateSdf(playerBoundingCircle.center, possibleIntersectors); - if (clearance >= playerBoundingCircle.radius) { - return playerPosition; + const clearance = evaluateSdf( + position, + this.objectContainer.findIntersecting( + BoundingBox.ofCircle(position, boundRadius), + ), + ); + if (clearance >= boundRadius) { + return position; } if (clearance > roomiestClearance) { roomiestClearance = clearance; - roomiestPosition = playerPosition; + roomiestPosition = position; } rotation += Math.PI / 8; diff --git a/backend/src/players/player-container.ts b/backend/src/players/player-container.ts index a430220..8fc8b2f 100644 --- a/backend/src/players/player-container.ts +++ b/backend/src/players/player-container.ts @@ -1,22 +1,25 @@ -import { CharacterTeam, PlayerInformation, Random, settings, Command } from 'shared'; +import { + CharacterTeam, + PlayerInformation, + Random, + settings, + Command, + GameObject, + PropertyUpdatesForObject, +} from 'shared'; import { Socket } from 'socket.io'; -import { PhysicalContainer } from '../physics/containers/physical-container'; +import { PhysicalContainer } from '../physics/physical-container'; import { NPC } from './npc'; import { Player } from './player'; -import { PlayerBase } from './player-base'; -import { ServerFullError } from './server-full-error'; +import { PlayerBase, Score } from './player-base'; -// Team and score carried over from the connection a reconnecting client is -// replacing, so it resumes the match instead of starting from zero. -export interface CarriedScore { +export interface CarriedScore extends Score { team: CharacterTeam; - kills: number; - deaths: number; } export class PlayerContainer { private _players: Array = []; - private _npcs: Array = []; + private npcs: Array = []; constructor( private readonly objects: PhysicalContainer, @@ -26,14 +29,14 @@ export class PlayerContainer { this.createNPCs(); } - public createNPCs() { + private createNPCs() { const newNpcCount = Math.min( - this.playerMaxCount - this._players.length - this._npcs.length, - this.npcMaxCount - this._npcs.length, + this.playerMaxCount - this._players.length - this.npcs.length, + this.npcMaxCount - this.npcs.length, ); for (let i = 0; i < newNpcCount; i++) { const name = `🤖 ${Random.choose(settings.npcNames)}`; - this._npcs.push( + this.npcs.push( new NPC({ name }, this, this.objects, this.getTeamOfNextPlayer(true)), ); } @@ -44,31 +47,20 @@ export class PlayerContainer { socket: Socket, carried?: CarriedScore, ): Player { - if (this._players.length >= this.playerMaxCount) { - throw new ServerFullError(); - } - const team = carried ? carried.team : this.getTeamOfNextPlayer(); - // Retire the bot before the player spawns: its body would otherwise push - // the spawn search away from the position it picked. const npcToReplace = - this._npcs.find((n) => n.team === team) ?? this._npcs.find((n) => n.team !== team); + this.npcs.find((n) => n.team === team) ?? this.npcs.find((n) => n.team !== team); npcToReplace?.destroy(); - this._npcs = this._npcs.filter((n) => n !== npcToReplace); + this.npcs = this.npcs.filter((n) => n !== npcToReplace); - const player = new Player(playerInfo, this, this.objects, team, socket); + const player = new Player(playerInfo, this, this.objects, team, socket, carried); this._players.push(player); - - if (carried) { - player.restoreScore(carried.kills, carried.deaths); - } - return player; } public get players(): Array { - return [...this._players, ...this._npcs]; + return [...this._players, ...this.npcs]; } public get count(): number { @@ -79,20 +71,22 @@ export class PlayerContainer { return this._players.length >= this.playerMaxCount; } - // Real connected players only — NPCs have no socket and are excluded. public get connectedPlayerRttsMs(): Array { return this._players.map((p) => p.rttMs); } public step(deltaTimeInSeconds: number) { - // Iterate the backing arrays directly: this runs per 200 Hz substep and the - // `players` getter allocates a new array every call. this._players.forEach((p) => p.step(deltaTimeInSeconds)); - this._npcs.forEach((p) => p.step(deltaTimeInSeconds)); + this.npcs.forEach((p) => p.step(deltaTimeInSeconds)); } - public stepCommunication(deltaTimeInSeconds: number) { - this._players.forEach((p) => p.stepCommunications(deltaTimeInSeconds)); + public stepCommunication( + deltaTimeInSeconds: number, + propertyUpdatesOf: (object: GameObject) => PropertyUpdatesForObject | undefined, + ) { + this._players.forEach((p) => + p.stepCommunications(deltaTimeInSeconds, propertyUpdatesOf), + ); } public endGame(winner: CharacterTeam) { @@ -120,12 +114,8 @@ export class PlayerContainer { } public deletePlayer(player: Player) { - const had = this._players.includes(player); - this._players = this._players.filter((p) => p !== player); - // Only refill bots if this player was actually ours: a stale socket from a - // previous round reports its disconnect against the new container, and - // topping up on that would over-fill the roster. - if (had) { + if (this._players.includes(player)) { + this._players = this._players.filter((p) => p !== player); this.createNPCs(); } } diff --git a/backend/src/players/player.ts b/backend/src/players/player.ts index 8787332..c4678c7 100644 --- a/backend/src/players/player.ts +++ b/backend/src/players/player.ts @@ -31,44 +31,42 @@ import { isFiniteNumber, } from 'shared'; import { Socket } from 'socket.io'; -import { BoundingBox } from '../physics/bounding-boxes/bounding-box'; -import { PhysicalContainer } from '../physics/containers/physical-container'; +import { BoundingBox } from '../physics/bounding-box'; +import { PhysicalContainer } from '../physics/physical-container'; import { PlayerContainer } from './player-container'; -import { PlayerBase } from './player-base'; +import { PlayerBase, Score } from './player-base'; import { CharacterPhysical } from '../objects/character-physical'; const pingIntervalSeconds = 1; - const minimumAspectRatio = 0.2; const maximumAspectRatio = 8; -const maximumBufferedBytes = settings.maxBufferedBytesPerClient; +const sheddableWhenBackedUp: ReadonlyArray = [ + PropertyUpdatesForObjects.type, + UpdateMinimap.type, + InputAcknowledgement.type, +]; export class Player extends PlayerBase { - private aspectRatio: number = 16 / 9; + public reconnectToken = ''; + public rttMs = 0; + + private aspectRatio = 16 / 9; private timeSinceLastMessage = 0; - // A Set: the per-snapshot diff is membership-heavy, and insertion-order - // iteration keeps the streamed object order unchanged. private objectsInViewArea = new Set(); + private commandsToBeSent: Array = []; + private winnerTeam?: CharacterTeam; + private lastInputClientTimeMs = 0; private lastInputReceiptMs = 0; private lastLeapClientTimeMs = 0; - public reconnectToken = ''; - - // Server-side latency stat, reported in GameServer's periodic log. - public rttMs = 0; private timeSinceLastPing = 0; private lastPingSentMs = 0; - // Nonce of the ping awaiting a reply: prevents a client forging Pongs or - // answering a stale ping to corrupt the measured RTT. private pendingPingNonce = 0; private nextPingNonce = 1; - // Input below is untrusted: every command arrives from `deserialize` - // (a bare JSON.parse, which accepts Infinity as `1e999`). A single - // non-finite value reaching the simulation is unrecoverable — it spreads - // through the shared SDF and leaves a character that never dies or respawns. + // Input is untrusted: one non-finite value reaching the simulation leaves a character that never dies. protected commandExecutors: CommandExecutors = { [SetAspectRatioActionCommand.type]: (v: SetAspectRatioActionCommand) => { this.aspectRatio = finiteInRange( @@ -84,7 +82,7 @@ export class Player extends PlayerBase { return; } this.observeClientTime(c.clientTimeMs); - this.character?.handleMovementAction(new MoveActionCommand(direction, 0)); + this.character?.setMoveDirection(direction); }, [PrimaryActionCommand.type]: (c: PrimaryActionCommand) => { const position = finiteVec2(c.position, settings.maxClientPositionMagnitude); @@ -98,48 +96,34 @@ export class Player extends PlayerBase { if (!isFiniteNumber(c.clientTimeMs)) { return; } - // Record receipt whether or not leap() accepts it: the predictor must - // stop replaying this leap either way. this.lastLeapClientTimeMs = c.clientTimeMs; this.observeClientTime(c.clientTimeMs); this.character?.leap(); }, - // Advances the acknowledgement at the client's send rate regardless of - // whether input changed — otherwise a held key freezes the predictor's - // replay window. [ClientHeartbeatCommand.type]: (c: ClientHeartbeatCommand) => this.observeClientTime(c.clientTimeMs), }; - // Tracks both the newest applied client-clock instant and the server-clock - // instant it arrived. The predictor needs both: which inputs are already in - // the snapshot, and how stale that answer was (see InputAcknowledgement). - private observeClientTime(clientTimeMs: number) { - if (isFiniteNumber(clientTimeMs) && clientTimeMs > this.lastInputClientTimeMs) { - this.lastInputClientTimeMs = clientTimeMs; - this.lastInputReceiptMs = performance.now(); - } - } - constructor( playerInfo: PlayerInformation, playerContainer: PlayerContainer, objectContainer: PhysicalContainer, team: CharacterTeam, private readonly socket: Socket, + score?: Score, ) { - super(playerInfo, playerContainer, objectContainer, team); + super(playerInfo, playerContainer, objectContainer, team, score); this.createCharacter(); - this.step(0); - - // Only one ping is ever in flight, so RTT is simply now − send-time. this.socket.on(TransportEvents.Pong, this.onPong); } - // Only the outstanding ping's nonce counts, once: a client cannot forge a - // Pong or answer a stale ping to skew the stat. Sitting on the live ping to - // inflate the measurement is bounded by the clamp, and the next ping retires - // the nonce, so any stall costs at most one ping interval. + private observeClientTime(clientTimeMs: number) { + if (isFiniteNumber(clientTimeMs) && clientTimeMs > this.lastInputClientTimeMs) { + this.lastInputClientTimeMs = clientTimeMs; + this.lastInputReceiptMs = performance.now(); + } + } + private readonly onPong = (nonce: unknown) => { if (this.pendingPingNonce === 0 || nonce !== this.pendingPingNonce) { return; @@ -151,86 +135,93 @@ export class Player extends PlayerBase { ); }; - // The Pong listener outlives the round: the socket survives a restart, so a - // retired Player would stay reachable through it unless removed explicitly. public detachFromSocket() { this.socket.off(TransportEvents.Pong, this.onPong); } - protected createCharacter() { - super.createCharacter(); - - this.objectsInViewArea.add(this.character!); - this.queueCommandSend(new CreatePlayerCommand(this.character!)); + protected createCharacter(): CharacterPhysical { + const character = super.createCharacter(); + this.objectsInViewArea.add(character); + this.queueCommandSend(new CreatePlayerCommand(character)); + return character; } - private winnerTeam?: CharacterTeam; public onGameEnded(winnerTeam: CharacterTeam) { this.winnerTeam = winnerTeam; } - // Retained so an in-flight shot can still credit a kill to this corpse: - // killCount is re-read at respawn, not snapshotted when the body died. - private dyingCharacter?: CharacterPhysical | null; - public step(deltaTimeInSeconds: number) { this.stepLifecycle(deltaTimeInSeconds); } - protected onCharacterDied(character: CharacterPhysical) { - this.dyingCharacter = character; + public queueCommandSend(command: Command) { + this.commandsToBeSent.push(command); } - protected onBeforeRespawn() { - if (this.dyingCharacter) { - this.sumKills = Math.max(this.sumKills, this.dyingCharacter.killCount); - this.dyingCharacter = null; + public stepCommunications( + deltaTime: number, + propertyUpdatesOf: (object: GameObject) => PropertyUpdatesForObject | undefined, + ) { + const remoteCalls: Array = []; + for (const object of this.objectsInViewArea) { + const calls = object.getRemoteCalls(); + if (calls.length > 0) { + remoteCalls.push(new RemoteCallsForObject(object.id, calls)); + } + } + if (remoteCalls.length > 0) { + this.queueCommandSend(new RemoteCallsForObjects(remoteCalls)); + } + + if ((this.timeSinceLastPing += deltaTime) > pingIntervalSeconds) { + this.timeSinceLastPing = 0; + this.lastPingSentMs = performance.now(); + this.pendingPingNonce = this.nextPingNonce++; + this.socket.emit(TransportEvents.Ping, this.pendingPingNonce); + } + + if ((this.timeSinceLastMessage += deltaTime) > settings.updateMessageInterval) { + this.timeSinceLastMessage = 0; + this.queueAnnouncement(); + this.queueSnapshot(propertyUpdatesOf); + this.sendQueuedCommandsToClient(); } } - private handleViewAreaUpdate() { - const viewArea = calculateViewArea(this.center, this.aspectRatio, 1.2); - const bb = new BoundingBox(); - bb.topLeft = viewArea.topLeft; - bb.size = viewArea.size; - - const inViewArea = new Set( - this.objectContainer.findIntersecting(bb).map((o) => o.gameObject), + private queueSnapshot( + propertyUpdatesOf: (object: GameObject) => PropertyUpdatesForObject | undefined, + ) { + const { topLeft, size } = calculateViewArea(this.center, this.aspectRatio, 1.2); + const viewBox = new BoundingBox( + topLeft[0], + topLeft[0] + size[0], + topLeft[1] - size[1], + topLeft[1], ); - // The owning character must always be in its own snapshot so the client - // predictor never loses its authoritative anchor. + const inViewArea = new Set( + this.objectContainer.findIntersecting(viewBox).map((o) => o.gameObject), + ); if (this.character) { inViewArea.add(this.character); } - // Set membership rather than Array.includes: this diff runs per player per - // snapshot over everything on screen and was quadratic in that. - const newlyIntersecting = [...inViewArea].filter( - (o) => !this.objectsInViewArea.has(o), - ); - - const noLongerIntersecting = [...this.objectsInViewArea].filter( - (o) => !inViewArea.has(o), - ); - + const created = [...inViewArea].filter((o) => !this.objectsInViewArea.has(o)); + const deleted = [...this.objectsInViewArea].filter((o) => !inViewArea.has(o)); this.objectsInViewArea = inViewArea; - if (noLongerIntersecting.length > 0) { - this.queueCommandSend( - new DeleteObjectsCommand(noLongerIntersecting.map((g) => g.id)), - ); + if (deleted.length > 0) { + this.queueCommandSend(new DeleteObjectsCommand(deleted.map((g) => g.id))); } - - if (newlyIntersecting.length > 0) { - this.queueCommandSend(new CreateObjectsCommand(newlyIntersecting)); + if (created.length > 0) { + this.queueCommandSend(new CreateObjectsCommand(created)); } this.queueCommandSend(new UpdateMinimap(this.getMinimapPlayers())); const propertyUpdates: Array = []; for (const object of this.objectsInViewArea) { - const update = object.getPropertyUpdatesForFrame(); + const update = propertyUpdatesOf(object); if (update) { propertyUpdates.push(update); } @@ -239,8 +230,6 @@ export class Player extends PlayerBase { new PropertyUpdatesForObjects(propertyUpdates, performance.now() / 1000), ); - // Tells the client how much of its own input is reflected in the snapshot - // so its predictor can replay the rest. Only while alive. if (this.character) { this.queueCommandSend( new InputAcknowledgement( @@ -255,8 +244,6 @@ export class Player extends PlayerBase { } } - // Every living player except this one, by absolute world position for the - // minimap. private getMinimapPlayers(): Array { return this.playerContainer.players .filter((p) => p !== this && p.character?.isAlive) @@ -266,42 +253,6 @@ export class Player extends PlayerBase { ); } - private commandsToBeSent: Array = []; - public queueCommandSend(command: Command) { - this.commandsToBeSent.push(command); - } - - public stepCommunications(deltaTime: number) { - // Test first and only build the command when there is something to send. - let remoteCalls: Array | undefined; - for (const object of this.objectsInViewArea) { - const calls = object.getRemoteCalls(); - if (calls.length > 0) { - (remoteCalls ??= []).push(new RemoteCallsForObject(object.id, calls)); - } - } - - if (remoteCalls) { - this.queueCommandSend(new RemoteCallsForObjects(remoteCalls)); - } - - if ((this.timeSinceLastPing += deltaTime) > pingIntervalSeconds) { - this.timeSinceLastPing = 0; - this.lastPingSentMs = performance.now(); - this.pendingPingNonce = this.nextPingNonce++; - this.socket.emit(TransportEvents.Ping, this.pendingPingNonce); - } - - if ((this.timeSinceLastMessage += deltaTime) > settings.updateMessageInterval) { - this.handleAnnouncements(); - this.handleViewAreaUpdate(); - this.sendQueuedCommandsToClient(); - this.timeSinceLastMessage = 0; - } - } - - // engine.io's Socket exposes no bufferedAmount — it lives on the underlying - // ws socket, and only the websocket transport has one. private get bufferedBytes(): number { const conn = this.socket.conn as | { transport?: { socket?: { bufferedAmount?: unknown } } } @@ -311,53 +262,31 @@ export class Player extends PlayerBase { } public sendQueuedCommandsToClient() { + // Only state the next snapshot regenerates may be dropped; create/delete are one-shot. + if (this.bufferedBytes > settings.maxBufferedBytesPerClient) { + this.commandsToBeSent = this.commandsToBeSent.filter( + (c) => !sheddableWhenBackedUp.includes(c.type), + ); + } if (this.commandsToBeSent.length === 0) { return; } - // Only state next tick regenerates in full may be dropped. Create/delete - // are one-shot: handleViewAreaUpdate has ALREADY advanced - // objectsPreviouslyInViewArea, so dropping them leaves this client - // permanently missing objects and holding ghosts. They go out even backed up. - if (this.bufferedBytes > maximumBufferedBytes) { - // Matched by command type, not `instanceof`: a class identity from a - // bundled shared module is not something to depend on. - const sheddable: ReadonlyArray = [ - PropertyUpdatesForObjects.type, - UpdateMinimap.type, - // Regenerated in full next tick — shed with the pose, never advance the - // replay anchor past a snapshot the client never received. - InputAcknowledgement.type, - ]; - this.commandsToBeSent = this.commandsToBeSent.filter( - (c) => !sheddable.includes(c.type), - ); - if (this.commandsToBeSent.length === 0) { - return; - } - } - this.socket.emit(TransportEvents.ServerToPlayer, serialize(this.commandsToBeSent)); this.commandsToBeSent = []; } - public get scoreSnapshot(): { kills: number; deaths: number } { - return { - kills: Math.max(this.sumKills, this.character?.killCount ?? 0), - deaths: this.sumDeaths, - }; - } - - private handleAnnouncements() { - let announcement = ''; + private queueAnnouncement() { if (this.winnerTeam) { - announcement = `Team ${this.winnerTeam} won 🎉`; + this.queueCommandSend( + new ServerAnnouncement( + `Team ${this.winnerTeam} won 🎉`, + ), + ); } else if (!this.character) { - announcement = `Reviving in ${Math.round(this.timeUntilRespawn)}…`; - } - - if (announcement) { - this.queueCommandSend(new ServerAnnouncement(announcement)); + this.queueCommandSend( + new ServerAnnouncement(`Reviving in ${Math.round(this.timeUntilRespawn)}…`), + ); } } } diff --git a/backend/webpack.config.js b/backend/webpack.config.js index 87a462a..0d1a0e3 100644 --- a/backend/webpack.config.js +++ b/backend/webpack.config.js @@ -33,8 +33,7 @@ module.exports = (env, argv) => ({ minimizer: [ new TerserJSPlugin({ exclude: /node_modules/, - // The custom serialization protocol keys on class names, so they must - // survive minification (see shared/src/serialization). + // The serialization protocol keys on class names. terserOptions: { keep_classnames: true, }, diff --git a/eslint.config.js b/eslint.config.js index 84a6892..20da74d 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,4 +1,3 @@ -// Flat config (ESLint 9+/10). Ported from the former .eslintrc.json. const tseslint = require('typescript-eslint'); const prettierRecommended = require('eslint-plugin-prettier/recommended'); const unusedImports = require('eslint-plugin-unused-imports'); @@ -25,7 +24,6 @@ module.exports = tseslint.config( '@typescript-eslint/explicit-module-boundary-types': 'off', '@typescript-eslint/no-non-null-assertion': 'off', '@typescript-eslint/no-empty-function': 'off', - // Renamed from the former no-var-requires; keep require() allowed. '@typescript-eslint/no-require-imports': 'off', }, }, diff --git a/frontend/src/index.ts b/frontend/src/index.ts index 99d95f2..ea8ff1d 100644 --- a/frontend/src/index.ts +++ b/frontend/src/index.ts @@ -1,6 +1,5 @@ import { glMatrix } from 'gl-matrix'; import { - applyArrayPlugins, LampBase, overrideDeserialization, PlanetBase, @@ -14,20 +13,12 @@ import '../static/favicons/apple-touch-icon.png'; import '../static/favicons/favicon-16x16.png'; import '../static/favicons/favicon-32x32.png'; import '../static/favicons/favicon.ico'; +import ResizeObserver from 'resize-observer-polyfill'; import { LandingPageBackground } from './scripts/landing-page-background'; import { JoinFormHandler } from './scripts/join-form-handler'; import { handleFullScreen } from './scripts/helper/handle-full-screen'; import { Game } from './scripts/game'; -import ResizeObserver from 'resize-observer-polyfill'; -import { OptionsHandler } from './scripts/options-handler'; -const hide = (e: HTMLElement, d?: boolean) => { - if (d) e.style.display = 'none'; - else e.style.visibility = 'hidden'; -}; -const show = (e: HTMLElement, d?: boolean, v?: string) => { - if (d) e.style.display = v!; - else e.style.visibility = 'inherit'; -}; +import { initializeOptions } from './scripts/options-handler'; import { SoundHandler, Sounds } from './scripts/sound-handler'; import { VibrationHandler } from './scripts/vibration-handler'; import { CharacterView } from './scripts/objects/types/character-view'; @@ -36,36 +27,43 @@ import { PlanetView } from './scripts/objects/types/planet-view'; import { ProjectileView } from './scripts/objects/types/projectile-view'; glMatrix.setMatrixArrayType(Array); -applyArrayPlugins(); overrideDeserialization(CharacterBase, CharacterView); overrideDeserialization(PlanetBase, PlanetView); overrideDeserialization(LampBase, LampView); overrideDeserialization(ProjectileBase, ProjectileView); -const landingUI = document.querySelector('#landing-ui') as HTMLElement; -const nameInput = document.querySelector('#name') as HTMLInputElement; -const joinGameForm = document.querySelector('#join-game-form') as HTMLFormElement; -const serverContainer = document.querySelector('#server-container') as HTMLElement; -const canvas = document.querySelector('canvas') as HTMLCanvasElement; -const overlay = document.querySelector('#overlay') as HTMLElement; -const settings = document.querySelector('#settings') as HTMLElement; -const toggleSettingsButton = document.querySelector( - '#toggle-settings-container', -) as HTMLElement; -const minimize = document.querySelector('#minimize') as HTMLElement; -const maximize = document.querySelector('#maximize') as HTMLElement; -const logoutButton = document.querySelector('#logout') as HTMLElement; -const enableSounds = document.querySelector('#enable-sounds') as HTMLInputElement; -const enableMusic = document.querySelector('#enable-music') as HTMLInputElement; -const enableVibration = document.querySelector('#enable-vibration') as HTMLInputElement; -const spinner = document.querySelector('#spinner-container') as HTMLElement; +const query = (selector: string) => + document.querySelector(selector) as T; + +const landingUI = query('#landing-ui'); +const nameInput = query('#name'); +const joinGameForm = query('#join-game-form'); +const serverContainer = query('#server-container'); +const canvas = query('canvas'); +const overlay = query('#overlay'); +const settings = query('#settings'); +const toggleSettingsButton = query('#toggle-settings-container'); +const minimize = query('#minimize'); +const maximize = query('#maximize'); +const logoutButton = query('#logout'); +const enableSounds = query('#enable-sounds'); +const enableMusic = query('#enable-music'); +const enableVibration = query('#enable-vibration'); +const spinner = query('#spinner-container'); const joinNotice = document.createElement('p'); joinNotice.className = 'join-notice'; joinNotice.style.display = 'none'; joinGameForm.prepend(joinNotice); +const setVisible = (e: HTMLElement, visible: boolean) => { + e.style.visibility = visible ? 'inherit' : 'hidden'; +}; +const setDisplayed = (e: HTMLElement, display: string | null) => { + e.style.display = display ?? 'none'; +}; + const toggleSettings = () => { settings.className = settings.className === 'open' ? '' : 'open'; SoundHandler.play(Sounds.click); @@ -76,21 +74,19 @@ const applyServerContainerShadows = () => { const bottomShadow = 'inset 0 8px 8px -8px rgba(0, 0, 0, 0.4)'; const { scrollHeight, clientHeight, scrollTop } = serverContainer; - if (scrollHeight > clientHeight) { - if (scrollTop <= 0) { - serverContainer.style.boxShadow = topShadow; - } else if (scrollTop + clientHeight >= scrollHeight) { - serverContainer.style.boxShadow = bottomShadow; - } else { - serverContainer.style.boxShadow = topShadow + ',' + bottomShadow; - } - } else { + if (scrollHeight <= clientHeight) { serverContainer.style.boxShadow = ''; + } else if (scrollTop <= 0) { + serverContainer.style.boxShadow = topShadow; + } else if (scrollTop + clientHeight >= scrollHeight) { + serverContainer.style.boxShadow = bottomShadow; + } else { + serverContainer.style.boxShadow = topShadow + ',' + bottomShadow; } }; const main = async () => { - let game: Game; + let game: Game | undefined; const storedUserName = localStorage.getItem('userName'); if (storedUserName) { @@ -113,7 +109,7 @@ const main = async () => { document.addEventListener('click', firstClickListener); if (!VibrationHandler.isVibrationEnabledHeuristics) { - hide(document.querySelector("label[for='enable-vibration']") as HTMLElement, true); + setDisplayed(query("label[for='enable-vibration']"), null); } handleFullScreen(minimize, maximize); @@ -122,28 +118,28 @@ const main = async () => { new ResizeObserver(applyServerContainerShadows).observe(serverContainer); serverContainer.addEventListener('scroll', applyServerContainerShadows); - OptionsHandler.initialize({ + initializeOptions({ soundsEnabled: enableSounds, vibrationEnabled: enableVibration, musicEnabled: enableMusic, }); logoutButton.addEventListener('click', () => { - game.destroy(); + game?.destroy(); toggleSettings(); }); - window.onpopstate = () => game.destroy(); + window.onpopstate = () => game?.destroy(); for (;;) { - show(spinner); - hide(logoutButton, true); - show(landingUI, true, 'flex'); + setVisible(spinner, true); + setDisplayed(logoutButton, null); + setDisplayed(landingUI, 'flex'); const background = new LandingPageBackground(canvas); const joinHandler = new JoinFormHandler(joinGameForm, serverContainer); await background.renderer; - hide(spinner); + setVisible(spinner, false); const playerDecision = await joinHandler.getPlayerDecision(); @@ -153,29 +149,25 @@ const main = async () => { history.pushState(true, ''); } - hide(landingUI, true); - show(spinner); + setDisplayed(landingUI, null); + setVisible(spinner, true); background.destroy(); game = new Game(playerDecision, canvas, overlay); const gameOver = game.start(); await game.started; - hide(spinner); - show(logoutButton, true, 'block'); + setVisible(spinner, false); + setDisplayed(logoutButton, 'block'); await gameOver; - const reason = game.lastRejectionReason; - if (reason) { - joinNotice.innerText = Game.rejectionText(reason); - joinNotice.style.display = 'block'; - } else { - joinNotice.style.display = 'none'; - } + const reason = game.rejectionReason; + joinNotice.innerText = reason ? Game.rejectionText(reason) : ''; + setDisplayed(joinNotice, reason ? 'block' : null); } }; main().catch((error) => { console.error(error); - hide(spinner); + setVisible(spinner, false); joinNotice.innerText = 'Something went wrong. Please reload the page.'; - joinNotice.style.display = 'block'; + setDisplayed(joinNotice, 'block'); }); diff --git a/frontend/src/scripts/charge-indicator.ts b/frontend/src/scripts/charge-indicator.ts index 1a3d01d..1bad137 100644 --- a/frontend/src/scripts/charge-indicator.ts +++ b/frontend/src/scripts/charge-indicator.ts @@ -1,4 +1,4 @@ -import { holdDurationToCharge } from 'shared'; +import { chargeHeldSince } from 'shared'; import { pointer } from './helper/pointer'; let element: HTMLElement | undefined; @@ -24,7 +24,7 @@ function beginChargeIndicator(x: number, y: number, followPointer = false) { function endChargeIndicator() { if (element) { cancelAnimationFrame(raf); - element.parentElement?.removeChild(element); + element.remove(); element = undefined; } } @@ -34,7 +34,7 @@ function updateChargeIndicator() { return; } - const charge = holdDurationToCharge((performance.now() - heldSince) / 1000); + const charge = chargeHeldSince(heldSince); element.style.opacity = charge < 0.12 ? '0' : '1'; element.style.background = `conic-gradient(rgba(255, 255, 255, 0.85) ${ charge * 360 @@ -44,15 +44,14 @@ function updateChargeIndicator() { if (isFollowingPointer) { const cursor = pointer.displayPosition; if (cursor) { - element.style.left = `${cursor.x}px`; - element.style.top = `${cursor.y}px`; + element.style.left = `${cursor[0]}px`; + element.style.top = `${cursor[1]}px`; } } raf = requestAnimationFrame(updateChargeIndicator); } -// The one public surface; the functions above stay module-private. export const ChargeIndicator = { begin: beginChargeIndicator, end: endChargeIndicator, diff --git a/frontend/src/scripts/commands/command-socket.ts b/frontend/src/scripts/commands/command-socket.ts index da06121..b68484d 100644 --- a/frontend/src/scripts/commands/command-socket.ts +++ b/frontend/src/scripts/commands/command-socket.ts @@ -1,49 +1,35 @@ import { ClientHeartbeatCommand, Command, - CommandReceiver, serialize, settings, TransportEvents, } from 'shared'; import { Socket } from 'socket.io-client'; -import { predictorNowMs } from '../helper/prediction/local-character-predictor'; +import { clientTimeMs } from '../helper/prediction/local-character-predictor'; const heartbeatIntervalMs = settings.clientSendInterval * 1000; - -// A frame that lands a hair short of the deadline still counts as due. At an -// exact ratio of frame rate to interval — 60 Hz against 30 Hz — whether the gap -// compares as reached is decided by floating-point noise, and the heartbeat -// slips a whole frame every third beat, drifting to ~21 Hz instead of 30. +// At an exact frame-rate/interval ratio (60 Hz vs 30 Hz) float noise would otherwise drop every third beat. const dueToleranceMs = 1; -export class CommandSocket extends CommandReceiver { - constructor(private readonly socket: Socket) { - super(); - } - +export class CommandSocket { private commandQueue: Array = []; private lastSendMs = -Infinity; - protected defaultCommandExecutor(command: Command) { + constructor(private readonly socket: Socket) {} + + public queue(command: Command) { this.commandQueue.push(command); } public sendQueuedCommands() { - // Nothing goes out while the transport is down. socket.io-client would - // otherwise buffer every emit in an uncapped sendBuffer and flush it on - // reconnect BEFORE the `connect` handler re-sends PlayerJoining — the - // server has no PlayerToServer listener attached yet and drops the lot. - // The local queue is dropped with it: these are timestamped inputs, and - // replaying a minute-old shot after the gap would be worse than losing it. + // socket.io-client would buffer emits made while disconnected and flush them before the re-join. if (!this.socket.connected) { this.commandQueue = []; return; } - const nowMs = predictorNowMs(); - - // Real input goes out on the frame it happens — that is the latency budget the player feels. An idle client still keeps the server's input acknowledgement moving (a held key generates no fresh command), paced by the clock not the frame rate; sending every rendered frame would exceed settings.maxInboundMessagesPerSecond, and since movement commands are edge-triggered and never re-sent, a discarded batch would drop the direction change for good. + const nowMs = clientTimeMs(); if ( this.commandQueue.length === 0 && nowMs - this.lastSendMs < heartbeatIntervalMs - dueToleranceMs @@ -52,7 +38,7 @@ export class CommandSocket extends CommandReceiver { } this.lastSendMs = nowMs; - this.commandQueue.push(new ClientHeartbeatCommand(Math.round(nowMs))); + this.commandQueue.push(new ClientHeartbeatCommand(nowMs)); this.socket.emit(TransportEvents.PlayerToServer, serialize(this.commandQueue)); this.commandQueue = []; } diff --git a/frontend/src/scripts/commands/input-generator.ts b/frontend/src/scripts/commands/input-generator.ts new file mode 100644 index 0000000..6c8ef50 --- /dev/null +++ b/frontend/src/scripts/commands/input-generator.ts @@ -0,0 +1,29 @@ +import { vec2 } from 'gl-matrix'; +import { + Command, + LeapActionCommand, + MoveActionCommand, + PrimaryActionCommand, +} from 'shared'; +import { + clientTimeMs, + localCharacterPredictor, +} from '../helper/prediction/local-character-predictor'; + +export abstract class InputGenerator { + constructor(protected readonly onCommand: (command: Command) => void) {} + + protected sendMove(direction: vec2) { + this.onCommand( + new MoveActionCommand(direction, localCharacterPredictor.recordInput(direction)), + ); + } + + protected sendLeap() { + this.onCommand(new LeapActionCommand(localCharacterPredictor.recordLeap())); + } + + protected sendPrimary(position: vec2, charge: number) { + this.onCommand(new PrimaryActionCommand(position, charge, clientTimeMs())); + } +} diff --git a/frontend/src/scripts/commands/keyboard-listener.ts b/frontend/src/scripts/commands/keyboard-listener.ts index 971a0c9..476396e 100644 --- a/frontend/src/scripts/commands/keyboard-listener.ts +++ b/frontend/src/scripts/commands/keyboard-listener.ts @@ -1,13 +1,11 @@ import { vec2 } from 'gl-matrix'; -import { CommandGenerator, LeapActionCommand, MoveActionCommand } from 'shared'; -import { localCharacterPredictor } from '../helper/prediction/local-character-predictor'; +import { InputGenerator } from './input-generator'; -export class KeyboardListener extends CommandGenerator { +export class KeyboardListener extends InputGenerator { private keysDown: Set = new Set(); - constructor() { - super(); - + constructor(onCommand: ConstructorParameters[0]) { + super(onCommand); addEventListener('keydown', this.keyDownListener); addEventListener('keyup', this.keyUpListener); addEventListener('blur', this.blurListener); @@ -15,45 +13,35 @@ export class KeyboardListener extends CommandGenerator { private keyDownListener = (event: KeyboardEvent) => { const key = event.code; - if ( - (key === 'Space' || key === 'ShiftLeft' || key === 'ShiftRight') && - !this.keysDown.has(key) - ) { - const clientTimeMs = localCharacterPredictor.recordLeap(); - this.sendCommandToSubscribers(new LeapActionCommand(clientTimeMs)); - } - if (this.keysDown.has(key)) { return; } this.keysDown.add(key); - this.generateCommands(); + if (key === 'Space' || key === 'ShiftLeft' || key === 'ShiftRight') { + this.sendLeap(); + } + this.sendMovement(); }; private keyUpListener = (event: KeyboardEvent) => { if (this.keysDown.delete(event.code)) { - this.generateCommands(); + this.sendMovement(); } }; private blurListener = () => { this.keysDown.clear(); - this.generateCommands(); + this.sendMovement(); }; - private generateCommands() { - const up = ~~(this.keysDown.has('KeyW') || this.keysDown.has('ArrowUp')); - const down = ~~(this.keysDown.has('KeyS') || this.keysDown.has('ArrowDown')); - const left = ~~(this.keysDown.has('KeyA') || this.keysDown.has('ArrowLeft')); - const right = ~~(this.keysDown.has('KeyD') || this.keysDown.has('ArrowRight')); - - const movement = vec2.fromValues(right - left, up - down); - if (vec2.squaredLength(movement) > 0) { - vec2.normalize(movement, movement); - } - - const clientTimeMs = localCharacterPredictor.recordInput(movement); - this.sendCommandToSubscribers(new MoveActionCommand(movement, clientTimeMs)); + private sendMovement() { + const down = (...codes: Array) => + codes.some((c) => this.keysDown.has(c)) ? 1 : 0; + const movement = vec2.fromValues( + down('KeyD', 'ArrowRight') - down('KeyA', 'ArrowLeft'), + down('KeyW', 'ArrowUp') - down('KeyS', 'ArrowDown'), + ); + this.sendMove(vec2.normalize(movement, movement)); } public destroy() { diff --git a/frontend/src/scripts/commands/mouse-listener.ts b/frontend/src/scripts/commands/mouse-listener.ts index fdd06e7..5747a14 100644 --- a/frontend/src/scripts/commands/mouse-listener.ts +++ b/frontend/src/scripts/commands/mouse-listener.ts @@ -1,28 +1,26 @@ import { vec2 } from 'gl-matrix'; -import { CommandGenerator, PrimaryActionCommand, holdDurationToCharge } from 'shared'; +import { chargeHeldSince, Command } from 'shared'; import { Game } from '../game'; import { ChargeIndicator } from '../charge-indicator'; -import { predictorNowMs } from '../helper/prediction/local-character-predictor'; import { pointer } from '../helper/pointer'; +import { InputGenerator } from './input-generator'; -export class MouseListener extends CommandGenerator { +export class MouseListener extends InputGenerator { private primaryDownAt: number | null = null; constructor( - private target: HTMLElement, + private readonly target: HTMLElement, private readonly game: Game, + onCommand: (command: Command) => void, ) { - super(); - + super(onCommand); target.addEventListener('mousedown', this.mouseDownListener); target.addEventListener('mousemove', this.mouseMoveListener); target.addEventListener('contextmenu', this.contextMenuListener); - // Release watched on the window, not the canvas — a release over UI or outside the window must still fire. window.addEventListener('mouseup', this.mouseUpListener); window.addEventListener('blur', this.cancelPrimary); } - // Store screen position; reproject to world each frame so gaze stays correct while the camera pans. private mouseMoveListener = (event: MouseEvent) => { pointer.displayPosition = vec2.fromValues(event.clientX, event.clientY); }; @@ -38,36 +36,24 @@ export class MouseListener extends CommandGenerator { if (event.button !== 0 || this.primaryDownAt === null) { return; } - ChargeIndicator.end(); - const charge = holdDurationToCharge((performance.now() - this.primaryDownAt) / 1000); + const charge = chargeHeldSince(this.primaryDownAt); this.primaryDownAt = null; - this.sendCommandToSubscribers( - new PrimaryActionCommand( - this.positionFromEvent(event), - charge, - Math.round(predictorNowMs()), - ), + this.sendPrimary( + this.game.displayToWorldCoordinates(vec2.fromValues(event.clientX, event.clientY)), + charge, ); }; private cancelPrimary = () => { - if (this.primaryDownAt !== null) { - this.primaryDownAt = null; - ChargeIndicator.end(); - } + this.primaryDownAt = null; + ChargeIndicator.end(); }; private contextMenuListener = (event: MouseEvent) => { event.preventDefault(); }; - private positionFromEvent(event: MouseEvent): vec2 { - return this.game.displayToWorldCoordinates( - vec2.fromValues(event.clientX, event.clientY), - ); - } - public destroy() { ChargeIndicator.end(); this.target.removeEventListener('mousedown', this.mouseDownListener); diff --git a/frontend/src/scripts/commands/touch-listener.ts b/frontend/src/scripts/commands/touch-listener.ts index a73bf5d..5b933ab 100644 --- a/frontend/src/scripts/commands/touch-listener.ts +++ b/frontend/src/scripts/commands/touch-listener.ts @@ -1,72 +1,53 @@ import { vec2 } from 'gl-matrix'; -import { - CommandGenerator, - MoveActionCommand, - PrimaryActionCommand, - LeapActionCommand, - holdDurationToCharge, - settings, -} from 'shared'; +import { chargeHeldSince, Command, settings } from 'shared'; import { Game } from '../game'; import { ChargeIndicator } from '../charge-indicator'; -import { - localCharacterPredictor, - predictorNowMs, -} from '../helper/prediction/local-character-predictor'; +import { InputGenerator } from './input-generator'; -export class TouchListener extends CommandGenerator { - private static readonly deadZone = 8; - private static readonly deltaScaling = 0.4; - private static readonly aimDeadZone = 18; +const deadZone = 8; +const deltaScaling = 0.4; +const aimDeadZone = 18; +const joystickMaxLength = 20; + +export class TouchListener extends InputGenerator { + private readonly joystick = document.createElement('div'); + private readonly joystickButton = document.createElement('div'); + private readonly fireButton = document.createElement('div'); + private readonly fireStrengthRing = document.createElement('div'); + private readonly fireAimLine = document.createElement('div'); + private readonly leapButton = document.createElement('div'); - private joystick: HTMLElement; - private joystickButton: HTMLElement; private isJoystickActive = false; - private touchStartPosition!: vec2; + private touchStartPosition = vec2.create(); private primaryDownAt: number | null = null; private gestureTouchId: number | null = null; - private fireButton: HTMLElement; - private fireStrengthRing: HTMLElement; - private fireAimLine!: HTMLElement; - private leapButton: HTMLElement; - private fireDownAt: number | null = null; - private fireButtonCenter: vec2 | null = null; - private fireAimScreen: vec2 | null = null; + private fireButtonCenter = vec2.create(); + private fireAim: vec2 | null = null; constructor( - private target: HTMLElement, - private overlay: HTMLElement, + private readonly target: HTMLElement, + private readonly overlay: HTMLElement, private readonly game: Game, + onCommand: (command: Command) => void, ) { - super(); + super(onCommand); - this.joystick = document.createElement('div'); this.joystick.className = 'joystick'; - this.joystickButton = document.createElement('div'); this.joystick.appendChild(this.joystickButton); - this.fireButton = document.createElement('div'); this.fireButton.className = 'touch-button fire'; - this.fireStrengthRing = document.createElement('div'); this.fireStrengthRing.className = 'strength-ring'; - this.fireButton.appendChild(this.fireStrengthRing); - this.fireAimLine = document.createElement('div'); this.fireAimLine.className = 'aim-line'; - this.fireButton.appendChild(this.fireAimLine); - + this.fireButton.append(this.fireStrengthRing, this.fireAimLine); this.fireButton.addEventListener('touchstart', this.fireButtonDownListener); this.fireButton.addEventListener('touchmove', this.fireButtonMoveListener); this.fireButton.addEventListener('touchend', this.fireButtonUpListener); - this.leapButton = document.createElement('div'); this.leapButton.className = 'touch-button leap'; this.leapButton.addEventListener('touchstart', this.leapButtonListener); - this.overlay.appendChild(this.fireButton); - this.overlay.appendChild(this.leapButton); - target.addEventListener('touchstart', this.touchStartListener); target.addEventListener('touchmove', this.touchMoveListener); target.addEventListener('touchend', this.touchEndListener); @@ -74,9 +55,6 @@ export class TouchListener extends CommandGenerator { } private findGestureTouch(list: TouchList): Touch | undefined { - if (this.gestureTouchId === null) { - return undefined; - } for (let i = 0; i < list.length; i++) { if (list[i].identifier === this.gestureTouchId) { return list[i]; @@ -87,27 +65,22 @@ export class TouchListener extends CommandGenerator { private touchStartListener = (event: TouchEvent) => { event.preventDefault(); + const touch = event.changedTouches[event.changedTouches.length - 1]; + if (!touch) { + return; + } if (this.isJoystickActive) { - const center = vec2.fromValues( - event.changedTouches[event.changedTouches.length - 1].clientX, - event.changedTouches[event.changedTouches.length - 1].clientY, - ); - this.sendCommandToSubscribers( - new PrimaryActionCommand( - this.game.displayToWorldCoordinates(center), - 0, - Math.round(predictorNowMs()), + this.sendPrimary( + this.game.displayToWorldCoordinates( + vec2.fromValues(touch.clientX, touch.clientY), ), + 0, ); } else { - const touch = event.changedTouches[0]; - if (!touch) { - return; - } this.gestureTouchId = touch.identifier; - this.touchStartPosition = vec2.fromValues(touch.clientX, touch.clientY); + vec2.set(this.touchStartPosition, touch.clientX, touch.clientY); this.primaryDownAt = performance.now(); - ChargeIndicator.begin(this.touchStartPosition.x, this.touchStartPosition.y); + ChargeIndicator.begin(touch.clientX, touch.clientY); } }; @@ -115,43 +88,36 @@ export class TouchListener extends CommandGenerator { event.preventDefault(); const touch = this.findGestureTouch(event.touches); - if (!touch || !this.touchStartPosition) { + if (!touch) { return; } - const touchPosition = vec2.fromValues(touch.clientX, touch.clientY); - - const delta = vec2.subtract(vec2.create(), touchPosition, this.touchStartPosition); - vec2.scale(delta, delta, TouchListener.deltaScaling); + const delta = vec2.fromValues( + touch.clientX - this.touchStartPosition[0], + touch.clientY - this.touchStartPosition[1], + ); + vec2.scale(delta, delta, deltaScaling); const deltaLength = vec2.length(delta); - if (!this.isJoystickActive && deltaLength > TouchListener.deadZone) { + if (!this.isJoystickActive && deltaLength > deadZone) { this.isJoystickActive = true; this.primaryDownAt = null; ChargeIndicator.end(); this.overlay.appendChild(this.joystick); - this.joystickButton.style.transform = `translateX(-50%) translateY(-50%)`; - this.joystick.style.transform = `translateX(${this.touchStartPosition.x}px) translateY(${this.touchStartPosition.y}px) translateX(-50%) translateY(-50%)`; + this.joystick.style.transform = `translateX(${this.touchStartPosition[0]}px) translateY(${this.touchStartPosition[1]}px) translateX(-50%) translateY(-50%)`; } - const maxLength = 20; - vec2.scale(delta, delta, Math.min(1, maxLength / deltaLength)); - this.joystickButton.style.transform = `translateX(${delta.x}px) translateY(${delta.y}px) translateX(-50%) translateY(-50%)`; + vec2.scale(delta, delta, Math.min(1, joystickMaxLength / deltaLength)); + this.joystickButton.style.transform = `translateX(${delta[0]}px) translateY(${delta[1]}px) translateX(-50%) translateY(-50%)`; - vec2.set(delta, delta.x, -delta.y); - if (deltaLength > TouchListener.deadZone) { - const direction = vec2.normalize(delta, delta); - this.sendMove(direction); + if (deltaLength > deadZone) { + vec2.set(delta, delta[0], -delta[1]); + this.sendMove(vec2.normalize(delta, delta)); } else { this.sendMove(vec2.create()); } }; - private sendMove(direction: vec2) { - const clientTimeMs = localCharacterPredictor.recordInput(direction); - this.sendCommandToSubscribers(new MoveActionCommand(direction, clientTimeMs)); - } - private touchEndListener = (event: TouchEvent) => { event.preventDefault(); @@ -161,27 +127,20 @@ export class TouchListener extends CommandGenerator { } this.gestureTouchId = null; - if (!this.isJoystickActive) { - ChargeIndicator.end(); - const charge = - this.primaryDownAt === null - ? 0 - : holdDurationToCharge((performance.now() - this.primaryDownAt) / 1000); - this.primaryDownAt = null; - const center = vec2.fromValues(touch.clientX, touch.clientY); - this.sendCommandToSubscribers( - new PrimaryActionCommand( - this.game.displayToWorldCoordinates(center), - charge, - Math.round(predictorNowMs()), - ), - ); - } else { + if (this.isJoystickActive) { this.releaseJoystick(); + return; } + + ChargeIndicator.end(); + const charge = this.primaryDownAt === null ? 0 : chargeHeldSince(this.primaryDownAt); + this.primaryDownAt = null; + this.sendPrimary( + this.game.displayToWorldCoordinates(vec2.fromValues(touch.clientX, touch.clientY)), + charge, + ); }; - // Also reached from touchcancel — otherwise a system gesture leaves the stick latched. private touchCancelListener = (event: TouchEvent) => { if (!this.findGestureTouch(event.changedTouches)) { return; @@ -196,53 +155,59 @@ export class TouchListener extends CommandGenerator { private releaseJoystick() { this.isJoystickActive = false; - this.joystick.parentElement?.removeChild(this.joystick); + this.joystick.remove(); this.sendMove(vec2.create()); } - private swallowTouch = (event: TouchEvent) => { + private swallowTouch(event: TouchEvent) { event.preventDefault(); event.stopPropagation(); - }; + } private leapButtonListener = (event: TouchEvent) => { this.swallowTouch(event); - const clientTimeMs = localCharacterPredictor.recordLeap(); - this.sendCommandToSubscribers(new LeapActionCommand(clientTimeMs)); + this.sendLeap(); }; private fireButtonDownListener = (event: TouchEvent) => { this.swallowTouch(event); this.fireDownAt = performance.now(); const rect = this.fireButton.getBoundingClientRect(); - this.fireButtonCenter = vec2.fromValues( + vec2.set( + this.fireButtonCenter, rect.left + rect.width / 2, rect.top + rect.height / 2, ); - this.fireAimScreen = null; + this.fireAim = null; ChargeIndicator.begin(this.fireButtonCenter[0], this.fireButtonCenter[1]); }; private fireButtonMoveListener = (event: TouchEvent) => { this.swallowTouch(event); - if (this.fireDownAt === null || !this.fireButtonCenter) { - return; - } const touch = event.targetTouches[0] ?? event.changedTouches[0]; - if (!touch) { + if (this.fireDownAt === null || !touch) { return; } - this.fireAimScreen = vec2.fromValues(touch.clientX, touch.clientY); - const dx = this.fireAimScreen[0] - this.fireButtonCenter[0]; - const dy = this.fireAimScreen[1] - this.fireButtonCenter[1]; - if (dx * dx + dy * dy > TouchListener.aimDeadZone * TouchListener.aimDeadZone) { + const aim = this.aimFromTouch(touch); + this.fireAim = aim; + if (aim) { this.fireAimLine.style.opacity = '1'; - this.fireAimLine.style.transform = `translateY(-50%) rotate(${Math.atan2(dy, dx)}rad)`; + this.fireAimLine.style.transform = `translateY(-50%) rotate(${Math.atan2(-aim[1], aim[0])}rad)`; } else { this.fireAimLine.style.opacity = '0'; } }; + private aimFromTouch(touch: Touch): vec2 | null { + const dx = touch.clientX - this.fireButtonCenter[0]; + const dy = touch.clientY - this.fireButtonCenter[1]; + if (dx * dx + dy * dy <= aimDeadZone * aimDeadZone) { + return null; + } + const aim = vec2.fromValues(dx, -dy); + return vec2.normalize(aim, aim); + } + private fireButtonUpListener = (event: TouchEvent) => { this.swallowTouch(event); ChargeIndicator.end(); @@ -251,48 +216,33 @@ export class TouchListener extends CommandGenerator { return; } - const charge = holdDurationToCharge((performance.now() - this.fireDownAt) / 1000); + const charge = chargeHeldSince(this.fireDownAt); this.fireDownAt = null; - const character = this.game.gameObjects.player; + const character = this.game.gameObjects.localPlayer; if (!character) { - this.fireButtonCenter = null; - this.fireAimScreen = null; return; } - // Screen drag → world aim direction (flip Y: screen +y is down). - let direction = character.facingDirection; - if (this.fireButtonCenter && this.fireAimScreen) { - const dx = this.fireAimScreen[0] - this.fireButtonCenter[0]; - const dy = this.fireAimScreen[1] - this.fireButtonCenter[1]; - if (dx * dx + dy * dy > TouchListener.aimDeadZone * TouchListener.aimDeadZone) { - direction = vec2.normalize(vec2.create(), vec2.fromValues(dx, -dy)); - } - } - this.fireButtonCenter = null; - this.fireAimScreen = null; - - const aim = vec2.scaleAndAdd( - vec2.create(), - character.bodyCenter, - direction, - settings.touchAimRange, - ); - this.sendCommandToSubscribers( - new PrimaryActionCommand(aim, charge, Math.round(predictorNowMs())), + const direction = this.fireAim ?? character.facingDirection; + this.fireAim = null; + this.sendPrimary( + vec2.scaleAndAdd( + vec2.create(), + character.bodyCenter, + direction, + settings.touchAimRange, + ), + charge, ); }; public update() { if (!this.fireButton.parentElement) { - this.overlay.appendChild(this.fireButton); - } - if (!this.leapButton.parentElement) { - this.overlay.appendChild(this.leapButton); + this.overlay.append(this.fireButton, this.leapButton); } - const character = this.game.gameObjects.player; + const character = this.game.gameObjects.localPlayer; if (character) { this.fireStrengthRing.style.background = `conic-gradient(rgba(255, 255, 255, 0.75) ${ character.strengthFraction * 360 @@ -312,7 +262,7 @@ export class TouchListener extends CommandGenerator { this.fireButton.removeEventListener('touchend', this.fireButtonUpListener); this.leapButton.removeEventListener('touchstart', this.leapButtonListener); - this.fireButton.parentElement?.removeChild(this.fireButton); - this.leapButton.parentElement?.removeChild(this.leapButton); + this.fireButton.remove(); + this.leapButton.remove(); } } diff --git a/frontend/src/scripts/configuration.ts b/frontend/src/scripts/configuration.ts index f41d8f9..d8d2dd0 100644 --- a/frontend/src/scripts/configuration.ts +++ b/frontend/src/scripts/configuration.ts @@ -1,13 +1,5 @@ -/** - * Game server origins. The join screen polls each and only shows those that - * respond, so listing an offline origin here is harmless. - */ const productionServers: Array = ['https://declared.schmelczer.dev']; -/** - * On localhost (webpack-dev-server) also offer the local backend on its default - * port; in production the page is served from its own hostname, so this never leaks. - */ const isDevelopment = typeof location !== 'undefined' && (location.hostname === 'localhost' || location.hostname === '127.0.0.1'); diff --git a/frontend/src/scripts/feedback-hud.ts b/frontend/src/scripts/feedback-hud.ts index ccc4636..ef7b251 100644 --- a/frontend/src/scripts/feedback-hud.ts +++ b/frontend/src/scripts/feedback-hud.ts @@ -22,7 +22,7 @@ function ensureRoot(): { root: HTMLElement; killfeed: HTMLElement } { function focusPoint(): { x: number; y: number } { const cursor = pointer.displayPosition; if (cursor) { - return { x: cursor.x, y: cursor.y }; + return { x: cursor[0], y: cursor[1] }; } return { x: window.innerWidth / 2, y: window.innerHeight / 2 }; } @@ -30,7 +30,7 @@ function focusPoint(): { x: number; y: number } { function addTransient(el: HTMLElement, lifetimeMs: number) { const { root: r } = ensureRoot(); r.appendChild(el); - setTimeout(() => el.parentElement?.removeChild(el), lifetimeMs); + setTimeout(() => el.remove(), lifetimeMs); } function hitMarker(charge = 0) { @@ -55,7 +55,7 @@ function killConfirmed(victimName?: string, streak = 1, charge = 0) { entry.className = 'kill-entry'; entry.innerHTML = `Eliminated ${escapeHtml(victimName ?? 'enemy')}`; kf.insertBefore(entry, kf.firstChild); - setTimeout(() => entry.parentElement?.removeChild(entry), 4500); + setTimeout(() => entry.remove(), 4500); const { x, y } = focusPoint(); const popup = document.createElement('div'); @@ -89,13 +89,13 @@ function showElimination(): void { } function hideElimination(): void { - elimination?.parentElement?.removeChild(elimination); + elimination?.remove(); elimination = undefined; } function resetFeedbackHud(): void { hideElimination(); - root?.parentElement?.removeChild(root); + root?.remove(); root = undefined; killfeed = undefined; } @@ -119,7 +119,6 @@ function escapeHtml(text: string): string { return div.innerHTML; } -// The one public surface; the functions above stay module-private. export const FeedbackHud = { hitMarker, killConfirmed, diff --git a/frontend/src/scripts/game.ts b/frontend/src/scripts/game.ts index 29ca72e..834757d 100644 --- a/frontend/src/scripts/game.ts +++ b/frontend/src/scripts/game.ts @@ -33,8 +33,6 @@ import { GameObjectContainer } from './objects/game-object-container'; import parser from 'socket.io-msgpack-parser'; import { CharacterShape } from './shapes/character-shape'; import { PlanetShape } from './shapes/planet-shape'; -import { RenderCommand } from './commands/types/render'; -import { StepCommand } from './commands/types/step'; import { serverTimeline } from './helper/server-timeline'; import { localCharacterPredictor, @@ -46,32 +44,57 @@ import { Minimap } from './minimap'; import { ScreenShake } from './screen-shake'; import { FeedbackHud } from './feedback-hud'; -// Ten attempts at the configured backoff is on the order of a minute of -// retrying before the client gives up and returns to the landing page. const maximumReconnectionAttempts = 10; export class Game extends CommandReceiver { public gameObjects = new GameObjectContainer(this); public renderer?: Renderer; + public rejectionReason?: JoinRejectionReason; + public readonly started: Promise; + private socket!: Socket; - private isBetweenGames = false; - - public started: Promise; - private resolveStarted!: () => unknown; - - private keyboardListener: KeyboardListener; - private mouseListener: MouseListener; - private touchListener: TouchListener; - - private scoreboard = new Scoreboard(); - private minimap = new Minimap(); - private announcementText = document.createElement('h2'); - private keystoneArrow?: HTMLElement; private socketReceiver!: CommandSocket; private tutorial!: Tutorial; - private reconnectToken?: string; + private resolveStarted!: () => void; + private isBetweenGames = false; + private isActive = true; + private isEnding = false; + private timeScaling = 1; + + private readonly keyboardListener: KeyboardListener; + private readonly mouseListener: MouseListener; + private readonly touchListener: TouchListener; + + private readonly scoreboard = new Scoreboard(); + private readonly minimap = new Minimap(); + private readonly announcementText = document.createElement('h2'); + private keystoneArrow?: HTMLElement; private connectionBanner?: HTMLElement; - private rejectionReason?: JoinRejectionReason; + private reconnectToken?: string; + private lastAspectRatio?: number; + private lastGameState?: UpdateGameState; + private lastMinimap?: UpdateMinimap; + private lastAnnouncementText = ''; + private timeSinceLastAnnouncement = 0; + private framesSinceLastLayoutUpdate = 0; + + protected commandExecutors: CommandExecutors = { + [ServerAnnouncement.type]: (c: ServerAnnouncement) => { + this.lastAnnouncementText = c.text; + this.timeSinceLastAnnouncement = 0; + }, + [UpdateGameState.type]: (c: UpdateGameState) => (this.lastGameState = c), + [InputAcknowledgement.type]: (c: InputAcknowledgement) => + localCharacterPredictor.acknowledge( + c.clientTimeMs, + c.movement, + c.lastLeapClientTimeMs, + c.ackAgeMs, + ), + [GameEndCommand.type]: () => (this.isEnding = true), + [UpdateMinimap.type]: (c: UpdateMinimap) => (this.lastMinimap = c), + [GameStartCommand.type]: () => this.initialize(), + }; constructor( private readonly playerDecision: PlayerDecision, @@ -82,9 +105,17 @@ export class Game extends CommandReceiver { this.started = new Promise((r) => (this.resolveStarted = r)); this.announcementText.className = 'announcement'; - this.keyboardListener = new KeyboardListener(); - this.mouseListener = new MouseListener(this.canvas, this); - this.touchListener = new TouchListener(this.canvas, this.overlay, this); + const onInput = (c: Command) => { + this.socketReceiver.queue(c); + this.tutorial.handleCommand(c); + }; + this.keyboardListener = new KeyboardListener(onInput); + this.mouseListener = new MouseListener(this.canvas, this, onInput); + this.touchListener = new TouchListener(this.canvas, this.overlay, this, onInput); + } + + protected defaultCommandExecutor(c: Command) { + this.gameObjects.handleCommand(c); } private initialize() { @@ -94,6 +125,7 @@ export class Game extends CommandReceiver { serverTimeline.reset(); localCharacterPredictor.reset(); ScreenShake.reset(); + this.gameObjects.reset(); this.gameObjects = new GameObjectContainer(this); this.overlay.innerHTML = ''; this.keystoneArrow = undefined; @@ -101,58 +133,50 @@ export class Game extends CommandReceiver { this.lastMinimap = undefined; this.isEnding = false; this.lastAnnouncementText = ''; - this.overlay.appendChild(this.scoreboard.element); - this.overlay.appendChild(this.minimap.element); this.announcementText.innerText = ''; this.timeScaling = 1; - this.overlay.appendChild(this.announcementText); + this.overlay.append( + this.scoreboard.element, + this.minimap.element, + this.announcementText, + ); this.tutorial = new Tutorial(this.overlay); this.socket = io(this.playerDecision.server, { reconnectionDelayMax: 10000, - // Must be finite. The default is Infinity, and with it the manager never - // gives up, so `reconnect_failed` below never fires and a client whose - // server is gone sits on a frozen world behind the banner forever. + // Must be finite, otherwise `reconnect_failed` never fires. reconnectionAttempts: maximumReconnectionAttempts, transports: ['websocket'], forceNew: true, parser, } as any); + this.socketReceiver = new CommandSocket(this.socket); - // In socket.io-client v4 reconnection events are emitted by the Manager (`socket.io`), not the Socket itself. this.socket.io.on('reconnect_attempt', () => { this.socket.io.opts.transports = ['polling', 'websocket']; }); - // A transport drop is not the end of the match — tearing the game down would cancel the reconnection the client is configured for. this.socket.on('disconnect', () => { - if (this.isBetweenGames) { - return; + if (!this.isBetweenGames) { + this.showConnectionBanner('Reconnecting…'); } - this.showConnectionBanner('Reconnecting…'); }); + // Fires for every (re)connection: a reconnect is a brand-new server-side player. this.socket.on('connect', () => { if (this.isBetweenGames) { return; } - // A reconnect is a brand-new server-side connection that has never seen a join, so the join must be re-sent or the client sits connected and invisible forever. this.hideConnectionBanner(); serverTimeline.reset(); localCharacterPredictor.reset(); - // A brand-new server-side player whose view-area bookkeeping starts empty: the dropped session's objects will never be retracted, so reset here. this.gameObjects.reset(); this.socket.emit(TransportEvents.PlayerJoining, { ...this.playerDecision, reconnectToken: this.reconnectToken, }); - // A reconnect gets a brand-new server-side Player, back on the default - // aspect ratio; the camera only reports a ratio when it changes, so - // without this re-send it would stay wrong for the rest of the match. if (this.lastAspectRatio !== undefined) { - this.socketReceiver.handleCommand( - new SetAspectRatioActionCommand(this.lastAspectRatio), - ); + this.socketReceiver.queue(new SetAspectRatioActionCommand(this.lastAspectRatio)); } }); @@ -172,13 +196,11 @@ export class Game extends CommandReceiver { this.destroy(); }); - // Echo the nonce: the server only accepts a reply matching the ping still outstanding, so a stale or duplicated Pong cannot move its RTT. this.socket.on(TransportEvents.Ping, (nonce: unknown) => { this.socket.emit(TransportEvents.Pong, nonce); }); this.socket.on(TransportEvents.ServerToPlayer, (serializedCommands: string) => { - // deserialize revives classes by name from the payload, so a hostile or corrupt field can throw inside JSON.parse's reviver — one malformed object must not take down the message pump or every later batch is lost with it. try { const commands: Array = deserialize(serializedCommands); commands.forEach((c) => { @@ -193,54 +215,12 @@ export class Game extends CommandReceiver { } }); - this.socketReceiver = new CommandSocket(this.socket); - this.keyboardListener.clearSubscribers(); - this.keyboardListener.subscribe(this.socketReceiver); - this.keyboardListener.subscribe(this.tutorial); - this.mouseListener.clearSubscribers(); - this.mouseListener.subscribe(this.socketReceiver); - this.mouseListener.subscribe(this.tutorial); - this.touchListener.clearSubscribers(); - this.touchListener.subscribe(this.socketReceiver); - this.touchListener.subscribe(this.tutorial); - - // Join is emitted from the `connect` handler above, which fires for the first connection and every reconnection alike — so one code path covers both and a reconnect can never be left unjoined. this.isBetweenGames = false; } - protected defaultCommandExecutor(c: Command) { - this.gameObjects.handleCommand(c); - } - - private lastGameState?: UpdateGameState; - private isEnding = false; - private timeScaling = 1; - - private lastAnnouncementText = ''; - protected commandExecutors: CommandExecutors = { - [ServerAnnouncement.type]: (c: ServerAnnouncement) => { - this.lastAnnouncementText = c.text; - this.timeSinceLastAnnouncement = 0; - }, - [UpdateGameState.type]: (c: UpdateGameState) => (this.lastGameState = c), - [InputAcknowledgement.type]: (c: InputAcknowledgement) => - localCharacterPredictor.acknowledge( - c.clientTimeMs, - c.movement, - c.lastLeapClientTimeMs, - c.ackAgeMs, - ), - [GameEndCommand.type]: () => (this.isEnding = true), - [UpdateMinimap.type]: (c: UpdateMinimap) => (this.lastMinimap = c), - [GameStartCommand.type]: this.initialize.bind(this), - }; - - private lastMinimap?: UpdateMinimap; - public async start(): Promise { - const noiseTexture = await renderNoise([256, 256], 2, 1); - this.initialize(); + const noiseTexture = await renderNoise([256, 256], 2, 1); await runAnimation( this.canvas, @@ -272,20 +252,17 @@ export class Game extends CommandReceiver { }, }, ); + this.socket.close(); + this.gameObjects.reset(); this.overlay.innerHTML = ''; this.hideConnectionBanner(); - // The HUD root lives on document.body, not the overlay, so it must be torn down explicitly or it stays painted over the landing page. FeedbackHud.reset(); this.keyboardListener.destroy(); this.mouseListener.destroy(); this.touchListener.destroy(); } - public get lastRejectionReason(): JoinRejectionReason | undefined { - return this.rejectionReason; - } - public static rejectionText(reason: JoinRejectionReason): string { switch (reason) { case JoinRejectionReason.ServerFull: @@ -319,38 +296,32 @@ export class Game extends CommandReceiver { return this.renderer?.displayToWorldCoordinates(p) ?? vec2.create(); } - private lastAspectRatio?: number; public aspectRatioChanged(aspectRatio: number) { this.lastAspectRatio = aspectRatio; - this.socketReceiver.handleCommand(new SetAspectRatioActionCommand(aspectRatio)); + this.socketReceiver.queue(new SetAspectRatioActionCommand(aspectRatio)); } - private isActive = true; public destroy() { this.isActive = false; } - private timeSinceLastAnnouncement = 0; - private framesSinceLastLayoutUpdate = 0; private gameLoop( renderer: Renderer, currentTime: DOMHighResTimeStamp, deltaTime: DOMHighResTimeStamp, ): boolean { + this.renderer = renderer; this.resolveStarted(); - // Must use the frame's timestamp, not performance.now(): the dispatch delay would land in the prediction replay window and jitter the predicted body. + // The frame timestamp, not performance.now(): dispatch jitter would land in the replay window. setFrameTimeMs(currentTime); deltaTime /= 1000; - // Camera impact decays on raw wall-clock time, before the end-game slow-motion scaling below — view-only, never the simulation, so it stays decoupled from prediction and netcode. + // Both run on wall-clock time: the end-game slow motion is already baked into the snapshots. ScreenShake.step(deltaTime); - - // Stepped before the end-game time scaling on purpose: the slow motion is already baked into the server's snapshots, so the playback cursor must keep running on wall-clock time. serverTimeline.step(deltaTime); - let shouldChangeLayout = false; - if (++this.framesSinceLastLayoutUpdate > 1) { - shouldChangeLayout = true; + const shouldChangeLayout = ++this.framesSinceLastLayoutUpdate > 1; + if (shouldChangeLayout) { this.framesSinceLastLayoutUpdate = 0; this.draw(); } @@ -366,17 +337,10 @@ export class Game extends CommandReceiver { deltaTime /= this.timeScaling; } - this.renderer = renderer; - - this.gameObjects.handleCommand(new StepCommand(deltaTime)); - this.gameObjects.handleCommand( - new RenderCommand(this.renderer, this.overlay, shouldChangeLayout), - ); - + this.gameObjects.step(deltaTime); + this.gameObjects.render(renderer, this.overlay, shouldChangeLayout); this.touchListener.update(); - this.tutorial.step(this.gameObjects); - this.socketReceiver.sendQueuedCommands(); return this.isActive; @@ -384,11 +348,11 @@ export class Game extends CommandReceiver { private draw() { if (this.lastGameState) { - this.scoreboard.update(this.lastGameState, this.gameObjects.player?.team); + this.scoreboard.update(this.lastGameState, this.gameObjects.localPlayer?.team); } this.minimap.update( - this.gameObjects.localPlayerPosition, + this.gameObjects.localPlayer?.position, this.lastMinimap?.players ?? [], ); @@ -400,11 +364,8 @@ export class Game extends CommandReceiver { } private handleKeystoneArrow() { - if (!this.renderer) { - return; - } const keystone = this.gameObjects.planets.find((p) => p.isKeystone); - if (!keystone) { + if (!this.renderer || !keystone) { if (this.keystoneArrow) { this.keystoneArrow.style.display = 'none'; } @@ -416,40 +377,34 @@ export class Game extends CommandReceiver { this.overlay.appendChild(this.keystoneArrow); } - const width = this.renderer.canvasSize.x; - const height = this.renderer.canvasSize.y; + const [width, height] = this.renderer.canvasSize; const display = this.renderer.worldToDisplayCoordinates(keystone.center); const margin = 48; const onScreen = - display.x >= margin && - display.x <= width - margin && - display.y >= margin && - display.y <= height - margin; + display[0] >= margin && + display[0] <= width - margin && + display[1] >= margin && + display[1] <= height - margin; this.keystoneArrow.className = 'keystone-arrow ' + keystone.team; - + this.keystoneArrow.style.display = onScreen ? 'none' : 'block'; if (onScreen) { - this.keystoneArrow.style.display = 'none'; return; } - this.keystoneArrow.style.display = 'block'; - const center = vec2.fromValues(width / 2, height / 2); - const dir = vec2.fromValues(display.x - center.x, display.y - center.y); - const angle = Math.atan2(dir.y, dir.x); + const dx = display[0] - width / 2; + const dy = display[1] - height / 2; + const angle = Math.atan2(dy, dx); - const aspectRatio = width / height; - const directionRatio = dir.x / dir.y; let deltaX: number, deltaY: number; - if (aspectRatio < Math.abs(directionRatio)) { - deltaX = (width / 2 - margin) * Math.sign(dir.x); - deltaY = deltaX / directionRatio; + if (width / height < Math.abs(dx / dy)) { + deltaX = (width / 2 - margin) * Math.sign(dx); + deltaY = (deltaX * dy) / dx; } else { - deltaY = (height / 2 - margin) * Math.sign(dir.y); - deltaX = deltaY * directionRatio; + deltaY = (height / 2 - margin) * Math.sign(dy); + deltaX = (deltaY * dx) / dy; } - const p = vec2.add(center, center, vec2.fromValues(deltaX, deltaY)); - this.keystoneArrow.style.transform = `translateX(${p.x}px) translateY(${p.y}px) translateX(-50%) translateY(-50%) rotate(${angle + Math.PI / 2}rad)`; + this.keystoneArrow.style.transform = `translateX(${width / 2 + deltaX}px) translateY(${height / 2 + deltaY}px) translateX(-50%) translateY(-50%) rotate(${angle + Math.PI / 2}rad)`; } } diff --git a/frontend/src/scripts/helper/interpolators/linear-interpolator.ts b/frontend/src/scripts/helper/interpolators/linear-interpolator.ts index fdd8a4b..133a4bf 100644 --- a/frontend/src/scripts/helper/interpolators/linear-interpolator.ts +++ b/frontend/src/scripts/helper/interpolators/linear-interpolator.ts @@ -13,11 +13,6 @@ const blendSeconds = 0.12; const maxFrames = 32; -/** - * Replays a server-streamed scalar by interpolating between timestamped - * snapshots at the render time; coasts briefly on the streamed rate of change - * when the buffer runs dry. - */ export class LinearInterpolator { private frames: Array = []; private blendOffset = 0; diff --git a/frontend/src/scripts/helper/interpolators/vec2-interpolator.ts b/frontend/src/scripts/helper/interpolators/vec2-interpolator.ts index 5a4544e..ef67ad8 100644 --- a/frontend/src/scripts/helper/interpolators/vec2-interpolator.ts +++ b/frontend/src/scripts/helper/interpolators/vec2-interpolator.ts @@ -1,7 +1,6 @@ import { vec2 } from 'gl-matrix'; import { LinearInterpolator } from './linear-interpolator'; -// Indexed access, not .x/.y, so it never depends on the vec2 prototype plugin. export class Vec2Interpolator { private x: LinearInterpolator; private y: LinearInterpolator; diff --git a/frontend/src/scripts/helper/prediction/client-character-world.ts b/frontend/src/scripts/helper/prediction/client-character-world.ts index f3b5df1..4668cbd 100644 --- a/frontend/src/scripts/helper/prediction/client-character-world.ts +++ b/frontend/src/scripts/helper/prediction/client-character-world.ts @@ -4,119 +4,43 @@ import { GroundSurface, Id, PhysicsBody, - planetDistance, - planetGravity, + PlanetBase, resolveCircleMovement, } from 'shared'; +import { PlanetView } from '../../objects/types/planet-view'; -// What the predictor needs to know about a planet to collide and be pulled by it. -export interface PredictablePlanet { - id: Id; - vertices: Array; - center: vec2; - radius: number; - rotation: number; - rotationSpeed: number; -} - -// A planet surface whose rotation advances during replay, in lockstep with the body — exactly as the server steps the planet before the character each tick. -class PlanetSurface implements GroundSurface { - public readonly canCollide = true; - public readonly isGround = true; - public readonly id: Id; - public center: vec2; - public angularVelocity: number; - private vertices: Array; - private radius: number; - private rotation = 0; - private cos = 1; - private sin = 0; - - constructor(planet: PredictablePlanet) { - this.id = planet.id; - this.center = planet.center; - this.vertices = planet.vertices; - this.radius = planet.radius; - this.angularVelocity = planet.rotationSpeed; - this.setRotation(planet.rotation); - } - - public sync(planet: PredictablePlanet) { - this.center = planet.center; - this.vertices = planet.vertices; - this.radius = planet.radius; - this.angularVelocity = planet.rotationSpeed; - this.setRotation(planet.rotation); - } - - private setRotation(rotation: number) { - this.rotation = rotation; - this.cos = Math.cos(rotation); - this.sin = Math.sin(rotation); - } - - public advance(deltaTimeInSeconds: number) { - this.setRotation(this.rotation + this.angularVelocity * deltaTimeInSeconds); - } - - public distance(target: vec2): number { - return planetDistance(target, this.vertices, this.center, this.cos, this.sin); - } - - public gravityAt(target: vec2): vec2 { - return planetGravity(this.center, this.radius, target); - } -} - -// Planets-only collision world for the predictor. Persistent surfaces keyed by id (currentPlanet stays valid across frames); never dispatches collision reactions — those are server-authoritative. +// Planets only, re-seeded from the newest snapshot every frame; never dispatches collisions. export class ClientCharacterWorld implements CharacterWorld { - private surfaces = new Map(); - private ordered: Array = []; + private surfaces: Array = []; - // Far planets contribute zero gravity (the falloff clamps to 0 past maxGravityDistance) and never collide, so the whole set can be handed to every query without a range filter. Ordered by id so the (rare) two-surface contact picks a stable surface. - public sync(planets: Array) { - const seen = new Set(); - for (const planet of planets) { - seen.add(planet.id); - const existing = this.surfaces.get(planet.id); - if (existing) { - existing.sync(planet); - } else { - this.surfaces.set(planet.id, new PlanetSurface(planet)); - } - } - for (const id of [...this.surfaces.keys()]) { - if (!seen.has(id)) { - this.surfaces.delete(id); - } - } - this.ordered = [...this.surfaces.entries()] - .sort((a, b) => Number(a[0]) - Number(b[0])) - .map((e) => e[1]); + public sync(planets: Array) { + this.surfaces = planets + .map((planet) => { + const surface = new PlanetBase(planet.id, planet.vertices); + surface.rotation = planet.snapshotRotation; + surface.angularVelocity = planet.snapshotRotationSpeed; + return surface; + }) + .sort((a, b) => a.id - b.id); } - // Advance every planet's collision frame by one replay substep, so surfaces and the carried body rotate together. Surfaces are re-synced to the newest snapshot rotation each frame (see sync), so the replay only ever steps forward from there. public advance(deltaTimeInSeconds: number) { - for (const surface of this.ordered) { - surface.advance(deltaTimeInSeconds); - } + this.surfaces.forEach((s) => s.advanceRotation(deltaTimeInSeconds)); } - public surfaceById(id: Id | undefined): GroundSurface | undefined { - return id == null ? undefined : this.surfaces.get(id); + public surfaceById(id: Id | null): GroundSurface | undefined { + return this.surfaces.find((s) => s.id === id); } - public groundsNear(): Array { - return this.ordered; + public groundsNear(_center: vec2, _radius: number): Array { + return this.surfaces; } public stepBody( body: PhysicsBody, deltaTimeInSeconds: number, ): GroundSurface | undefined { - const { hitObject } = resolveCircleMovement(body, deltaTimeInSeconds, this.ordered); - return hitObject && (hitObject as GroundSurface).isGround - ? (hitObject as GroundSurface) - : undefined; + const hit = resolveCircleMovement(body, deltaTimeInSeconds, this.surfaces); + return hit instanceof PlanetBase ? hit : undefined; } } diff --git a/frontend/src/scripts/helper/prediction/input-history.ts b/frontend/src/scripts/helper/prediction/input-history.ts index be372fb..fab8f7a 100644 --- a/frontend/src/scripts/helper/prediction/input-history.ts +++ b/frontend/src/scripts/helper/prediction/input-history.ts @@ -7,7 +7,6 @@ interface InputSample { const retainMs = 1500; -// Timeline of local movement directions; the predictor replays exactly the input the server receives. export class InputHistory { private samples: Array = []; diff --git a/frontend/src/scripts/helper/prediction/local-character-predictor.ts b/frontend/src/scripts/helper/prediction/local-character-predictor.ts index e7b3407..f437042 100644 --- a/frontend/src/scripts/helper/prediction/local-character-predictor.ts +++ b/frontend/src/scripts/helper/prediction/local-character-predictor.ts @@ -5,25 +5,26 @@ import { CharacterMovementState, PhysicsBody, settings, + clamp, + easeVec2, stepCharacterMovement, applyLeapImpulse, tickPlanetDetachment, feetRadius, headRadius, } from 'shared'; -import { ClientCharacterWorld, PredictablePlanet } from './client-character-world'; +import { ClientCharacterWorld } from './client-character-world'; +import { PlanetView } from '../../objects/types/planet-view'; import { InputHistory } from './input-history'; -// Must match the server's tick source — the replay integrates over this dt. const stepSeconds = settings.targetPhysicsDeltaTimeInSeconds; const stepMs = stepSeconds * 1000; -// Frame timestamp, not performance.now() — dispatch jitter would land in the replay window. let frameTimeMs = 0; export const setFrameTimeMs = (timeMs: number): void => { frameTimeMs = timeMs; }; -export const predictorNowMs = (): number => frameTimeMs; +export const clientTimeMs = (): number => Math.round(frameTimeMs); const maxReplayMs = 400; @@ -33,7 +34,6 @@ const snapDistance = 250; const upwards = vec2.fromValues(0, 1); -// Velocity starts at zero — the sim rebuilds it each tick, so it is not carried state. const makeBody = (center: vec2, radius: number, normal: vec2): PhysicsBody => ({ center: vec2.clone(center), radius, @@ -47,15 +47,12 @@ export class LocalCharacterPredictor { private readonly world = new ClientCharacterWorld(); private authoritative?: { head: Circle; leftFoot: Circle; rightFoot: Circle }; - // Replay anchor: built from both halves of the ack (input time + ackAge), advances forward only. private replayAnchorMs?: number; private movement?: CharacterMovementSnapshot; private leapHistory: Array = []; - // Last leap the server folded into streamed momentum; newer ones only, so a leap is never applied twice. private lastLeapAckMs = -Infinity; private currentStrength = settings.playerMaxStrength; - // Dead players don't predict — the server ignores their input, so moving would desync. private alive = true; private renderHead = new Circle(vec2.create(), headRadius); @@ -74,7 +71,7 @@ export class LocalCharacterPredictor { } public recordInput(direction: vec2): number { - const timeMs = Math.round(frameTimeMs); + const timeMs = clientTimeMs(); this.inputHistory.record(direction, timeMs); return timeMs; } @@ -85,7 +82,6 @@ export class LocalCharacterPredictor { lastLeapClientTimeMs: number, ackAgeMs = 0, ): void { - // The anchor only advances forward; movement state and leap boundary always adopt the latest authoritative values. const anchor = clientTimeMs + Math.max(0, ackAgeMs); if (this.replayAnchorMs === undefined || anchor > this.replayAnchorMs) { this.replayAnchorMs = anchor; @@ -98,9 +94,8 @@ export class LocalCharacterPredictor { this.authoritative = { head, leftFoot, rightFoot }; } - // Recorded regardless of acceptance — a rejected leap self-corrects via streamed momentum. public recordLeap(): number { - const timeMs = Math.round(frameTimeMs); + const timeMs = clientTimeMs(); this.leapHistory.push(timeMs); const cutoff = timeMs - 1500; while (this.leapHistory.length > 0 && this.leapHistory[0] <= cutoff) { @@ -137,7 +132,6 @@ export class LocalCharacterPredictor { ); } - // While spawn-in/death the server freezes walking (head radius < nominal) — don't predict, let interpolation show it. private get isAnimatingInOrOut(): boolean { return ( this.authoritative !== undefined && @@ -145,7 +139,7 @@ export class LocalCharacterPredictor { ); } - public update(planets: Array, frameSeconds: number): boolean { + public update(planets: Array, frameSeconds: number): boolean { if (!this.alive || !this.canPredict || this.isAnimatingInOrOut) { this.hasRender = false; return false; @@ -166,14 +160,12 @@ export class LocalCharacterPredictor { private simulate(): CharacterMovementState { const auth = this.authoritative!; const movement = this.movement!; - const now = Math.round(frameTimeMs); - // Replay unconfirmed input from anchor to now, clamped so a stalled tab can't grind hundreds of steps. - const startMs = Math.min(now, Math.max(this.replayAnchorMs!, now - maxReplayMs)); + const now = clientTimeMs(); + const startMs = clamp(this.replayAnchorMs!, now - maxReplayMs, now); const windowMs = Math.max(0, now - startMs); const steps = Math.floor(windowMs / stepMs); const remainderSeconds = (windowMs - steps * stepMs) / 1000; - // Full authoritative state — replay is a pure function of (snapshot, input, window) and cannot drift. const state: CharacterMovementState = { head: makeBody(auth.head.center, auth.head.radius, upwards), leftFoot: makeBody( @@ -189,14 +181,10 @@ export class LocalCharacterPredictor { direction: movement.direction, currentPlanet: this.world.surfaceById(movement.groundPlanetId), secondsSinceOnSurface: movement.secondsSinceOnSurface, - // Leaps before the window are baked in; inside it they're re-applied — neither double-counted. bodyVelocity: vec2.fromValues(movement.bodyVelocity[0], movement.bodyVelocity[1]), }; - // Planet frames are synced to the newest snapshot's rotation, then advanced forward in lockstep with the body — no rewind needed. - const cooldownMs = settings.leapCooldownSeconds * 1000; - // Mirror the server: each accepted leap spends strength, so a burst is gated by running strength. let availableStrength = this.currentStrength; let lastLeapMs = -Infinity; @@ -211,7 +199,6 @@ export class LocalCharacterPredictor { if ( leapMs >= t && leapMs < t + stepMs && - // Only leaps the server hasn't seeded — never both seeded and replayed. leapMs > this.lastLeapAckMs && state.currentPlanet && leapMs - lastLeapMs >= cooldownMs && @@ -226,7 +213,6 @@ export class LocalCharacterPredictor { t += stepMs; } - // Sub-tick the remainder so the pose is continuous in the window length, not quantized to stepMs. if (remainderSeconds > 0) { tickPlanetDetachment(state, remainderSeconds); stepCharacterMovement( @@ -254,18 +240,13 @@ export class LocalCharacterPredictor { } private easeRenderTo(state: CharacterMovementState, frameSeconds: number): void { - const q = 1 - Math.exp(-frameSeconds / smoothSeconds); - this.easePart(this.renderHead, state.head, q); - this.easePart(this.renderLeftFoot, state.leftFoot, q); - this.easePart(this.renderRightFoot, state.rightFoot, q); + this.easePart(this.renderHead, state.head, frameSeconds); + this.easePart(this.renderLeftFoot, state.leftFoot, frameSeconds); + this.easePart(this.renderRightFoot, state.rightFoot, frameSeconds); } - private easePart(render: Circle, target: PhysicsBody, q: number): void { - if (vec2.distance(render.center, target.center) > snapDistance) { - vec2.copy(render.center, target.center); - } else { - vec2.lerp(render.center, render.center, target.center, q); - } + private easePart(render: Circle, target: PhysicsBody, frameSeconds: number): void { + easeVec2(render.center, target.center, frameSeconds, smoothSeconds, snapDistance); render.radius = target.radius; } } diff --git a/frontend/src/scripts/helper/server-timeline.ts b/frontend/src/scripts/helper/server-timeline.ts index 7121da1..9ac449f 100644 --- a/frontend/src/scripts/helper/server-timeline.ts +++ b/frontend/src/scripts/helper/server-timeline.ts @@ -6,35 +6,24 @@ const maxRateAdjustment = 0.25; const resyncSeconds = 0.3; -/** - * Playback clock for streamed server state — renders interpolationDelaySeconds - * behind the server clock so a newer snapshot is usually available; the cursor - * adjusts its rate gently and only snaps after a long divergence. - */ class ServerTimeline { private cursor?: number; private newestSnapshotTime?: number; private sinceNewestSnapshot = 0; - private _snapshotTime = 0; - - public get snapshotTime(): number { - return this._snapshotTime; - } + public snapshotTime = 0; public get renderTime(): number { return this.cursor ?? 0; } public onSnapshot(timestamp: number) { - this._snapshotTime = timestamp; + this.snapshotTime = timestamp; if (this.newestSnapshotTime === undefined || timestamp > this.newestSnapshotTime) { this.newestSnapshotTime = timestamp; this.sinceNewestSnapshot = 0; } } - // Must be called with unscaled wall-clock time, even during the end-game - // slow motion: the slowdown is already baked into the snapshots. public step(deltaTimeInSeconds: number) { if (this.newestSnapshotTime === undefined) { return; @@ -63,7 +52,7 @@ class ServerTimeline { this.cursor = undefined; this.newestSnapshotTime = undefined; this.sinceNewestSnapshot = 0; - this._snapshotTime = 0; + this.snapshotTime = 0; } } diff --git a/frontend/src/scripts/join-form-handler.ts b/frontend/src/scripts/join-form-handler.ts index ebc4d6b..c5592cb 100644 --- a/frontend/src/scripts/join-form-handler.ts +++ b/frontend/src/scripts/join-form-handler.ts @@ -10,52 +10,43 @@ export type PlayerDecision = { }; const pollInterval = 8000; + export class JoinFormHandler { - private joinButton: HTMLButtonElement; - private waitingForDecision: Promise; - private resolvePlayerDecision!: (d: PlayerDecision) => void; - private pollServersTimer: any; + private readonly joinButton: HTMLButtonElement; + private readonly decision: Promise; + private readonly pollServersTimer: ReturnType; + private servers: Array = []; + private keyUpListener = (e: KeyboardEvent) => { - // requestSubmit() (not submit()) fires onsubmit and runs HTML5 validation, so Enter matches clicking Join. if (e.key === 'Enter' && !this.joinButton.disabled) { this.form.requestSubmit(); } }; constructor( - private form: HTMLFormElement, + private readonly form: HTMLFormElement, private readonly container: HTMLElement, ) { this.joinButton = form.querySelector('button[type="submit"]') as HTMLButtonElement; this.joinButton.disabled = true; - this.waitingForDecision = new Promise((r) => (this.resolvePlayerDecision = r)); - new FormData(form); + this.decision = new Promise((resolve) => { + form.onsubmit = (e) => { + e.preventDefault(); + SoundHandler.play(Sounds.click); + const data = new FormData(form); + resolve({ name: String(data.get('name')), server: String(data.get('server')) }); + }; + }); + this.decision.then(() => this.destroy()); addEventListener('keyup', this.keyUpListener); - - form.onsubmit = (e) => { - SoundHandler.play(Sounds.click); - const result: PlayerDecision = ( - Array.from((new FormData(form) as any).entries()) as Array<[string, any]> - ).reduce((result, [name, value]) => { - (result as any)[name] = value; - return result; - }, {}) as any; - - this.resolvePlayerDecision(result); - - e.preventDefault(); - }; - - this.pollServersTimer = setInterval(this.loadServers.bind(this), pollInterval); + this.pollServersTimer = setInterval(() => this.loadServers(), pollInterval); this.loadServers(); - this.waitForFinish(); } - private async waitForFinish() { - await this.waitingForDecision; - this.destroy(); + public getPlayerDecision(): Promise { + return this.decision; } private destroy() { @@ -64,77 +55,86 @@ export class JoinFormHandler { this.servers.forEach((s) => s.destroy()); } - private servers: Array = []; - private async loadServers() { - const serverList = servers.filter((u) => !this.servers.find((s) => s.url === u)); + private loadServers() { + servers + .filter((url) => !this.servers.some((s) => s.url === url)) + .forEach(async (url) => { + const controller = new AbortController(); + setTimeout(() => controller.abort(), pollInterval * 0.8); - serverList.map(async (url) => { - const controller = new AbortController(); - const signal = controller.signal; - setTimeout(() => controller.abort(), pollInterval * 0.8); + let content: ServerInformation; + try { + const response = await fetch(url + serverInformationEndpoint, { + signal: controller.signal, + }); + if (!response.ok) { + return; + } + content = await response.json(); + } catch { + return; + } - let response: Response | undefined; - try { - response = await fetch(url + serverInformationEndpoint, { signal }); - } catch {} - - if (response?.ok) { - const content: ServerInformation = await response.json(); - if (!this.servers.find((s) => s.url === url)) { + if (!this.servers.some((s) => s.url === url)) { const server = new ServerChooserOption( content, url, - this.removeServer.bind(this), + (s) => this.removeServer(s), this.servers.length === 0, ); this.servers.push(server); this.joinButton.disabled = false; this.container.appendChild(server.element); } - } - }); + }); } private removeServer(server: ServerChooserOption) { this.servers = this.servers.filter((s) => s !== server); - if (!this.servers.length) { - this.joinButton.disabled = true; - } - } - - public async getPlayerDecision(): Promise { - return this.waitingForDecision; + this.joinButton.disabled = this.servers.length === 0; } } -class ServerChooserOption { - private divElement = document.createElement('div'); - private inputElement = document.createElement('input'); - private labelElement = document.createElement('label'); - private serverNameElement = document.createElement('span'); - private completionElement = document.createElement('span'); +const roundCompletionTexts = [ + 'Just started', + 'Just started', + 'Ongoing', + 'Halfway through', + 'Nearly over', + 'About to finish', + 'Game is over', +]; - private socket: Socket; +class ServerChooserOption { + public readonly element = document.createElement('div'); + private readonly serverNameElement = document.createElement('span'); + private readonly completionElement = document.createElement('span'); + private readonly socket: Socket; constructor( - private content: ServerInformation, + private readonly content: ServerInformation, public readonly url: string, - private onDestroy: (v: ServerChooserOption) => unknown, + private readonly onDestroy: (v: ServerChooserOption) => unknown, isFirst: boolean, ) { - this.inputElement.required = true; - this.inputElement.type = 'radio'; - this.inputElement.id = this.inputElement.value = url; - this.inputElement.name = 'server'; - this.inputElement.checked = isFirst; - this.labelElement.htmlFor = url; - this.labelElement.onclick = () => SoundHandler.play(Sounds.click); - this.divElement.appendChild(this.inputElement); - this.divElement.appendChild(this.labelElement); - this.labelElement.appendChild(this.serverNameElement); - this.labelElement.appendChild(document.createElement('br')); - this.labelElement.appendChild(this.completionElement); + const input = document.createElement('input'); + input.required = true; + input.type = 'radio'; + input.id = input.value = url; + input.name = 'server'; + input.checked = isFirst; + + const label = document.createElement('label'); + label.htmlFor = url; + label.onclick = () => SoundHandler.play(Sounds.click); + label.append( + this.serverNameElement, + document.createElement('br'), + this.completionElement, + ); this.completionElement.className = 'completion'; + + this.element.append(input, label); this.setServerInfoLabelText(); this.socket = io(url, { @@ -144,8 +144,7 @@ class ServerChooserOption { parser, } as any); - this.socket.io.on('reconnect_failed', this.destroy.bind(this)); - + this.socket.io.on('reconnect_failed', () => this.destroy()); this.socket.on('connect', () => this.socket.emit(TransportEvents.SubscribeForServerInfoUpdates), ); @@ -161,32 +160,16 @@ class ServerChooserOption { public destroy() { this.socket.close(); - this.divElement.parentElement?.removeChild(this.divElement); + this.element.remove(); this.onDestroy(this); } - private getRoundCompletionText(percent: number): string { - const texts = [ - 'Just started', - 'Just started', - 'Ongoing', - 'Halfway through', - 'Nearly over', - 'About to finish', - 'Game is over', - ]; - - return texts[Math.floor((percent / 100) * (texts.length - 1))]; - } - private setServerInfoLabelText() { - this.serverNameElement.innerText = `${this.content.serverName} - ${this.content.playerCount}/${this.content.playerLimit} players`; - this.completionElement.innerText = this.getRoundCompletionText( - this.content.gameStatePercent, - ); - } - - public get element(): HTMLElement { - return this.divElement; + const { serverName, playerCount, playerLimit, gameStatePercent } = this.content; + this.serverNameElement.innerText = `${serverName} - ${playerCount}/${playerLimit} players`; + this.completionElement.innerText = + roundCompletionTexts[ + Math.floor((gameStatePercent / 100) * (roundCompletionTexts.length - 1)) + ]; } } diff --git a/frontend/src/scripts/landing-page-background.ts b/frontend/src/scripts/landing-page-background.ts index f6565f0..ca1863b 100644 --- a/frontend/src/scripts/landing-page-background.ts +++ b/frontend/src/scripts/landing-page-background.ts @@ -1,27 +1,34 @@ -import { vec2, vec3 } from 'gl-matrix'; +import { ReadonlyVec2, vec2, vec3 } from 'gl-matrix'; import { CircleLight, FilteringOptions, - hsl, Renderer, renderNoise, runAnimation, WrapOptions, } from 'sdf-2d'; -import { settings, PlanetBase, Random } from 'shared'; +import { settings, PlanetBase, Random, hsl } from 'shared'; import { PlanetShape } from './shapes/planet-shape'; const bluePlanet = 0; const redPlanet = 0.85; export class LandingPageBackground { + public readonly renderer: Promise; + private isActive = true; - public renderer: Promise; private resolveRenderer!: (r: Renderer) => unknown; + private scene?: { + canvasSize: vec2; + topPlanet: PlanetShape; + bottomPlanet: PlanetShape; + planetAngle: number; + planetDistance: number; + }; constructor(canvas: HTMLCanvasElement) { - this.start(canvas); this.renderer = new Promise((r) => (this.resolveRenderer = r)); + this.start(canvas); } private async start(canvas: HTMLCanvasElement): Promise { @@ -30,14 +37,8 @@ export class LandingPageBackground { runAnimation( canvas, [ - { - ...PlanetShape.descriptor, - shaderCombinationSteps: [0, 1, 2], - }, - { - ...CircleLight.descriptor, - shaderCombinationSteps: [0, 2], - }, + { ...PlanetShape.descriptor, shaderCombinationSteps: [0, 1, 2] }, + { ...CircleLight.descriptor, shaderCombinationSteps: [0, 2] }, ], this.gameLoop.bind(this), { @@ -59,79 +60,77 @@ export class LandingPageBackground { ); } - private gameLoop(renderer: Renderer, time: DOMHighResTimeStamp, _: number): boolean { + private buildScene(canvasSize: ReadonlyVec2) { Random.seed = 42; - this.resolveRenderer(renderer); - - renderer.setViewArea(vec2.fromValues(0, renderer.canvasSize.y), renderer.canvasSize); - - const topPlanetPosition = vec2.fromValues( - 0.7 * renderer.canvasSize.x, - 0.7 * renderer.canvasSize.y, - ); - + const topPosition = vec2.scale(vec2.create(), canvasSize, 0.7); const topPlanet = new PlanetShape( PlanetBase.createPlanetVertices( - topPlanetPosition, + topPosition, Random.getRandomInRange(150, 400), Random.getRandomInRange(150, 400), Random.getRandomInRange(10, 20), ), redPlanet, ); - topPlanet.randomOffset = Random.getRandom(); - topPlanet.rotation = (time / 1000) * 0.09; - - const bottomPlanetPosition = vec2.fromValues( - 0.3 * renderer.canvasSize.x, - 0.3 * renderer.canvasSize.y, - ); + const bottomPosition = vec2.scale(vec2.create(), canvasSize, 0.3); const bottomPlanet = new PlanetShape( PlanetBase.createPlanetVertices( - bottomPlanetPosition, + bottomPosition, Random.getRandomInRange(150, 800), Random.getRandomInRange(150, 400), Random.getRandomInRange(10, 40), ), bluePlanet, ); - bottomPlanet.randomOffset = Random.getRandom(); - bottomPlanet.rotation = (time / 1000) * -0.06; - const planetDistance = vec2.subtract( - vec2.create(), - topPlanetPosition, - bottomPlanetPosition, - ); - const planetDistanceLength = vec2.length(planetDistance); - const planetDirection = vec2.normalize(planetDistance, planetDistance); - const planetAngle = Math.atan2(planetDirection.y, planetDirection.x); + const between = vec2.subtract(vec2.create(), topPosition, bottomPosition); + this.scene = { + canvasSize: vec2.clone(canvasSize), + topPlanet, + bottomPlanet, + planetAngle: Math.atan2(between[1], between[0]), + planetDistance: vec2.length(between), + }; + return this.scene; + } - renderer.addDrawable(topPlanet); - renderer.addDrawable(bottomPlanet); + private gameLoop(renderer: Renderer, time: DOMHighResTimeStamp): boolean { + this.resolveRenderer(renderer); + renderer.setViewArea(vec2.fromValues(0, renderer.canvasSize[1]), renderer.canvasSize); + + const scene = + this.scene && vec2.exactEquals(this.scene.canvasSize, renderer.canvasSize) + ? this.scene + : this.buildScene(renderer.canvasSize); + + scene.topPlanet.rotation = (time / 1000) * 0.09; + scene.bottomPlanet.rotation = (time / 1000) * -0.06; + renderer.addDrawable(scene.topPlanet); + renderer.addDrawable(scene.bottomPlanet); + + const lightDistance = scene.planetDistance * 1.2; renderer.addDrawable( new CircleLight( this.calculateLightPosition( renderer, - planetAngle, - planetDistanceLength * 1.2, + scene.planetAngle, + lightDistance, -time / 3000, ), hsl(25, 75, 60), 0.75, ), ); - renderer.addDrawable( new CircleLight( this.calculateLightPosition( renderer, - planetAngle, - planetDistanceLength * 1.2, + scene.planetAngle, + lightDistance, time / 2000 + Math.PI, ), hsl(249, 79, 70), @@ -152,12 +151,9 @@ export class LandingPageBackground { length * Math.sin(t), length * Math.sin(t) * Math.cos(t), ); - const canvasCenter = vec2.scale(vec2.create(), renderer.canvasSize, 0.5); - vec2.add(lightPosition, lightPosition, canvasCenter); - vec2.rotate(lightPosition, lightPosition, canvasCenter, angle); - return lightPosition; + return vec2.rotate(lightPosition, lightPosition, canvasCenter, angle); } public destroy() { diff --git a/frontend/src/scripts/minimap.ts b/frontend/src/scripts/minimap.ts index 92fc0c2..7074738 100644 --- a/frontend/src/scripts/minimap.ts +++ b/frontend/src/scripts/minimap.ts @@ -1,18 +1,11 @@ import { vec2 } from 'gl-matrix'; -import { CharacterTeam, Id, settings } from 'shared'; - -export interface MinimapBlip { - id: Id; - position: vec2; - team: CharacterTeam; -} +import { CharacterTeam, Id, MinimapPlayer, settings } from 'shared'; export class Minimap { public readonly element = document.createElement('canvas'); private readonly ctx: CanvasRenderingContext2D; private readonly colors: Record; - // Smoothed per player so the 25 Hz snapshots glide rather than step between frames. private readonly smoothed = new Map(); private bufferSize = 0; @@ -30,7 +23,7 @@ export class Minimap { }; } - public update(localPosition: vec2 | undefined, players: Array) { + public update(localPosition: vec2 | undefined, players: Array) { const size = this.element.clientWidth; if (size === 0) { return; @@ -45,8 +38,8 @@ export class Minimap { const scale = innerRadius / settings.worldRadius; const toMap = (world: vec2): { x: number; y: number } => { - let dx = world.x * scale; - let dy = -world.y * scale; // world Y points up, canvas Y points down + let dx = world[0] * scale; + let dy = -world[1] * scale; const distance = Math.hypot(dx, dy); if (distance > innerRadius) { dx = (dx / distance) * innerRadius; @@ -109,8 +102,6 @@ export class Minimap { this.element.width = target; this.element.height = target; } - // Setting the buffer size resets the transform, so (re)establish it every - // frame and draw in CSS pixels regardless of the device pixel ratio. this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); } } diff --git a/frontend/src/scripts/objects/game-object-container.ts b/frontend/src/scripts/objects/game-object-container.ts index 82dc34f..69f03db 100644 --- a/frontend/src/scripts/objects/game-object-container.ts +++ b/frontend/src/scripts/objects/game-object-container.ts @@ -1,38 +1,35 @@ -import { vec2 } from 'gl-matrix'; +import { Renderer } from 'sdf-2d'; import { Circle, - Command, CommandExecutors, CommandReceiver, CreateObjectsCommand, CreatePlayerCommand, DeleteObjectsCommand, - GameObject, Id, PropertyUpdatesForObjects, RemoteCallsForObjects, settings, UpdatePropertyCommand, } from 'shared'; -import { BeforeDestroyCommand } from '../commands/types/before-destroy'; -import { RenderCommand } from '../commands/types/render'; -import { StepCommand } from '../commands/types/step'; import { FeedbackHud } from '../feedback-hud'; import { Game } from '../game'; import { serverTimeline } from '../helper/server-timeline'; -import { PredictablePlanet } from '../helper/prediction/client-character-world'; import { localCharacterPredictor } from '../helper/prediction/local-character-predictor'; import { Camera } from './types/camera'; import { CharacterView } from './types/character-view'; import { PlanetView } from './types/planet-view'; +import { View } from './view'; export class GameObjectContainer extends CommandReceiver { - protected objects: Map = new Map(); - public player!: CharacterView; - public camera: Camera = new Camera(this.game); + public player?: CharacterView; + public readonly camera: Camera; + public planets: Array = []; + + private objects: Map = new Map(); private wasLocalPlayerAlive = false; - // Create/delete are admitted/retired on the render timeline (not the wire) so interpolated objects don't pop at the muzzle. + // Creates and deletes take effect on the render timeline, not on arrival. private visibleFrom = new Map(); private deleteAt = new Map(); private awaitingCreateStamp: Array = []; @@ -50,42 +47,10 @@ export class GameObjectContainer extends CommandReceiver { [CreateObjectsCommand.type]: (c: CreateObjectsCommand) => c.objects.forEach((o) => { - this.addObject(o as GameObject); - this.awaitingCreateStamp.push((o as GameObject).id); + this.addObject(o as View); + this.awaitingCreateStamp.push(o.id); }), - [StepCommand.type]: (c: StepCommand) => { - // Fall back to newest snapshot if a batch had no property updates, so nothing is stranded. - this.stampPending(serverTimeline.snapshotTime); - this.retireDueObjects(); - this.defaultCommandExecutor(c); - - // Alive = object exists AND health > 0 (player pointer outlives the body). - const bodyPresent = !!this.player && this.objects.has(this.player.id); - const alive = bodyPresent && this.player.health > 0; - - if (this.wasLocalPlayerAlive && !alive) { - FeedbackHud.showElimination(); - } - this.wasLocalPlayerAlive = alive; - - if (bodyPresent) { - // Override the local player's interpolated pose with the predicted one; suppressed while dead (server ignores dead input). - localCharacterPredictor.setAlive(alive); - localCharacterPredictor.setStrength( - this.player.strengthFraction * settings.playerMaxStrength, - ); - if ( - localCharacterPredictor.update(this.predictablePlanets(), c.deltaTimeInSeconds) - ) { - this.player.head = localCharacterPredictor.head; - this.player.leftFoot = localCharacterPredictor.leftFoot; - this.player.rightFoot = localCharacterPredictor.rightFoot; - } - this.camera.follow(this.player.position, c.deltaTimeInSeconds); - } - }, - [RemoteCallsForObjects.type]: (c: RemoteCallsForObjects) => c.callsForObjects.forEach((c) => this.objects.get(c.id)?.processRemoteCalls(c.calls), @@ -93,11 +58,11 @@ export class GameObjectContainer extends CommandReceiver { [PropertyUpdatesForObjects.type]: (c: PropertyUpdatesForObjects) => { serverTimeline.onSnapshot(c.timestamp); - // Create/delete precede property updates in a batch — timestamp is known here. this.stampPending(c.timestamp); c.updates.forEach((u) => { - u.updates.forEach((au) => this.objects.get(u.id)?.handleCommand(au)); - if (this.player && u.id === this.player.id) { + const object = this.objects.get(u.id); + u.updates.forEach((au) => object?.updateProperty?.(au)); + if (object && object === this.player) { this.feedPredictor(u.updates); } }); @@ -107,19 +72,15 @@ export class GameObjectContainer extends CommandReceiver { c.ids.forEach((id: Id) => this.awaitingDeleteStamp.push(id)), }; - constructor(private game: Game) { + constructor(game: Game) { super(); + this.camera = new Camera(game); } - /** - * A reconnect is a new server-side player that never sends deletes for the - * old session's objects — clear them or they stay as frozen ghosts. `player` - * is left stale: reads are gated on `objects`, and the next CreatePlayerCommand - * replaces it. - */ public reset() { - this.objects.forEach((o) => o.handleCommand(new BeforeDestroyCommand())); + this.objects.forEach((o) => o.beforeDestroy?.()); this.objects.clear(); + this.planets = []; this.visibleFrom.clear(); this.deleteAt.clear(); this.awaitingCreateStamp = []; @@ -127,32 +88,43 @@ export class GameObjectContainer extends CommandReceiver { this.wasLocalPlayerAlive = false; } - public get localPlayerPosition(): vec2 | undefined { - return this.player && this.objects.has(this.player.id) - ? this.player.position - : undefined; + public get localPlayer(): CharacterView | undefined { + return this.player && this.objects.has(this.player.id) ? this.player : undefined; } - public get planets(): Array { - const planets: Array = []; - this.objects.forEach((o) => { - if (o instanceof PlanetView) { - planets.push(o); + public step(deltaTimeInSeconds: number) { + this.stampPending(serverTimeline.snapshotTime); + this.retireDueObjects(); + this.objects.forEach((o) => o.step(deltaTimeInSeconds)); + + const player = this.localPlayer; + const alive = !!player && player.health > 0; + if (this.wasLocalPlayerAlive && !alive) { + FeedbackHud.showElimination(); + } + this.wasLocalPlayerAlive = alive; + + if (player) { + localCharacterPredictor.setAlive(alive); + localCharacterPredictor.setStrength( + player.strengthFraction * settings.playerMaxStrength, + ); + if (localCharacterPredictor.update(this.planets, deltaTimeInSeconds)) { + player.head = localCharacterPredictor.head; + player.leftFoot = localCharacterPredictor.leftFoot; + player.rightFoot = localCharacterPredictor.rightFoot; } - }); - return planets; + this.camera.follow(player.position, deltaTimeInSeconds); + } } - protected defaultCommandExecutor(c: Command) { - const isRender = c.type === RenderCommand.type; + public render(renderer: Renderer, overlay: HTMLElement, shouldChangeLayout: boolean) { this.objects.forEach((o) => { - // Stepping runs even for not-yet-visible objects so they enter the scene already moving. - if (isRender && !this.isVisible(o.id)) { - return; + if (this.isVisible(o.id)) { + o.render(renderer, overlay, shouldChangeLayout); } - o.handleCommand(c); }); - this.camera.handleCommand(c); + this.camera.draw(renderer); } private isVisible(id: Id): boolean { @@ -173,52 +145,41 @@ export class GameObjectContainer extends CommandReceiver { } private retireDueObjects() { - if (this.deleteAt.size === 0) { - return; - } const renderTime = serverTimeline.renderTime; for (const [id, at] of [...this.deleteAt]) { if (renderTime >= at) { - this.deleteAt.delete(id); this.deleteObject(id); } } } - // Feed the predictor the raw (not interpolated) authoritative pose. private feedPredictor(updates: Array) { - let head: Circle | undefined; - let leftFoot: Circle | undefined; - let rightFoot: Circle | undefined; + const pose: Partial> = {}; for (const u of updates) { - if (u.propertyKey === 'head') head = u.propertyValue as Circle; - else if (u.propertyKey === 'leftFoot') leftFoot = u.propertyValue as Circle; - else if (u.propertyKey === 'rightFoot') rightFoot = u.propertyValue as Circle; + if ( + u.propertyKey === 'head' || + u.propertyKey === 'leftFoot' || + u.propertyKey === 'rightFoot' + ) { + pose[u.propertyKey] = u.propertyValue; + } } - if (head && leftFoot && rightFoot) { - localCharacterPredictor.setAuthoritative(head, leftFoot, rightFoot); + if (pose.head && pose.leftFoot && pose.rightFoot) { + localCharacterPredictor.setAuthoritative(pose.head, pose.leftFoot, pose.rightFoot); } } - private predictablePlanets(): Array { - return this.planets.map((p) => ({ - id: p.id, - vertices: p.vertices, - center: p.center, - radius: p.radius, - rotation: p.predictionRotation, - rotationSpeed: p.predictionRotationSpeed, - })); - } - - private addObject(object: GameObject) { + private addObject(object: View) { this.objects.set(object.id, object); + if (object instanceof PlanetView) { + this.planets.push(object); + } } private deleteObject(id: Id) { - const object = this.objects.get(id); - object?.handleCommand(new BeforeDestroyCommand()); + this.objects.get(id)?.beforeDestroy?.(); this.objects.delete(id); + this.planets = this.planets.filter((p) => p.id !== id); this.visibleFrom.delete(id); this.deleteAt.delete(id); } diff --git a/frontend/src/scripts/objects/types/camera.ts b/frontend/src/scripts/objects/types/camera.ts index 3b9b004..b39e10e 100644 --- a/frontend/src/scripts/objects/types/camera.ts +++ b/frontend/src/scripts/objects/types/camera.ts @@ -1,56 +1,29 @@ import { vec2 } from 'gl-matrix'; -import { - calculateViewArea, - CommandExecutors, - CommandReceiver, - mixRgb, - settings, -} from 'shared'; -import { RenderCommand } from '../../commands/types/render'; +import { Renderer } from 'sdf-2d'; +import { calculateViewArea, easeVec2, mixRgb, settings } from 'shared'; import { Game } from '../../game'; import { ScreenShake } from '../../screen-shake'; -export class Camera extends CommandReceiver { - public center: vec2 = vec2.create(); +const followSeconds = 0.08; +const snapDistance = 1500; + +export class Camera { + public readonly center: vec2 = vec2.create(); private aspectRatio?: number; - // A short exponential lag masks any residual stepping in the followed - // position without the camera noticeably trailing during normal movement. - private static readonly followSeconds = 0.08; - - // Swooshing across half the map after a respawn would be disorienting; - // beyond this distance the camera cuts instead. - private static readonly snapDistance = 1500; - - constructor(private game: Game) { - super(); - } + constructor(private readonly game: Game) {} public follow(target: vec2, deltaTimeInSeconds: number) { - if (vec2.distance(target, this.center) > Camera.snapDistance) { - vec2.copy(this.center, target); - return; - } - - const q = 1 - Math.exp(-deltaTimeInSeconds / Camera.followSeconds); - vec2.lerp(this.center, this.center, target, q); + easeVec2(this.center, target, deltaTimeInSeconds, followSeconds, snapDistance); } - protected commandExecutors: CommandExecutors = { - [RenderCommand.type]: this.draw.bind(this), - }; - - private draw({ renderer }: RenderCommand) { - const canvasAspectRatio = renderer.canvasSize.x / renderer.canvasSize.y; + public draw(renderer: Renderer) { + const canvasAspectRatio = renderer.canvasSize[0] / renderer.canvasSize[1]; if (canvasAspectRatio !== this.aspectRatio) { this.aspectRatio = canvasAspectRatio; this.game.aspectRatioChanged(canvasAspectRatio); } - // Shake displaces only the rendered view centre and the zoom-punch shrinks - // only the rendered view area — neither touches the followed position, so - // impacts jolt the frame without nudging the camera off the player. Passing - // the zoom as oversizeRatio scales the area about the (shaken) centre. const shakenCenter = vec2.fromValues( this.center[0] + ScreenShake.offsetX, this.center[1] + ScreenShake.offsetY, diff --git a/frontend/src/scripts/objects/types/character-view.ts b/frontend/src/scripts/objects/types/character-view.ts index 6fe951b..2efa0ca 100644 --- a/frontend/src/scripts/objects/types/character-view.ts +++ b/frontend/src/scripts/objects/types/character-view.ts @@ -1,6 +1,5 @@ import { vec2, vec3 } from 'gl-matrix'; import { CircleLight, Renderer } from 'sdf-2d'; - import { characterCenter, Circle, @@ -12,12 +11,8 @@ import { clamp01, mix, strengthToCharge, - CommandExecutors, UpdatePropertyCommand, } from 'shared'; -import { BeforeDestroyCommand } from '../../commands/types/before-destroy'; -import { RenderCommand } from '../../commands/types/render'; -import { StepCommand } from '../../commands/types/step'; import { CircleInterpolator } from '../../helper/interpolators/circle-interpolator'; import { LinearInterpolator } from '../../helper/interpolators/linear-interpolator'; import { pointer } from '../../helper/pointer'; @@ -26,11 +21,10 @@ import { SoundHandler, Sounds } from '../../sound-handler'; import { VibrationHandler } from '../../vibration-handler'; import { FeedbackHud } from '../../feedback-hud'; import { ScreenShake } from '../../screen-shake'; +import { View } from '../view'; const muzzleFlashDecaySeconds = 0.12; const hitFlashDecaySeconds = 0.15; - -// No radius knob on CircleLight, so the death burst is sold by a bright (HDR) colour with a fast-decaying intensity. const deathBurstDecaySeconds = 0.42; const deathBurstMaxIntensity = 1.7; const deathBurstColor = vec3.fromValues(2.5, 2.3, 2.1); @@ -42,34 +36,29 @@ const deathIcon = ''; -export class CharacterView extends CharacterBase { - private shape: CharacterShape; - private muzzleFlash: CircleLight; - private muzzleFlashIntensity = 0; - private hitFlashIntensity = 0; - private deathBurst: CircleLight; - private deathBurstIntensity = 0; - private strength = settings.playerMaxStrength; - private strengthInterpolator = new LinearInterpolator(settings.playerMaxStrength); - private nameElement: HTMLElement = document.createElement('div'); - private statsElement: HTMLElement = document.createElement('div'); - private killCountElement: HTMLElement = document.createElement('span'); - private deathCountElement: HTMLElement = document.createElement('span'); - private healthElement: HTMLElement = document.createElement('div'); - private chargeElement: HTMLElement = document.createElement('div'); - +export class CharacterView extends CharacterBase implements View { public isMainCharacter = false; - private headInterpolator: CircleInterpolator; - private leftFootInterpolator: CircleInterpolator; - private rightFootInterpolator: CircleInterpolator; + private readonly shape: CharacterShape; + private readonly muzzleFlash: CircleLight; + private readonly deathBurst: CircleLight; + private muzzleFlashIntensity = 0; + private hitFlashIntensity = 0; + private deathBurstIntensity = 0; + private strength = settings.playerMaxStrength; - protected commandExecutors: CommandExecutors = { - [RenderCommand.type]: this.draw.bind(this), - [StepCommand.type]: this.step.bind(this), - [BeforeDestroyCommand.type]: this.beforeDestroy.bind(this), - [UpdatePropertyCommand.type]: this.updateProperty.bind(this), - }; + private readonly headInterpolator: CircleInterpolator; + private readonly leftFootInterpolator: CircleInterpolator; + private readonly rightFootInterpolator: CircleInterpolator; + private readonly strengthInterpolator = new LinearInterpolator( + settings.playerMaxStrength, + ); + + private readonly nameElement = document.createElement('div'); + private readonly healthElement = document.createElement('div'); + private readonly chargeElement = document.createElement('div'); + private readonly killCountElement = document.createElement('span'); + private readonly deathCountElement = document.createElement('span'); constructor( id: Id, @@ -78,29 +67,30 @@ export class CharacterView extends CharacterBase { deathCount: number, team: CharacterTeam, health: number, - head?: Circle, - leftFoot?: Circle, - rightFoot?: Circle, + head: Circle, + leftFoot: Circle, + rightFoot: Circle, ) { super(id, name, killCount, deathCount, team, health, head, leftFoot, rightFoot); this.shape = new CharacterShape(settings.colorIndices[team]); this.muzzleFlash = new CircleLight( - vec2.clone(this.head!.center), + vec2.clone(head.center), settings.paletteDim[settings.colorIndices[team]], 0, ); - this.deathBurst = new CircleLight(vec2.clone(this.head!.center), deathBurstColor, 0); + this.deathBurst = new CircleLight(vec2.clone(head.center), deathBurstColor, 0); - this.headInterpolator = new CircleInterpolator(this.head!); - this.leftFootInterpolator = new CircleInterpolator(this.leftFoot!); - this.rightFootInterpolator = new CircleInterpolator(this.rightFoot!); + this.headInterpolator = new CircleInterpolator(head); + this.leftFootInterpolator = new CircleInterpolator(leftFoot); + this.rightFootInterpolator = new CircleInterpolator(rightFoot); this.nameElement.className = 'player-tag ' + this.team; this.nameElement.innerText = this.name; this.healthElement.className = 'health'; this.chargeElement.className = 'charge'; - this.statsElement.className = 'stats'; + const stats = document.createElement('div'); + stats.className = 'stats'; this.killCountElement.className = 'value'; this.deathCountElement.className = 'value'; const killStat = document.createElement('span'); @@ -111,29 +101,27 @@ export class CharacterView extends CharacterBase { deathStat.className = 'stat deaths'; deathStat.innerHTML = deathIcon; deathStat.appendChild(this.deathCountElement); - this.statsElement.append(killStat, deathStat); + stats.append(killStat, deathStat); - this.nameElement.appendChild(this.healthElement); - this.nameElement.appendChild(this.chargeElement); - this.nameElement.appendChild(this.statsElement); + this.nameElement.append(this.healthElement, this.chargeElement, stats); } public get position(): vec2 { - return this.head!.center; + return this.head.center; } public get bodyCenter(): vec2 { - return characterCenter(this.head!, this.leftFoot!, this.rightFoot!); + return characterCenter(this.head, this.leftFoot, this.rightFoot); } public get facingDirection(): vec2 { const footAverage = vec2.add( vec2.create(), - this.leftFoot!.center, - this.rightFoot!.center, + this.leftFoot.center, + this.rightFoot.center, ); vec2.scale(footAverage, footAverage, 0.5); - const forward = vec2.subtract(footAverage, this.head!.center, footAverage); + const forward = vec2.subtract(footAverage, this.head.center, footAverage); return vec2.length(forward) > 0 ? vec2.normalize(forward, forward) : vec2.fromValues(0, 1); @@ -143,21 +131,18 @@ export class CharacterView extends CharacterBase { return clamp01(this.strength / settings.playerMaxStrength); } - private updateProperty({ + public updateProperty({ propertyKey, propertyValue, rateOfChange, }: UpdatePropertyCommand) { if (propertyKey === 'head') { this.headInterpolator.addFrame(propertyValue, rateOfChange); - } - if (propertyKey === 'leftFoot') { + } else if (propertyKey === 'leftFoot') { this.leftFootInterpolator.addFrame(propertyValue, rateOfChange); - } - if (propertyKey === 'rightFoot') { + } else if (propertyKey === 'rightFoot') { this.rightFootInterpolator.addFrame(propertyValue, rateOfChange); - } - if (propertyKey === 'strength') { + } else if (propertyKey === 'strength') { this.strengthInterpolator.addFrame(propertyValue, rateOfChange); } } @@ -184,7 +169,6 @@ export class CharacterView extends CharacterBase { if (this.isMainCharacter) { VibrationHandler.vibrate(150); } - // Visible to everyone who can see the body: a white-hot flash plus a full-body whiteout. this.deathBurstIntensity = 1; this.hitFlashIntensity = 1; } @@ -193,7 +177,6 @@ export class CharacterView extends CharacterBase { if (!this.isMainCharacter) { return; } - // Layer a meaty thud under the crisp confirmation tick; a charged hit lands lower and harder. SoundHandler.play(Sounds.hit, mix(0.35, 0.7, charge), mix(1.3, 0.95, charge)); SoundHandler.play(Sounds.click, mix(0.4, 0.75, charge), mix(1.7, 1.1, charge)); ScreenShake.add(mix(0.22, 0.5, charge)); @@ -205,7 +188,6 @@ export class CharacterView extends CharacterBase { if (!this.isMainCharacter) { return; } - // A heavy low thud for the kill with a brighter confirmation over the top, a hard frame jolt, a zoom-punch, and a double-thump rumble. SoundHandler.play(Sounds.hit, 1, mix(0.62, 0.5, charge)); SoundHandler.play(Sounds.click, 0.9, mix(0.6, 0.45, charge)); ScreenShake.add(mix(0.75, 1, charge)); @@ -215,16 +197,21 @@ export class CharacterView extends CharacterBase { } public onLeap() { - if (!this.isMainCharacter) { - return; + if (this.isMainCharacter) { + SoundHandler.play(Sounds.shoot, 0.3, 1.5); } - SoundHandler.play(Sounds.shoot, 0.3, 1.5); } - private step({ deltaTimeInSeconds }: StepCommand): void { - this.head! = this.headInterpolator.getValue(deltaTimeInSeconds); - this.leftFoot! = this.leftFootInterpolator.getValue(deltaTimeInSeconds); - this.rightFoot! = this.rightFootInterpolator.getValue(deltaTimeInSeconds); + public onShoot(strength: number) { + const q = strengthToCharge(strength); + SoundHandler.play(Sounds.shoot, mix(0.55, 1, q), mix(1.15, 0.8, q)); + this.muzzleFlashIntensity = mix(0.35, 1, q); + } + + public step(deltaTimeInSeconds: number) { + this.head = this.headInterpolator.getValue(deltaTimeInSeconds); + this.leftFoot = this.leftFootInterpolator.getValue(deltaTimeInSeconds); + this.rightFoot = this.rightFootInterpolator.getValue(deltaTimeInSeconds); this.strength = clamp( this.strengthInterpolator.getValue(deltaTimeInSeconds), @@ -237,7 +224,7 @@ export class CharacterView extends CharacterBase { 0, this.muzzleFlashIntensity - deltaTimeInSeconds / muzzleFlashDecaySeconds, ); - this.muzzleFlash.center = this.head!.center; + this.muzzleFlash.center = this.head.center; this.muzzleFlash.intensity = this.muzzleFlashIntensity; } @@ -254,35 +241,30 @@ export class CharacterView extends CharacterBase { this.deathBurstIntensity - deltaTimeInSeconds / deathBurstDecaySeconds, ); this.deathBurst.center = this.bodyCenter; - // Square the envelope so the flash blooms then drops off sharply rather - // than fading out in a flat ramp. this.deathBurst.intensity = deathBurstMaxIntensity * this.deathBurstIntensity * this.deathBurstIntensity; } } - public onShoot(strength: number) { - const q = strengthToCharge(strength); - SoundHandler.play(Sounds.shoot, mix(0.55, 1, q), mix(1.15, 0.8, q)); - this.muzzleFlashIntensity = mix(0.35, 1, q); + public beforeDestroy() { + this.nameElement.remove(); } - private beforeDestroy(): void { - this.nameElement.parentElement?.removeChild(this.nameElement); - } - - private draw({ renderer, overlay, shouldChangeLayout }: RenderCommand): void { + public render(renderer: Renderer, overlay: HTMLElement, shouldChangeLayout: boolean) { if (shouldChangeLayout) { if (!this.nameElement.parentElement) { overlay.appendChild(this.nameElement); } const screenPosition = renderer.worldToDisplayCoordinates( - this.calculateTextPosition(), + vec2.scaleAndAdd( + vec2.create(), + this.head.center, + this.facingDirection, + this.head.radius + 80, + ), ); - - this.nameElement.style.transform = `translateX(${screenPosition.x}px) translateY(${screenPosition.y}px) translateX(-50%) translateY(-50%) rotate(-15deg)`; - + this.nameElement.style.transform = `translateX(${screenPosition[0]}px) translateY(${screenPosition[1]}px) translateX(-50%) translateY(-50%) rotate(-15deg)`; this.healthElement.style.width = (50 * this.health) / settings.playerMaxHealth + 'px'; this.chargeElement.style.width = @@ -293,13 +275,12 @@ export class CharacterView extends CharacterBase { this.shape.hitFlash = this.hitFlashIntensity; this.shape.gazeTarget = this.calculateGazeTarget(renderer); - this.shape.setCircles([this.head!, this.leftFoot!, this.rightFoot!]); + this.shape.setCircles([this.head, this.leftFoot, this.rightFoot]); renderer.addDrawable(this.shape); if (this.muzzleFlashIntensity > 0) { renderer.addDrawable(this.muzzleFlash); } - if (this.deathBurstIntensity > 0) { renderer.addDrawable(this.deathBurst); } @@ -310,21 +291,11 @@ export class CharacterView extends CharacterBase { if (this.isMainCharacter && cursor) { return renderer.displayToWorldCoordinates(cursor); } - return vec2.scaleAndAdd( vec2.create(), - this.head!.center, + this.head.center, this.facingDirection, - this.head!.radius * 8, - ); - } - - private calculateTextPosition(): vec2 { - return vec2.scaleAndAdd( - vec2.create(), - this.head!.center, - this.facingDirection, - this.head!.radius + 80, + this.head.radius * 8, ); } } diff --git a/frontend/src/scripts/objects/types/lamp-view.ts b/frontend/src/scripts/objects/types/lamp-view.ts index e4db4f6..e4f32ce 100644 --- a/frontend/src/scripts/objects/types/lamp-view.ts +++ b/frontend/src/scripts/objects/types/lamp-view.ts @@ -1,20 +1,13 @@ import { vec2, vec3 } from 'gl-matrix'; -import { CircleLight } from 'sdf-2d'; -import { CommandExecutors, Id, LampBase, mixRgb, settings } from 'shared'; -import { RenderCommand } from '../../commands/types/render'; -import { StepCommand } from '../../commands/types/step'; - -export class LampView extends LampBase { - private light: CircleLight; +import { CircleLight, Renderer } from 'sdf-2d'; +import { Id, LampBase, mix, mixRgb, settings, smoothing } from 'shared'; +import { View } from '../view'; +export class LampView extends LampBase implements View { + private readonly light: CircleLight; private targetColor: vec3; private targetLightness: number; - protected commandExecutors: CommandExecutors = { - [RenderCommand.type]: this.draw.bind(this), - [StepCommand.type]: this.step.bind(this), - }; - constructor(id: Id, center: vec2, color: vec3, lightness: number) { super(id, center, color, lightness); this.light = new CircleLight(vec2.clone(center), vec3.clone(color), lightness); @@ -27,13 +20,13 @@ export class LampView extends LampBase { this.targetLightness = lightness; } - private step({ deltaTimeInSeconds }: StepCommand): void { - const t = 1 - Math.exp(-deltaTimeInSeconds / settings.lampLerpSeconds); + public step(deltaTimeInSeconds: number) { + const t = smoothing(deltaTimeInSeconds, settings.lampLerpSeconds); this.light.color = mixRgb(this.light.color, this.targetColor, t); - this.light.intensity += (this.targetLightness - this.light.intensity) * t; + this.light.intensity = mix(this.light.intensity, this.targetLightness, t); } - private draw({ renderer }: RenderCommand): void { + public render(renderer: Renderer) { renderer.addDrawable(this.light); } } diff --git a/frontend/src/scripts/objects/types/planet-view.ts b/frontend/src/scripts/objects/types/planet-view.ts index 7c4df7b..28e0d47 100644 --- a/frontend/src/scripts/objects/types/planet-view.ts +++ b/frontend/src/scripts/objects/types/planet-view.ts @@ -1,63 +1,40 @@ import { vec2, vec3 } from 'gl-matrix'; -import { CircleLight } from 'sdf-2d'; +import { CircleLight, Renderer } from 'sdf-2d'; import { Id, Random, PlanetBase, UpdatePropertyCommand, - CommandExecutors, CharacterTeam, settings, } from 'shared'; -import { BeforeDestroyCommand } from '../../commands/types/before-destroy'; -import { RenderCommand } from '../../commands/types/render'; -import { StepCommand } from '../../commands/types/step'; import { LinearInterpolator } from '../../helper/interpolators/linear-interpolator'; import { PlanetShape } from '../../shapes/planet-shape'; +import { View } from '../view'; const fallingPointLifetimeMs = 2000; -// Global budget for simultaneously-lit capture flares, so a clustered wave can't -// white out the SDF exposure; excess flips still pulse the ring/toast but skip the light. -abstract class FlareBudget { - private static active = 0; +let activeFlares = 0; - public static tryAcquire(): boolean { - if (FlareBudget.active >= settings.maxConcurrentFlipFlares) { - return false; - } - FlareBudget.active++; - return true; - } +export class PlanetView extends PlanetBase implements View { + // Rotation as of the newest snapshot: the predictor collides at this phase, not the drawn one. + public snapshotRotation = 0; + public snapshotRotationSpeed = 0; - public static release(): void { - FlareBudget.active = Math.max(0, FlareBudget.active - 1); - } -} - -export class PlanetView extends PlanetBase { - private shape: PlanetShape; - private ownershipProgress: HTMLElement; - // Rotation is owned by the backend and streamed in; the interpolator replays it on the snapshot timeline, in sync with characters on the surface. + private readonly shape: PlanetShape; + private readonly ownershipProgress = document.createElement('div'); private readonly rotationInterpolator = new LinearInterpolator(0); private flareLight?: CircleLight; private flareIntensity = 0; private holdsFlareSlot = false; - - protected commandExecutors: CommandExecutors = { - [RenderCommand.type]: this.draw.bind(this), - [StepCommand.type]: this.step.bind(this), - [BeforeDestroyCommand.type]: this.beforeDestroy.bind(this), - [UpdatePropertyCommand.type]: this.updateProperty.bind(this), - }; + private lastGeneratedPoint?: number; + private lastGradient?: string; constructor(id: Id, vertices: Array, ownership = 0.5, isKeystone = false) { super(id, vertices, ownership, isKeystone); this.shape = new PlanetShape(vertices, ownership); this.shape.randomOffset = Random.getRandom(); - - this.ownershipProgress = document.createElement('div'); this.ownershipProgress.className = 'ownership' + (isKeystone ? ' keystone' : ''); } @@ -65,52 +42,11 @@ export class PlanetView extends PlanetBase { this.ownershipProgress.classList.toggle('contested', contested); } - private rotationSpeed = 0; - // Newest streamed rotation (server-current at the latest snapshot), NOT the lagged - // rendered one. The predictor seeds the body from the same snapshot's pose, so it - // must collide at THIS phase — using the drawn (lagged) angle biases the body off the surface. - private latestRotation = 0; - public get predictionRotation(): number { - return this.latestRotation; - } - public get predictionRotationSpeed(): number { - return this.rotationSpeed; - } - - private step({ deltaTimeInSeconds }: StepCommand): void { - this.shape.rotation = this.rotationInterpolator.getValue(deltaTimeInSeconds); - this.shape.colorMixQ = this.ownership; - - if (this.flareIntensity > 0) { - this.flareIntensity = Math.max( - 0, - this.flareIntensity - deltaTimeInSeconds / settings.lampFlareDecaySeconds, - ); - - if (this.flareLight) { - this.flareLight.intensity = - settings.lampFlareIntensity * this.flareIntensity * this.flareIntensity; - } - - if (this.flareIntensity === 0) { - this.releaseFlareSlot(); - } - } - } - - private releaseFlareSlot(): void { - if (this.holdsFlareSlot) { - this.holdsFlareSlot = false; - FlareBudget.release(); - } - } - - private lastGeneratedPoint?: number; public generatedPoints(value: number) { this.lastGeneratedPoint = value; } - public onFlipped(team: CharacterTeam): void { + public onFlipped(team: CharacterTeam) { const color = settings.palette[settings.colorIndices[team]]; if (!this.flareLight) { @@ -120,44 +56,70 @@ export class PlanetView extends PlanetBase { } if (!this.holdsFlareSlot) { - if (!FlareBudget.tryAcquire()) { + if (activeFlares >= settings.maxConcurrentFlipFlares) { return; } + activeFlares++; this.holdsFlareSlot = true; } this.flareIntensity = 1; } - private beforeDestroy(): void { - this.ownershipProgress.parentElement?.removeChild(this.ownershipProgress); - this.releaseFlareSlot(); - } - - private updateProperty({ + public updateProperty({ propertyKey, propertyValue, rateOfChange, - }: UpdatePropertyCommand): void { + }: UpdatePropertyCommand) { if (propertyKey === 'rotation') { this.rotationInterpolator.addFrame(propertyValue, rateOfChange); - this.latestRotation = propertyValue; - this.rotationSpeed = rateOfChange; + this.snapshotRotation = propertyValue; + this.snapshotRotationSpeed = rateOfChange; } else { this.ownership = propertyValue; } } - private draw({ renderer, overlay, shouldChangeLayout }: RenderCommand): void { + public step(deltaTimeInSeconds: number) { + this.shape.rotation = this.rotationInterpolator.getValue(deltaTimeInSeconds); + this.shape.colorMixQ = this.ownership; + + if (this.flareIntensity > 0) { + this.flareIntensity = Math.max( + 0, + this.flareIntensity - deltaTimeInSeconds / settings.lampFlareDecaySeconds, + ); + if (this.flareLight) { + this.flareLight.intensity = + settings.lampFlareIntensity * this.flareIntensity * this.flareIntensity; + } + if (this.flareIntensity === 0) { + this.releaseFlareSlot(); + } + } + } + + public beforeDestroy() { + this.ownershipProgress.remove(); + this.releaseFlareSlot(); + } + + private releaseFlareSlot() { + if (this.holdsFlareSlot) { + this.holdsFlareSlot = false; + activeFlares--; + } + } + + public render(renderer: Renderer, overlay: HTMLElement, shouldChangeLayout: boolean) { if (shouldChangeLayout) { if (!this.ownershipProgress.parentElement) { overlay.appendChild(this.ownershipProgress); } const screenPosition = renderer.worldToDisplayCoordinates(this.center); + this.ownershipProgress.style.transform = `translateX(${screenPosition[0]}px) translateY(${screenPosition[1]}px) translateX(-50%) translateY(-50%)`; - this.ownershipProgress.style.transform = `translateX(${screenPosition.x}px) translateY(${screenPosition.y}px) translateX(-50%) translateY(-50%)`; - // Re-parsing a conic-gradient per planet per layout frame is not free; ownership moves slower than the ring shows. const gradient = this.getGradient(); if (gradient !== this.lastGradient) { this.lastGradient = gradient; @@ -168,14 +130,10 @@ export class PlanetView extends PlanetBase { const element = document.createElement('div'); element.className = 'falling-point ' + (this.ownership < 0.5 ? 'blue' : 'red'); element.innerText = '+' + this.lastGeneratedPoint; - element.style.left = `${screenPosition.x}px`; - element.style.top = `${screenPosition.y}px`; + element.style.left = `${screenPosition[0]}px`; + element.style.top = `${screenPosition[1]}px`; overlay.appendChild(element); - setTimeout( - () => element.parentElement?.removeChild(element), - fallingPointLifetimeMs, - ); - + setTimeout(() => element.remove(), fallingPointLifetimeMs); this.lastGeneratedPoint = undefined; } } @@ -187,16 +145,11 @@ export class PlanetView extends PlanetBase { } } - private lastGradient?: string; private getGradient(): string { - const sideBlue = this.ownership < 0.5; - // Keep the ring neutral through the same dead-band that gates scoring - // (settings.planetControlThreshold), so "the ring fills" and "this planet - // pays my team" happen together rather than disagreeing. const control = Math.abs(this.ownership - 0.5); const t = settings.planetControlThreshold; const sidePercent = control <= t ? 0 : ((control - t) / (0.5 - t)) * 100; - return sideBlue + return this.ownership < 0.5 ? `conic-gradient( var(--bright-blue) ${sidePercent}%, var(--bright-blue) ${sidePercent}%, diff --git a/frontend/src/scripts/objects/types/projectile-view.ts b/frontend/src/scripts/objects/types/projectile-view.ts index 5843e59..be6d710 100644 --- a/frontend/src/scripts/objects/types/projectile-view.ts +++ b/frontend/src/scripts/objects/types/projectile-view.ts @@ -1,27 +1,18 @@ import { vec2 } from 'gl-matrix'; -import { CircleLight } from 'sdf-2d'; +import { CircleLight, Renderer } from 'sdf-2d'; import { CharacterTeam, - CommandExecutors, Id, ProjectileBase, settings, UpdatePropertyCommand, } from 'shared'; -import { RenderCommand } from '../../commands/types/render'; -import { StepCommand } from '../../commands/types/step'; import { Vec2Interpolator } from '../../helper/interpolators/vec2-interpolator'; +import { View } from '../view'; -export class ProjectileView extends ProjectileBase { - private light: CircleLight; - - private centerInterpolator: Vec2Interpolator; - - protected commandExecutors: CommandExecutors = { - [RenderCommand.type]: this.draw.bind(this), - [StepCommand.type]: this.handleStep.bind(this), - [UpdatePropertyCommand.type]: this.updateProperty.bind(this), - }; +export class ProjectileView extends ProjectileBase implements View { + private readonly light: CircleLight; + private readonly centerInterpolator: Vec2Interpolator; constructor( id: Id, @@ -39,12 +30,12 @@ export class ProjectileView extends ProjectileBase { this.centerInterpolator = new Vec2Interpolator(center); } - private updateProperty({ propertyValue, rateOfChange }: UpdatePropertyCommand): void { + public updateProperty({ propertyValue, rateOfChange }: UpdatePropertyCommand) { this.centerInterpolator.addFrame(propertyValue, rateOfChange); } - private handleStep({ deltaTimeInSeconds }: StepCommand): void { - this.step(deltaTimeInSeconds); + public step(deltaTimeInSeconds: number) { + super.step(deltaTimeInSeconds); this.center = this.centerInterpolator.getValue(deltaTimeInSeconds); this.light.center = this.center; @@ -54,7 +45,7 @@ export class ProjectileView extends ProjectileBase { ); } - private draw({ renderer }: RenderCommand): void { + public render(renderer: Renderer) { renderer.addDrawable(this.light); } } diff --git a/frontend/src/scripts/objects/view.ts b/frontend/src/scripts/objects/view.ts new file mode 100644 index 0000000..af4be1c --- /dev/null +++ b/frontend/src/scripts/objects/view.ts @@ -0,0 +1,9 @@ +import { Renderer } from 'sdf-2d'; +import { GameObject, UpdatePropertyCommand } from 'shared'; + +export interface View extends GameObject { + step(deltaTimeInSeconds: number): void; + render(renderer: Renderer, overlay: HTMLElement, shouldChangeLayout: boolean): void; + beforeDestroy?(): void; + updateProperty?(update: UpdatePropertyCommand): void; +} diff --git a/frontend/src/scripts/options-handler.ts b/frontend/src/scripts/options-handler.ts index 8479fb4..c5f41bf 100644 --- a/frontend/src/scripts/options-handler.ts +++ b/frontend/src/scripts/options-handler.ts @@ -1,77 +1,57 @@ import { SoundHandler, Sounds } from './sound-handler'; import { VibrationHandler } from './vibration-handler'; -interface Options { +export interface Options { vibrationEnabled: boolean; soundsEnabled: boolean; musicEnabled: boolean; } -export abstract class OptionsHandler { - private static initialized = false; - private static _options: Options = { - vibrationEnabled: true, - soundsEnabled: true, - musicEnabled: true, - }; +export const options: Options = { + vibrationEnabled: true, + soundsEnabled: true, + musicEnabled: true, +}; - public static initialize(inputElements: { - [k in Extract]: HTMLInputElement; - }) { - if (localStorage.getItem('options')) { - const stored: Partial | null = JSON.parse( - localStorage.getItem('options')!, - ); - this._options = { - ...this._options, - ...stored, - }; - } +const save = () => localStorage.setItem('options', JSON.stringify(options)); - if (this._options.musicEnabled) { - SoundHandler.playAmbient(); - } +export const initializeOptions = ( + inputElements: Record, +) => { + const stored = localStorage.getItem('options'); + if (stored) { + Object.assign(options, JSON.parse(stored)); + } - for (const k in inputElements) { - const element = inputElements[k as keyof Options]; - element.checked = OptionsHandler._options[k as keyof Options]; - element.addEventListener('change', function () { - OptionsHandler._options[k as keyof Options] = this.checked; - if (!this.checked && k === 'soundsEnabled') { - OptionsHandler._options.musicEnabled = false; - inputElements.musicEnabled.checked = false; + if (options.musicEnabled) { + SoundHandler.playAmbient(); + } + + for (const key of Object.keys(inputElements) as Array) { + const element = inputElements[key]; + element.checked = options[key]; + element.addEventListener('change', () => { + options[key] = element.checked; + if (!element.checked && key === 'soundsEnabled') { + options.musicEnabled = false; + inputElements.musicEnabled.checked = false; + SoundHandler.stopAmbient(); + } + + if (key === 'musicEnabled') { + if (element.checked) { + SoundHandler.playAmbient(); + } else { SoundHandler.stopAmbient(); } + } - if (k === 'musicEnabled') { - if (this.checked) { - SoundHandler.playAmbient(); - } else { - SoundHandler.stopAmbient(); - } - } + if (element.checked && key === 'vibrationEnabled') { + VibrationHandler.vibrate(100); + } - if (this.checked && k === 'vibrationEnabled') { - VibrationHandler.vibrate(100); - } - - SoundHandler.play(Sounds.click); - OptionsHandler.save(); - }); - } - - this.initialized = true; + SoundHandler.play(Sounds.click); + save(); + }); } - - private static save() { - localStorage.setItem('options', JSON.stringify(this._options)); - } - - public static get options(): Options { - if (!this.initialized) { - throw new Error('Uninitialized'); - } - - return this._options; - } -} +}; diff --git a/frontend/src/scripts/screen-shake.ts b/frontend/src/scripts/screen-shake.ts index 98b84e6..9b606da 100644 --- a/frontend/src/scripts/screen-shake.ts +++ b/frontend/src/scripts/screen-shake.ts @@ -46,8 +46,6 @@ function resetScreenShake(): void { offsetYValue = 0; } -// The one public surface. The functions above stay module-private so each -// operation has exactly one name. export const ScreenShake = { add: addTrauma, addPunch, diff --git a/frontend/src/scripts/shapes/character-shape.ts b/frontend/src/scripts/shapes/character-shape.ts index 0fe1840..f8422a9 100644 --- a/frontend/src/scripts/shapes/character-shape.ts +++ b/frontend/src/scripts/shapes/character-shape.ts @@ -19,7 +19,6 @@ export class CharacterShape extends Drawable { return distance(target, circleCenter) - radius; } - // Distance to the segment a->b; subtract a radius to get a capsule (a rounded thick line). float segmentDistance(vec2 a, vec2 b, vec2 target) { vec2 pa = target - a; vec2 ba = b - a; @@ -37,7 +36,6 @@ export class CharacterShape extends Drawable { float headRadius = characterHeadRadii[i]; float footRadius = characterFootRadii[i]; - // The server rotates the whole posture toward travel, so the head leads: forward is the facing direction, perp its 90deg rotation, so the face sits on the leading side at any rotation. vec2 footAverage = (leftFoot + rightFoot) * 0.5; vec2 toHead = head - footAverage; float toHeadLength = length(toHead); @@ -66,7 +64,6 @@ export class CharacterShape extends Drawable { float scleraRadius = renderRadius * 0.26; float pupilRadius = renderRadius * 0.11; - // The normalize is guarded so a cursor resting exactly on an eye can't produce NaNs. vec2 gazeTarget = characterGazeTargets[i]; float pupilReach = (scleraRadius - pupilRadius) * 0.6; vec2 toLeftGaze = gazeTarget - leftEyeCenter; @@ -83,7 +80,7 @@ export class CharacterShape extends Drawable { circleDistance(rightEyeCenter + rightGaze * pupilReach, pupilRadius, target) ); - // Computed here in uniform control flow — NOT inside the body branch below — so the screen-space derivatives fwidth needs stay defined. WebGL1 fallback keeps the plain world-space band (no fwidth in GLSL ES 1.00). + // Uniform control flow on purpose: fwidth needs defined derivatives. float eyeAaBase = renderRadius * 0.025; #ifdef WEBGL2_IS_AVAILABLE float scleraAa = max(eyeAaBase, fwidth(sclera)); @@ -101,7 +98,6 @@ export class CharacterShape extends Drawable { clamp(characterFlash[i], 0.0, 1.0) ); - // The sclera albedo is HDR (>> 1) so the shading pass — which multiplies by dim scene light before clamping — still renders a clean white eye instead of dimming to a dark socket. Needs sdf-2d's float colour buffer; on 8-bit fallback it clamps to plain white. color = mix(color, vec4(10.0, 10.0, 10.0, 1.0), 1.0 - smoothstep(-scleraAa, scleraAa, sclera)); color = mix(color, vec4(0.04, 0.04, 0.07, 1.0), 1.0 - smoothstep(-pupilAa, pupilAa, pupil)); } diff --git a/frontend/src/scripts/shapes/planet-shape.ts b/frontend/src/scripts/shapes/planet-shape.ts index 7d8ac96..492b122 100644 --- a/frontend/src/scripts/shapes/planet-shape.ts +++ b/frontend/src/scripts/shapes/planet-shape.ts @@ -2,7 +2,7 @@ import { mat2d, vec2, vec3, vec4 } from 'gl-matrix'; import { PolygonFactory, DrawableDescriptor, Drawable } from 'sdf-2d'; import { settings } from 'shared'; -export const colorToString = (v: vec3 | vec4): string => +const colorToString = (v: vec3 | vec4): string => `vec4(${v[0]}, ${v[1]}, ${v[2]}, ${v.length > 3 ? v[3] : 1})`; export class PlanetShape extends PolygonFactory(settings.planetEdgeCount, 0) { @@ -58,7 +58,6 @@ export class PlanetShape extends PolygonFactory(settings.planetEdgeCount, 0) { float cr = cos(rotation); float sr = sin(rotation); - // Evaluate the SDF in the planet's own rotating frame so the outline turns with its terrain. vec2 targetCenterDelta = target - center; float targetDistance = length(targetCenterDelta); vec2 localTarget = center + vec2( @@ -127,7 +126,6 @@ export class PlanetShape extends PolygonFactory(settings.planetEdgeCount, 0) { public randomOffset = 0; public rotation = 0; - // Circle about the vertex centroid — the point the shader spins around (planetMinDistance above). Vertices are immutable after construction, so it is cached once. private readonly cullCenter: vec2; private readonly cullRadius: number; diff --git a/frontend/src/scripts/sound-handler.ts b/frontend/src/scripts/sound-handler.ts index c36445f..77180d0 100644 --- a/frontend/src/scripts/sound-handler.ts +++ b/frontend/src/scripts/sound-handler.ts @@ -1,15 +1,16 @@ +import { clamp01 } from 'shared'; import hitSound from '../../static/hit.mp3'; import shootSound from '../../static/shoot.mp3'; import clickSound from '../../static/click.mp3'; import ambientAudio from '../../static/ambient.mp3'; -import { OptionsHandler } from './options-handler'; +import { options } from './options-handler'; export const Sounds = { hit: 'hit', shoot: 'shoot', click: 'click' } as const; export type Sound = (typeof Sounds)[keyof typeof Sounds]; let sounds: Record; let isAmbientPlaying = false; -const ambientSnd = new Audio(ambientAudio); +const ambient = new Audio(ambientAudio); let initialized = false; async function initializeSound(src: string): Promise { @@ -26,29 +27,28 @@ async function initialize( onPlayKeypress: () => unknown = () => null, onPauseKeypress: () => unknown = () => null, ) { - sounds = { - hit: await initializeSound(hitSound), - shoot: await initializeSound(shootSound), - click: await initializeSound(clickSound), - }; + const [hit, shoot, click] = await Promise.all( + [hitSound, shootSound, clickSound].map(initializeSound), + ); + sounds = { hit, shoot, click }; - await ambientSnd.play(); - ambientSnd.muted = true; + await ambient.play(); + ambient.muted = true; initialized = true; - ambientSnd.onpause = onPauseKeypress; - ambientSnd.onplay = onPlayKeypress; + ambient.onpause = onPauseKeypress; + ambient.onplay = onPlayKeypress; - ambientSnd.muted = false; - ambientSnd.volume = 0.5; - ambientSnd.loop = true; + ambient.muted = false; + ambient.volume = 0.5; + ambient.loop = true; if (!isAmbientPlaying) { - ambientSnd.pause(); + ambient.pause(); } } function play(snd: Sound, volume = 1, playbackRate = 1) { - if (!initialized || !OptionsHandler.options.soundsEnabled) { + if (!initialized || !options.soundsEnabled) { return; } @@ -58,7 +58,7 @@ function play(snd: Sound, volume = 1, playbackRate = 1) { if (!isBusy) { audio.currentTime = 0; } - audio.volume = Math.max(0, Math.min(1, volume)); + audio.volume = clamp01(volume); audio.playbackRate = playbackRate; void audio.play().catch(() => undefined); } @@ -66,14 +66,14 @@ function play(snd: Sound, volume = 1, playbackRate = 1) { function playAmbient() { isAmbientPlaying = true; if (initialized) { - ambientSnd.play(); + ambient.play(); } } function stopAmbient() { isAmbientPlaying = false; if (initialized) { - ambientSnd.pause(); + ambient.pause(); } } diff --git a/frontend/src/scripts/tutorial.ts b/frontend/src/scripts/tutorial.ts index c0d1155..ba047ef 100644 --- a/frontend/src/scripts/tutorial.ts +++ b/frontend/src/scripts/tutorial.ts @@ -71,7 +71,7 @@ export class Tutorial extends CommandReceiver { return; } - const player = gameObjects.player; + const player = gameObjects.localPlayer; if (!player) { return; } diff --git a/frontend/src/scripts/vibration-handler.ts b/frontend/src/scripts/vibration-handler.ts index 0ca5e76..7d56f59 100644 --- a/frontend/src/scripts/vibration-handler.ts +++ b/frontend/src/scripts/vibration-handler.ts @@ -1,14 +1,11 @@ -import { OptionsHandler } from './options-handler'; +import { options } from './options-handler'; export const VibrationHandler = { vibrate(pattern: number | number[]) { - if (OptionsHandler.options.vibrationEnabled && 'vibrate' in navigator) { + if (options.vibrationEnabled && 'vibrate' in navigator) { navigator.vibrate(pattern); } }, - get isVibrationEnabled() { - return 'vibrate' in navigator; - }, get isVibrationEnabledHeuristics() { return 'vibrate' in navigator && 'ontouchstart' in window; }, diff --git a/frontend/webpack.config.js b/frontend/webpack.config.js index f729fa2..bbbca6b 100644 --- a/frontend/webpack.config.js +++ b/frontend/webpack.config.js @@ -22,8 +22,6 @@ module.exports = (env, argv) => { host: '0.0.0.0', allowedHosts: 'all', }, - // webpack-dev-server 5 no longer accepts `watchOptions` under `devServer`; - // polling is configured on the compiler instead (needed for some FS mounts). watchOptions: { poll: true, }, @@ -33,23 +31,16 @@ module.exports = (env, argv) => { new HtmlWebpackPlugin({ template: './src/index.html', }), - // The SVG UI icons used to be inlined into the HTML by the (abandoned, - // webpack-4-only) html-webpack-inline-svg-plugin. They are now emitted as - // static files and referenced via `static/.svg` from index.html. new CopyWebpackPlugin({ patterns: [{ from: 'static/*.svg', to: 'static/[name][ext]' }], }), - // Inline the extracted CSS into the HTML for production builds (replaces - // the abandoned html-webpack-inline-source-plugin). In development the CSS - // stays a separate, linked file for faster rebuilds. ...(isProduction ? [new HTMLInlineCSSWebpackPlugin()] : []), ], optimization: { minimizer: [ new TerserJSPlugin({ exclude: /node_modules/, - // The custom serialization protocol keys on class names, so they must - // survive minification (see shared/src/serialization). + // The serialization protocol keys on class names. terserOptions: { keep_classnames: true, }, @@ -84,23 +75,12 @@ module.exports = (env, argv) => { ], }, { - // SVGs referenced from CSS (`mask-image`/`background-image`) and JS - // must be inlined as real `data:` URIs. svg-url-loader emits a CJS - // module (`module.exports = "data:..."`) which webpack 5 + css-loader 7 - // write out verbatim as a `.svg` asset file, so the browser fetches JS - // instead of an image and the mask/background silently fails. webpack 5's - // built-in `asset/inline` produces a proper data URI instead. - // (HTML icons are unaffected: they are copied by CopyWebpackPlugin - // and referenced by literal `static/*.svg` paths, not through this rule.) + // svg-url-loader emits a CJS module that css-loader 7 writes out verbatim. test: /\.svg$/, type: 'asset/inline', }, { - // Use oneOf so each asset matches exactly one rule (og-image.png would - // otherwise match both the generic png rule and its own rule). - // The directory is encoded in `name` (not `outputPath`) and has no - // leading slash: with the default `publicPath: 'auto'` a leading slash - // produces a doubled slash in the emitted URL (e.g. `//static/x.mp3`). + // No leading slash in `name`: with publicPath 'auto' it doubles the slash. oneOf: [ { test: /og-image\.png$/, diff --git a/shared/src/commands/command.ts b/shared/src/commands/command.ts index 1142b7a..7670416 100644 --- a/shared/src/commands/command.ts +++ b/shared/src/commands/command.ts @@ -1,9 +1,9 @@ export abstract class Command { public static get type(): string { - return (this as any).name; + return this.name; } public get type(): string { - return (this as any).constructor.name; + return this.constructor.name; } } diff --git a/shared/src/commands/types/actions/client-heartbeat.ts b/shared/src/commands/types/actions/client-heartbeat.ts index 013485d..e2e0644 100644 --- a/shared/src/commands/types/actions/client-heartbeat.ts +++ b/shared/src/commands/types/actions/client-heartbeat.ts @@ -1,10 +1,6 @@ import { serializable } from '../../../serialization/serializable'; import { Command } from '../../command'; -// Appended to every client -> server batch. Movement is only sent on change, -// so without this the server's "newest input applied" marker freezes while a -// key is held, pinning the predictor's replay window. This carries the client's -// wall-clock so the marker advances at the send rate regardless of input change. @serializable export class ClientHeartbeatCommand extends Command { public constructor(public readonly clientTimeMs: number = 0) { diff --git a/shared/src/commands/types/actions/leap-action.ts b/shared/src/commands/types/actions/leap-action.ts index a7ca5c8..3681de2 100644 --- a/shared/src/commands/types/actions/leap-action.ts +++ b/shared/src/commands/types/actions/leap-action.ts @@ -1,9 +1,6 @@ import { serializable } from '../../../serialization/serializable'; import { Command } from '../../command'; -// clientTimeMs is echoed back via InputAcknowledgement so the predictor knows -// which leaps the server has already folded into the streamed launch momentum -// and must not replay again. @serializable export class LeapActionCommand extends Command { public constructor(public readonly clientTimeMs: number = 0) { diff --git a/shared/src/commands/types/actions/move-action.ts b/shared/src/commands/types/actions/move-action.ts index 2cecb1c..7f2a455 100644 --- a/shared/src/commands/types/actions/move-action.ts +++ b/shared/src/commands/types/actions/move-action.ts @@ -4,10 +4,6 @@ import { Command } from '../../command'; @serializable export class MoveActionCommand extends Command { - // clientTimeMs (integer ms, survives the serializer's toFixed(3)) is echoed - // back via InputAcknowledgement so the client knows how much of its input - // timeline is baked into a snapshot and can replay the rest. Defaults to 0 - // for inputs the server itself synthesises. public constructor( public readonly direction: vec2, public readonly clientTimeMs: number = 0, diff --git a/shared/src/commands/types/actions/primary-action.ts b/shared/src/commands/types/actions/primary-action.ts index 762feeb..ea60b20 100644 --- a/shared/src/commands/types/actions/primary-action.ts +++ b/shared/src/commands/types/actions/primary-action.ts @@ -7,9 +7,6 @@ export class PrimaryActionCommand extends Command { public constructor( public readonly position: vec2, public readonly charge: number = 0, - // Client wall-clock at shot release, echoed back via InputAcknowledgement - // like the other actions' — it advances the predictor's replay anchor past - // this shot. Appended last so the existing wire order is unchanged. public readonly clientTimeMs: number = 0, ) { super(); diff --git a/shared/src/commands/types/game-start.ts b/shared/src/commands/types/game-start.ts index 15f14c8..4e56570 100644 --- a/shared/src/commands/types/game-start.ts +++ b/shared/src/commands/types/game-start.ts @@ -3,10 +3,6 @@ import { Command } from '../command'; @serializable export class GameStartCommand extends Command { - constructor() { - super(); - } - public toArray(): Array { return []; } diff --git a/shared/src/commands/types/input-acknowledgement.ts b/shared/src/commands/types/input-acknowledgement.ts index 2359288..79f6a83 100644 --- a/shared/src/commands/types/input-acknowledgement.ts +++ b/shared/src/commands/types/input-acknowledgement.ts @@ -3,16 +3,7 @@ import { Id } from '../../communication/communication'; import { serializable } from '../../serialization/serializable'; import { Command } from '../command'; -/** - * Movement state carried tick-to-tick that is NOT in the pose. Together with - * the pose (which travels as ordinary property updates) this is the complete - * state `stepCharacterMovement` needs, so the predictor resumes the server's - * simulation rather than approximating it. - * - * `direction` is not recoverable from the pose: the body is sprung *towards* - * the posture that direction implies and lags it while walking, so deriving the - * angle back out is off by tens of degrees. - */ +// `direction` is not recoverable from the pose: the body lags the posture it is sprung towards. @serializable export class CharacterMovementSnapshot { public constructor( @@ -20,7 +11,7 @@ export class CharacterMovementSnapshot { public readonly bodyVelocity: vec2, public readonly leftFootNormal: vec2, public readonly rightFootNormal: vec2, - public readonly groundPlanetId: Id, + public readonly groundPlanetId: Id | null, public readonly secondsSinceOnSurface: number, ) {} @@ -36,21 +27,12 @@ export class CharacterMovementSnapshot { } } -// Sent server -> owning client only. Carries the clientTimeMs of the most -// recent input the server had received when the snapshot was taken (so the -// predictor replays only the inputs the server hasn't seen) and the movement -// state the replay resumes from. See LocalCharacterPredictor. @serializable export class InputAcknowledgement extends Command { public constructor( public readonly clientTimeMs: number, public readonly movement: CharacterMovementSnapshot, - // clientTimeMs of the most recent leap the server has received: any leap at - // or before it is already in movement.bodyVelocity and must not be replayed. public readonly lastLeapClientTimeMs: number, - // Age of the acknowledged input at snapshot time. Adding it back places the - // replay anchor at the snapshot instant instead of whichever input landed - // last, keeping the predicted pose free of the send-cadence beat. public readonly ackAgeMs: number = 0, ) { super(); diff --git a/shared/src/commands/types/property-updates-for-objects.ts b/shared/src/commands/types/property-updates-for-objects.ts index 1ed4b1e..aa4ec34 100644 --- a/shared/src/commands/types/property-updates-for-objects.ts +++ b/shared/src/commands/types/property-updates-for-objects.ts @@ -6,7 +6,6 @@ import { Command } from '../command'; export class PropertyUpdatesForObjects extends Command { constructor( public readonly updates: Array, - // Only differences between timestamps are meaningful. public readonly timestamp: number, ) { super(); diff --git a/shared/src/commands/types/remote-calls-for-objects.ts b/shared/src/commands/types/remote-calls-for-objects.ts index 3a99962..f1d14fc 100644 --- a/shared/src/commands/types/remote-calls-for-objects.ts +++ b/shared/src/commands/types/remote-calls-for-objects.ts @@ -1,4 +1,5 @@ -import { Id, RemoteCall } from '../../main'; +import { Id } from '../../communication/communication'; +import { RemoteCall } from '../../objects/game-object'; import { serializable } from '../../serialization/serializable'; import { Command } from '../command'; diff --git a/shared/src/communication/communication.ts b/shared/src/communication/communication.ts index c23f7c4..51a9cd6 100644 --- a/shared/src/communication/communication.ts +++ b/shared/src/communication/communication.ts @@ -1,9 +1,7 @@ -export type Id = number | null; +export type Id = number; let currentId = 0; -export const id = (): number => { - return currentId++; -}; +export const id = (): Id => currentId++; export interface PlayerInformation { name: string; diff --git a/shared/src/helper/calculate-view-area.ts b/shared/src/helper/calculate-view-area.ts index e23f886..5c36b56 100644 --- a/shared/src/helper/calculate-view-area.ts +++ b/shared/src/helper/calculate-view-area.ts @@ -1,5 +1,5 @@ import { vec2 } from 'gl-matrix'; -import { settings } from '../main'; +import { settings } from '../settings'; export const calculateViewArea = ( center: vec2, diff --git a/shared/src/helper/charge.ts b/shared/src/helper/charge.ts index be2a67a..80cd5c8 100644 --- a/shared/src/helper/charge.ts +++ b/shared/src/helper/charge.ts @@ -4,8 +4,9 @@ import { settings } from '../settings'; export const holdDurationToCharge = (heldSeconds: number): number => clamp01(heldSeconds / settings.chargeShotFullHoldSeconds); -// Inverse of the server's projectile mix(); both sides need this number, so it -// lives here rather than being derived twice. +export const chargeHeldSince = (heldSinceMs: number): number => + holdDurationToCharge((performance.now() - heldSinceMs) / 1000); + export const strengthToCharge = (strength: number): number => clamp01( (strength - settings.chargeShotStrengthMin) / diff --git a/shared/src/helper/ease.ts b/shared/src/helper/ease.ts new file mode 100644 index 0000000..13f9f78 --- /dev/null +++ b/shared/src/helper/ease.ts @@ -0,0 +1,15 @@ +import { vec2 } from 'gl-matrix'; + +export const smoothing = (deltaTimeInSeconds: number, seconds: number): number => + 1 - Math.exp(-deltaTimeInSeconds / seconds); + +export const easeVec2 = ( + out: vec2, + target: vec2, + deltaTimeInSeconds: number, + seconds: number, + snapDistance: number, +): vec2 => + vec2.distance(out, target) > snapDistance + ? vec2.copy(out, target) + : vec2.lerp(out, out, target, smoothing(deltaTimeInSeconds, seconds)); diff --git a/shared/src/helper/finite.ts b/shared/src/helper/finite.ts index ddfb6c4..e659aaf 100644 --- a/shared/src/helper/finite.ts +++ b/shared/src/helper/finite.ts @@ -1,9 +1,9 @@ import { vec2 } from 'gl-matrix'; +import { clamp } from './clamp'; export const isFiniteNumber = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value); -/** A finite number within [min, max], or `fallback` if the input is unusable. */ export const finiteInRange = ( value: unknown, min: number, @@ -13,15 +13,9 @@ export const finiteInRange = ( if (!isFiniteNumber(value)) { return fallback; } - return Math.min(max, Math.max(min, value)); + return clamp(value, min, max); }; -/** - * A finite 2-vector whose magnitude is within `maxMagnitude`, or `undefined` if - * the input is not a usable pair of finite numbers. Callers should treat - * `undefined` as "drop this command" rather than substituting a default, so a - * malformed action is ignored instead of being silently reinterpreted. - */ export const finiteVec2 = (value: unknown, maxMagnitude: number): vec2 | undefined => { if (!value || typeof value !== 'object') { return undefined; @@ -47,10 +41,6 @@ export const finiteVec2 = (value: unknown, maxMagnitude: number): vec2 | undefin return result; }; -/** - * Coerces non-strings: a non-string survives `.slice()` and round-trips through - * the serializer revived as a class on every peer, throwing in the reviver. - */ export const sanitizeName = (value: unknown, maxLength: number): string => { const text = typeof value === 'string' ? value : String(value ?? ''); return text.slice(0, maxLength); diff --git a/shared/src/helper/mix-rgb.ts b/shared/src/helper/mix-rgb.ts index b3959b4..17195af 100644 --- a/shared/src/helper/mix-rgb.ts +++ b/shared/src/helper/mix-rgb.ts @@ -2,8 +2,6 @@ import { vec3 } from 'gl-matrix'; import { clamp01 } from './clamp'; import { mix } from './mix'; -// Component-wise lerp of two colours. Kept as a helper because the alternative -// is the same clamp-plus-three-mixes expansion repeated at every call site. export const mixRgb = (a: vec3, b: vec3, q: number): vec3 => { const clampedQ = clamp01(q); return vec3.fromValues( diff --git a/shared/src/helper/random.ts b/shared/src/helper/random.ts index 7e49865..d200cf0 100644 --- a/shared/src/helper/random.ts +++ b/shared/src/helper/random.ts @@ -8,9 +8,6 @@ function getRandom(): number { } export const Random = { - get seed() { - return seed; - }, set seed(value: number) { seed = value; }, diff --git a/shared/src/main.ts b/shared/src/main.ts index 8501417..befadfc 100644 --- a/shared/src/main.ts +++ b/shared/src/main.ts @@ -10,13 +10,11 @@ export * from './commands/types/game-start'; export * from './commands/types/update-minimap'; export * from './commands/types/update-game-state'; export * from './commands/command-receiver'; -export * from './commands/command-generator'; export * from './commands/types/actions/move-action'; export * from './commands/types/actions/primary-action'; export * from './commands/types/actions/leap-action'; export * from './commands/types/actions/client-heartbeat'; export * from './commands/types/actions/set-aspect-ratio-action'; -export * from './helper/array'; export * from './helper/hsl'; export * from './helper/rgb255'; export * from './helper/clamp'; @@ -25,13 +23,13 @@ export * from './helper/charge'; export * from './helper/calculate-view-area'; export * from './helper/circle'; export * from './helper/mix'; +export * from './helper/ease'; export * from './helper/mix-rgb'; export * from './helper/random'; export * from './communication/communication'; export * from './objects/game-object'; export * from './serialization/deserialize'; export * from './serialization/serialize'; -export * from './serialization/serializes-to'; export * from './serialization/serializable'; export * from './serialization/override-deserialization'; export * from './objects/types/character-base'; diff --git a/shared/src/objects/game-object.ts b/shared/src/objects/game-object.ts index fbcd26f..54afd7e 100644 --- a/shared/src/objects/game-object.ts +++ b/shared/src/objects/game-object.ts @@ -1,5 +1,4 @@ import { Command } from '../commands/command'; -import { CommandReceiver } from '../commands/command-receiver'; import { Id } from '../communication/communication'; import { serializable } from '../serialization/serializable'; @@ -15,8 +14,6 @@ export class RemoteCall { } } -// Wire property keys — compiler-checked at both the producing (server) and -// consuming (client) ends; wire strings are unchanged. export type SyncPropertyKey = | 'head' | 'leftFoot' @@ -53,67 +50,51 @@ export class PropertyUpdatesForObject { } } -let currentUpdateGeneration = 0; -export const beginPropertyUpdateGeneration = (): void => { - currentUpdateGeneration++; -}; +// Security: remote calls are dispatched by a raw string off the network. +const allowedRemoteCalls: ReadonlySet = new Set([ + 'onShoot', + 'onLeap', + 'onDie', + 'onHitConfirmed', + 'onKillConfirmed', + 'setHealth', + 'setKillCount', + 'onFlipped', + 'setContested', + 'generatedPoints', + 'setLight', +]); -export abstract class GameObject extends CommandReceiver { +export abstract class GameObject { private remoteCalls: Array = []; - private updateGeneration = -1; - private cachedPropertyUpdates?: PropertyUpdatesForObject; - // Security: processRemoteCalls dispatches by a raw string off the network. - // Keep this allow-list in sync with the remoteCall() emitters on *-physical. - private static readonly allowedRemoteCalls: ReadonlySet = new Set([ - 'onShoot', - 'onLeap', - 'onDie', - 'onHitConfirmed', - 'onKillConfirmed', - 'setHealth', - 'setKillCount', - 'onFlipped', - 'setContested', - 'generatedPoints', - 'setLight', - ]); - - constructor(public readonly id: Id) { - super(); - } + constructor(public readonly id: Id) {} public processRemoteCalls(remoteCalls: Array) { - remoteCalls.forEach((r) => { - if (!GameObject.allowedRemoteCalls.has(r.functionName)) { - console.warn(`Dropped disallowed remote call: ${r.functionName}`); - return; + for (const { functionName, args } of remoteCalls) { + if (!allowedRemoteCalls.has(functionName)) { + console.warn(`Dropped disallowed remote call: ${functionName}`); + continue; } - const fn = this[r.functionName as keyof this]; + const fn = this[functionName as keyof this]; if (typeof fn === 'function') { - (fn as (...args: Array) => unknown).apply(this, r.args); + (fn as (...args: Array) => unknown).apply(this, args); } - }); + } } public getPropertyUpdates(): PropertyUpdatesForObject | undefined { return undefined; } - public getPropertyUpdatesForFrame(): PropertyUpdatesForObject | undefined { - if (this.updateGeneration !== currentUpdateGeneration) { - this.updateGeneration = currentUpdateGeneration; - this.cachedPropertyUpdates = this.getPropertyUpdates(); - } - return this.cachedPropertyUpdates; - } - public getRemoteCalls(): Array { return this.remoteCalls; } public resetRemoteCalls() { - this.remoteCalls = []; + if (this.remoteCalls.length > 0) { + this.remoteCalls = []; + } } protected remoteCall(name: string & keyof this, ...args: Array) { diff --git a/shared/src/objects/types/character-base.ts b/shared/src/objects/types/character-base.ts index 631d91e..d860730 100644 --- a/shared/src/objects/types/character-base.ts +++ b/shared/src/objects/types/character-base.ts @@ -1,7 +1,6 @@ import { Id } from '../../communication/communication'; import { Circle } from '../../helper/circle'; import { serializable } from '../../serialization/serializable'; -import { toArrayFromFields } from '../../serialization/serialized-fields'; import { GameObject } from '../game-object'; export enum CharacterTeam { @@ -12,18 +11,6 @@ export enum CharacterTeam { @serializable export class CharacterBase extends GameObject { - private static readonly serializedFields = [ - 'id', - 'name', - 'killCount', - 'deathCount', - 'team', - 'health', - 'head', - 'leftFoot', - 'rightFoot', - ] as const; - constructor( id: Id, public name: string, @@ -31,23 +18,20 @@ export class CharacterBase extends GameObject { public deathCount: number, public team: CharacterTeam, public health: number, - public head?: Circle, - public leftFoot?: Circle, - public rightFoot?: Circle, + public head: Circle, + public leftFoot: Circle, + public rightFoot: Circle, ) { super(id); } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - public onShoot(strength: number) {} + public onShoot(_strength: number) {} public onLeap() {} - // eslint-disable-next-line @typescript-eslint/no-unused-vars - public onHitConfirmed(charge?: number) {} + public onHitConfirmed(_charge?: number) {} - // eslint-disable-next-line @typescript-eslint/no-unused-vars - public onKillConfirmed(victimName?: string, streak?: number, charge?: number) {} + public onKillConfirmed(_victimName?: string, _streak?: number, _charge?: number) {} public setHealth(health: number) { this.health = health; @@ -60,6 +44,16 @@ export class CharacterBase extends GameObject { } public toArray(): Array { - return toArrayFromFields(this, CharacterBase.serializedFields); + return [ + this.id, + this.name, + this.killCount, + this.deathCount, + this.team, + this.health, + this.head, + this.leftFoot, + this.rightFoot, + ]; } } diff --git a/shared/src/objects/types/lamp-base.ts b/shared/src/objects/types/lamp-base.ts index 00c20bb..aba7d83 100644 --- a/shared/src/objects/types/lamp-base.ts +++ b/shared/src/objects/types/lamp-base.ts @@ -1,17 +1,10 @@ import { vec2, vec3 } from 'gl-matrix'; -import { Id, serializable } from '../../main'; -import { toArrayFromFields } from '../../serialization/serialized-fields'; +import { Id } from '../../communication/communication'; +import { serializable } from '../../serialization/serializable'; import { GameObject } from '../game-object'; @serializable export class LampBase extends GameObject { - private static readonly serializedFields = [ - 'id', - 'center', - 'color', - 'lightness', - ] as const; - constructor( id: Id, public center: vec2, @@ -21,10 +14,9 @@ export class LampBase extends GameObject { super(id); } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - public setLight(color: vec3, lightness: number) {} + public setLight(_color: vec3, _lightness: number) {} public toArray(): Array { - return toArrayFromFields(this, LampBase.serializedFields); + return [this.id, this.center, this.color, this.lightness]; } } diff --git a/shared/src/objects/types/planet-base.ts b/shared/src/objects/types/planet-base.ts index 84a8e6d..b193100 100644 --- a/shared/src/objects/types/planet-base.ts +++ b/shared/src/objects/types/planet-base.ts @@ -2,23 +2,23 @@ import { vec2 } from 'gl-matrix'; import { Random } from '../../helper/random'; import { settings } from '../../settings'; import { serializable } from '../../serialization/serializable'; -import { toArrayFromFields } from '../../serialization/serialized-fields'; import { GameObject } from '../game-object'; import { Id } from '../../communication/communication'; import { CharacterTeam } from './character-base'; +import { GroundSurface } from '../../physics/sdf'; +import { planetDistance, planetGravity } from '../../physics/planet-sdf'; @serializable -export class PlanetBase extends GameObject { - // centre/radius are derived from vertices, so not serialized. - private static readonly serializedFields = [ - 'id', - 'vertices', - 'ownership', - 'isKeystone', - ] as const; - +export class PlanetBase extends GameObject implements GroundSurface { + public readonly canCollide = true; + public readonly isGround = true; public readonly center: vec2; public readonly radius: number; + public angularVelocity = 0; + + private _rotation = 0; + private cosRotation = 1; + private sinRotation = 0; constructor( id: Id, @@ -31,11 +31,9 @@ export class PlanetBase extends GameObject { vec2.scale(this.center, this.center, 1 / vertices.length); this.radius = vertices.reduce((sum, v) => sum + vec2.distance(this.center, v), 0) / - this.vertices.length; + vertices.length; } - // The one capture rule: server flips and scoring, client tints and the - // keystone arrow all read this. public get team(): CharacterTeam { return Math.abs(this.ownership - 0.5) < settings.planetControlThreshold ? CharacterTeam.neutral @@ -44,12 +42,37 @@ export class PlanetBase extends GameObject { : CharacterTeam.red; } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - public generatedPoints(value: number) {} - // eslint-disable-next-line @typescript-eslint/no-unused-vars - public onFlipped(team: CharacterTeam) {} - // eslint-disable-next-line @typescript-eslint/no-unused-vars - public setContested(contested: boolean) {} + public get rotation(): number { + return this._rotation; + } + + public set rotation(value: number) { + this._rotation = value; + this.cosRotation = Math.cos(value); + this.sinRotation = Math.sin(value); + } + + public advanceRotation(deltaTimeInSeconds: number) { + this.rotation += this.angularVelocity * deltaTimeInSeconds; + } + + public distance(target: vec2): number { + return planetDistance( + target, + this.vertices, + this.center, + this.cosRotation, + this.sinRotation, + ); + } + + public gravityAt(position: vec2): vec2 { + return planetGravity(this.center, this.radius, position); + } + + public generatedPoints(_value: number) {} + public onFlipped(_team: CharacterTeam) {} + public setContested(_contested: boolean) {} public static createPlanetVertices( center: vec2, @@ -76,6 +99,6 @@ export class PlanetBase extends GameObject { } public toArray(): Array { - return toArrayFromFields(this, PlanetBase.serializedFields); + return [this.id, this.vertices, this.ownership, this.isKeystone]; } } diff --git a/shared/src/objects/types/projectile-base.ts b/shared/src/objects/types/projectile-base.ts index 0d68e4d..2450398 100644 --- a/shared/src/objects/types/projectile-base.ts +++ b/shared/src/objects/types/projectile-base.ts @@ -1,21 +1,12 @@ import { vec2 } from 'gl-matrix'; import { settings } from '../../settings'; import { serializable } from '../../serialization/serializable'; -import { toArrayFromFields } from '../../serialization/serialized-fields'; import { GameObject } from '../game-object'; import { Id } from '../../communication/communication'; import { CharacterTeam } from './character-base'; @serializable export class ProjectileBase extends GameObject { - private static readonly serializedFields = [ - 'id', - 'center', - 'radius', - 'team', - 'strength', - ] as const; - constructor( id: Id, public center: vec2, @@ -32,6 +23,6 @@ export class ProjectileBase extends GameObject { } public toArray(): Array { - return toArrayFromFields(this, ProjectileBase.serializedFields); + return [this.id, this.center, this.radius, this.team, this.strength]; } } diff --git a/shared/src/physics/character-movement.ts b/shared/src/physics/character-movement.ts index 49cac9a..1098478 100644 --- a/shared/src/physics/character-movement.ts +++ b/shared/src/physics/character-movement.ts @@ -3,8 +3,6 @@ import { settings } from '../settings'; import { GroundSurface, PhysicsBody } from './sdf'; import { interpolateAngles } from './interpolate-angles'; -// Body layout — must match CharacterPhysical verbatim so the predicted body -// equals the authoritative one to the bit. export const headRadius = 50; export const feetRadius = 20; @@ -33,7 +31,6 @@ export const rightFootOffset = vec2.subtract( ); export const boundRadius = (headRadius + feetRadius * 2) * 2; -// Shared by backend CharacterPhysical and the client predictor. export interface CharacterMovementState { readonly head: PhysicsBody; readonly leftFoot: PhysicsBody; @@ -41,21 +38,14 @@ export interface CharacterMovementState { direction: number; currentPlanet: GroundSurface | undefined; secondsSinceOnSurface: number; - // Persistent launch momentum; walking zeroes per-part velocity each tick, so - // anything that must carry lives here. The client predictor leaves it zero and - // snaps to the server's impulses; the server uses it for full movement. bodyVelocity: vec2; } -// The server backs this with its spatial container (dispatching collision -// reactions); the client backs it with planets only, dispatching nothing. export interface CharacterWorld { groundsNear(center: vec2, radius: number): Array; stepBody(body: PhysicsBody, deltaTimeInSeconds: number): GroundSurface | undefined; } -// Exported so the backend's CirclePhysical accumulates forces through the exact -// same expression, f32 intermediate included. export const applyForce = ( body: PhysicsBody, force: vec2, @@ -68,8 +58,7 @@ export const applyForce = ( ); }; -// ((head + leftFoot) + rightFoot) / 3 — do not reassociate; every reader of the -// character centre uses this exact association. +// ((head + leftFoot) + rightFoot) / 3 — do not reassociate. export const characterCenter = ( head: { center: vec2 }, leftFoot: { center: vec2 }, @@ -98,7 +87,6 @@ const springMove = ( const desiredPosition = vec2.add(vec2.create(), center, offset); vec2.rotate(desiredPosition, desiredPosition, center, state.direction); const positionDelta = vec2.subtract(vec2.create(), desiredPosition, body.center); - // dt arrives later at integration, so per-tick displacement is positionDelta * stiffness * dt. vec2.scaleAndAdd(body.velocity, body.velocity, positionDelta, stiffness); }; @@ -121,8 +109,6 @@ const keepPosture = (state: CharacterMovementState) => { springMove(state, state.head, center, headOffset, settings.postureHeadStiffness); }; -// Ride a planet's spin: rotate the body about the planet centre by the same -// per-tick angle the collision SDF turns by (negative, matching R(-rotation)). const carryWithRotatingPlanet = ( state: CharacterMovementState, deltaTimeInSeconds: number, @@ -148,8 +134,6 @@ const carryWithRotatingPlanet = ( ); }; -// Shared so the server's leap() and the client's prediction apply the exact -// same impulse. Caller does gating; no-op when not on a surface. export const applyLeapImpulse = (state: CharacterMovementState, moveDirection: vec2) => { const planet = state.currentPlanet; if (!planet) { @@ -179,7 +163,6 @@ export const applyLeapImpulse = (state: CharacterMovementState, moveDirection: v vec2.normalize(launch, launch); vec2.scaleAndAdd(state.bodyVelocity, state.bodyVelocity, launch, settings.leapSpeed); - // Slingshot: tangential velocity of the spinning surface (same motion carryWithRotatingPlanet imparts). const center = characterCenter(state.head, state.leftFoot, state.rightFoot); const omega = planet.angularVelocity; const surfaceVelocity = vec2.fromValues( @@ -197,8 +180,6 @@ export const applyLeapImpulse = (state: CharacterMovementState, moveDirection: v state.secondsSinceOnSurface = settings.planetDetachmentSeconds; }; -// Kept as its own step: on the server it runs before the ownership/scoring -// blocks; the client calls it at the head of each tick. export const tickPlanetDetachment = ( state: CharacterMovementState, deltaTimeInSeconds: number, @@ -210,8 +191,6 @@ export const tickPlanetDetachment = ( } }; -// Inject body momentum onto every part right before they step, so the whole -// body translates rigidly without disturbing the posture springs. No-op while walking. const applyBodyMomentum = (state: CharacterMovementState) => { if (vec2.squaredLength(state.bodyVelocity) === 0) { return; @@ -221,8 +200,6 @@ const applyBodyMomentum = (state: CharacterMovementState) => { vec2.add(state.head.velocity, state.head.velocity, state.bodyVelocity); }; -// Shared so the living body, the client predictor, and the ragdoll corpse all -// brake identically. export const decayMomentum = ( bodyVelocity: vec2, onGround: boolean, @@ -251,7 +228,7 @@ const decayBodyMomentum = (state: CharacterMovementState, deltaTimeInSeconds: nu decayMomentum(state.bodyVelocity, !!state.currentPlanet, deltaTimeInSeconds); }; -const sumGravity = (grounds: Array, position: vec2): vec2 => +export const sumGravity = (grounds: Array, position: vec2): vec2 => grounds.reduce( (sum, ground) => vec2.add(sum, sum, ground.gravityAt(position)), vec2.create(), @@ -267,15 +244,16 @@ const latchGround = ( } }; -// The exact movement block of CharacterPhysical.step (gravity → movement force -// → on/off-planet branch → posture → step the parts); server-only concerns are -// left to the caller. `inputDirection` is the already-normalized movement direction. export const stepCharacterMovement = ( state: CharacterMovementState, world: CharacterWorld, inputDirection: vec2, deltaTimeInSeconds: number, ) => { + vec2.zero(state.head.velocity); + vec2.zero(state.leftFoot.velocity); + vec2.zero(state.rightFoot.velocity); + const movementForce = vec2.scale( inputDirection, inputDirection, @@ -285,8 +263,6 @@ export const stepCharacterMovement = ( applyForce(state.rightFoot, movementForce, deltaTimeInSeconds); if (!state.currentPlanet) { - // Widest query the character makes — only run while airborne; grounded is - // the common case. const center = characterCenter(state.head, state.leftFoot, state.rightFoot); const grounds = world.groundsNear(center, boundRadius + settings.maxGravityDistance); const leftFootGravity = sumGravity(grounds, state.leftFoot.center); @@ -341,10 +317,6 @@ export const stepCharacterMovement = ( applyBodyMomentum(state); - // Only the feet latch. The grounded branch above projects gravity onto the - // foot contact normals and leap() launches off them, so latching on a head - // contact would ground the body against normals no part of it is touching — - // a head-first tumble would read as standing. latchGround(state, world.stepBody(state.leftFoot, deltaTimeInSeconds)); latchGround(state, world.stepBody(state.rightFoot, deltaTimeInSeconds)); world.stepBody(state.head, deltaTimeInSeconds); diff --git a/shared/src/physics/depenetrate-circle.ts b/shared/src/physics/depenetrate-circle.ts index e39572e..85bb04a 100644 --- a/shared/src/physics/depenetrate-circle.ts +++ b/shared/src/physics/depenetrate-circle.ts @@ -3,9 +3,6 @@ import { PhysicsBody, Sdf } from './sdf'; import { evaluateSdf } from './evaluate-sdf'; import { sdfNormal } from './sdf-normal'; -// marchCircle assumes an overlap-free start (inside, it registers a zero- -// distance hit and never moves), so any overlap from rotating surfaces must be -// resolved here before marching. export const depenetrateCircle = ( body: PhysicsBody, possibleIntersectors: Array, diff --git a/shared/src/physics/evaluate-sdf.ts b/shared/src/physics/evaluate-sdf.ts index 0f0cac7..e8f3cb5 100644 --- a/shared/src/physics/evaluate-sdf.ts +++ b/shared/src/physics/evaluate-sdf.ts @@ -1,8 +1,6 @@ import { vec2 } from 'gl-matrix'; import { Sdf } from './sdf'; -// Innermost leaf of the physics stack — plain loop, not filter().reduce(), -// to avoid the per-call intermediate array. export const evaluateSdf = (target: vec2, objects: Array): number => { let minimum = 1000000; for (let i = 0; i < objects.length; i++) { diff --git a/shared/src/physics/march-circle.ts b/shared/src/physics/march-circle.ts index 7d72ac1..4e17329 100644 --- a/shared/src/physics/march-circle.ts +++ b/shared/src/physics/march-circle.ts @@ -12,30 +12,20 @@ export interface MarchResult { const minimumStep = 0.5; const maximumSteps = 256; -// Raymarch a circle by `delta`, stopping at the first surface it would overlap. -// Shared so server and client resolve motion identically; the collision reaction -// is not dispatched here — on a real hit `onHit(intersecting)` is invoked by the -// caller. Advances by the free gap (distance minus radius), as sphere-tracing requires. export const marchCircle = ( body: PhysicsBody, delta: vec2, possibleIntersectors: Array, - ignoreCollision = false, onHit?: (intersecting: Sdf) => void, ): MarchResult => { const deltaLength = vec2.length(delta); - - // Zero-length move cannot resolve a contact by advancing; report no hit so a - // caller looping on `hitSurface` doesn't spin forever. if (!(deltaLength > 0)) { return { hitSurface: false }; } const direction = vec2.normalize(vec2.create(), delta); - const rayEnd = vec2.create(); let travelled = 0; - // Furthest point along the ray known to be overlap-free; a hit rewinds here. let lastFreeDistance = 0; for (let step = 0; step < maximumSteps; step++) { @@ -48,19 +38,6 @@ export const marchCircle = ( (i) => i.distance(rayEnd) <= body.radius, )!; - if (ignoreCollision) { - // Pass through, but still report the contact so repeated marches keep - // progressing. The normal comes along: `hitSurface: true` is the - // contract callers read it under, and one without it would be a - // TypeError in whichever caller first enables pass-through. - vec2.scaleAndAdd(body.center, body.center, direction, deltaLength); - return { - hitSurface: true, - normal: sdfNormal(rayEnd, [intersecting]), - hitObject: intersecting, - }; - } - onHit?.(intersecting); vec2.scaleAndAdd(rayEnd, body.center, direction, lastFreeDistance); @@ -85,11 +62,7 @@ export const marchCircle = ( ); } - // lastFreeDistance, not deltaLength: on a completed march they are equal, and - // if the step budget ran out it is the furthest point actually checked. vec2.scaleAndAdd(body.center, body.center, direction, lastFreeDistance); - return { - hitSurface: false, - }; + return { hitSurface: false }; }; diff --git a/shared/src/physics/planet-sdf.ts b/shared/src/physics/planet-sdf.ts index ea7e72f..d54183c 100644 --- a/shared/src/physics/planet-sdf.ts +++ b/shared/src/physics/planet-sdf.ts @@ -2,8 +2,6 @@ import { vec2 } from 'gl-matrix'; import { clamp, clamp01 } from '../helper/clamp'; import { settings } from '../settings'; -// R(-rotation) about the centre, matching the shader's localTarget = center + R(rotation)*(target-center). -// cos/sin passed in for memoisation; `out` written in place for the hot path. export const toPlanetLocalFrame = ( out: vec2, target: vec2, @@ -16,22 +14,14 @@ export const toPlanetLocalFrame = ( return vec2.set(out, center[0] + cos * dx - sin * dy, center[1] + sin * dx + cos * dy); }; -// Innermost leaf — module-level scratch to avoid per-call allocation. Nothing -// re-enters planetDistance and none of these outlive the call. -// -// Plain array literals, NOT vec2.create(): imports are evaluated before either -// entry point reaches its glMatrix.setMatrixArrayType(Array) call, so -// vec2.create() here would hand back Float32Array and quietly run the one -// outline the server and the client must agree on at f32 while every other -// vector in the simulation is f64. +// Plain array literals, not vec2.create(): these are allocated before +// setMatrixArrayType(Array) runs and must be f64 like the rest of the simulation. const localPoint: vec2 = [0, 0]; const targetFromDelta: vec2 = [0, 0]; const toFromDelta: vec2 = [0, 0]; const closestOnEdge: vec2 = [0, 0]; const edgeSample: vec2 = [0, 0]; -// The one and only planet outline: the authoritative server and the client -// predictor both collide against it, which is what lets prediction reconcile. export const planetDistance = ( target: vec2, vertices: Array, diff --git a/shared/src/physics/resolve-circle-movement.ts b/shared/src/physics/resolve-circle-movement.ts index 9d728cf..a0cd612 100644 --- a/shared/src/physics/resolve-circle-movement.ts +++ b/shared/src/physics/resolve-circle-movement.ts @@ -3,19 +3,12 @@ import { PhysicsBody, Sdf } from './sdf'; import { depenetrateCircle } from './depenetrate-circle'; import { marchCircle } from './march-circle'; -// Position-resolution half of CirclePhysical.stepManually, extracted so server -// and client integrate a body identically. The caller owns the broadphase -// (gathering `possibleIntersectors`, incl. the swept-radius bump); everything -// from depenetration onward lives here. `onHit` is threaded to marchCircle so -// the backend dispatches collision reactions at the same points; the client -// passes none. Velocity is reset at the end — the character re-applies all -// forces from zero every tick, so retained velocity would double-integrate and diverge. export const resolveCircleMovement = ( body: PhysicsBody, deltaTimeInSeconds: number, possibleIntersectors: Array, onHit?: (intersecting: Sdf) => void, -): { hitObject?: Sdf; velocity: vec2 } => { +): Sdf | undefined => { let delta = vec2.scale(vec2.create(), body.velocity, deltaTimeInSeconds); depenetrateCircle(body, possibleIntersectors); @@ -24,7 +17,6 @@ export const resolveCircleMovement = ( body, delta, possibleIntersectors, - false, onHit, ); @@ -43,12 +35,9 @@ export const resolveCircleMovement = ( if (vec2.length(body.velocity) > 50) { delta = vec2.scale(vec2.create(), body.velocity, deltaTimeInSeconds); - marchCircle(body, delta, possibleIntersectors, false, onHit); + marchCircle(body, delta, possibleIntersectors, onHit); } } - const lastVelocity = vec2.clone(body.velocity); - vec2.zero(body.velocity); - - return { hitObject, velocity: lastVelocity }; + return hitObject; }; diff --git a/shared/src/physics/sdf-normal.ts b/shared/src/physics/sdf-normal.ts index 96ba9cc..cc5df2a 100644 --- a/shared/src/physics/sdf-normal.ts +++ b/shared/src/physics/sdf-normal.ts @@ -2,10 +2,7 @@ import { vec2 } from 'gl-matrix'; import { Sdf } from './sdf'; import { evaluateSdf } from './evaluate-sdf'; -// Reused probe point — sdfNormal is called on every depenetration pass and -// raymarch hit. A plain array literal for the same reason as planet-sdf's -// scratch: it is allocated before setMatrixArrayType(Array) runs, and the -// gradient must be computed at f64 like the rest of the simulation. +// Plain array literal for the same reason as planet-sdf's scratch vectors. const probe: vec2 = [0, 0]; const sampleAt = (x: number, y: number, objects: Array): number => { @@ -13,9 +10,6 @@ const sampleAt = (x: number, y: number, objects: Array): number => { return evaluateSdf(probe, objects); }; -// Central-difference gradient. Can be zero where samples cancel (e.g. on the -// medial axis) — callers must handle that. Indexed access keeps it independent -// of the vec2 .x/.y prototype plugin. export const sdfNormal = (target: vec2, objects: Array): vec2 => { const dx = sampleAt(target[0] + 0.01, target[1], objects) - @@ -25,5 +19,5 @@ export const sdfNormal = (target: vec2, objects: Array): vec2 => { sampleAt(target[0], target[1] - 0.01, objects); const normal = vec2.fromValues(dx, dy); - return vec2.squaredLength(normal) > 0 ? vec2.normalize(normal, normal) : normal; + return vec2.normalize(normal, normal); }; diff --git a/shared/src/physics/sdf.ts b/shared/src/physics/sdf.ts index e33e81e..f2202e3 100644 --- a/shared/src/physics/sdf.ts +++ b/shared/src/physics/sdf.ts @@ -1,11 +1,7 @@ import { vec2 } from 'gl-matrix'; -// Minimal signed-distance collider. Backend Physicals and the client's planet -// surfaces both satisfy this. export interface Sdf { readonly canCollide: boolean; - // Planets set this so a body landing on one can latch it as ground; replaces - // the server's `instanceof PlanetPhysical` with a structural flag both share. readonly isGround?: boolean; distance(target: vec2): number; } diff --git a/shared/src/serialization/deserialize.ts b/shared/src/serialization/deserialize.ts index d5d2418..d0513b6 100644 --- a/shared/src/serialization/deserialize.ts +++ b/shared/src/serialization/deserialize.ts @@ -1,14 +1,12 @@ import { serializableMapping } from './serializable-mapping'; -export const deserialize = (json: string): any => { - return JSON.parse(json, (_, v) => { - if (v !== null && Object.prototype.hasOwnProperty.call(v, '0')) { - const Ctor = serializableMapping.get(v[0]); +export const deserialize = (json: string): any => + JSON.parse(json, (_, value) => { + if (Array.isArray(value) && typeof value[0] === 'string') { + const Ctor = serializableMapping.get(value[0]); if (Ctor) { - return new Ctor(...v.slice(1)); + return new Ctor(...value.slice(1)); } - return v; } - return v; + return value; }); -}; diff --git a/shared/src/serialization/serializable-mapping.ts b/shared/src/serialization/serializable-mapping.ts index 2de8ad3..d741b31 100644 --- a/shared/src/serialization/serializable-mapping.ts +++ b/shared/src/serialization/serializable-mapping.ts @@ -1,9 +1,5 @@ export type SerializableClass = { new (...args: Array): unknown; name: string }; -// Wire format: the property the decorators stamp on and serialize() looks for -// to recognise a decorated class. Defined once on purpose — per-file copies -// would have to agree byte-for-byte, and a typo in one of them compiles and -// type-checks cleanly while every networked object degrades to a raw array. export const MANGLE = '__serializable_type'; export const serializableMapping = new Map(); diff --git a/shared/src/serialization/serializable.ts b/shared/src/serialization/serializable.ts index 79852f3..71952d3 100644 --- a/shared/src/serialization/serializable.ts +++ b/shared/src/serialization/serializable.ts @@ -1,12 +1,7 @@ import { MANGLE, SerializableClass, serializableMapping } from './serializable-mapping'; export const serializable = (type: SerializableClass): any => { - if (!serializableMapping.has(type.name)) { - serializableMapping.set(type.name, type); - } - - Object.defineProperty(type, MANGLE, { value: type.name }); + serializableMapping.set(type.name, type); Object.defineProperty(type.prototype, MANGLE, { value: type.name }); - return type; }; diff --git a/shared/src/serialization/serialize.ts b/shared/src/serialization/serialize.ts index 2a91848..6772ba5 100644 --- a/shared/src/serialization/serialize.ts +++ b/shared/src/serialization/serialize.ts @@ -1,12 +1,9 @@ import { MANGLE } from './serializable-mapping'; -export const serialize = (object: any): string => { - return JSON.stringify(object, (_, value) => { +export const serialize = (object: any): string => + JSON.stringify(object, (_, value) => { if (value && value[MANGLE]) { - const props = value.toArray() as Array; - props.unshift(value[MANGLE]); - return props; + return [value[MANGLE], ...value.toArray()]; } - return value?.toFixed ? Number(value.toFixed(3)) : value; + return typeof value === 'number' ? Math.round(value * 1000) / 1000 : value; }); -}; diff --git a/shared/src/settings.ts b/shared/src/settings.ts index 67f4aeb..1a6a0fb 100644 --- a/shared/src/settings.ts +++ b/shared/src/settings.ts @@ -7,8 +7,6 @@ const redColor = rgb255(209, 86, 82); const q = 2.5; const blueColorDim = rgb255(64 * q, 105 * q, 165 * q); const redColorDim = rgb255(209 * q, 86 * q, 82 * q); -const bluePlanetColor = blueColorDim; -const redPlanetColor = redColorDim; export const settings = { lightCutoffDistance: 600, @@ -30,8 +28,6 @@ export const settings = { maxGravityDistance: 800, minGravityDistance: 1, maxGravityQ: 5000, - // Neutral dead-band around 50% ownership. The single capture rule: flips, - // scoring and every client-side tint all read PlanetBase.team. planetControlThreshold: 0.12, playerMaxHealth: 100, maxGravityStrength: 50000, @@ -67,7 +63,7 @@ export const settings = { chargeShotSpeedMax: 3400, backgroundGradient: [rgb255(90, 38, 43), rgb255(43, 39, 73)], blueColor, - bluePlanetColor, + bluePlanetColor: blueColorDim, npcNames: [ 'Adam', 'Andrew', @@ -119,7 +115,7 @@ export const settings = { 'Wyatt', ], redColor, - redPlanetColor, + redPlanetColor: redColorDim, colorIndices: { [CharacterTeam.blue]: 0, [CharacterTeam.neutral]: 1, @@ -168,20 +164,14 @@ export const settings = { chargeShotRecoilMax: 650, - // DoS guard: parsing is synchronous with the physics loop, so one oversized - // message stalls the tick for everyone. maxInboundMessageBytes: 16 * 1024, - // Must NOT be the frame rate: per-render-frame sends exceed the inbound - // allowance on high-Hz displays, and dropped batches lose edge-triggered - // movement commands that are never re-sent. + // Must not be the frame rate: dropped batches lose edge-triggered movement commands. clientSendInterval: 1 / 30, maxInboundMessagesPerSecond: 240, maxInboundMessageBurst: 480, - // The nonce check makes the Pong reply unforgeable/single-use; this bounds - // what stalling one can buy. maxMeasuredRttMs: 1000, maxBufferedBytesPerClient: 256 * 1024, diff --git a/test/backend-regressions.test.ts b/test/backend-regressions.test.ts index e3b9640..9b3aadb 100644 --- a/test/backend-regressions.test.ts +++ b/test/backend-regressions.test.ts @@ -1,185 +1,150 @@ -// Regression net for backend defects — drives the real classes. import { describe, it, expect, beforeEach } from 'vitest'; import { createRequire } from 'node:module'; -import { PhysicalContainer } from '../backend/src/physics/containers/physical-container'; -import { BoundingBox } from '../backend/src/physics/bounding-boxes/bounding-box'; +import { PhysicalContainer } from '../backend/src/physics/physical-container'; +import { BoundingBox } from '../backend/src/physics/bounding-box'; import { ProjectilePhysical } from '../backend/src/objects/projectile-physical'; import { CharacterPhysical } from '../backend/src/objects/character-physical'; -import { StepCommand, GeneratePointsCommand } from '../backend/src/commands/commands'; const require = createRequire(import.meta.url); const shared = require('../shared/lib/main.js'); const { vec2 } = require('../shared/node_modules/gl-matrix'); -const { applyArrayPlugins, Random, settings, CharacterTeam } = shared; - -applyArrayPlugins(); +const { Random, settings, CharacterTeam } = shared; const step = settings.targetPhysicsDeltaTimeInSeconds; class PointsSpy { public blue = 0; public red = 0; - public handleCommand(command: unknown) { - if (command instanceof GeneratePointsCommand) { - this.blue += command.blue; - this.red += command.red; - } + public addPoints(blue: number, red: number) { + this.blue += blue; + this.red += red; } + public announce() {} } -const boxAround = (center: number[], radius: number) => { - const box = new BoundingBox(); - box.xMin = center[0] - radius; - box.xMax = center[0] + radius; - box.yMin = center[1] - radius; - box.yMax = center[1] + radius; - return box; +const makeWorld = () => { + const game = new PointsSpy(); + return { game, container: new PhysicalContainer(game) }; }; +const spawnProjectile = (container: PhysicalContainer) => + new ProjectilePhysical( + vec2.fromValues(0, 0), + 20, + 40, + CharacterTeam.blue, + vec2.fromValues(3000, 0), + { team: CharacterTeam.blue } as never, + container, + 0, + ); + +const spawnCharacter = (container: PhysicalContainer) => { + const character = new CharacterPhysical( + 'tester', + 0, + 0, + CharacterTeam.blue, + container, + vec2.fromValues(0, 0), + ); + container.addObject(character); + return character; +}; + +const projectilesIn = (container: PhysicalContainer) => + container + .findIntersecting(BoundingBox.ofCircle([0, 0], 5000)) + .filter((o) => o.gameObject instanceof ProjectilePhysical) + .map((o) => o.gameObject as ProjectilePhysical); + beforeEach(() => { Random.seed = 1; }); describe('projectile broadphase registration', () => { it('follows the projectile instead of staying at the muzzle', () => { - const container = new PhysicalContainer(); - container.initialize(); - - const projectile = new ProjectilePhysical( - vec2.fromValues(0, 0), - 20, - 40, - CharacterTeam.blue, - vec2.fromValues(3000, 0), - { team: CharacterTeam.blue } as never, - container, - 0, - ); + const { container } = makeWorld(); + const projectile = spawnProjectile(container); container.addObject(projectile); for (let i = 0; i < 40; i++) { - container.handleCommand(new StepCommand(step, new PointsSpy() as never)); + container.step(step); } expect(projectile.center[0]).toBeGreaterThan(400); - expect(container.findIntersecting(boxAround(projectile.center, 100))).toContain( + expect( + container.findIntersecting(BoundingBox.ofCircle(projectile.center, 100)), + ).toContain(projectile); + expect(container.findIntersecting(BoundingBox.ofCircle([0, 0], 100))).not.toContain( projectile, ); - expect(container.findIntersecting(boxAround([0, 0], 100))).not.toContain(projectile); }); it('spawns a shot just clear of the shooter, not a jump downrange', () => { - const container = new PhysicalContainer(); - container.initialize(); - const projectile = new ProjectilePhysical( - vec2.fromValues(0, 0), - 20, - 40, - CharacterTeam.blue, - vec2.fromValues(3000, 0), - { team: CharacterTeam.blue } as never, - container, - 0, - ); + const { container } = makeWorld(); + const projectile = spawnProjectile(container); expect(projectile.center[0]).toBeLessThanOrEqual(15); }); }); describe('scoring', () => { - const spawnCharacter = (container: PhysicalContainer) => { - const character = new CharacterPhysical( - 'tester', - 0, - 0, - CharacterTeam.blue, - container, - vec2.fromValues(0, 0), - ); - container.addObject(character); - return character; - }; - it('awards nothing for an administrative death', () => { - const container = new PhysicalContainer(); - container.initialize(); + const { game, container } = makeWorld(); const character = spawnCharacter(container); - const game = new PointsSpy(); - character.onDie(); // what PlayerBase.destroy() does on a socket drop - container.handleCommand(new StepCommand(step, game as never)); + character.onDie(); + container.step(step); expect(game.blue).toBe(0); expect(game.red).toBe(0); }); it('still awards a kill for a combat death', () => { - const container = new PhysicalContainer(); - container.initialize(); + const { game, container } = makeWorld(); const character = spawnCharacter(container); - const game = new PointsSpy(); character.onDie(true); - container.handleCommand(new StepCommand(step, game as never)); + container.step(step); expect(game.red).toBe(settings.playerKillPoint); expect(game.blue).toBe(0); }); + + it('awards a combat death only once', () => { + const { game, container } = makeWorld(); + const character = spawnCharacter(container); + + character.onDie(true); + character.onDie(true); + + expect(game.red).toBe(settings.playerKillPoint); + }); }); describe('charged shots', () => { it('scales the shot by the strength actually paid, not the requested charge', () => { - const container = new PhysicalContainer(); - container.initialize(); - const character = new CharacterPhysical( - 'tester', - 0, - 0, - CharacterTeam.blue, - container, - vec2.fromValues(0, 0), - ); - container.addObject(character); - + const { container } = makeWorld(); + const character = spawnCharacter(container); (character as unknown as { projectileStrength: number }).projectileStrength = settings.chargeShotStrengthMin; - const before = container.findIntersecting(boxAround([0, 0], 5000)).length; character.shootTowards(vec2.fromValues(1000, 0), 1); - const spawned = container - .findIntersecting(boxAround([0, 0], 5000)) - .filter((o) => o.gameObject instanceof ProjectilePhysical) - .map((o) => o.gameObject as ProjectilePhysical); + const [spawned] = projectilesIn(container); - expect(container.findIntersecting(boxAround([0, 0], 5000)).length).toBeGreaterThan( - before, - ); - expect(spawned[0].charge).toBeCloseTo(0, 5); - expect(spawned[0].radius).toBeCloseTo(settings.chargeShotRadiusMin, 5); + expect(spawned).toBeDefined(); + expect(spawned.charge).toBeCloseTo(0, 5); + expect(spawned.radius).toBeCloseTo(settings.chargeShotRadiusMin, 5); }); it('ignores a shot aimed at the shooter itself', () => { - const container = new PhysicalContainer(); - container.initialize(); - const character = new CharacterPhysical( - 'tester', - 0, - 0, - CharacterTeam.blue, - container, - vec2.fromValues(0, 0), - ); - container.addObject(character); + const { container } = makeWorld(); + const character = spawnCharacter(container); - const projectilesBefore = container - .findIntersecting(boxAround([0, 0], 5000)) - .filter((o) => o.gameObject instanceof ProjectilePhysical).length; character.shootTowards(vec2.clone(character.center), 1); - const projectilesAfter = container - .findIntersecting(boxAround([0, 0], 5000)) - .filter((o) => o.gameObject instanceof ProjectilePhysical).length; - expect(projectilesAfter).toBe(projectilesBefore); + expect(projectilesIn(container)).toHaveLength(0); }); }); diff --git a/test/backpressure.test.ts b/test/backpressure.test.ts index b3cccac..921bf88 100644 --- a/test/backpressure.test.ts +++ b/test/backpressure.test.ts @@ -1,15 +1,13 @@ -// Slow-client backpressure: what a Player sheds when its socket stops draining. import { describe, it, expect, beforeEach } from 'vitest'; import { createRequire } from 'node:module'; -import { PhysicalContainer } from '../backend/src/physics/containers/physical-container'; +import { PhysicalContainer } from '../backend/src/physics/physical-container'; import { PlayerContainer } from '../backend/src/players/player-container'; import { createWorld } from '../backend/src/create-world'; const require = createRequire(import.meta.url); const shared = require('../shared/lib/main.js'); const { - applyArrayPlugins, Random, settings, TransportEvents, @@ -21,9 +19,6 @@ const { ServerAnnouncement, } = shared; -applyArrayPlugins(); - -// Shaped like engine.io: the byte count lives on the ws socket under the transport. class FakeSocket { public readonly sent: Array<{ event: string; payload: unknown }> = []; public readonly conn = { transport: { socket: { bufferedAmount: 0 } } }; @@ -48,9 +43,8 @@ class FakeSocket { const makePlayer = () => { Random.seed = 3; - const objects = new PhysicalContainer(); + const objects = new PhysicalContainer({ addPoints() {}, announce() {} }); createWorld(objects); - objects.initialize(); const players = new PlayerContainer(objects, 4, 0); const socket = new FakeSocket(); const player = players.createPlayer({ name: 'player' }, socket as never); diff --git a/test/client-send-cadence.test.ts b/test/client-send-cadence.test.ts index 4093ff2..b94ffce 100644 --- a/test/client-send-cadence.test.ts +++ b/test/client-send-cadence.test.ts @@ -1,4 +1,3 @@ -// Client outbound send cadence vs. server inbound allowance. import { describe, it, expect } from 'vitest'; import { createRequire } from 'node:module'; @@ -8,9 +7,7 @@ import { setFrameTimeMs } from '../frontend/src/scripts/helper/prediction/local- const require = createRequire(import.meta.url); const shared = require('../shared/lib/main.js'); const { vec2 } = require('../shared/node_modules/gl-matrix'); -const { applyArrayPlugins, settings, MoveActionCommand, ClientHeartbeatCommand } = shared; - -applyArrayPlugins(); +const { settings, MoveActionCommand, ClientHeartbeatCommand } = shared; const drive = ( frameRate: number, @@ -57,7 +54,7 @@ describe('client send cadence', () => { let sentOn = -1; const batches = drive(144, 0.1, (commandSocket) => { if (sentOn === -1) { - commandSocket.handleCommand(new MoveActionCommand(vec2.fromValues(1, 0), 0)); + commandSocket.queue(new MoveActionCommand(vec2.fromValues(1, 0), 0)); sentOn = 0; } }); @@ -73,15 +70,12 @@ describe('client send cadence', () => { expect(batches.every((b) => b.includes(ClientHeartbeatCommand.name))).toBe(true); }); - // socket.io-client buffers emits made while disconnected in an uncapped - // sendBuffer and flushes them on reconnect, ahead of the re-join — so every - // one of them is both a leak and wasted bandwidth. it('sends nothing at all while the transport is down', () => { const batches = drive( 60, 30, (commandSocket) => - commandSocket.handleCommand(new MoveActionCommand(vec2.fromValues(1, 0), 0)), + commandSocket.queue(new MoveActionCommand(vec2.fromValues(1, 0), 0)), false, ); diff --git a/test/connection-lifecycle.test.ts b/test/connection-lifecycle.test.ts index 06cb90d..5ec9dbc 100644 --- a/test/connection-lifecycle.test.ts +++ b/test/connection-lifecycle.test.ts @@ -1,4 +1,3 @@ -// Connection lifecycle: join guard, refusal reasons, reconnect — driven through a fake socket.io. import { describe, it, expect, beforeEach, vi } from 'vitest'; import { createRequire } from 'node:module'; @@ -7,10 +6,7 @@ import { defaultOptions } from '../backend/src/options'; const require = createRequire(import.meta.url); const shared = require('../shared/lib/main.js'); -const { applyArrayPlugins, Random, TransportEvents, JoinRejectionReason, settings } = - shared; - -applyArrayPlugins(); +const { Random, TransportEvents, JoinRejectionReason, settings } = shared; class FakeSocket { public readonly sent: Array<{ event: string; payload: unknown }> = []; @@ -40,7 +36,6 @@ class FakeSocket { this.disconnected = true; } - /** Number of listeners the server has attached for an event. */ public listenerCount(event: string): number { return (this.handlers.get(event) ?? []).length; } @@ -142,9 +137,6 @@ describe('hostile payloads', () => { expect(server.serverInfo.playerCount).toBe(4); }); - // socket.io dispatches listeners with no try/catch of its own, so anything - // thrown out of a connection-scoped handler is an uncaught exception: the - // process exits and every player in the match is dropped with it. it.each([ ['no payload at all', undefined], ['null', null], @@ -190,7 +182,6 @@ describe('reconnect', () => { expect(server.serverInfo.playerCount).toBe(1); expect(second.disconnected).toBe(false); expect(second.lastPayloadFor(TransportEvents.JoinRejected)).toBeUndefined(); - // Fresh token: the old one cannot be replayed. expect(second.lastPayloadFor(TransportEvents.PlayerJoined)).not.toBe(token); }); @@ -251,9 +242,6 @@ describe('inbound rate limiting', () => { }); describe('pre-join rate limiting', () => { - // These listeners exist before any join, so the limiter inside - // onPlayerToServer never sees them: unmetered, each one is a reply or an - // allocation an unauthenticated client can ask for at line rate. it('clamps a flood of joins on an already-joined connection', () => { const { attach } = startServer({ playerLimit: 4, npcCount: 0 }); const socket = attach(); @@ -312,7 +300,6 @@ describe('reconnect tokens', () => { }); describe('reconnect while the dropped socket is still live', () => { - // engine.io needs ~45 s to notice a transport drop; the client is back in ~1 s. it('retires the ghost so the returning player keeps its slot, team and score', () => { const { server, attach } = startServer({ playerLimit: 1, npcCount: 0 }); @@ -321,7 +308,6 @@ describe('reconnect while the dropped socket is still live', () => { const token = first.lastPayloadFor(TransportEvents.PlayerJoined) as string; expect(server.serverInfo.playerCount).toBe(1); - // No 'disconnect' fired: the server still holds the dead socket's player. const second = attach(); second.fire(TransportEvents.PlayerJoining, { name: 'flaky', reconnectToken: token }); diff --git a/test/physics-determinism.test.mjs b/test/physics-determinism.test.mjs index a4409c9..bbb36f6 100644 --- a/test/physics-determinism.test.mjs +++ b/test/physics-determinism.test.mjs @@ -1,10 +1,7 @@ -// Determinism guard: client predictor and server run the same stepCharacterMovement. import { describe, it, expect } from 'vitest'; import { createRequire } from 'node:module'; const require = createRequire(import.meta.url); -// Numerics match production because test/setup.mjs has already applied -// glMatrix.setMatrixArrayType(Array) — without it this would pin f32. const shared = require('../shared/lib/main.js'); const { vec2 } = require('../shared/node_modules/gl-matrix'); @@ -12,8 +9,6 @@ const { stepCharacterMovement, tickPlanetDetachment, resolveCircleMovement, - planetDistance, - planetGravity, PlanetBase, Random, headRadius, @@ -23,6 +18,8 @@ const { rightFootOffset, } = shared; +const stepSeconds = 1 / 200; + const makeBody = (center, radius) => ({ center: vec2.clone(center), radius, @@ -31,113 +28,73 @@ const makeBody = (center, radius) => ({ restitution: 0, }); -// Free-space world (no planets): exercises the deterministic core without planet SDF geometry. -const emptyWorld = { - groundsNear: () => [], +const makeState = (start) => ({ + head: makeBody(vec2.add(vec2.create(), start, headOffset), headRadius), + leftFoot: makeBody(vec2.add(vec2.create(), start, leftFootOffset), feetRadius), + rightFoot: makeBody(vec2.add(vec2.create(), start, rightFootOffset), feetRadius), + direction: 0, + currentPlanet: undefined, + secondsSinceOnSurface: 1, + bodyVelocity: vec2.create(), +}); + +const makeWorld = (grounds) => ({ + groundsNear: () => grounds, stepBody: (body, dt) => { - resolveCircleMovement(body, dt, []); - return undefined; + const hit = resolveCircleMovement(body, dt, grounds); + return hit && hit.isGround ? hit : undefined; }, -}; +}); -const stepSeconds = 1 / 200; - -// A spinning planet, built the way create-world builds one, so the SDF/gravity/on-surface branches have a pin too. -const makeSpinningPlanetWorld = () => { - Random.seed = 20; - const vertices = PlanetBase.createPlanetVertices( - vec2.fromValues(0, -900), - 1700, - 1900, - 70, - ); - const base = new PlanetBase(1, vertices); - let rotation = 0; - const surface = { - canCollide: true, - isGround: true, - center: base.center, - angularVelocity: 0.09, - advance: (dt) => (rotation += surface.angularVelocity * dt), - distance: (target) => - planetDistance( - target, - vertices, - base.center, - Math.cos(rotation), - Math.sin(rotation), - ), - gravityAt: (target) => planetGravity(base.center, base.radius, target), - }; - const grounds = [surface]; - return { - surface, - world: { - groundsNear: () => grounds, - stepBody: (body, dt) => { - const { hitObject } = resolveCircleMovement(body, dt, grounds); - return hitObject && hitObject.isGround ? hitObject : undefined; - }, - }, - }; -}; - -const runPlanetSimulation = () => { - const { surface, world } = makeSpinningPlanetWorld(); - const start = vec2.fromValues(0, 250); - const state = { - head: makeBody(vec2.add(vec2.create(), start, headOffset), headRadius), - leftFoot: makeBody(vec2.add(vec2.create(), start, leftFootOffset), feetRadius), - rightFoot: makeBody(vec2.add(vec2.create(), start, rightFootOffset), feetRadius), - direction: 0, - currentPlanet: undefined, - secondsSinceOnSurface: 1, - bodyVelocity: vec2.create(), - }; - - for (let i = 0; i < 900; i++) { - // Fall in, then walk right, then left. - const input = i < 200 ? vec2.create() : vec2.fromValues(i < 600 ? 1 : -1, 0); - tickPlanetDetachment(state, stepSeconds); - stepCharacterMovement(state, world, input, stepSeconds); - surface.advance(stepSeconds); - } - - const round = (v) => Math.round(v * 1000) / 1000; - return JSON.stringify({ - head: [round(state.head.center[0]), round(state.head.center[1])], - leftFoot: [round(state.leftFoot.center[0]), round(state.leftFoot.center[1])], - rightFoot: [round(state.rightFoot.center[0]), round(state.rightFoot.center[1])], - direction: round(state.direction), - onSurface: !!state.currentPlanet, - secondsSinceOnSurface: round(state.secondsSinceOnSurface), - }); -}; +const round = (v) => Math.round(v * 1000) / 1000; +const roundVec = (v) => [round(v[0]), round(v[1])]; const runSimulation = () => { - const start = vec2.fromValues(100, 100); - const state = { - head: makeBody(vec2.add(vec2.create(), start, headOffset), headRadius), - leftFoot: makeBody(vec2.add(vec2.create(), start, leftFootOffset), feetRadius), - rightFoot: makeBody(vec2.add(vec2.create(), start, rightFootOffset), feetRadius), - direction: 0, - currentPlanet: undefined, - secondsSinceOnSurface: 1, - bodyVelocity: vec2.create(), - }; + const state = makeState(vec2.fromValues(100, 100)); + const world = makeWorld([]); for (let i = 0; i < 300; i++) { const angle = i * 0.1; - const input = vec2.fromValues(Math.cos(angle), Math.sin(angle)); - stepCharacterMovement(state, emptyWorld, input, stepSeconds); + stepCharacterMovement( + state, + world, + vec2.fromValues(Math.cos(angle), Math.sin(angle)), + stepSeconds, + ); } - const round = (v) => Math.round(v * 1000) / 1000; return JSON.stringify({ - head: [round(state.head.center[0]), round(state.head.center[1])], - leftFoot: [round(state.leftFoot.center[0]), round(state.leftFoot.center[1])], - rightFoot: [round(state.rightFoot.center[0]), round(state.rightFoot.center[1])], - bodyVelocity: [round(state.bodyVelocity[0]), round(state.bodyVelocity[1])], + head: roundVec(state.head.center), + leftFoot: roundVec(state.leftFoot.center), + rightFoot: roundVec(state.rightFoot.center), + bodyVelocity: roundVec(state.bodyVelocity), + }); +}; + +const runPlanetSimulation = () => { + Random.seed = 20; + const planet = new PlanetBase( + 1, + PlanetBase.createPlanetVertices(vec2.fromValues(0, -900), 1700, 1900, 70), + ); + planet.angularVelocity = 0.09; + const world = makeWorld([planet]); + const state = makeState(vec2.fromValues(0, 250)); + + for (let i = 0; i < 900; i++) { + const input = i < 200 ? vec2.create() : vec2.fromValues(i < 600 ? 1 : -1, 0); + tickPlanetDetachment(state, stepSeconds); + stepCharacterMovement(state, world, input, stepSeconds); + planet.advanceRotation(stepSeconds); + } + + return JSON.stringify({ + head: roundVec(state.head.center), + leftFoot: roundVec(state.leftFoot.center), + rightFoot: roundVec(state.rightFoot.center), + direction: round(state.direction), + onSurface: !!state.currentPlanet, + secondsSinceOnSurface: round(state.secondsSinceOnSurface), }); }; @@ -147,7 +104,6 @@ describe('shared character simulation determinism', () => { }); it('matches the pinned reference pose (changes only with intentional physics edits)', () => { - // Regression pin — update deliberately when physics behaviour changes. expect(runSimulation()).toMatchSnapshot(); }); diff --git a/test/prediction-reconciliation.test.ts b/test/prediction-reconciliation.test.ts index efd883a..8ed2ba9 100644 --- a/test/prediction-reconciliation.test.ts +++ b/test/prediction-reconciliation.test.ts @@ -1,4 +1,3 @@ -// Reconciliation guard for the client predictor: acknowledged pose, deterministic replay, replay-window behaviour. import { describe, it, expect } from 'vitest'; import { LocalCharacterPredictor, @@ -95,7 +94,6 @@ describe('local prediction reconciliation', () => { predictor.setAlive(false); setFrameTimeMs(5200); - // Regression: a dead body must not be predicted/moved. expect(predictor.update([], 1 / 60)).toBe(false); }); @@ -157,7 +155,6 @@ describe('local prediction reconciliation', () => { }); it('replays the streamed facing direction, not one guessed from the pose', () => { - // Head trails 35 units behind: atan2 gives ~0.57 rad, server direction is 0. const walkingPose = () => ({ head: { center: [-35, 37], radius: HEAD_RADIUS }, leftFoot: { center: [-33, -18], radius: FEET_RADIUS }, diff --git a/test/serialization.test.mjs b/test/serialization.test.mjs index 1260fdb..5f85482 100644 --- a/test/serialization.test.mjs +++ b/test/serialization.test.mjs @@ -1,4 +1,3 @@ -// Round-trip tests for the wire serializer, exercising the BUILT shared bundle. import { describe, it, expect } from 'vitest'; import { createRequire } from 'node:module'; @@ -13,12 +12,10 @@ const { MoveActionCommand, UpdatePropertyCommand, PropertyUpdatesForObject, - // networked entity bases — their toArray() must mirror their constructor order CharacterBase, PlanetBase, ProjectileBase, LampBase, - // geometry — single source of truth shared by server & client prediction headRadius, feetRadius, boundRadius, @@ -37,7 +34,6 @@ describe('serialization round-trip (built shared lib)', () => { it('round-trips a Circle and rounds floats to 3 decimals', () => { const out = deserialize(serialize(new Circle([12.34567, -7.1], 5.43219))); expect(out).toBeInstanceOf(Circle); - // serialize.ts rounds every float via toFixed(3) expect(out.center[0]).toBeCloseTo(12.346, 6); expect(out.center[1]).toBeCloseTo(-7.1, 6); expect(out.radius).toBeCloseTo(5.432, 6); @@ -64,7 +60,6 @@ describe('serialization round-trip (built shared lib)', () => { }); it('preserves per-item class identity across a mixed batch', () => { - // A clobbered name→constructor mapping surfaces as the wrong class. const batch = [ new ServerAnnouncement('a'), new MoveActionCommand([0, 1], 5), @@ -127,14 +122,25 @@ describe('networked entity round-trips (toArray ↔ constructor contract)', () = it('round-trips a PlanetBase (derived centre/radius recomputed from vertices)', () => { const out = deserialize( - serialize(new PlanetBase(10, [[0, 0], [100, 0], [50, 100]], 0.7, true)), + serialize( + new PlanetBase( + 10, + [ + [0, 0], + [100, 0], + [50, 100], + ], + 0.7, + true, + ), + ), ); expect(out).toBeInstanceOf(PlanetBase); expect(out.id).toBe(10); expect(out.vertices).toHaveLength(3); expect(out.ownership).toBeCloseTo(0.7, 5); expect(out.isKeystone).toBe(true); - expect(out.center[0]).toBeCloseTo(50, 5); // recomputed by the constructor + expect(out.center[0]).toBeCloseTo(50, 5); }); }); @@ -142,7 +148,7 @@ describe('shared character geometry — single source of truth', () => { it('matches the known body layout', () => { expect(headRadius).toBe(50); expect(feetRadius).toBe(20); - expect(boundRadius).toBe((headRadius + feetRadius * 2) * 2); // 180 + expect(boundRadius).toBe((headRadius + feetRadius * 2) * 2); }); it('posture offsets are measured from the centre of mass (they sum to ~0)', () => { diff --git a/test/setup.mjs b/test/setup.mjs index fb99774..d55aa06 100644 --- a/test/setup.mjs +++ b/test/setup.mjs @@ -1,12 +1,4 @@ -// Production calls glMatrix.setMatrixArrayType(Array) before anything else -// (backend/src/main.ts, frontend/src/index.ts), which makes every vector an f64 -// Array instead of the default Float32Array. Without the same call here the -// suite pins the numerics of a build nobody ships — including module-level -// scratch vectors, which are allocated the moment a module is imported. -// -// One call per installed gl-matrix: the prebuilt shared bundle keeps gl-matrix -// external and resolves shared/node_modules, while the backend and frontend -// sources the tests import directly resolve their own copies. +// Production makes every vector an f64 Array before any module-level vector is allocated. import { createRequire } from 'node:module'; const require = createRequire(import.meta.url); diff --git a/vitest.config.ts b/vitest.config.ts index 086f2a0..4bf89fe 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,8 +2,6 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { - // Runs before any test module is imported, which matters: module-level - // vectors are allocated at import time and cannot be converted afterwards. setupFiles: ['./test/setup.mjs'], }, }); -- 2.54.0 From 86e97df0dca2a83c7b8b3454dc71ee640cc97115 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Wed, 26 Aug 2026 07:47:30 +0100 Subject: [PATCH 08/15] , --- backend/src/game-server.ts | 8 +- backend/src/players/player-container.ts | 3 +- backend/src/players/player.ts | 16 +- frontend/src/main.scss | 2 - frontend/src/scripts/game.ts | 3 + .../prediction/local-character-predictor.ts | 62 ++-- frontend/src/scripts/minimap.ts | 9 +- .../scripts/objects/game-object-container.ts | 3 +- frontend/src/scripts/objects/types/camera.ts | 12 +- shared/src/helper/ease.ts | 19 ++ shared/src/physics/character-movement.ts | 24 +- test/render-smoothness.test.ts | 288 ++++++++++++++++++ 12 files changed, 414 insertions(+), 35 deletions(-) create mode 100644 test/render-smoothness.test.ts diff --git a/backend/src/game-server.ts b/backend/src/game-server.ts index 9bfac97..7c081c6 100644 --- a/backend/src/game-server.ts +++ b/backend/src/game-server.ts @@ -301,6 +301,7 @@ export class GameServer implements GameEvents { private handlePhysics() { const frameStart = process.hrtime.bigint(); + const frameStartMs = performance.now(); const delta = Number(frameStart - this.lastPhysicsBase) / 1e9; this.lastPhysicsBase = frameStart; @@ -348,8 +349,13 @@ export class GameServer implements GameEvents { this.players.step(scaledDelta); } + // Physics has only advanced to the start of this frame less whatever is still in the + // accumulator. Timing anything off the send time instead would put up to one physics step + // of noise on every interpolated position and on the client's replay anchor. + const simulatedThroughMs = frameStartMs - this.physicsAccumulator * 1000; + const propertyUpdates = new Map(); - this.players.stepCommunication(delta, (object) => { + this.players.stepCommunication(delta, simulatedThroughMs, (object) => { if (!propertyUpdates.has(object)) { propertyUpdates.set(object, object.getPropertyUpdates()); } diff --git a/backend/src/players/player-container.ts b/backend/src/players/player-container.ts index 8fc8b2f..d82497f 100644 --- a/backend/src/players/player-container.ts +++ b/backend/src/players/player-container.ts @@ -82,10 +82,11 @@ export class PlayerContainer { public stepCommunication( deltaTimeInSeconds: number, + simulatedThroughMs: number, propertyUpdatesOf: (object: GameObject) => PropertyUpdatesForObject | undefined, ) { this._players.forEach((p) => - p.stepCommunications(deltaTimeInSeconds, propertyUpdatesOf), + p.stepCommunications(deltaTimeInSeconds, simulatedThroughMs, propertyUpdatesOf), ); } diff --git a/backend/src/players/player.ts b/backend/src/players/player.ts index c4678c7..baa9f1a 100644 --- a/backend/src/players/player.ts +++ b/backend/src/players/player.ts @@ -160,6 +160,7 @@ export class Player extends PlayerBase { public stepCommunications( deltaTime: number, + simulatedThroughMs: number, propertyUpdatesOf: (object: GameObject) => PropertyUpdatesForObject | undefined, ) { const remoteCalls: Array = []; @@ -180,15 +181,20 @@ export class Player extends PlayerBase { this.socket.emit(TransportEvents.Ping, this.pendingPingNonce); } + // Subtracted, not zeroed: zeroing would stretch every interval to the next physics frame. if ((this.timeSinceLastMessage += deltaTime) > settings.updateMessageInterval) { - this.timeSinceLastMessage = 0; + this.timeSinceLastMessage = Math.min( + this.timeSinceLastMessage - settings.updateMessageInterval, + settings.updateMessageInterval, + ); this.queueAnnouncement(); - this.queueSnapshot(propertyUpdatesOf); + this.queueSnapshot(simulatedThroughMs, propertyUpdatesOf); this.sendQueuedCommandsToClient(); } } private queueSnapshot( + simulatedThroughMs: number, propertyUpdatesOf: (object: GameObject) => PropertyUpdatesForObject | undefined, ) { const { topLeft, size } = calculateViewArea(this.center, this.aspectRatio, 1.2); @@ -227,7 +233,7 @@ export class Player extends PlayerBase { } } this.queueCommandSend( - new PropertyUpdatesForObjects(propertyUpdates, performance.now() / 1000), + new PropertyUpdatesForObjects(propertyUpdates, simulatedThroughMs / 1000), ); if (this.character) { @@ -236,8 +242,10 @@ export class Player extends PlayerBase { this.lastInputClientTimeMs, this.character.movementSnapshot, this.lastLeapClientTimeMs, + // Aged against the instant the pose is from, not the send time: the difference is + // the un-simulated remainder, and it would land straight in the client's replay. this.lastInputReceiptMs > 0 - ? Math.max(0, performance.now() - this.lastInputReceiptMs) + ? Math.max(0, simulatedThroughMs - this.lastInputReceiptMs) : 0, ), ); diff --git a/frontend/src/main.scss b/frontend/src/main.scss index 0009666..021ab71 100644 --- a/frontend/src/main.scss +++ b/frontend/src/main.scss @@ -127,7 +127,6 @@ body { .player-tag { border-radius: 1000px; - transition: transform 200ms; &.blue { color: $bright-blue; @@ -255,7 +254,6 @@ body { // Off-screen pointer to the keystone "Heart", tinted by its current owner. .keystone-arrow { - transition: transform 150ms; opacity: 0.9; @include square($large-icon); diff --git a/frontend/src/scripts/game.ts b/frontend/src/scripts/game.ts index 834757d..d4bc384 100644 --- a/frontend/src/scripts/game.ts +++ b/frontend/src/scripts/game.ts @@ -347,6 +347,9 @@ export class Game extends CommandReceiver { } private draw() { + // Before any style writes this frame, so the read cannot force a synchronous layout. + this.minimap.measure(); + if (this.lastGameState) { this.scoreboard.update(this.lastGameState, this.gameObjects.localPlayer?.team); } diff --git a/frontend/src/scripts/helper/prediction/local-character-predictor.ts b/frontend/src/scripts/helper/prediction/local-character-predictor.ts index f437042..6e52ae5 100644 --- a/frontend/src/scripts/helper/prediction/local-character-predictor.ts +++ b/frontend/src/scripts/helper/prediction/local-character-predictor.ts @@ -5,8 +5,7 @@ import { CharacterMovementState, PhysicsBody, settings, - clamp, - easeVec2, + followVec2, stepCharacterMovement, applyLeapImpulse, tickPlanetDetachment, @@ -24,10 +23,12 @@ let frameTimeMs = 0; export const setFrameTimeMs = (timeMs: number): void => { frameTimeMs = timeMs; }; -export const clientTimeMs = (): number => Math.round(frameTimeMs); +export const clientTimeMs = (): number => frameTimeMs; const maxReplayMs = 400; +// Only a correction has to fade; the character's own motion is carried straight through, so this +// buys smoothing without costing responsiveness. const smoothSeconds = 0.06; const snapDistance = 250; @@ -58,6 +59,7 @@ export class LocalCharacterPredictor { private renderHead = new Circle(vec2.create(), headRadius); private renderLeftFoot = new Circle(vec2.create(), feetRadius); private renderRightFoot = new Circle(vec2.create(), feetRadius); + private previousTargets = [vec2.create(), vec2.create(), vec2.create()]; private hasRender = false; public get head(): Circle { @@ -152,7 +154,7 @@ export class LocalCharacterPredictor { this.snapRenderTo(predicted); this.hasRender = true; } else { - this.easeRenderTo(predicted, frameSeconds); + this.followRenderTo(predicted, frameSeconds); } return true; } @@ -161,10 +163,10 @@ export class LocalCharacterPredictor { const auth = this.authoritative!; const movement = this.movement!; const now = clientTimeMs(); - const startMs = clamp(this.replayAnchorMs!, now - maxReplayMs, now); + const startMs = Math.max(this.replayAnchorMs!, now - maxReplayMs); const windowMs = Math.max(0, now - startMs); const steps = Math.floor(windowMs / stepMs); - const remainderSeconds = (windowMs - steps * stepMs) / 1000; + const partialStep = (windowMs - steps * stepMs) / stepMs; const state: CharacterMovementState = { head: makeBody(auth.head.center, auth.head.radius, upwards), @@ -191,9 +193,10 @@ export class LocalCharacterPredictor { let t = startMs; for (let i = 0; i < steps; i++) { const input = this.inputHistory.directionAt(t); + // Planets first: the server's container holds them ahead of every character. + this.world.advance(stepSeconds); tickPlanetDetachment(state, stepSeconds); stepCharacterMovement(state, this.world, input, stepSeconds); - this.world.advance(stepSeconds); for (const leapMs of this.leapHistory) { if ( @@ -213,21 +216,32 @@ export class LocalCharacterPredictor { t += stepMs; } - if (remainderSeconds > 0) { - tickPlanetDetachment(state, remainderSeconds); + // A shorter final step would not integrate like the server's fixed one, so take a whole + // step and read the pose part-way along it instead. + if (partialStep > 0) { + const from = [state.head, state.leftFoot, state.rightFoot].map((b) => + vec2.clone(b.center), + ); + this.world.advance(stepSeconds); + tickPlanetDetachment(state, stepSeconds); stepCharacterMovement( state, this.world, - this.inputHistory.directionAt(now), - remainderSeconds, + this.inputHistory.directionAt(t), + stepSeconds, + ); + [state.head, state.leftFoot, state.rightFoot].forEach((body, i) => + vec2.lerp(body.center, from[i], body.center, partialStep), ); - this.world.advance(remainderSeconds); } return state; } private snapRenderTo(state: CharacterMovementState): void { + this.previousTargets = [state.head, state.leftFoot, state.rightFoot].map((b) => + vec2.clone(b.center), + ); this.renderHead = new Circle(vec2.clone(state.head.center), state.head.radius); this.renderLeftFoot = new Circle( vec2.clone(state.leftFoot.center), @@ -239,14 +253,26 @@ export class LocalCharacterPredictor { ); } - private easeRenderTo(state: CharacterMovementState, frameSeconds: number): void { - this.easePart(this.renderHead, state.head, frameSeconds); - this.easePart(this.renderLeftFoot, state.leftFoot, frameSeconds); - this.easePart(this.renderRightFoot, state.rightFoot, frameSeconds); + private followRenderTo(state: CharacterMovementState, frameSeconds: number): void { + this.followPart(this.renderHead, state.head, 0, frameSeconds); + this.followPart(this.renderLeftFoot, state.leftFoot, 1, frameSeconds); + this.followPart(this.renderRightFoot, state.rightFoot, 2, frameSeconds); } - private easePart(render: Circle, target: PhysicsBody, frameSeconds: number): void { - easeVec2(render.center, target.center, frameSeconds, smoothSeconds, snapDistance); + private followPart( + render: Circle, + target: PhysicsBody, + index: number, + frameSeconds: number, + ): void { + followVec2( + render.center, + target.center, + this.previousTargets[index], + frameSeconds, + smoothSeconds, + snapDistance, + ); render.radius = target.radius; } } diff --git a/frontend/src/scripts/minimap.ts b/frontend/src/scripts/minimap.ts index 7074738..a9187b2 100644 --- a/frontend/src/scripts/minimap.ts +++ b/frontend/src/scripts/minimap.ts @@ -8,6 +8,7 @@ export class Minimap { private readonly colors: Record; private readonly smoothed = new Map(); private bufferSize = 0; + private size = 0; constructor() { this.element.className = 'minimap'; @@ -23,8 +24,14 @@ export class Minimap { }; } + // Reading clientWidth after this frame's style writes would force a synchronous layout of + // the whole overlay inside the animation frame; the size only changes when the window does. + public measure() { + this.size = this.element.clientWidth; + } + public update(localPosition: vec2 | undefined, players: Array) { - const size = this.element.clientWidth; + const size = this.size; if (size === 0) { return; } diff --git a/frontend/src/scripts/objects/game-object-container.ts b/frontend/src/scripts/objects/game-object-container.ts index 69f03db..be522da 100644 --- a/frontend/src/scripts/objects/game-object-container.ts +++ b/frontend/src/scripts/objects/game-object-container.ts @@ -119,12 +119,13 @@ export class GameObjectContainer extends CommandReceiver { } public render(renderer: Renderer, overlay: HTMLElement, shouldChangeLayout: boolean) { + // First: everything below projects world coordinates through this frame's view area. + this.camera.draw(renderer); this.objects.forEach((o) => { if (this.isVisible(o.id)) { o.render(renderer, overlay, shouldChangeLayout); } }); - this.camera.draw(renderer); } private isVisible(id: Id): boolean { diff --git a/frontend/src/scripts/objects/types/camera.ts b/frontend/src/scripts/objects/types/camera.ts index b39e10e..ea8ee4d 100644 --- a/frontend/src/scripts/objects/types/camera.ts +++ b/frontend/src/scripts/objects/types/camera.ts @@ -1,6 +1,6 @@ import { vec2 } from 'gl-matrix'; import { Renderer } from 'sdf-2d'; -import { calculateViewArea, easeVec2, mixRgb, settings } from 'shared'; +import { calculateViewArea, followVec2, mixRgb, settings } from 'shared'; import { Game } from '../../game'; import { ScreenShake } from '../../screen-shake'; @@ -9,12 +9,20 @@ const snapDistance = 1500; export class Camera { public readonly center: vec2 = vec2.create(); + private readonly previousTarget = vec2.create(); private aspectRatio?: number; constructor(private readonly game: Game) {} public follow(target: vec2, deltaTimeInSeconds: number) { - easeVec2(this.center, target, deltaTimeInSeconds, followSeconds, snapDistance); + followVec2( + this.center, + target, + this.previousTarget, + deltaTimeInSeconds, + followSeconds, + snapDistance, + ); } public draw(renderer: Renderer) { diff --git a/shared/src/helper/ease.ts b/shared/src/helper/ease.ts index 13f9f78..789ddc0 100644 --- a/shared/src/helper/ease.ts +++ b/shared/src/helper/ease.ts @@ -13,3 +13,22 @@ export const easeVec2 = ( vec2.distance(out, target) > snapDistance ? vec2.copy(out, target) : vec2.lerp(out, out, target, smoothing(deltaTimeInSeconds, seconds)); + +// Follows a *moving* target. `easeVec2` alone lags one by an amount that depends on the frame +// duration, so an uneven frame — or a dropped one — changes the lag and the follower appears to +// speed up or stall. Carrying the target's own displacement first leaves only the residual to +// decay: a target at a steady speed is tracked exactly, whatever the frame times do, and a +// correction still fades out over `seconds`. +export const followVec2 = ( + out: vec2, + target: vec2, + previousTarget: vec2, + deltaTimeInSeconds: number, + seconds: number, + snapDistance: number, +): vec2 => { + out[0] += target[0] - previousTarget[0]; + out[1] += target[1] - previousTarget[1]; + vec2.copy(previousTarget, target); + return easeVec2(out, target, deltaTimeInSeconds, seconds, snapDistance); +}; diff --git a/shared/src/physics/character-movement.ts b/shared/src/physics/character-movement.ts index 1098478..81c2b08 100644 --- a/shared/src/physics/character-movement.ts +++ b/shared/src/physics/character-movement.ts @@ -2,6 +2,7 @@ import { vec2 } from 'gl-matrix'; import { settings } from '../settings'; import { GroundSurface, PhysicsBody } from './sdf'; import { interpolateAngles } from './interpolate-angles'; +import { smoothing } from '../helper/ease'; export const headRadius = 50; export const feetRadius = 20; @@ -69,11 +70,20 @@ export const characterCenter = ( return vec2.scale(center, center, 1 / 3); }; -const setDirection = (state: CharacterMovementState, direction: vec2) => { +// Tuned so one physics step still turns the same 20% it always did, but a replay that splits +// or merges steps now lands on the same facing instead of one that depends on the step count. +const directionSmoothingSeconds = + settings.targetPhysicsDeltaTimeInSeconds / Math.log(1 / (1 - 0.2)); + +const setDirection = ( + state: CharacterMovementState, + direction: vec2, + deltaTimeInSeconds: number, +) => { state.direction = interpolateAngles( state.direction, Math.atan2(direction[1], direction[0]) + Math.PI / 2, - 0.2, + smoothing(deltaTimeInSeconds, directionSmoothingSeconds), ); }; @@ -255,7 +265,7 @@ export const stepCharacterMovement = ( vec2.zero(state.rightFoot.velocity); const movementForce = vec2.scale( - inputDirection, + vec2.create(), inputDirection, settings.maxAcceleration, ); @@ -272,7 +282,11 @@ export const stepCharacterMovement = ( applyForce(state.rightFoot, rightFootGravity, deltaTimeInSeconds); const sumForce = vec2.subtract(vec2.create(), leftFootGravity, movementForce); - setDirection(state, vec2.length(sumForce) === 0 ? vec2.fromValues(0, -1) : sumForce); + setDirection( + state, + vec2.length(sumForce) === 0 ? vec2.fromValues(0, -1) : sumForce, + deltaTimeInSeconds, + ); } else { carryWithRotatingPlanet(state, deltaTimeInSeconds); @@ -310,7 +324,7 @@ export const stepCharacterMovement = ( if (vec2.length(gravity) <= settings.planetDetachmentForceThreshold) { state.currentPlanet = undefined; } - setDirection(state, gravity); + setDirection(state, gravity, deltaTimeInSeconds); } keepPosture(state); diff --git a/test/render-smoothness.test.ts b/test/render-smoothness.test.ts new file mode 100644 index 0000000..299a4f4 --- /dev/null +++ b/test/render-smoothness.test.ts @@ -0,0 +1,288 @@ +import { describe, it, expect } from 'vitest'; +import { + LocalCharacterPredictor, + setFrameTimeMs, +} from '../frontend/src/scripts/helper/prediction/local-character-predictor'; +import { ClientCharacterWorld } from '../frontend/src/scripts/helper/prediction/client-character-world'; +import { + stepCharacterMovement, + tickPlanetDetachment, + headRadius, + feetRadius, +} from '../shared/src/physics/character-movement'; +import { followVec2 } from '../shared/src/helper/ease'; +import { settings } from '../shared/src/settings'; + +const stepSeconds = settings.targetPhysicsDeltaTimeInSeconds; +const snapshotMs = settings.updateMessageInterval * 1000; +const heartbeatMs = settings.clientSendInterval * 1000; +const planetRadius = 800; + +// The client's frame clock is rebased to its first frame (sdf-2d passes currentTime - startTime), +// so it shares no origin with the server's clock. Anything that only works when the two happen to +// agree is not a fix. +const clientClockOffsetMs = 987_654.321; + +const vertices = Array.from({ length: settings.planetEdgeCount }, (_, i) => { + const angle = (i / settings.planetEdgeCount) * -Math.PI * 2; + return [planetRadius * Math.cos(angle), planetRadius * Math.sin(angle)]; +}); + +const planetAt = (rotation: number) => + ({ id: 1, vertices, snapshotRotation: rotation, snapshotRotationSpeed: 0 }) as never; + +const body = (center: Array, radius: number) => ({ + center: center.slice(), + radius, + velocity: [0, 0], + lastNormal: [0, 1], + restitution: 0, +}); + +interface Conditions { + fps: number; + /** One way. The round trip is twice this. */ + wireMs: number; + /** Client CPU spent between the frame timestamp and the socket emit at the end of the loop. */ + emitLagMs: number; + /** Peak random variation in frame duration, as a fraction of the nominal frame. */ + frameJitter: number; + /** Probability that a frame is missed entirely and the next one is twice as long. */ + dropRate: number; + /** Server work that can delay a socket callback past the packet's arrival. */ + serverBusyMs: number; +} + +interface Result { + /** Peak-to-peak variation in apparent speed, over its mean. Zero means perfectly even motion. */ + speedSpread: number; + /** Peak-to-peak wobble of the character's screen position about its own mean, in world units. */ + screenWobble: number; + /** How far the rendered character trails the true server pose, in world units. */ + lagUnits: number; +} + +// Runs a real server tick loop, a real wire, and a real render loop against the predictor, and +// reports how steady the character's *on-screen* travel is. On-screen, not in world space: the +// camera follows the character, so what the player sees is the residual between the two. +const measure = ({ + fps, + wireMs, + emitLagMs, + frameJitter, + dropRate, + serverBusyMs, +}: Conditions): Result => { + const world = new ClientCharacterWorld(); + world.sync([planetAt(0)]); + + const server: any = { + head: body([0, planetRadius + 90], headRadius), + leftFoot: body([-33, planetRadius + 35], feetRadius), + rightFoot: body([33, planetRadius + 35], feetRadius), + direction: 0, + currentPlanet: world.surfaceById(1), + secondsSinceOnSurface: 0, + bodyVelocity: [0, 0], + }; + + const input = [1, 0]; + const predictor = new LocalCharacterPredictor(); + predictor.setStrength(settings.playerMaxStrength); + + // Deterministic frame-time noise: a real display never delivers a perfectly even cadence. + let seed = 12345; + const random = () => ((seed = (seed * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff); + + const nominalFrameMs = 1000 / fps; + const toServer: Array<{ atMs: number; clientStampMs: number }> = []; + const toClient: Array<{ atMs: number; ackAgeMs: number; clientStampMs: number; pose: any }> = []; + const samples: Array<{ + screen: Array; + world: Array; + atMs: number; + error: number; + }> = []; + + // The client's own view of the character, and the camera that chases it. + const camera = [0, planetRadius + 90]; + const cameraPreviousTarget = [0, planetRadius + 90]; + + let simMs = 0; + let nextSnapshotMs = 0; + let nextFrameAtMs = 0; + let lastHeartbeatClientMs = -Infinity; + let lastInputClientTimeMs = 0; + let lastInputReceiptMs = 0; + let hasPressed = false; + + + while (simMs < 8000) { + tickPlanetDetachment(server, stepSeconds); + stepCharacterMovement(server, world, input.slice() as never, stepSeconds); + simMs += stepSeconds * 1000; + + while (toServer.length > 0 && toServer[0].atMs <= simMs) { + const packet = toServer.shift()!; + if (packet.clientStampMs > lastInputClientTimeMs) { + lastInputClientTimeMs = packet.clientStampMs; + // Socket callbacks run between physics frames, so this is the true arrival instant, + // not the frame boundary — plus however long the frame in progress still had to run. + lastInputReceiptMs = packet.atMs + serverBusyMs; + } + } + + if (simMs >= nextSnapshotMs) { + nextSnapshotMs += snapshotMs; + toClient.push({ + atMs: simMs + wireMs, + clientStampMs: lastInputClientTimeMs, + ackAgeMs: lastInputReceiptMs > 0 ? Math.max(0, simMs - lastInputReceiptMs) : 0, + pose: { + head: server.head.center.slice(), + leftFoot: server.leftFoot.center.slice(), + rightFoot: server.rightFoot.center.slice(), + movement: { + direction: server.direction, + bodyVelocity: server.bodyVelocity.slice(), + leftFootNormal: server.leftFoot.lastNormal.slice(), + rightFootNormal: server.rightFoot.lastNormal.slice(), + groundPlanetId: 1, + secondsSinceOnSurface: server.secondsSinceOnSurface, + }, + }, + }); + } + + while (toClient.length > 0 && toClient[0].atMs <= simMs) { + const packet = toClient.shift()!; + predictor.acknowledge( + packet.clientStampMs, + packet.pose.movement, + -Infinity, + packet.ackAgeMs, + ); + predictor.setAuthoritative( + { center: packet.pose.head, radius: headRadius } as never, + { center: packet.pose.leftFoot, radius: feetRadius } as never, + { center: packet.pose.rightFoot, radius: feetRadius } as never, + ); + } + + while (nextFrameAtMs <= simMs) { + const frameClientMs = nextFrameAtMs + clientClockOffsetMs; + setFrameTimeMs(frameClientMs); + + // Movement input is edge-triggered, exactly as KeyboardListener sends it: recorded once + // when the key goes down, then held by both sides. + if (!hasPressed) { + hasPressed = true; + predictor.recordInput(input as never); + } + + const dropped = random() < dropRate; + const jitter = 1 + (random() * 2 - 1) * frameJitter; + const frameMs = nominalFrameMs * jitter * (dropped ? 2 : 1); + + if (!dropped) { + predictor.update([planetAt(0)], frameMs / 1000); + const head = predictor.head.center; + followVec2( + camera as never, + head, + cameraPreviousTarget as never, + frameMs / 1000, + 0.08, + 1500, + ); + samples.push({ + screen: [head[0] - camera[0], head[1] - camera[1]], + world: [head[0], head[1]], + atMs: frameClientMs, + error: Math.hypot(head[0] - server.head.center[0], head[1] - server.head.center[1]), + }); + } + + // Stamped at the top of the frame, emitted at the bottom. + if (frameClientMs - lastHeartbeatClientMs >= heartbeatMs - 1) { + lastHeartbeatClientMs = frameClientMs; + toServer.push({ + atMs: nextFrameAtMs + emitLagMs + wireMs, + clientStampMs: frameClientMs, + }); + } + + nextFrameAtMs += frameMs; + } + } + + const tail = samples.slice(Math.floor(samples.length / 2)); + + // Apparent speed: world travel divided by how long the frame was on screen. A longer frame + // should show proportionally more travel; anything else reads as a lurch. + const speeds = tail + .slice(1) + .map( + (s, i) => + Math.hypot(s.world[0] - tail[i].world[0], s.world[1] - tail[i].world[1]) / + (s.atMs - tail[i].atMs), + ); + const meanSpeed = speeds.reduce((a, b) => a + b, 0) / speeds.length; + + // Screen position: the camera settles to a constant lag, so anything left is visible wobble. + const centre = [0, 1].map((axis) => { + const values = tail.map((s) => s.screen[axis]); + return values.reduce((a, b) => a + b, 0) / values.length; + }); + const wobble = Math.max( + ...tail.map((s) => Math.hypot(s.screen[0] - centre[0], s.screen[1] - centre[1])), + ); + + if ((globalThis as any).__dump) { + console.log('--- apparent speed (u/ms) ---'); + speeds.slice(0, 40).forEach((v, i) => console.log(`${i} ${v.toFixed(4)}`)); + } + return { + speedSpread: (Math.max(...speeds) - Math.min(...speeds)) / meanSpeed, + screenWobble: wobble, + lagUnits: tail.reduce((sum, s) => sum + s.error, 0) / tail.length, + }; +}; + +const CONDITIONS: Array<[string, Conditions]> = [ + ['localhost, clean frames', { fps: 60, wireMs: 0.2, emitLagMs: 2, frameJitter: 0, dropRate: 0, serverBusyMs: 0 }], + ['localhost, 144 Hz', { fps: 144, wireMs: 0.2, emitLagMs: 2, frameJitter: 0, dropRate: 0, serverBusyMs: 0 }], + ['localhost, frame jitter', { fps: 60, wireMs: 0.2, emitLagMs: 2, frameJitter: 0.15, dropRate: 0, serverBusyMs: 0 }], + ['localhost, 2% dropped', { fps: 60, wireMs: 0.2, emitLagMs: 2, frameJitter: 0.05, dropRate: 0.02, serverBusyMs: 0 }], + ['localhost, variable CPU', { fps: 60, wireMs: 0.2, emitLagMs: 8, frameJitter: 0.15, dropRate: 0.02, serverBusyMs: 0 }], + ['30 ms link', { fps: 60, wireMs: 15, emitLagMs: 2, frameJitter: 0.05, dropRate: 0, serverBusyMs: 0 }], + ['120 ms link', { fps: 60, wireMs: 60, emitLagMs: 2, frameJitter: 0.05, dropRate: 0, serverBusyMs: 0 }], +]; + +describe('local character render smoothness', () => { + it('keeps on-screen travel even under realistic clocks and frame pacing', () => { + ;(globalThis as any).__dump = true + measure(CONDITIONS[0][1]) + ;(globalThis as any).__dump = false + const results: Array<[string, Result]> = CONDITIONS.map(([label, c]) => [label, measure(c)]); + + for (const [label, r] of results) { + console.log( + `${label.padEnd(26)} speed=${(r.speedSpread * 100).toFixed(1).padStart(6)}% ` + + `wobble=${r.screenWobble.toFixed(2).padStart(6)}u ` + + `lag=${r.lagUnits.toFixed(1).padStart(5)}u `, + ); + } + + for (const [label, r] of results) { + // Even travel: what the eye reads as smooth motion. + expect(r.speedSpread, label).toBeLessThan(0.05); + // No sub-pixel swimming against the camera, which follows the same character. + expect(r.screenWobble, label).toBeLessThan(1); + } + + // Prediction exists to cancel latency; a follower that lags undoes that on every link. + const localhost = results.find(([label]) => label === 'localhost, clean frames')![1]; + expect(localhost.lagUnits).toBeLessThan(5); + }); +}); -- 2.54.0 From 47603c058e5234ac55c82dcdf80fd83bbacb054e Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Thu, 27 Aug 2026 08:09:08 +0100 Subject: [PATCH 09/15] Remove more --- backend/package.json | 2 +- .../prediction/local-character-predictor.ts | 67 +++---------------- .../scripts/objects/game-object-container.ts | 2 +- 3 files changed, 10 insertions(+), 61 deletions(-) diff --git a/backend/package.json b/backend/package.json index d655b4c..02b9087 100644 --- a/backend/package.json +++ b/backend/package.json @@ -13,7 +13,7 @@ }, "scripts": { "build": "npx webpack --mode production", - "dev": "concurrently --kill-others-on-fail \"webpack --mode development -w\" \"nodemon --legacy-watch dist/main.js\"", + "dev": "concurrently --kill-others-on-fail \"webpack --mode development -w\" \"nodemon --legacy-watch --delay 500ms dist/main.js\"", "try-build": "npm run build && cd dist && node main.js && cd -" }, "dependencies": { diff --git a/frontend/src/scripts/helper/prediction/local-character-predictor.ts b/frontend/src/scripts/helper/prediction/local-character-predictor.ts index 6e52ae5..97c37cc 100644 --- a/frontend/src/scripts/helper/prediction/local-character-predictor.ts +++ b/frontend/src/scripts/helper/prediction/local-character-predictor.ts @@ -5,7 +5,6 @@ import { CharacterMovementState, PhysicsBody, settings, - followVec2, stepCharacterMovement, applyLeapImpulse, tickPlanetDetachment, @@ -27,12 +26,6 @@ export const clientTimeMs = (): number => frameTimeMs; const maxReplayMs = 400; -// Only a correction has to fade; the character's own motion is carried straight through, so this -// buys smoothing without costing responsiveness. -const smoothSeconds = 0.06; - -const snapDistance = 250; - const upwards = vec2.fromValues(0, 1); const makeBody = (center: vec2, radius: number, normal: vec2): PhysicsBody => ({ @@ -59,8 +52,6 @@ export class LocalCharacterPredictor { private renderHead = new Circle(vec2.create(), headRadius); private renderLeftFoot = new Circle(vec2.create(), feetRadius); private renderRightFoot = new Circle(vec2.create(), feetRadius); - private previousTargets = [vec2.create(), vec2.create(), vec2.create()]; - private hasRender = false; public get head(): Circle { return this.renderHead; @@ -122,7 +113,6 @@ export class LocalCharacterPredictor { this.replayAnchorMs = undefined; this.movement = undefined; this.currentStrength = settings.playerMaxStrength; - this.hasRender = false; this.alive = true; } @@ -141,21 +131,21 @@ export class LocalCharacterPredictor { ); } - public update(planets: Array, frameSeconds: number): boolean { + public update(planets: Array): boolean { if (!this.alive || !this.canPredict || this.isAnimatingInOrOut) { - this.hasRender = false; return false; } this.world.sync(planets); const predicted = this.simulate(); - if (!this.hasRender) { - this.snapRenderTo(predicted); - this.hasRender = true; - } else { - this.followRenderTo(predicted, frameSeconds); - } + vec2.copy(this.renderHead.center, predicted.head.center); + this.renderHead.radius = predicted.head.radius; + vec2.copy(this.renderLeftFoot.center, predicted.leftFoot.center); + this.renderLeftFoot.radius = predicted.leftFoot.radius; + vec2.copy(this.renderRightFoot.center, predicted.rightFoot.center); + this.renderRightFoot.radius = predicted.rightFoot.radius; + return true; } @@ -193,7 +183,6 @@ export class LocalCharacterPredictor { let t = startMs; for (let i = 0; i < steps; i++) { const input = this.inputHistory.directionAt(t); - // Planets first: the server's container holds them ahead of every character. this.world.advance(stepSeconds); tickPlanetDetachment(state, stepSeconds); stepCharacterMovement(state, this.world, input, stepSeconds); @@ -216,8 +205,6 @@ export class LocalCharacterPredictor { t += stepMs; } - // A shorter final step would not integrate like the server's fixed one, so take a whole - // step and read the pose part-way along it instead. if (partialStep > 0) { const from = [state.head, state.leftFoot, state.rightFoot].map((b) => vec2.clone(b.center), @@ -237,44 +224,6 @@ export class LocalCharacterPredictor { return state; } - - private snapRenderTo(state: CharacterMovementState): void { - this.previousTargets = [state.head, state.leftFoot, state.rightFoot].map((b) => - vec2.clone(b.center), - ); - this.renderHead = new Circle(vec2.clone(state.head.center), state.head.radius); - this.renderLeftFoot = new Circle( - vec2.clone(state.leftFoot.center), - state.leftFoot.radius, - ); - this.renderRightFoot = new Circle( - vec2.clone(state.rightFoot.center), - state.rightFoot.radius, - ); - } - - private followRenderTo(state: CharacterMovementState, frameSeconds: number): void { - this.followPart(this.renderHead, state.head, 0, frameSeconds); - this.followPart(this.renderLeftFoot, state.leftFoot, 1, frameSeconds); - this.followPart(this.renderRightFoot, state.rightFoot, 2, frameSeconds); - } - - private followPart( - render: Circle, - target: PhysicsBody, - index: number, - frameSeconds: number, - ): void { - followVec2( - render.center, - target.center, - this.previousTargets[index], - frameSeconds, - smoothSeconds, - snapDistance, - ); - render.radius = target.radius; - } } export const localCharacterPredictor = new LocalCharacterPredictor(); diff --git a/frontend/src/scripts/objects/game-object-container.ts b/frontend/src/scripts/objects/game-object-container.ts index be522da..f28848f 100644 --- a/frontend/src/scripts/objects/game-object-container.ts +++ b/frontend/src/scripts/objects/game-object-container.ts @@ -109,7 +109,7 @@ export class GameObjectContainer extends CommandReceiver { localCharacterPredictor.setStrength( player.strengthFraction * settings.playerMaxStrength, ); - if (localCharacterPredictor.update(this.planets, deltaTimeInSeconds)) { + if (localCharacterPredictor.update(this.planets)) { player.head = localCharacterPredictor.head; player.leftFoot = localCharacterPredictor.leftFoot; player.rightFoot = localCharacterPredictor.rightFoot; -- 2.54.0 From c074bc2645fa8cf67f72740b654348ef77491d3c Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Mon, 14 Sep 2026 08:04:19 +0100 Subject: [PATCH 10/15] claude --- backend/src/game-events.ts | 13 ++++++++++ backend/src/main.ts | 9 +------ backend/src/objects/character-physical.ts | 24 +++++++------------ backend/src/objects/circle-physical.ts | 12 ++-------- backend/src/objects/planet-physical.ts | 16 ++++++++----- backend/src/objects/projectile-physical.ts | 9 +++---- backend/src/physics/bounding-box.ts | 8 +++++++ backend/src/physics/physical.ts | 7 ++---- backend/src/players/npc.ts | 14 +++++++---- backend/src/players/player-base.ts | 14 ++++++----- backend/src/players/player-container.ts | 15 ++++++++---- backend/src/players/player.ts | 22 +++++++---------- frontend/src/scripts/charge-indicator.ts | 4 ++-- .../src/scripts/commands/input-generator.ts | 2 +- .../src/scripts/commands/keyboard-listener.ts | 3 ++- .../src/scripts/commands/mouse-listener.ts | 9 +++---- .../src/scripts/commands/touch-listener.ts | 13 ++++++---- frontend/src/scripts/feedback-hud.ts | 4 ++-- frontend/src/scripts/game.ts | 21 +++++++++------- .../src/scripts/helper/centered-transform.ts | 2 ++ frontend/src/scripts/helper/pointer.ts | 8 ++++++- .../prediction/local-character-predictor.ts | 4 ++-- .../scripts/objects/game-object-container.ts | 13 +++++----- frontend/src/scripts/objects/types/camera.ts | 2 +- .../scripts/objects/types/character-view.ts | 11 ++++++--- .../src/scripts/objects/types/planet-view.ts | 6 ++++- frontend/src/scripts/tutorial.ts | 6 ++--- shared/src/commands/command-receiver.ts | 4 ++-- shared/src/commands/command.ts | 10 +------- .../types/actions/client-heartbeat.ts | 2 +- .../src/commands/types/actions/leap-action.ts | 2 +- .../src/commands/types/actions/move-action.ts | 2 +- .../commands/types/actions/primary-action.ts | 4 ++-- shared/src/helper/charge.ts | 5 +--- shared/src/objects/game-object.ts | 4 +--- shared/src/objects/types/planet-base.ts | 13 ++++++---- shared/src/physics/evaluate-sdf.ts | 2 +- shared/src/settings.ts | 2 -- test/backpressure.test.ts | 5 ++-- 39 files changed, 178 insertions(+), 148 deletions(-) create mode 100644 frontend/src/scripts/helper/centered-transform.ts diff --git a/backend/src/game-events.ts b/backend/src/game-events.ts index 58475fc..e4ae22b 100644 --- a/backend/src/game-events.ts +++ b/backend/src/game-events.ts @@ -1,4 +1,17 @@ +import { CharacterTeam } from 'shared'; + export interface GameEvents { addPoints(blue: number, red: number): void; announce(text: string): void; } + +export const addPointsForTeam = ( + game: GameEvents, + team: CharacterTeam, + points: number, +): void => { + game.addPoints( + team === CharacterTeam.blue ? points : 0, + team === CharacterTeam.red ? points : 0, + ); +}; diff --git a/backend/src/main.ts b/backend/src/main.ts index 1f7115e..60a92fe 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -32,14 +32,7 @@ const io = new IoServer(server, { const gameServer = new GameServer(io, options); -app.use( - cors({ - origin: (_, callback) => { - callback(null, true); - }, - credentials: true, - }), -); +app.use(cors({ origin: true, credentials: true })); app.get(serverInformationEndpoint, (_, res) => { res.json(gameServer.serverInfo); diff --git a/backend/src/objects/character-physical.ts b/backend/src/objects/character-physical.ts index fd870d0..4d847e6 100644 --- a/backend/src/objects/character-physical.ts +++ b/backend/src/objects/character-physical.ts @@ -13,6 +13,7 @@ import { clamp01, strengthToCharge, stepCharacterMovement, + applyForce, applyLeapImpulse, decayMomentum, tickPlanetDetachment, @@ -27,12 +28,13 @@ import { rightFootOffset, boundRadius, } from 'shared'; +import { addPointsForTeam } from '../game-events'; import { Physical } from '../physics/physical'; import { CirclePhysical } from './circle-physical'; import { PhysicalContainer } from '../physics/physical-container'; import { BoundingBox } from '../physics/bounding-box'; import { ProjectilePhysical } from './projectile-physical'; -import { PlanetPhysical, planetsIn } from './planet-physical'; +import { PlanetPhysical, planetsNear } from './planet-physical'; const placeholder = new Circle(vec2.create(), 0); @@ -99,8 +101,7 @@ export class CharacterPhysical extends CharacterBase implements Physical { } private readonly movementWorld: CharacterWorld = { - groundsNear: (center, radius) => - planetsIn(this.container.findIntersecting(BoundingBox.ofCircle(center, radius))), + groundsNear: (center, radius) => planetsNear(this.container, center, radius), stepBody: (body, deltaTimeInSeconds) => { const hit = (body as CirclePhysical).stepManually(deltaTimeInSeconds); return hit instanceof PlanetPhysical ? hit : undefined; @@ -139,12 +140,7 @@ export class CharacterPhysical extends CharacterBase implements Physical { } public get boundingBox(): BoundingBox { - const center = this.center; - this.box.xMin = center[0] - boundRadius; - this.box.xMax = center[0] + boundRadius; - this.box.yMin = center[1] - boundRadius; - this.box.yMax = center[1] + boundRadius; - return this.box; + return this.box.setCircle(this.center, boundRadius); } public get gameObject(): this { @@ -309,11 +305,9 @@ export class CharacterPhysical extends CharacterBase implements Physical { this.remoteCall('onDie'); if (killedInCombat) { - const points = settings.playerKillPoint; - this.container.game.addPoints( - this.team === CharacterTeam.blue ? 0 : points, - this.team === CharacterTeam.red ? 0 : points, - ); + const opposingTeam = + this.team === CharacterTeam.blue ? CharacterTeam.red : CharacterTeam.blue; + addPointsForTeam(this.container.game, opposingTeam, settings.playerKillPoint); } } @@ -427,7 +421,7 @@ export class CharacterPhysical extends CharacterBase implements Physical { let grounded = false; for (const part of this.parts) { vec2.copy(part.velocity, this.bodyVelocity); - part.applyForce(sumGravity(planets, part.center), deltaTime); + applyForce(part, sumGravity(planets, part.center), deltaTime); if (part.stepManually(deltaTime) instanceof PlanetPhysical) { grounded = true; } diff --git a/backend/src/objects/circle-physical.ts b/backend/src/objects/circle-physical.ts index 7cccc47..1b70122 100644 --- a/backend/src/objects/circle-physical.ts +++ b/backend/src/objects/circle-physical.ts @@ -1,5 +1,5 @@ import { vec2 } from 'gl-matrix'; -import { applyForce, Circle, GameObject, resolveCircleMovement } from 'shared'; +import { Circle, GameObject, resolveCircleMovement } from 'shared'; import { BoundingBox } from '../physics/bounding-box'; import { PhysicalContainer } from '../physics/physical-container'; import { Physical } from '../physics/physical'; @@ -22,11 +22,7 @@ export class CirclePhysical extends Circle implements Physical { } public get boundingBox(): BoundingBox { - this.box.xMin = this.center[0] - this.radius; - this.box.xMax = this.center[0] + this.radius; - this.box.yMin = this.center[1] - this.radius; - this.box.yMax = this.center[1] + this.radius; - return this.box; + return this.box.setCircle(this.center, this.radius); } public get gameObject(): GameObject { @@ -37,10 +33,6 @@ export class CirclePhysical extends Circle implements Physical { this.owner.onCollision?.(other); } - public applyForce(force: vec2, timeInSeconds: number) { - applyForce(this, force, timeInSeconds); - } - public stepManually( deltaTimeInSeconds: number, possibleIntersectors?: Array, diff --git a/backend/src/objects/planet-physical.ts b/backend/src/objects/planet-physical.ts index 950a548..7afdc3d 100644 --- a/backend/src/objects/planet-physical.ts +++ b/backend/src/objects/planet-physical.ts @@ -10,15 +10,23 @@ import { PropertyUpdatesForObject, UpdatePropertyCommand, } from 'shared'; -import { GameEvents } from '../game-events'; +import { addPointsForTeam, GameEvents } from '../game-events'; import { BoundingBox } from '../physics/bounding-box'; import { Physical } from '../physics/physical'; +import { PhysicalContainer } from '../physics/physical-container'; import { LampPhysical } from './lamp-physical'; import type { CharacterPhysical } from './character-physical'; export const planetsIn = (objects: Array): Array => objects.filter((o): o is PlanetPhysical => o instanceof PlanetPhysical); +export const planetsNear = ( + container: PhysicalContainer, + center: vec2, + radius: number, +): Array => + planetsIn(container.findIntersecting(BoundingBox.ofCircle(center, radius))); + export class PlanetPhysical extends PlanetBase implements Physical { public readonly boundingBox: BoundingBox; public readonly sizePointMultiplier: number; @@ -80,11 +88,7 @@ export class PlanetPhysical extends PlanetBase implements Physical { } private awardToOwner(base: number) { - const value = Math.round(base * this.sizePointMultiplier); - this.game.addPoints( - this.team === CharacterTeam.blue ? value : 0, - this.team === CharacterTeam.red ? value : 0, - ); + addPointsForTeam(this.game, this.team, Math.round(base * this.sizePointMultiplier)); } private resolveCapture(deltaTime: number) { diff --git a/backend/src/objects/projectile-physical.ts b/backend/src/objects/projectile-physical.ts index fd8496c..ce0e2bd 100644 --- a/backend/src/objects/projectile-physical.ts +++ b/backend/src/objects/projectile-physical.ts @@ -84,10 +84,11 @@ export class ProjectilePhysical extends ProjectileBase implements Physical { } public onCollision(other: GameObject) { - if ( - !(other instanceof CharacterPhysical && other.team === this.team) && - this.bounceCount++ === settings.projectileMaxBounceCount - ) { + if (other instanceof CharacterPhysical && other.team === this.team) { + return; + } + this.bounceCount++; + if (this.bounceCount > settings.projectileMaxBounceCount) { this.destroy(); } } diff --git a/backend/src/physics/bounding-box.ts b/backend/src/physics/bounding-box.ts index 0cc6477..feb0d7a 100644 --- a/backend/src/physics/bounding-box.ts +++ b/backend/src/physics/bounding-box.ts @@ -17,6 +17,14 @@ export class BoundingBox { ); } + public setCircle(center: vec2, radius: number): BoundingBox { + this.xMin = center[0] - radius; + this.xMax = center[0] + radius; + this.yMin = center[1] - radius; + this.yMax = center[1] + radius; + return this; + } + public intersects(other: BoundingBox): boolean { return ( this.xMin < other.xMax && diff --git a/backend/src/physics/physical.ts b/backend/src/physics/physical.ts index 4e3fc1a..65d6cb2 100644 --- a/backend/src/physics/physical.ts +++ b/backend/src/physics/physical.ts @@ -1,13 +1,10 @@ -import { vec2 } from 'gl-matrix'; -import { GameObject } from 'shared'; +import { GameObject, Sdf } from 'shared'; import { BoundingBox } from './bounding-box'; -export interface Physical { - readonly canCollide: boolean; +export interface Physical extends Sdf { readonly boundingBox: BoundingBox; readonly gameObject: GameObject; - distance(target: vec2): number; step?(deltaTimeInSeconds: number): void; onCollision?(other: GameObject): void; } diff --git a/backend/src/players/npc.ts b/backend/src/players/npc.ts index 3fa8bef..d5ee0b8 100644 --- a/backend/src/players/npc.ts +++ b/backend/src/players/npc.ts @@ -1,5 +1,12 @@ import { vec2 } from 'gl-matrix'; -import { PlayerInformation, settings, Random, CharacterTeam, Id } from 'shared'; +import { + PlayerInformation, + settings, + Random, + CharacterTeam, + Id, + evaluateSdf, +} from 'shared'; import { PhysicalContainer } from '../physics/physical-container'; import { BoundingBox } from '../physics/bounding-box'; import { PlayerContainer } from './player-container'; @@ -321,10 +328,7 @@ export class NPC extends PlayerBase { let traveled = tuning.lineOfSightStartOffset; const position = vec2.scaleAndAdd(vec2.create(), this.center, direction, traveled); while (traveled < totalDistance) { - let sdf = Infinity; - for (const planet of planets) { - sdf = Math.min(sdf, planet.distance(position)); - } + const sdf = evaluateSdf(position, planets); if (sdf < tuning.lineOfSightClearance) { return false; } diff --git a/backend/src/players/player-base.ts b/backend/src/players/player-base.ts index 25a6751..ff3312d 100644 --- a/backend/src/players/player-base.ts +++ b/backend/src/players/player-base.ts @@ -12,7 +12,7 @@ import { import { PhysicalContainer } from '../physics/physical-container'; import { BoundingBox } from '../physics/bounding-box'; import { CharacterPhysical } from '../objects/character-physical'; -import { PlanetPhysical, planetsIn } from '../objects/planet-physical'; +import { PlanetPhysical, planetsNear } from '../objects/planet-physical'; import { PlayerContainer } from './player-container'; const maximumNameLength = 40; @@ -65,7 +65,9 @@ export abstract class PlayerBase extends CommandReceiver { return this.character; } - public abstract step(deltaTimeInSeconds: number): void; + public step(deltaTimeInSeconds: number) { + this.stepLifecycle(deltaTimeInSeconds); + } protected stepLifecycle(deltaTimeInSeconds: number): CharacterPhysical | undefined { if (this.character) { @@ -89,10 +91,10 @@ export abstract class PlayerBase extends CommandReceiver { } private findSpawnCenter(): vec2 { - const planets = planetsIn( - this.objectContainer.findIntersecting( - BoundingBox.ofCircle(vec2.create(), settings.worldRadius * 2), - ), + const planets = planetsNear( + this.objectContainer, + vec2.create(), + settings.worldRadius * 2, ); const friendly = planets.filter((p) => p.team === this.team); diff --git a/backend/src/players/player-container.ts b/backend/src/players/player-container.ts index d82497f..7b5bac9 100644 --- a/backend/src/players/player-container.ts +++ b/backend/src/players/player-container.ts @@ -36,9 +36,7 @@ export class PlayerContainer { ); for (let i = 0; i < newNpcCount; i++) { const name = `🤖 ${Random.choose(settings.npcNames)}`; - this.npcs.push( - new NPC({ name }, this, this.objects, this.getTeamOfNextPlayer(true)), - ); + this.npcs.push(new NPC({ name }, this, this.objects, this.getTeamOfNextNpc())); } } @@ -102,8 +100,15 @@ export class PlayerContainer { this._players.forEach((p) => p.sendQueuedCommandsToClient()); } - private getTeamOfNextPlayer(isNpc = false): CharacterTeam { - const players = isNpc ? this.players : this._players; + private getTeamOfNextPlayer(): CharacterTeam { + return this.teamOfNext(this._players); + } + + private getTeamOfNextNpc(): CharacterTeam { + return this.teamOfNext(this.players); + } + + private teamOfNext(players: Array): CharacterTeam { const blueCount = players.filter((p) => p.team === CharacterTeam.blue).length; const redCount = players.filter((p) => p.team === CharacterTeam.red).length; diff --git a/backend/src/players/player.ts b/backend/src/players/player.ts index baa9f1a..e29951d 100644 --- a/backend/src/players/player.ts +++ b/backend/src/players/player.ts @@ -42,9 +42,9 @@ const minimumAspectRatio = 0.2; const maximumAspectRatio = 8; const sheddableWhenBackedUp: ReadonlyArray = [ - PropertyUpdatesForObjects.type, - UpdateMinimap.type, - InputAcknowledgement.type, + PropertyUpdatesForObjects.name, + UpdateMinimap.name, + InputAcknowledgement.name, ]; export class Player extends PlayerBase { @@ -68,7 +68,7 @@ export class Player extends PlayerBase { // Input is untrusted: one non-finite value reaching the simulation leaves a character that never dies. protected commandExecutors: CommandExecutors = { - [SetAspectRatioActionCommand.type]: (v: SetAspectRatioActionCommand) => { + [SetAspectRatioActionCommand.name]: (v: SetAspectRatioActionCommand) => { this.aspectRatio = finiteInRange( v.aspectRatio, minimumAspectRatio, @@ -76,7 +76,7 @@ export class Player extends PlayerBase { this.aspectRatio, ); }, - [MoveActionCommand.type]: (c: MoveActionCommand) => { + [MoveActionCommand.name]: (c: MoveActionCommand) => { const direction = finiteVec2(c.direction, 1); if (!direction) { return; @@ -84,7 +84,7 @@ export class Player extends PlayerBase { this.observeClientTime(c.clientTimeMs); this.character?.setMoveDirection(direction); }, - [PrimaryActionCommand.type]: (c: PrimaryActionCommand) => { + [PrimaryActionCommand.name]: (c: PrimaryActionCommand) => { const position = finiteVec2(c.position, settings.maxClientPositionMagnitude); if (!position) { return; @@ -92,7 +92,7 @@ export class Player extends PlayerBase { this.observeClientTime(c.clientTimeMs); this.character?.shootTowards(position, finiteInRange(c.charge, 0, 1, 0)); }, - [LeapActionCommand.type]: (c: LeapActionCommand) => { + [LeapActionCommand.name]: (c: LeapActionCommand) => { if (!isFiniteNumber(c.clientTimeMs)) { return; } @@ -100,7 +100,7 @@ export class Player extends PlayerBase { this.observeClientTime(c.clientTimeMs); this.character?.leap(); }, - [ClientHeartbeatCommand.type]: (c: ClientHeartbeatCommand) => + [ClientHeartbeatCommand.name]: (c: ClientHeartbeatCommand) => this.observeClientTime(c.clientTimeMs), }; @@ -150,10 +150,6 @@ export class Player extends PlayerBase { this.winnerTeam = winnerTeam; } - public step(deltaTimeInSeconds: number) { - this.stepLifecycle(deltaTimeInSeconds); - } - public queueCommandSend(command: Command) { this.commandsToBeSent.push(command); } @@ -273,7 +269,7 @@ export class Player extends PlayerBase { // Only state the next snapshot regenerates may be dropped; create/delete are one-shot. if (this.bufferedBytes > settings.maxBufferedBytesPerClient) { this.commandsToBeSent = this.commandsToBeSent.filter( - (c) => !sheddableWhenBackedUp.includes(c.type), + (c) => !sheddableWhenBackedUp.includes(c.constructor.name), ); } if (this.commandsToBeSent.length === 0) { diff --git a/frontend/src/scripts/charge-indicator.ts b/frontend/src/scripts/charge-indicator.ts index 1bad137..e068eb6 100644 --- a/frontend/src/scripts/charge-indicator.ts +++ b/frontend/src/scripts/charge-indicator.ts @@ -1,5 +1,5 @@ import { chargeHeldSince } from 'shared'; -import { pointer } from './helper/pointer'; +import { getDisplayPosition } from './helper/pointer'; let element: HTMLElement | undefined; let heldSince = 0; @@ -42,7 +42,7 @@ function updateChargeIndicator() { element.classList.toggle('full', charge >= 1); if (isFollowingPointer) { - const cursor = pointer.displayPosition; + const cursor = getDisplayPosition(); if (cursor) { element.style.left = `${cursor[0]}px`; element.style.top = `${cursor[1]}px`; diff --git a/frontend/src/scripts/commands/input-generator.ts b/frontend/src/scripts/commands/input-generator.ts index 6c8ef50..39c7759 100644 --- a/frontend/src/scripts/commands/input-generator.ts +++ b/frontend/src/scripts/commands/input-generator.ts @@ -10,7 +10,7 @@ import { localCharacterPredictor, } from '../helper/prediction/local-character-predictor'; -export abstract class InputGenerator { +export class InputGenerator { constructor(protected readonly onCommand: (command: Command) => void) {} protected sendMove(direction: vec2) { diff --git a/frontend/src/scripts/commands/keyboard-listener.ts b/frontend/src/scripts/commands/keyboard-listener.ts index 476396e..7674247 100644 --- a/frontend/src/scripts/commands/keyboard-listener.ts +++ b/frontend/src/scripts/commands/keyboard-listener.ts @@ -1,10 +1,11 @@ import { vec2 } from 'gl-matrix'; +import type { Command } from 'shared'; import { InputGenerator } from './input-generator'; export class KeyboardListener extends InputGenerator { private keysDown: Set = new Set(); - constructor(onCommand: ConstructorParameters[0]) { + constructor(onCommand: (command: Command) => void) { super(onCommand); addEventListener('keydown', this.keyDownListener); addEventListener('keyup', this.keyUpListener); diff --git a/frontend/src/scripts/commands/mouse-listener.ts b/frontend/src/scripts/commands/mouse-listener.ts index 5747a14..5055c67 100644 --- a/frontend/src/scripts/commands/mouse-listener.ts +++ b/frontend/src/scripts/commands/mouse-listener.ts @@ -1,8 +1,9 @@ import { vec2 } from 'gl-matrix'; -import { chargeHeldSince, Command } from 'shared'; -import { Game } from '../game'; +import { chargeHeldSince } from 'shared'; +import type { Command } from 'shared'; +import type { Game } from '../game'; import { ChargeIndicator } from '../charge-indicator'; -import { pointer } from '../helper/pointer'; +import { setDisplayPosition } from '../helper/pointer'; import { InputGenerator } from './input-generator'; export class MouseListener extends InputGenerator { @@ -22,7 +23,7 @@ export class MouseListener extends InputGenerator { } private mouseMoveListener = (event: MouseEvent) => { - pointer.displayPosition = vec2.fromValues(event.clientX, event.clientY); + setDisplayPosition(vec2.fromValues(event.clientX, event.clientY)); }; private mouseDownListener = (event: MouseEvent) => { diff --git a/frontend/src/scripts/commands/touch-listener.ts b/frontend/src/scripts/commands/touch-listener.ts index 5b933ab..31aa502 100644 --- a/frontend/src/scripts/commands/touch-listener.ts +++ b/frontend/src/scripts/commands/touch-listener.ts @@ -1,7 +1,9 @@ import { vec2 } from 'gl-matrix'; -import { chargeHeldSince, Command, settings } from 'shared'; -import { Game } from '../game'; +import { chargeHeldSince, settings } from 'shared'; +import type { Command } from 'shared'; +import type { Game } from '../game'; import { ChargeIndicator } from '../charge-indicator'; +import { centeredTransform } from '../helper/centered-transform'; import { InputGenerator } from './input-generator'; const deadZone = 8; @@ -104,11 +106,14 @@ export class TouchListener extends InputGenerator { this.primaryDownAt = null; ChargeIndicator.end(); this.overlay.appendChild(this.joystick); - this.joystick.style.transform = `translateX(${this.touchStartPosition[0]}px) translateY(${this.touchStartPosition[1]}px) translateX(-50%) translateY(-50%)`; + this.joystick.style.transform = centeredTransform( + this.touchStartPosition[0], + this.touchStartPosition[1], + ); } vec2.scale(delta, delta, Math.min(1, joystickMaxLength / deltaLength)); - this.joystickButton.style.transform = `translateX(${delta[0]}px) translateY(${delta[1]}px) translateX(-50%) translateY(-50%)`; + this.joystickButton.style.transform = centeredTransform(delta[0], delta[1]); if (deltaLength > deadZone) { vec2.set(delta, delta[0], -delta[1]); diff --git a/frontend/src/scripts/feedback-hud.ts b/frontend/src/scripts/feedback-hud.ts index ef7b251..4d59359 100644 --- a/frontend/src/scripts/feedback-hud.ts +++ b/frontend/src/scripts/feedback-hud.ts @@ -1,5 +1,5 @@ import { settings } from 'shared'; -import { pointer } from './helper/pointer'; +import { getDisplayPosition } from './helper/pointer'; let root: HTMLElement | undefined; let killfeed: HTMLElement | undefined; @@ -20,7 +20,7 @@ function ensureRoot(): { root: HTMLElement; killfeed: HTMLElement } { } function focusPoint(): { x: number; y: number } { - const cursor = pointer.displayPosition; + const cursor = getDisplayPosition(); if (cursor) { return { x: cursor[0], y: cursor[1] }; } diff --git a/frontend/src/scripts/game.ts b/frontend/src/scripts/game.ts index d4bc384..2267d64 100644 --- a/frontend/src/scripts/game.ts +++ b/frontend/src/scripts/game.ts @@ -34,6 +34,7 @@ import parser from 'socket.io-msgpack-parser'; import { CharacterShape } from './shapes/character-shape'; import { PlanetShape } from './shapes/planet-shape'; import { serverTimeline } from './helper/server-timeline'; +import { centeredTransform } from './helper/centered-transform'; import { localCharacterPredictor, setFrameTimeMs, @@ -79,21 +80,21 @@ export class Game extends CommandReceiver { private framesSinceLastLayoutUpdate = 0; protected commandExecutors: CommandExecutors = { - [ServerAnnouncement.type]: (c: ServerAnnouncement) => { + [ServerAnnouncement.name]: (c: ServerAnnouncement) => { this.lastAnnouncementText = c.text; this.timeSinceLastAnnouncement = 0; }, - [UpdateGameState.type]: (c: UpdateGameState) => (this.lastGameState = c), - [InputAcknowledgement.type]: (c: InputAcknowledgement) => + [UpdateGameState.name]: (c: UpdateGameState) => (this.lastGameState = c), + [InputAcknowledgement.name]: (c: InputAcknowledgement) => localCharacterPredictor.acknowledge( c.clientTimeMs, c.movement, c.lastLeapClientTimeMs, c.ackAgeMs, ), - [GameEndCommand.type]: () => (this.isEnding = true), - [UpdateMinimap.type]: (c: UpdateMinimap) => (this.lastMinimap = c), - [GameStartCommand.type]: () => this.initialize(), + [GameEndCommand.name]: () => (this.isEnding = true), + [UpdateMinimap.name]: (c: UpdateMinimap) => (this.lastMinimap = c), + [GameStartCommand.name]: () => this.initialize(), }; constructor( @@ -220,6 +221,7 @@ export class Game extends CommandReceiver { public async start(): Promise { this.initialize(); + this.resolveStarted(); const noiseTexture = await renderNoise([256, 256], 2, 1); await runAnimation( @@ -311,7 +313,6 @@ export class Game extends CommandReceiver { deltaTime: DOMHighResTimeStamp, ): boolean { this.renderer = renderer; - this.resolveStarted(); // The frame timestamp, not performance.now(): dispatch jitter would land in the replay window. setFrameTimeMs(currentTime); deltaTime /= 1000; @@ -408,6 +409,10 @@ export class Game extends CommandReceiver { deltaX = (deltaY * dx) / dy; } - this.keystoneArrow.style.transform = `translateX(${width / 2 + deltaX}px) translateY(${height / 2 + deltaY}px) translateX(-50%) translateY(-50%) rotate(${angle + Math.PI / 2}rad)`; + this.keystoneArrow.style.transform = centeredTransform( + width / 2 + deltaX, + height / 2 + deltaY, + ` rotate(${angle + Math.PI / 2}rad)`, + ); } } diff --git a/frontend/src/scripts/helper/centered-transform.ts b/frontend/src/scripts/helper/centered-transform.ts new file mode 100644 index 0000000..9496fc2 --- /dev/null +++ b/frontend/src/scripts/helper/centered-transform.ts @@ -0,0 +1,2 @@ +export const centeredTransform = (x: number, y: number, extra = ''): string => + `translateX(${x}px) translateY(${y}px) translateX(-50%) translateY(-50%)${extra}`; diff --git a/frontend/src/scripts/helper/pointer.ts b/frontend/src/scripts/helper/pointer.ts index cda73ee..e126c41 100644 --- a/frontend/src/scripts/helper/pointer.ts +++ b/frontend/src/scripts/helper/pointer.ts @@ -1,3 +1,9 @@ import { vec2 } from 'gl-matrix'; -export const pointer = { displayPosition: null as vec2 | null }; +let displayPosition: vec2 | null = null; + +export const setDisplayPosition = (position: vec2): void => { + displayPosition = position; +}; + +export const getDisplayPosition = (): vec2 | null => displayPosition; diff --git a/frontend/src/scripts/helper/prediction/local-character-predictor.ts b/frontend/src/scripts/helper/prediction/local-character-predictor.ts index 97c37cc..b3e290f 100644 --- a/frontend/src/scripts/helper/prediction/local-character-predictor.ts +++ b/frontend/src/scripts/helper/prediction/local-character-predictor.ts @@ -12,7 +12,7 @@ import { headRadius, } from 'shared'; import { ClientCharacterWorld } from './client-character-world'; -import { PlanetView } from '../../objects/types/planet-view'; +import type { PlanetView } from '../../objects/types/planet-view'; import { InputHistory } from './input-history'; const stepSeconds = settings.targetPhysicsDeltaTimeInSeconds; @@ -32,7 +32,7 @@ const makeBody = (center: vec2, radius: number, normal: vec2): PhysicsBody => ({ center: vec2.clone(center), radius, velocity: vec2.create(), - lastNormal: vec2.fromValues(normal[0], normal[1]), + lastNormal: vec2.clone(normal), restitution: 0, }); diff --git a/frontend/src/scripts/objects/game-object-container.ts b/frontend/src/scripts/objects/game-object-container.ts index f28848f..20cc31b 100644 --- a/frontend/src/scripts/objects/game-object-container.ts +++ b/frontend/src/scripts/objects/game-object-container.ts @@ -13,7 +13,7 @@ import { UpdatePropertyCommand, } from 'shared'; import { FeedbackHud } from '../feedback-hud'; -import { Game } from '../game'; +import type { Game } from '../game'; import { serverTimeline } from '../helper/server-timeline'; import { localCharacterPredictor } from '../helper/prediction/local-character-predictor'; import { Camera } from './types/camera'; @@ -36,7 +36,7 @@ export class GameObjectContainer extends CommandReceiver { private awaitingDeleteStamp: Array = []; protected commandExecutors: CommandExecutors = { - [CreatePlayerCommand.type]: (c: CreatePlayerCommand) => { + [CreatePlayerCommand.name]: (c: CreatePlayerCommand) => { this.player = c.character as CharacterView; this.player.isMainCharacter = true; this.addObject(this.player); @@ -45,18 +45,18 @@ export class GameObjectContainer extends CommandReceiver { this.wasLocalPlayerAlive = true; }, - [CreateObjectsCommand.type]: (c: CreateObjectsCommand) => + [CreateObjectsCommand.name]: (c: CreateObjectsCommand) => c.objects.forEach((o) => { this.addObject(o as View); this.awaitingCreateStamp.push(o.id); }), - [RemoteCallsForObjects.type]: (c: RemoteCallsForObjects) => + [RemoteCallsForObjects.name]: (c: RemoteCallsForObjects) => c.callsForObjects.forEach((c) => this.objects.get(c.id)?.processRemoteCalls(c.calls), ), - [PropertyUpdatesForObjects.type]: (c: PropertyUpdatesForObjects) => { + [PropertyUpdatesForObjects.name]: (c: PropertyUpdatesForObjects) => { serverTimeline.onSnapshot(c.timestamp); this.stampPending(c.timestamp); c.updates.forEach((u) => { @@ -68,7 +68,7 @@ export class GameObjectContainer extends CommandReceiver { }); }, - [DeleteObjectsCommand.type]: (c: DeleteObjectsCommand) => + [DeleteObjectsCommand.name]: (c: DeleteObjectsCommand) => c.ids.forEach((id: Id) => this.awaitingDeleteStamp.push(id)), }; @@ -80,6 +80,7 @@ export class GameObjectContainer extends CommandReceiver { public reset() { this.objects.forEach((o) => o.beforeDestroy?.()); this.objects.clear(); + this.player = undefined; this.planets = []; this.visibleFrom.clear(); this.deleteAt.clear(); diff --git a/frontend/src/scripts/objects/types/camera.ts b/frontend/src/scripts/objects/types/camera.ts index ea8ee4d..ca768e4 100644 --- a/frontend/src/scripts/objects/types/camera.ts +++ b/frontend/src/scripts/objects/types/camera.ts @@ -1,7 +1,7 @@ import { vec2 } from 'gl-matrix'; import { Renderer } from 'sdf-2d'; import { calculateViewArea, followVec2, mixRgb, settings } from 'shared'; -import { Game } from '../../game'; +import type { Game } from '../../game'; import { ScreenShake } from '../../screen-shake'; const followSeconds = 0.08; diff --git a/frontend/src/scripts/objects/types/character-view.ts b/frontend/src/scripts/objects/types/character-view.ts index 2efa0ca..18e5434 100644 --- a/frontend/src/scripts/objects/types/character-view.ts +++ b/frontend/src/scripts/objects/types/character-view.ts @@ -13,9 +13,10 @@ import { strengthToCharge, UpdatePropertyCommand, } from 'shared'; +import { centeredTransform } from '../../helper/centered-transform'; import { CircleInterpolator } from '../../helper/interpolators/circle-interpolator'; import { LinearInterpolator } from '../../helper/interpolators/linear-interpolator'; -import { pointer } from '../../helper/pointer'; +import { getDisplayPosition } from '../../helper/pointer'; import { CharacterShape } from '../../shapes/character-shape'; import { SoundHandler, Sounds } from '../../sound-handler'; import { VibrationHandler } from '../../vibration-handler'; @@ -264,7 +265,11 @@ export class CharacterView extends CharacterBase implements View { this.head.radius + 80, ), ); - this.nameElement.style.transform = `translateX(${screenPosition[0]}px) translateY(${screenPosition[1]}px) translateX(-50%) translateY(-50%) rotate(-15deg)`; + this.nameElement.style.transform = centeredTransform( + screenPosition[0], + screenPosition[1], + ' rotate(-15deg)', + ); this.healthElement.style.width = (50 * this.health) / settings.playerMaxHealth + 'px'; this.chargeElement.style.width = @@ -287,7 +292,7 @@ export class CharacterView extends CharacterBase implements View { } private calculateGazeTarget(renderer: Renderer): vec2 { - const cursor = pointer.displayPosition; + const cursor = getDisplayPosition(); if (this.isMainCharacter && cursor) { return renderer.displayToWorldCoordinates(cursor); } diff --git a/frontend/src/scripts/objects/types/planet-view.ts b/frontend/src/scripts/objects/types/planet-view.ts index 28e0d47..a0ded36 100644 --- a/frontend/src/scripts/objects/types/planet-view.ts +++ b/frontend/src/scripts/objects/types/planet-view.ts @@ -8,6 +8,7 @@ import { CharacterTeam, settings, } from 'shared'; +import { centeredTransform } from '../../helper/centered-transform'; import { LinearInterpolator } from '../../helper/interpolators/linear-interpolator'; import { PlanetShape } from '../../shapes/planet-shape'; import { View } from '../view'; @@ -118,7 +119,10 @@ export class PlanetView extends PlanetBase implements View { } const screenPosition = renderer.worldToDisplayCoordinates(this.center); - this.ownershipProgress.style.transform = `translateX(${screenPosition[0]}px) translateY(${screenPosition[1]}px) translateX(-50%) translateY(-50%)`; + this.ownershipProgress.style.transform = centeredTransform( + screenPosition[0], + screenPosition[1], + ); const gradient = this.getGradient(); if (gradient !== this.lastGradient) { diff --git a/frontend/src/scripts/tutorial.ts b/frontend/src/scripts/tutorial.ts index ba047ef..e227c71 100644 --- a/frontend/src/scripts/tutorial.ts +++ b/frontend/src/scripts/tutorial.ts @@ -34,17 +34,17 @@ export class Tutorial extends CommandReceiver { private standingBaseline = 0; protected commandExecutors: CommandExecutors = { - [MoveActionCommand.type]: (c: MoveActionCommand) => { + [MoveActionCommand.name]: (c: MoveActionCommand) => { if (this.clearsOn() === 'move' && vec2.length(c.direction) > 0) { this.advance(); } }, - [PrimaryActionCommand.type]: () => { + [PrimaryActionCommand.name]: () => { if (this.clearsOn() === 'shoot') { this.advance(); } }, - [LeapActionCommand.type]: () => { + [LeapActionCommand.name]: () => { if (this.clearsOn() === 'leap') { this.advance(); } diff --git a/shared/src/commands/command-receiver.ts b/shared/src/commands/command-receiver.ts index 51f8600..447df6e 100644 --- a/shared/src/commands/command-receiver.ts +++ b/shared/src/commands/command-receiver.ts @@ -10,9 +10,9 @@ export abstract class CommandReceiver { protected defaultCommandExecutor(_: Command) {} public handleCommand(command: Command) { - const commandType = command.type; + const commandType = command.constructor.name; - if (Object.prototype.hasOwnProperty.call(this.commandExecutors, commandType)) { + if (commandType in this.commandExecutors) { this.commandExecutors[commandType]!(command); } else { this.defaultCommandExecutor(command); diff --git a/shared/src/commands/command.ts b/shared/src/commands/command.ts index 7670416..9074445 100644 --- a/shared/src/commands/command.ts +++ b/shared/src/commands/command.ts @@ -1,9 +1 @@ -export abstract class Command { - public static get type(): string { - return this.name; - } - - public get type(): string { - return this.constructor.name; - } -} +export abstract class Command {} diff --git a/shared/src/commands/types/actions/client-heartbeat.ts b/shared/src/commands/types/actions/client-heartbeat.ts index e2e0644..af4ef01 100644 --- a/shared/src/commands/types/actions/client-heartbeat.ts +++ b/shared/src/commands/types/actions/client-heartbeat.ts @@ -3,7 +3,7 @@ import { Command } from '../../command'; @serializable export class ClientHeartbeatCommand extends Command { - public constructor(public readonly clientTimeMs: number = 0) { + public constructor(public readonly clientTimeMs: number) { super(); } diff --git a/shared/src/commands/types/actions/leap-action.ts b/shared/src/commands/types/actions/leap-action.ts index 3681de2..1cc1f54 100644 --- a/shared/src/commands/types/actions/leap-action.ts +++ b/shared/src/commands/types/actions/leap-action.ts @@ -3,7 +3,7 @@ import { Command } from '../../command'; @serializable export class LeapActionCommand extends Command { - public constructor(public readonly clientTimeMs: number = 0) { + public constructor(public readonly clientTimeMs: number) { super(); } diff --git a/shared/src/commands/types/actions/move-action.ts b/shared/src/commands/types/actions/move-action.ts index 7f2a455..ea82c43 100644 --- a/shared/src/commands/types/actions/move-action.ts +++ b/shared/src/commands/types/actions/move-action.ts @@ -6,7 +6,7 @@ import { Command } from '../../command'; export class MoveActionCommand extends Command { public constructor( public readonly direction: vec2, - public readonly clientTimeMs: number = 0, + public readonly clientTimeMs: number, ) { super(); } diff --git a/shared/src/commands/types/actions/primary-action.ts b/shared/src/commands/types/actions/primary-action.ts index ea60b20..ea78ae7 100644 --- a/shared/src/commands/types/actions/primary-action.ts +++ b/shared/src/commands/types/actions/primary-action.ts @@ -6,8 +6,8 @@ import { Command } from '../../command'; export class PrimaryActionCommand extends Command { public constructor( public readonly position: vec2, - public readonly charge: number = 0, - public readonly clientTimeMs: number = 0, + public readonly charge: number, + public readonly clientTimeMs: number, ) { super(); } diff --git a/shared/src/helper/charge.ts b/shared/src/helper/charge.ts index 80cd5c8..9842cc9 100644 --- a/shared/src/helper/charge.ts +++ b/shared/src/helper/charge.ts @@ -1,11 +1,8 @@ import { clamp01 } from './clamp'; import { settings } from '../settings'; -export const holdDurationToCharge = (heldSeconds: number): number => - clamp01(heldSeconds / settings.chargeShotFullHoldSeconds); - export const chargeHeldSince = (heldSinceMs: number): number => - holdDurationToCharge((performance.now() - heldSinceMs) / 1000); + clamp01((performance.now() - heldSinceMs) / 1000 / settings.chargeShotFullHoldSeconds); export const strengthToCharge = (strength: number): number => clamp01( diff --git a/shared/src/objects/game-object.ts b/shared/src/objects/game-object.ts index 54afd7e..6c8890c 100644 --- a/shared/src/objects/game-object.ts +++ b/shared/src/objects/game-object.ts @@ -92,9 +92,7 @@ export abstract class GameObject { } public resetRemoteCalls() { - if (this.remoteCalls.length > 0) { - this.remoteCalls = []; - } + this.remoteCalls = []; } protected remoteCall(name: string & keyof this, ...args: Array) { diff --git a/shared/src/objects/types/planet-base.ts b/shared/src/objects/types/planet-base.ts index b193100..f2ef614 100644 --- a/shared/src/objects/types/planet-base.ts +++ b/shared/src/objects/types/planet-base.ts @@ -27,11 +27,16 @@ export class PlanetBase extends GameObject implements GroundSurface { public readonly isKeystone: boolean = false, ) { super(id); - this.center = vertices.reduce((sum, v) => vec2.add(sum, sum, v), vec2.create()); + this.center = vec2.create(); + for (const vertex of vertices) { + vec2.add(this.center, this.center, vertex); + } vec2.scale(this.center, this.center, 1 / vertices.length); - this.radius = - vertices.reduce((sum, v) => sum + vec2.distance(this.center, v), 0) / - vertices.length; + let totalRadius = 0; + for (const vertex of vertices) { + totalRadius += vec2.distance(this.center, vertex); + } + this.radius = totalRadius / vertices.length; } public get team(): CharacterTeam { diff --git a/shared/src/physics/evaluate-sdf.ts b/shared/src/physics/evaluate-sdf.ts index e8f3cb5..3a51c65 100644 --- a/shared/src/physics/evaluate-sdf.ts +++ b/shared/src/physics/evaluate-sdf.ts @@ -2,7 +2,7 @@ import { vec2 } from 'gl-matrix'; import { Sdf } from './sdf'; export const evaluateSdf = (target: vec2, objects: Array): number => { - let minimum = 1000000; + let minimum = Infinity; for (let i = 0; i < objects.length; i++) { const object = objects[i]; if (object.canCollide) { diff --git a/shared/src/settings.ts b/shared/src/settings.ts index 1a6a0fb..0227d43 100644 --- a/shared/src/settings.ts +++ b/shared/src/settings.ts @@ -62,7 +62,6 @@ export const settings = { chargeShotSpeedMin: 2500, chargeShotSpeedMax: 3400, backgroundGradient: [rgb255(90, 38, 43), rgb255(43, 39, 73)], - blueColor, bluePlanetColor: blueColorDim, npcNames: [ 'Adam', @@ -114,7 +113,6 @@ export const settings = { 'Will', 'Wyatt', ], - redColor, redPlanetColor: redColorDim, colorIndices: { [CharacterTeam.blue]: 0, diff --git a/test/backpressure.test.ts b/test/backpressure.test.ts index 921bf88..b82fa1d 100644 --- a/test/backpressure.test.ts +++ b/test/backpressure.test.ts @@ -33,7 +33,7 @@ class FakeSocket { this.sent.push({ event, payload }); } - public get lastBatch(): Array<{ type: string }> { + public get lastBatch(): Array<{ constructor: { name: string } }> { const last = [...this.sent] .reverse() .find((m) => m.event === TransportEvents.ServerToPlayer); @@ -61,7 +61,8 @@ const queueOneSnapshot = (player: ReturnType['player']) => { player.queueCommandSend(new PropertyUpdatesForObjects([], 1)); }; -const typesIn = (batch: Array<{ type: string }>) => batch.map((c) => c.type); +const typesIn = (batch: Array<{ constructor: { name: string } }>) => + batch.map((c) => c.constructor.name); describe('slow-client backpressure', () => { beforeEach(() => { -- 2.54.0 From 244620105ac77ac6d6ad8a4a06d9eb620deacc18 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Mon, 14 Sep 2026 21:29:18 +0100 Subject: [PATCH 11/15] astra --- README.md | 29 ++ backend/src/game-server.ts | 32 +- backend/src/objects/character-physical.ts | 50 +-- backend/src/objects/planet-physical.ts | 11 +- backend/src/objects/projectile-physical.ts | 16 +- backend/src/options.ts | 2 +- backend/src/players/player-container.ts | 22 +- backend/src/players/player.ts | 48 ++- frontend/src/index.ts | 26 +- .../src/scripts/commands/command-socket.ts | 5 + .../src/scripts/commands/keyboard-listener.ts | 29 +- .../src/scripts/commands/mouse-listener.ts | 1 + .../src/scripts/commands/touch-listener.ts | 80 +++- frontend/src/scripts/game.ts | 109 ++--- .../src/scripts/helper/handle-full-screen.ts | 31 +- .../interpolators/linear-interpolator.ts | 5 +- .../prediction/client-character-world.ts | 7 +- .../prediction/local-character-predictor.ts | 152 +++++-- .../src/scripts/helper/server-timeline.ts | 19 +- frontend/src/scripts/helper/storage.ts | 16 + frontend/src/scripts/join-form-handler.ts | 94 +++-- .../src/scripts/landing-page-background.ts | 8 +- .../scripts/objects/game-object-container.ts | 46 ++- frontend/src/scripts/objects/types/camera.ts | 17 +- .../scripts/objects/types/character-view.ts | 6 +- .../scripts/objects/types/projectile-view.ts | 18 +- frontend/src/scripts/options-handler.ts | 16 +- frontend/src/scripts/sound-handler.ts | 14 +- frontend/webpack.config.js | 2 + package.json | 2 + shared/src/commands/command-receiver.ts | 2 +- .../commands/types/input-acknowledgement.ts | 2 + shared/src/helper/ease.ts | 32 -- shared/src/helper/finite.ts | 9 +- shared/src/helper/random.ts | 6 +- shared/src/objects/game-object.ts | 2 +- shared/src/physics/character-movement.ts | 57 +-- shared/src/physics/march-circle.ts | 16 +- shared/src/physics/resolve-circle-movement.ts | 54 +-- .../physics-determinism.test.mjs.snap | 2 +- test/backend-regressions.test.ts | 390 +++++++++++++++++- test/backpressure.test.ts | 57 ++- test/client-object-lifecycle.test.ts | 206 +++++++++ test/client-send-cadence.test.ts | 17 + test/frontend-regressions.test.ts | 302 ++++++++++++++ test/physics-determinism.test.mjs | 36 ++ test/prediction-reconciliation.test.ts | 136 +++++- test/render-smoothness.test.ts | 233 +++++++---- test/serialization.test.mjs | 8 + test/setup.mjs | 3 +- test/shared-regressions.test.ts | 111 +++++ 51 files changed, 2078 insertions(+), 516 deletions(-) create mode 100644 frontend/src/scripts/helper/storage.ts create mode 100644 test/client-object-lifecycle.test.ts create mode 100644 test/frontend-regressions.test.ts create mode 100644 test/shared-regressions.test.ts diff --git a/README.md b/README.md index ce57285..3ae3aca 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,35 @@ A 2-dimensional multiplayer game utilising ray tracing. For optimised 2D ray tracing, [SDF-2D](https://github.com/schmelczerandras/sdf-2d) is used. +## Development + +Run `npm install && npm run init` once, then `npm run dev`. Run `npm test` for +regressions, `npm run lint:check` for formatting and lint, and `npm run build` for +all production bundles. Tests rebuild the shared library before running. + +## Movement and networking + +The server owns physics and combat. It simulates at 200 Hz, keeps each player's +latest held movement direction, and sends snapshots at 25 Hz. Clients send input +changes immediately on their next frame and send idle heartbeats at 30 Hz. + +The local player replays input from the latest authoritative pose using the same +physics as the server. The acknowledgement includes movement state, remaining leap +cooldown, and the age of the acknowledged input, so the client can align replay +without synchronizing wall clocks. Nearby planets are included even when offscreen +because their gravity affects prediction. Replay stops after 400 ms without a fresh +anchor; only reconciliation corrections are eased over 80 ms. The camera follows +the displayed player directly. + +Other objects interpolate snapshots on a server timeline delayed by 100 ms, with +at most 60 ms of extrapolation. Creation and deletion use that same timeline. +Remote effects use that timeline too; feedback for the local player is immediate. +End-of-round slow motion comes from server snapshots and disables local prediction; +outgoing velocity and projectile fade rates are expressed per wall-clock second. +When a connection backs up, regenerable state can be skipped; lifecycle messages +keep their timestamps. The frontend bundles one `gl-matrix` instance so prediction +and server physics both use JavaScript's full numeric precision. + ## Deployment CI/CD runs on Forgejo Actions (`.forgejo/workflows/deploy.yml`). On a push to diff --git a/backend/src/game-server.ts b/backend/src/game-server.ts index 7c081c6..5e8d20c 100644 --- a/backend/src/game-server.ts +++ b/backend/src/game-server.ts @@ -109,6 +109,9 @@ export class GameServer implements GameEvents { this.matchPointAnnounced = {}; this.isInEndGame = false; this.timeScaling = 1; + this.physicsAccumulator = 0; + this.timeSinceLastServerStateUpdate = 0; + this.timeSinceLastPointUpdate = 0; previousPlayers?.queueCommandForEachClient(new GameStartCommand()); previousPlayers?.sendQueuedCommands(); } @@ -139,15 +142,18 @@ export class GameServer implements GameEvents { const player = this.players.createPlayer(playerInfo, socket, carried); - const onPlayerToServer = (json: string) => { + const onPlayerToServer = (json: unknown) => { try { - if (json.length > settings.maxInboundMessageBytes) { + if (typeof json !== 'string' || json.length > settings.maxInboundMessageBytes) { return; } if (!this.allowInboundMessage(socket)) { return; } const commands: Array = deserialize(json); + if (!Array.isArray(commands)) { + return; + } commands.forEach((c) => player.handleCommand(c)); } catch (e) { console.error('Error while processing command', e); @@ -354,14 +360,20 @@ export class GameServer implements GameEvents { // of noise on every interpolated position and on the client's replay anchor. const simulatedThroughMs = frameStartMs - this.physicsAccumulator * 1000; - const propertyUpdates = new Map(); - this.players.stepCommunication(delta, simulatedThroughMs, (object) => { - if (!propertyUpdates.has(object)) { - propertyUpdates.set(object, object.getPropertyUpdates()); - } - return propertyUpdates.get(object); - }); - this.objects.resetRemoteCalls(); + if (substeps > 0) { + const propertyUpdates = new Map(); + this.players.stepCommunication( + substeps * fixedDelta, + simulatedThroughMs, + (object) => { + if (!propertyUpdates.has(object)) { + propertyUpdates.set(object, object.getPropertyUpdates(1 / this.timeScaling)); + } + return propertyUpdates.get(object); + }, + ); + this.objects.resetRemoteCalls(); + } const elapsed = Number(process.hrtime.bigint() - frameStart) / 1e9; setTimeout(() => this.handlePhysics(), Math.max(0, fixedDelta - elapsed) * 1000); diff --git a/backend/src/objects/character-physical.ts b/backend/src/objects/character-physical.ts index 4d847e6..d38b1da 100644 --- a/backend/src/objects/character-physical.ts +++ b/backend/src/objects/character-physical.ts @@ -62,8 +62,7 @@ export class CharacterPhysical extends CharacterBase implements Physical { private lastSyncedHealth = settings.playerMaxHealth; private killStreak = 0; - private movementDirections: Array = []; - private lastMovementDirection = vec2.create(); + private readonly movementDirection = vec2.create(); private readonly previousPose = [0, 1, 2].map(() => new Circle(vec2.create(), 0)); private readonly velocities = [0, 1, 2].map(() => new Circle(vec2.create(), 0)); @@ -136,6 +135,7 @@ export class CharacterPhysical extends CharacterBase implements Physical { vec2.clone(this.rightFoot.lastNormal), this.groundPlanet?.id ?? null, this.secondsSinceOnSurface, + Math.max(0, settings.leapCooldownSeconds - this.timeSinceLastLeap), ); } @@ -162,7 +162,7 @@ export class CharacterPhysical extends CharacterBase implements Physical { } public setMoveDirection(direction: vec2) { - this.movementDirections.push(direction); + vec2.normalize(this.movementDirection, direction); } public addKill(victimName: string, charge = 0) { @@ -170,11 +170,13 @@ export class CharacterPhysical extends CharacterBase implements Physical { this.killStreak++; this.remoteCall('setKillCount', this.killCount); - this.health = Math.min( - settings.playerMaxHealth, - this.health + settings.playerKillHealthReward, - ); - this.syncHealth(); + if (this.isAlive) { + this.health = Math.min( + settings.playerMaxHealth, + this.health + settings.playerKillHealthReward, + ); + this.syncHealth(); + } this.remoteCall('onKillConfirmed', victimName, this.killStreak, charge); } @@ -293,7 +295,7 @@ export class CharacterPhysical extends CharacterBase implements Physical { this.timeSinceLastLeap = 0; this.projectileStrength -= settings.leapStrengthCost; - applyLeapImpulse(this, this.lastMovementDirection); + applyLeapImpulse(this, this.movementDirection); this.remoteCall('onLeap'); } @@ -311,8 +313,14 @@ export class CharacterPhysical extends CharacterBase implements Physical { } } - public getPropertyUpdates(): PropertyUpdatesForObject { - const [headVelocity, leftFootVelocity, rightFootVelocity] = this.velocities; + public getPropertyUpdates(timeScale = 1): PropertyUpdatesForObject { + const [headVelocity, leftFootVelocity, rightFootVelocity] = this.velocities.map( + (velocity) => + new Circle( + vec2.scale(vec2.create(), velocity.center, timeScale), + velocity.radius * timeScale, + ), + ); return new PropertyUpdatesForObject(this.id, [ new UpdatePropertyCommand('head', this.head, headVelocity), new UpdatePropertyCommand('leftFoot', this.leftFoot, leftFootVelocity), @@ -320,7 +328,7 @@ export class CharacterPhysical extends CharacterBase implements Physical { new UpdatePropertyCommand( 'strength', this.projectileStrength, - settings.playerStrengthRegenerationPerSeconds, + settings.playerStrengthRegenerationPerSeconds * timeScale, ), ]); } @@ -387,27 +395,11 @@ export class CharacterPhysical extends CharacterBase implements Physical { stepCharacterMovement( this, this.movementWorld, - this.averageAndResetMovementDirections(), + this.movementDirection, deltaTimeInSeconds, ); } - private averageAndResetMovementDirections(): vec2 { - const direction = vec2.create(); - if (this.movementDirections.length === 0) { - vec2.copy(direction, this.lastMovementDirection); - } else { - for (const d of this.movementDirections) { - vec2.add(direction, direction, d); - } - vec2.scale(direction, direction, 1 / this.movementDirections.length); - this.lastMovementDirection = - this.movementDirections[this.movementDirections.length - 1]; - this.movementDirections = []; - } - return vec2.normalize(direction, direction); - } - private animateScaling(q: number) { this.head.radius = headRadius * q; this.leftFoot.radius = this.rightFoot.radius = feetRadius * q; diff --git a/backend/src/objects/planet-physical.ts b/backend/src/objects/planet-physical.ts index 7afdc3d..4c77ba4 100644 --- a/backend/src/objects/planet-physical.ts +++ b/backend/src/objects/planet-physical.ts @@ -95,6 +95,9 @@ export class PlanetPhysical extends PlanetBase implements Physical { let blue = 0; let red = 0; for (const c of this.presentCharacters) { + if (!c.isAlive) { + continue; + } if (c.team === CharacterTeam.blue) { blue++; } else if (c.team === CharacterTeam.red) { @@ -172,10 +175,14 @@ export class PlanetPhysical extends PlanetBase implements Physical { this.ownership = clamp01(this.ownership); } - public getPropertyUpdates(): PropertyUpdatesForObject { + public getPropertyUpdates(timeScale = 1): PropertyUpdatesForObject { return new PropertyUpdatesForObject(this.id, [ new UpdatePropertyCommand('ownership', this.ownership, 0), - new UpdatePropertyCommand('rotation', this.rotation, this.angularVelocity), + new UpdatePropertyCommand( + 'rotation', + this.rotation, + this.angularVelocity * timeScale, + ), ]); } } diff --git a/backend/src/objects/projectile-physical.ts b/backend/src/objects/projectile-physical.ts index ce0e2bd..6ced842 100644 --- a/backend/src/objects/projectile-physical.ts +++ b/backend/src/objects/projectile-physical.ts @@ -93,13 +93,25 @@ export class ProjectilePhysical extends ProjectileBase implements Physical { } } - public getPropertyUpdates(): PropertyUpdatesForObject { + public getPropertyUpdates(timeScale = 1): PropertyUpdatesForObject { return new PropertyUpdatesForObject(this.id, [ - new UpdatePropertyCommand('center', this.center, this.object.velocity), + new UpdatePropertyCommand( + 'center', + this.center, + vec2.scale(vec2.create(), this.object.velocity, timeScale), + ), + new UpdatePropertyCommand( + 'strength', + this.strength, + -settings.projectileFadeSpeed * timeScale, + ), ]); } public step(deltaTimeInSeconds: number) { + if (!this.isAlive) { + return; + } super.step(deltaTimeInSeconds); if (this.strength <= 0) { diff --git a/backend/src/options.ts b/backend/src/options.ts index 99482e3..e3b2dab 100644 --- a/backend/src/options.ts +++ b/backend/src/options.ts @@ -14,6 +14,6 @@ export const defaultOptions: Options = { name: isDevelopment ? 'Dev server' : 'Doppler server', playerLimit: 16, npcCount: 8, - seed: Math.random(), + seed: (Math.random() * 0x100000000) >>> 0, scoreLimit: 2500, }; diff --git a/backend/src/players/player-container.ts b/backend/src/players/player-container.ts index 7b5bac9..0232e9f 100644 --- a/backend/src/players/player-container.ts +++ b/backend/src/players/player-container.ts @@ -30,13 +30,15 @@ export class PlayerContainer { } private createNPCs() { - const newNpcCount = Math.min( - this.playerMaxCount - this._players.length - this.npcs.length, - this.npcMaxCount - this.npcs.length, - ); + const newNpcCount = + Math.min(this.playerMaxCount, this.npcMaxCount) - + this._players.length - + this.npcs.length; for (let i = 0; i < newNpcCount; i++) { const name = `🤖 ${Random.choose(settings.npcNames)}`; - this.npcs.push(new NPC({ name }, this, this.objects, this.getTeamOfNextNpc())); + this.npcs.push( + new NPC({ name }, this, this.objects, this.teamOfNext(this.players)), + ); } } @@ -45,7 +47,7 @@ export class PlayerContainer { socket: Socket, carried?: CarriedScore, ): Player { - const team = carried ? carried.team : this.getTeamOfNextPlayer(); + const team = carried ? carried.team : this.teamOfNext(this._players); const npcToReplace = this.npcs.find((n) => n.team === team) ?? this.npcs.find((n) => n.team !== team); @@ -100,14 +102,6 @@ export class PlayerContainer { this._players.forEach((p) => p.sendQueuedCommandsToClient()); } - private getTeamOfNextPlayer(): CharacterTeam { - return this.teamOfNext(this._players); - } - - private getTeamOfNextNpc(): CharacterTeam { - return this.teamOfNext(this.players); - } - private teamOfNext(players: Array): CharacterTeam { const blueCount = players.filter((p) => p.team === CharacterTeam.blue).length; const redCount = players.filter((p) => p.team === CharacterTeam.red).length; diff --git a/backend/src/players/player.ts b/backend/src/players/player.ts index e29951d..20912ca 100644 --- a/backend/src/players/player.ts +++ b/backend/src/players/player.ts @@ -29,6 +29,7 @@ import { finiteInRange, finiteVec2, isFiniteNumber, + boundRadius, } from 'shared'; import { Socket } from 'socket.io'; import { BoundingBox } from '../physics/bounding-box'; @@ -36,6 +37,7 @@ import { PhysicalContainer } from '../physics/physical-container'; import { PlayerContainer } from './player-container'; import { PlayerBase, Score } from './player-base'; import { CharacterPhysical } from '../objects/character-physical'; +import { planetsNear } from '../objects/planet-physical'; const pingIntervalSeconds = 1; const minimumAspectRatio = 0.2; @@ -59,7 +61,7 @@ export class Player extends PlayerBase { private lastInputClientTimeMs = 0; private lastInputReceiptMs = 0; - private lastLeapClientTimeMs = 0; + private lastLeapClientTimeMs = -1; private timeSinceLastPing = 0; private lastPingSentMs = 0; @@ -178,7 +180,7 @@ export class Player extends PlayerBase { } // Subtracted, not zeroed: zeroing would stretch every interval to the next physics frame. - if ((this.timeSinceLastMessage += deltaTime) > settings.updateMessageInterval) { + if ((this.timeSinceLastMessage += deltaTime) >= settings.updateMessageInterval) { this.timeSinceLastMessage = Math.min( this.timeSinceLastMessage - settings.updateMessageInterval, settings.updateMessageInterval, @@ -206,6 +208,14 @@ export class Player extends PlayerBase { ); if (this.character) { inViewArea.add(this.character); + // Prediction needs every gravity source, including planets outside a narrow viewport. + for (const planet of planetsNear( + this.objectContainer, + this.character.center, + boundRadius + settings.maxGravityDistance, + )) { + inViewArea.add(planet); + } } const created = [...inViewArea].filter((o) => !this.objectsInViewArea.has(o)); @@ -258,19 +268,45 @@ export class Player extends PlayerBase { } private get bufferedBytes(): number { - const conn = this.socket.conn as - | { transport?: { socket?: { bufferedAmount?: unknown } } } + const conn = this.socket.conn as unknown as + | { + transport?: { socket?: { bufferedAmount?: unknown } }; + writeBuffer?: Array<{ data?: unknown }>; + } | undefined; const buffered = conn?.transport?.socket?.bufferedAmount; - return typeof buffered === 'number' ? buffered : 0; + let bytes = typeof buffered === 'number' ? buffered : 0; + // Engine.IO queues packets here while the transport is waiting for its write callback. + for (const { data } of conn?.writeBuffer ?? []) { + if (typeof data === 'string') { + bytes += Buffer.byteLength(data); + } else if (ArrayBuffer.isView(data) || data instanceof ArrayBuffer) { + bytes += data.byteLength; + } + } + return bytes; } public sendQueuedCommandsToClient() { // Only state the next snapshot regenerates may be dropped; create/delete are one-shot. if (this.bufferedBytes > settings.maxBufferedBytesPerClient) { - this.commandsToBeSent = this.commandsToBeSent.filter( + const hasReliableCommands = this.commandsToBeSent.some( (c) => !sheddableWhenBackedUp.includes(c.constructor.name), ); + this.commandsToBeSent = hasReliableCommands + ? this.commandsToBeSent.flatMap((c): Array => { + // Lifecycle events still need their snapshot time to enter the render timeline. + if (c.constructor.name === PropertyUpdatesForObjects.name) { + return [ + new PropertyUpdatesForObjects( + [], + (c as PropertyUpdatesForObjects).timestamp, + ), + ]; + } + return sheddableWhenBackedUp.includes(c.constructor.name) ? [] : [c]; + }) + : []; } if (this.commandsToBeSent.length === 0) { return; diff --git a/frontend/src/index.ts b/frontend/src/index.ts index ea8ff1d..4c7780f 100644 --- a/frontend/src/index.ts +++ b/frontend/src/index.ts @@ -15,7 +15,7 @@ import '../static/favicons/favicon-32x32.png'; import '../static/favicons/favicon.ico'; import ResizeObserver from 'resize-observer-polyfill'; import { LandingPageBackground } from './scripts/landing-page-background'; -import { JoinFormHandler } from './scripts/join-form-handler'; +import { JoinFormHandler, PlayerDecision } from './scripts/join-form-handler'; import { handleFullScreen } from './scripts/helper/handle-full-screen'; import { Game } from './scripts/game'; import { initializeOptions } from './scripts/options-handler'; @@ -25,6 +25,7 @@ import { CharacterView } from './scripts/objects/types/character-view'; import { LampView } from './scripts/objects/types/lamp-view'; import { PlanetView } from './scripts/objects/types/planet-view'; import { ProjectileView } from './scripts/objects/types/projectile-view'; +import { loadStoredValue, saveStoredValue } from './scripts/helper/storage'; glMatrix.setMatrixArrayType(Array); @@ -88,9 +89,9 @@ const applyServerContainerShadows = () => { const main = async () => { let game: Game | undefined; - const storedUserName = localStorage.getItem('userName'); - if (storedUserName) { - nameInput.value = JSON.parse(storedUserName); + const storedUserName = loadStoredValue('userName'); + if (typeof storedUserName === 'string') { + nameInput.value = storedUserName; } const firstClickListener = () => { @@ -138,12 +139,17 @@ const main = async () => { const background = new LandingPageBackground(canvas); const joinHandler = new JoinFormHandler(joinGameForm, serverContainer); - await background.renderer; - setVisible(spinner, false); + let playerDecision: PlayerDecision; + try { + await background.renderer; + setVisible(spinner, false); + playerDecision = await joinHandler.getPlayerDecision(); + } finally { + background.destroy(); + joinHandler.destroy(); + } - const playerDecision = await joinHandler.getPlayerDecision(); - - localStorage.setItem('userName', JSON.stringify(playerDecision.name)); + saveStoredValue('userName', playerDecision.name); if (!history.state) { history.pushState(true, ''); @@ -151,7 +157,6 @@ const main = async () => { setDisplayed(landingUI, null); setVisible(spinner, true); - background.destroy(); game = new Game(playerDecision, canvas, overlay); const gameOver = game.start(); await game.started; @@ -168,6 +173,7 @@ const main = async () => { main().catch((error) => { console.error(error); setVisible(spinner, false); + setDisplayed(landingUI, 'flex'); joinNotice.innerText = 'Something went wrong. Please reload the page.'; setDisplayed(joinNotice, 'block'); }); diff --git a/frontend/src/scripts/commands/command-socket.ts b/frontend/src/scripts/commands/command-socket.ts index b68484d..aa94639 100644 --- a/frontend/src/scripts/commands/command-socket.ts +++ b/frontend/src/scripts/commands/command-socket.ts @@ -22,6 +22,11 @@ export class CommandSocket { this.commandQueue.push(command); } + public reset() { + this.commandQueue = []; + this.lastSendMs = -Infinity; + } + public sendQueuedCommands() { // socket.io-client would buffer emits made while disconnected and flush them before the re-join. if (!this.socket.connected) { diff --git a/frontend/src/scripts/commands/keyboard-listener.ts b/frontend/src/scripts/commands/keyboard-listener.ts index 7674247..b6b0938 100644 --- a/frontend/src/scripts/commands/keyboard-listener.ts +++ b/frontend/src/scripts/commands/keyboard-listener.ts @@ -2,6 +2,18 @@ import { vec2 } from 'gl-matrix'; import type { Command } from 'shared'; import { InputGenerator } from './input-generator'; +const movementKeys = new Set([ + 'KeyW', + 'KeyA', + 'KeyS', + 'KeyD', + 'ArrowUp', + 'ArrowLeft', + 'ArrowDown', + 'ArrowRight', +]); +const leapKeys = new Set(['Space', 'ShiftLeft', 'ShiftRight']); + export class KeyboardListener extends InputGenerator { private keysDown: Set = new Set(); @@ -14,18 +26,23 @@ export class KeyboardListener extends InputGenerator { private keyDownListener = (event: KeyboardEvent) => { const key = event.code; + if (!movementKeys.has(key) && !leapKeys.has(key)) { + return; + } + event.preventDefault(); if (this.keysDown.has(key)) { return; } this.keysDown.add(key); - if (key === 'Space' || key === 'ShiftLeft' || key === 'ShiftRight') { + if (leapKeys.has(key)) { this.sendLeap(); + } else { + this.sendMovement(); } - this.sendMovement(); }; private keyUpListener = (event: KeyboardEvent) => { - if (this.keysDown.delete(event.code)) { + if (this.keysDown.delete(event.code) && movementKeys.has(event.code)) { this.sendMovement(); } }; @@ -35,6 +52,12 @@ export class KeyboardListener extends InputGenerator { this.sendMovement(); }; + public resendMovement() { + if ([...this.keysDown].some((key) => movementKeys.has(key))) { + this.sendMovement(); + } + } + private sendMovement() { const down = (...codes: Array) => codes.some((c) => this.keysDown.has(c)) ? 1 : 0; diff --git a/frontend/src/scripts/commands/mouse-listener.ts b/frontend/src/scripts/commands/mouse-listener.ts index 5055c67..611c9e9 100644 --- a/frontend/src/scripts/commands/mouse-listener.ts +++ b/frontend/src/scripts/commands/mouse-listener.ts @@ -28,6 +28,7 @@ export class MouseListener extends InputGenerator { private mouseDownListener = (event: MouseEvent) => { if (event.button === 0) { + this.mouseMoveListener(event); this.primaryDownAt = performance.now(); ChargeIndicator.begin(event.clientX, event.clientY, true); } diff --git a/frontend/src/scripts/commands/touch-listener.ts b/frontend/src/scripts/commands/touch-listener.ts index 31aa502..3af1895 100644 --- a/frontend/src/scripts/commands/touch-listener.ts +++ b/frontend/src/scripts/commands/touch-listener.ts @@ -21,12 +21,13 @@ export class TouchListener extends InputGenerator { private isJoystickActive = false; private touchStartPosition = vec2.create(); + private movement = vec2.create(); private primaryDownAt: number | null = null; private gestureTouchId: number | null = null; private fireDownAt: number | null = null; + private fireTouchId: number | null = null; private fireButtonCenter = vec2.create(); - private fireAim: vec2 | null = null; constructor( private readonly target: HTMLElement, @@ -46,6 +47,7 @@ export class TouchListener extends InputGenerator { this.fireButton.addEventListener('touchstart', this.fireButtonDownListener); this.fireButton.addEventListener('touchmove', this.fireButtonMoveListener); this.fireButton.addEventListener('touchend', this.fireButtonUpListener); + this.fireButton.addEventListener('touchcancel', this.fireButtonCancelListener); this.leapButton.className = 'touch-button leap'; this.leapButton.addEventListener('touchstart', this.leapButtonListener); @@ -54,11 +56,12 @@ export class TouchListener extends InputGenerator { target.addEventListener('touchmove', this.touchMoveListener); target.addEventListener('touchend', this.touchEndListener); target.addEventListener('touchcancel', this.touchCancelListener); + window.addEventListener('blur', this.cancelTouches); } - private findGestureTouch(list: TouchList): Touch | undefined { + private findTouch(list: TouchList, id: number | null): Touch | undefined { for (let i = 0; i < list.length; i++) { - if (list[i].identifier === this.gestureTouchId) { + if (list[i].identifier === id) { return list[i]; } } @@ -78,7 +81,7 @@ export class TouchListener extends InputGenerator { ), 0, ); - } else { + } else if (this.gestureTouchId === null) { this.gestureTouchId = touch.identifier; vec2.set(this.touchStartPosition, touch.clientX, touch.clientY); this.primaryDownAt = performance.now(); @@ -89,7 +92,7 @@ export class TouchListener extends InputGenerator { private touchMoveListener = (event: TouchEvent) => { event.preventDefault(); - const touch = this.findGestureTouch(event.touches); + const touch = this.findTouch(event.touches, this.gestureTouchId); if (!touch) { return; } @@ -117,16 +120,17 @@ export class TouchListener extends InputGenerator { if (deltaLength > deadZone) { vec2.set(delta, delta[0], -delta[1]); - this.sendMove(vec2.normalize(delta, delta)); + vec2.normalize(this.movement, delta); } else { - this.sendMove(vec2.create()); + vec2.zero(this.movement); } + this.sendMove(vec2.clone(this.movement)); }; private touchEndListener = (event: TouchEvent) => { event.preventDefault(); - const touch = this.findGestureTouch(event.changedTouches); + const touch = this.findTouch(event.changedTouches, this.gestureTouchId); if (!touch) { return; } @@ -147,7 +151,7 @@ export class TouchListener extends InputGenerator { }; private touchCancelListener = (event: TouchEvent) => { - if (!this.findGestureTouch(event.changedTouches)) { + if (!this.findTouch(event.changedTouches, this.gestureTouchId)) { return; } this.gestureTouchId = null; @@ -161,7 +165,30 @@ export class TouchListener extends InputGenerator { private releaseJoystick() { this.isJoystickActive = false; this.joystick.remove(); - this.sendMove(vec2.create()); + vec2.zero(this.movement); + this.sendMove(vec2.clone(this.movement)); + } + + public resendMovement() { + if (this.isJoystickActive) { + this.sendMove(vec2.clone(this.movement)); + } + } + + private cancelTouches = () => { + this.gestureTouchId = null; + this.primaryDownAt = null; + this.cancelFire(); + if (this.isJoystickActive) { + this.releaseJoystick(); + } + }; + + private cancelFire() { + this.fireTouchId = null; + this.fireDownAt = null; + this.fireAimLine.style.opacity = '0'; + ChargeIndicator.end(); } private swallowTouch(event: TouchEvent) { @@ -176,6 +203,11 @@ export class TouchListener extends InputGenerator { private fireButtonDownListener = (event: TouchEvent) => { this.swallowTouch(event); + const touch = event.changedTouches[0]; + if (this.fireTouchId !== null || !touch) { + return; + } + this.fireTouchId = touch.identifier; this.fireDownAt = performance.now(); const rect = this.fireButton.getBoundingClientRect(); vec2.set( @@ -183,18 +215,16 @@ export class TouchListener extends InputGenerator { rect.left + rect.width / 2, rect.top + rect.height / 2, ); - this.fireAim = null; ChargeIndicator.begin(this.fireButtonCenter[0], this.fireButtonCenter[1]); }; private fireButtonMoveListener = (event: TouchEvent) => { this.swallowTouch(event); - const touch = event.targetTouches[0] ?? event.changedTouches[0]; + const touch = this.findTouch(event.touches, this.fireTouchId); if (this.fireDownAt === null || !touch) { return; } const aim = this.aimFromTouch(touch); - this.fireAim = aim; if (aim) { this.fireAimLine.style.opacity = '1'; this.fireAimLine.style.transform = `translateY(-50%) rotate(${Math.atan2(-aim[1], aim[0])}rad)`; @@ -215,22 +245,21 @@ export class TouchListener extends InputGenerator { private fireButtonUpListener = (event: TouchEvent) => { this.swallowTouch(event); - ChargeIndicator.end(); - this.fireAimLine.style.opacity = '0'; - if (this.fireDownAt === null) { + const touch = this.findTouch(event.changedTouches, this.fireTouchId); + if (this.fireDownAt === null || !touch) { return; } const charge = chargeHeldSince(this.fireDownAt); - this.fireDownAt = null; + const aim = this.aimFromTouch(touch); + this.cancelFire(); const character = this.game.gameObjects.localPlayer; if (!character) { return; } - const direction = this.fireAim ?? character.facingDirection; - this.fireAim = null; + const direction = aim ?? character.facingDirection; this.sendPrimary( vec2.scaleAndAdd( vec2.create(), @@ -242,10 +271,20 @@ export class TouchListener extends InputGenerator { ); }; + private fireButtonCancelListener = (event: TouchEvent) => { + this.swallowTouch(event); + if (this.findTouch(event.changedTouches, this.fireTouchId)) { + this.cancelFire(); + } + }; + public update() { if (!this.fireButton.parentElement) { this.overlay.append(this.fireButton, this.leapButton); } + if (this.isJoystickActive && !this.joystick.parentElement) { + this.overlay.appendChild(this.joystick); + } const character = this.game.gameObjects.localPlayer; if (character) { @@ -261,13 +300,16 @@ export class TouchListener extends InputGenerator { this.target.removeEventListener('touchmove', this.touchMoveListener); this.target.removeEventListener('touchend', this.touchEndListener); this.target.removeEventListener('touchcancel', this.touchCancelListener); + window.removeEventListener('blur', this.cancelTouches); this.fireButton.removeEventListener('touchstart', this.fireButtonDownListener); this.fireButton.removeEventListener('touchmove', this.fireButtonMoveListener); this.fireButton.removeEventListener('touchend', this.fireButtonUpListener); + this.fireButton.removeEventListener('touchcancel', this.fireButtonCancelListener); this.leapButton.removeEventListener('touchstart', this.leapButtonListener); this.fireButton.remove(); this.leapButton.remove(); + this.joystick.remove(); } } diff --git a/frontend/src/scripts/game.ts b/frontend/src/scripts/game.ts index 2267d64..e76fd80 100644 --- a/frontend/src/scripts/game.ts +++ b/frontend/src/scripts/game.ts @@ -59,8 +59,6 @@ export class Game extends CommandReceiver { private resolveStarted!: () => void; private isBetweenGames = false; private isActive = true; - private isEnding = false; - private timeScaling = 1; private readonly keyboardListener: KeyboardListener; private readonly mouseListener: MouseListener; @@ -92,7 +90,7 @@ export class Game extends CommandReceiver { c.lastLeapClientTimeMs, c.ackAgeMs, ), - [GameEndCommand.name]: () => (this.isEnding = true), + [GameEndCommand.name]: () => (localCharacterPredictor.enabled = false), [UpdateMinimap.name]: (c: UpdateMinimap) => (this.lastMinimap = c), [GameStartCommand.name]: () => this.initialize(), }; @@ -125,6 +123,7 @@ export class Game extends CommandReceiver { this.socket?.close(); serverTimeline.reset(); localCharacterPredictor.reset(); + localCharacterPredictor.enabled = true; ScreenShake.reset(); this.gameObjects.reset(); this.gameObjects = new GameObjectContainer(this); @@ -132,10 +131,8 @@ export class Game extends CommandReceiver { this.keystoneArrow = undefined; this.connectionBanner = undefined; this.lastMinimap = undefined; - this.isEnding = false; this.lastAnnouncementText = ''; this.announcementText.innerText = ''; - this.timeScaling = 1; this.overlay.append( this.scoreboard.element, this.minimap.element, @@ -172,6 +169,7 @@ export class Game extends CommandReceiver { serverTimeline.reset(); localCharacterPredictor.reset(); this.gameObjects.reset(); + this.socketReceiver.reset(); this.socket.emit(TransportEvents.PlayerJoining, { ...this.playerDecision, reconnectToken: this.reconnectToken, @@ -220,49 +218,64 @@ export class Game extends CommandReceiver { } public async start(): Promise { - this.initialize(); - this.resolveStarted(); - const noiseTexture = await renderNoise([256, 256], 2, 1); + setFrameTimeMs(0); + try { + this.initialize(); + this.resolveStarted(); + let renderError: unknown; + const noiseTexture = await renderNoise([256, 256], 2, 1); - await runAnimation( - this.canvas, - [ - PlanetShape.descriptor, - CharacterShape.descriptor, - { - ...CircleLight.descriptor, - shaderCombinationSteps: [0, 1, 2, 4, 8, 16], + await runAnimation( + this.canvas, + [ + PlanetShape.descriptor, + CharacterShape.descriptor, + { + ...CircleLight.descriptor, + shaderCombinationSteps: [0, 1, 2, 4, 8, 16], + }, + ], + (renderer, currentTime, deltaTime) => { + try { + return this.gameLoop(renderer, currentTime, deltaTime); + } catch (error) { + renderError = error; + return false; + } }, - ], - this.gameLoop.bind(this), - { - shadowTraceCount: 16, - paletteSize: settings.paletteDim.length, - colorPalette: settings.paletteDim, - enableHighDpiRendering: true, - lightCutoffDistance: settings.lightCutoffDistance, - lightOverlapReduction: settings.lightOverlapReduction, - textures: { - noiseTexture: { - source: noiseTexture, - overrides: { - maxFilter: FilteringOptions.LINEAR, - wrapS: WrapOptions.MIRRORED_REPEAT, - wrapT: WrapOptions.MIRRORED_REPEAT, + { + shadowTraceCount: 16, + paletteSize: settings.paletteDim.length, + colorPalette: settings.paletteDim, + enableHighDpiRendering: true, + lightCutoffDistance: settings.lightCutoffDistance, + lightOverlapReduction: settings.lightOverlapReduction, + textures: { + noiseTexture: { + source: noiseTexture, + overrides: { + maxFilter: FilteringOptions.LINEAR, + wrapS: WrapOptions.MIRRORED_REPEAT, + wrapT: WrapOptions.MIRRORED_REPEAT, + }, }, }, }, - }, - ); - - this.socket.close(); - this.gameObjects.reset(); - this.overlay.innerHTML = ''; - this.hideConnectionBanner(); - FeedbackHud.reset(); - this.keyboardListener.destroy(); - this.mouseListener.destroy(); - this.touchListener.destroy(); + ); + if (renderError) { + throw renderError; + } + } finally { + this.resolveStarted(); + this.socket?.close(); + this.gameObjects.reset(); + this.overlay.innerHTML = ''; + this.hideConnectionBanner(); + FeedbackHud.reset(); + this.keyboardListener.destroy(); + this.mouseListener.destroy(); + this.touchListener.destroy(); + } } public static rejectionText(reason: JoinRejectionReason): string { @@ -307,6 +320,11 @@ export class Game extends CommandReceiver { this.isActive = false; } + public resendMovement() { + this.keyboardListener.resendMovement(); + this.touchListener.resendMovement(); + } + private gameLoop( renderer: Renderer, currentTime: DOMHighResTimeStamp, @@ -333,11 +351,6 @@ export class Game extends CommandReceiver { this.lastAnnouncementText = ''; } - if (this.isEnding) { - this.timeScaling *= Math.pow(settings.endGameDeltaScaling, deltaTime); - deltaTime /= this.timeScaling; - } - this.gameObjects.step(deltaTime); this.gameObjects.render(renderer, this.overlay, shouldChangeLayout); this.touchListener.update(); diff --git a/frontend/src/scripts/helper/handle-full-screen.ts b/frontend/src/scripts/helper/handle-full-screen.ts index c5b414c..e0ab2b4 100644 --- a/frontend/src/scripts/helper/handle-full-screen.ts +++ b/frontend/src/scripts/helper/handle-full-screen.ts @@ -18,32 +18,25 @@ export const handleFullScreen = ( }; showButtons(); - - let currentWindowHeight = innerHeight; - - const followToggle = () => { - SoundHandler.play(Sounds.click); - showButtons(); - currentWindowHeight = innerHeight; - }; + document.addEventListener('fullscreenchange', showButtons); const triggerToggle = async () => { - await (isInFullScreen() - ? document.exitFullscreen() - : document.body.requestFullscreen()); - followToggle(); + try { + await (isInFullScreen() + ? document.exitFullscreen() + : document.body.requestFullscreen()); + SoundHandler.play(Sounds.click); + } catch { + // The browser may deny fullscreen (for example, when embedded). + } }; addEventListener('keydown', (e) => { if (e.key === 'F11') { - triggerToggle(); e.preventDefault(); - } - }); - - addEventListener('resize', () => { - if (isInFullScreen() && currentWindowHeight > innerHeight) { - followToggle(); + if (!e.repeat) { + void triggerToggle(); + } } }); diff --git a/frontend/src/scripts/helper/interpolators/linear-interpolator.ts b/frontend/src/scripts/helper/interpolators/linear-interpolator.ts index 133a4bf..6a43cd3 100644 --- a/frontend/src/scripts/helper/interpolators/linear-interpolator.ts +++ b/frontend/src/scripts/helper/interpolators/linear-interpolator.ts @@ -28,7 +28,10 @@ export class LinearInterpolator { const newest = this.frames.length > 0 ? this.frames[this.frames.length - 1] : null; const wasCoasting = this.isCoasting; - if (newest && time <= newest.time) { + if (newest && time < newest.time) { + return; + } + if (newest && time === newest.time) { this.frames[this.frames.length - 1] = { time: newest.time, value, diff --git a/frontend/src/scripts/helper/prediction/client-character-world.ts b/frontend/src/scripts/helper/prediction/client-character-world.ts index 4668cbd..1fb19b3 100644 --- a/frontend/src/scripts/helper/prediction/client-character-world.ts +++ b/frontend/src/scripts/helper/prediction/client-character-world.ts @@ -9,11 +9,16 @@ import { } from 'shared'; import { PlanetView } from '../../objects/types/planet-view'; +export type PlanetSnapshot = Pick< + PlanetView, + 'id' | 'vertices' | 'snapshotRotation' | 'snapshotRotationSpeed' +>; + // Planets only, re-seeded from the newest snapshot every frame; never dispatches collisions. export class ClientCharacterWorld implements CharacterWorld { private surfaces: Array = []; - public sync(planets: Array) { + public sync(planets: Array) { this.surfaces = planets .map((planet) => { const surface = new PlanetBase(planet.id, planet.vertices); diff --git a/frontend/src/scripts/helper/prediction/local-character-predictor.ts b/frontend/src/scripts/helper/prediction/local-character-predictor.ts index b3e290f..ccf248b 100644 --- a/frontend/src/scripts/helper/prediction/local-character-predictor.ts +++ b/frontend/src/scripts/helper/prediction/local-character-predictor.ts @@ -11,7 +11,7 @@ import { feetRadius, headRadius, } from 'shared'; -import { ClientCharacterWorld } from './client-character-world'; +import { ClientCharacterWorld, PlanetSnapshot } from './client-character-world'; import type { PlanetView } from '../../objects/types/planet-view'; import { InputHistory } from './input-history'; @@ -25,6 +25,18 @@ export const setFrameTimeMs = (timeMs: number): void => { export const clientTimeMs = (): number => frameTimeMs; const maxReplayMs = 400; +const correctionSeconds = 0.08; +const snapDistance = 1500; + +interface ReplaySnapshot { + pose: { head: Circle; leftFoot: Circle; rightFoot: Circle }; + movement: CharacterMovementSnapshot; + anchorMs: number; + leapAckMs: number; + strength: number; + planets: Array; + horizonState?: CharacterMovementState; +} const upwards = vec2.fromValues(0, 1); @@ -37,6 +49,7 @@ const makeBody = (center: vec2, radius: number, normal: vec2): PhysicsBody => ({ }); export class LocalCharacterPredictor { + public enabled = true; private readonly inputHistory = new InputHistory(); private readonly world = new ClientCharacterWorld(); @@ -48,6 +61,9 @@ export class LocalCharacterPredictor { private currentStrength = settings.playerMaxStrength; private alive = true; + private previousSnapshot?: ReplaySnapshot; + private lastUpdateMs?: number; + private readonly correction = vec2.create(); private renderHead = new Circle(vec2.create(), headRadius); private renderLeftFoot = new Circle(vec2.create(), feetRadius); @@ -75,10 +91,7 @@ export class LocalCharacterPredictor { lastLeapClientTimeMs: number, ackAgeMs = 0, ): void { - const anchor = clientTimeMs + Math.max(0, ackAgeMs); - if (this.replayAnchorMs === undefined || anchor > this.replayAnchorMs) { - this.replayAnchorMs = anchor; - } + this.replayAnchorMs = clientTimeMs + Math.max(0, ackAgeMs); this.movement = movement; this.lastLeapAckMs = lastLeapClientTimeMs; } @@ -114,6 +127,9 @@ export class LocalCharacterPredictor { this.movement = undefined; this.currentStrength = settings.playerMaxStrength; this.alive = true; + this.previousSnapshot = undefined; + this.lastUpdateMs = undefined; + vec2.zero(this.correction); } public get canPredict(): boolean { @@ -132,29 +148,77 @@ export class LocalCharacterPredictor { } public update(planets: Array): boolean { - if (!this.alive || !this.canPredict || this.isAnimatingInOrOut) { + if (!this.enabled || !this.alive || !this.canPredict || this.isAnimatingInOrOut) { + this.previousSnapshot = undefined; + this.lastUpdateMs = undefined; + vec2.zero(this.correction); return false; } - this.world.sync(planets); - const predicted = this.simulate(); + const now = clientTimeMs(); + const deltaSeconds = + this.lastUpdateMs === undefined ? 0 : (now - this.lastUpdateMs) / 1000; + vec2.scale( + this.correction, + this.correction, + Math.exp(-deltaSeconds / correctionSeconds), + ); + const snapshot: ReplaySnapshot = + this.previousSnapshot && this.previousSnapshot.pose === this.authoritative + ? this.previousSnapshot + : { + pose: this.authoritative!, + movement: this.movement!, + anchorMs: this.replayAnchorMs!, + leapAckMs: this.lastLeapAckMs, + strength: this.currentStrength, + planets: planets.map( + ({ id, vertices, snapshotRotation, snapshotRotationSpeed }) => ({ + id, + vertices, + snapshotRotation, + snapshotRotationSpeed, + }), + ), + }; + const predicted = this.simulate(snapshot); - vec2.copy(this.renderHead.center, predicted.head.center); - this.renderHead.radius = predicted.head.radius; - vec2.copy(this.renderLeftFoot.center, predicted.leftFoot.center); - this.renderLeftFoot.radius = predicted.leftFoot.radius; - vec2.copy(this.renderRightFoot.center, predicted.rightFoot.center); - this.renderRightFoot.radius = predicted.rightFoot.radius; + if (this.previousSnapshot && this.previousSnapshot.pose !== snapshot.pose) { + // Compare both snapshots at the same instant: smooth only the correction, + // leaving fresh input and ordinary movement immediate. + const previous = this.simulate(this.previousSnapshot); + vec2.add( + this.correction, + this.correction, + vec2.subtract(vec2.create(), previous.head.center, predicted.head.center), + ); + if (vec2.length(this.correction) > snapDistance) { + vec2.zero(this.correction); + } + } + for (const [render, body] of [ + [this.renderHead, predicted.head], + [this.renderLeftFoot, predicted.leftFoot], + [this.renderRightFoot, predicted.rightFoot], + ]) { + vec2.add(render.center, body.center, this.correction); + render.radius = body.radius; + } + this.previousSnapshot = snapshot; + this.lastUpdateMs = now; return true; } - private simulate(): CharacterMovementState { - const auth = this.authoritative!; - const movement = this.movement!; + private simulate(snapshot: ReplaySnapshot): CharacterMovementState { + if (snapshot.horizonState) { + return snapshot.horizonState; + } + this.world.sync(snapshot.planets); + const { pose: auth, movement } = snapshot; const now = clientTimeMs(); - const startMs = Math.max(this.replayAnchorMs!, now - maxReplayMs); - const windowMs = Math.max(0, now - startMs); + const startMs = snapshot.anchorMs; + const windowMs = Math.min(maxReplayMs, Math.max(0, now - startMs)); const steps = Math.floor(windowMs / stepMs); const partialStep = (windowMs - steps * stepMs) / stepMs; @@ -176,39 +240,32 @@ export class LocalCharacterPredictor { bodyVelocity: vec2.fromValues(movement.bodyVelocity[0], movement.bodyVelocity[1]), }; - const cooldownMs = settings.leapCooldownSeconds * 1000; - let availableStrength = this.currentStrength; - let lastLeapMs = -Infinity; + let availableStrength = snapshot.strength; + let cooldown = movement.leapCooldownRemaining ?? 0; let t = startMs; - for (let i = 0; i < steps; i++) { - const input = this.inputHistory.directionAt(t); - this.world.advance(stepSeconds); - tickPlanetDetachment(state, stepSeconds); - stepCharacterMovement(state, this.world, input, stepSeconds); - + for (let i = 0; i < steps + (partialStep > 0 ? 1 : 0); i++) { + const fraction = i < steps ? 1 : partialStep; + const from = + fraction < 1 + ? [state.head, state.leftFoot, state.rightFoot].map((body) => + vec2.clone(body.center), + ) + : undefined; for (const leapMs of this.leapHistory) { if ( leapMs >= t && - leapMs < t + stepMs && - leapMs > this.lastLeapAckMs && + leapMs < t + stepMs * fraction && + leapMs > snapshot.leapAckMs && state.currentPlanet && - leapMs - lastLeapMs >= cooldownMs && + cooldown <= 0 && availableStrength >= settings.leapStrengthCost ) { applyLeapImpulse(state, this.inputHistory.directionAt(leapMs)); availableStrength -= settings.leapStrengthCost; - lastLeapMs = leapMs; + cooldown = settings.leapCooldownSeconds; } } - - t += stepMs; - } - - if (partialStep > 0) { - const from = [state.head, state.leftFoot, state.rightFoot].map((b) => - vec2.clone(b.center), - ); this.world.advance(stepSeconds); tickPlanetDetachment(state, stepSeconds); stepCharacterMovement( @@ -217,11 +274,22 @@ export class LocalCharacterPredictor { this.inputHistory.directionAt(t), stepSeconds, ); - [state.head, state.leftFoot, state.rightFoot].forEach((body, i) => - vec2.lerp(body.center, from[i], body.center, partialStep), + cooldown = Math.max(0, cooldown - stepSeconds); + availableStrength = Math.min( + settings.playerMaxStrength, + availableStrength + settings.playerStrengthRegenerationPerSeconds * stepSeconds, ); + if (from) { + [state.head, state.leftFoot, state.rightFoot].forEach((body, index) => + vec2.lerp(body.center, from[index], body.center, fraction), + ); + } + t += stepMs; } + if (windowMs === maxReplayMs) { + snapshot.horizonState = state; + } return state; } } diff --git a/frontend/src/scripts/helper/server-timeline.ts b/frontend/src/scripts/helper/server-timeline.ts index 9ac449f..d4b17fc 100644 --- a/frontend/src/scripts/helper/server-timeline.ts +++ b/frontend/src/scripts/helper/server-timeline.ts @@ -9,7 +9,7 @@ const resyncSeconds = 0.3; class ServerTimeline { private cursor?: number; private newestSnapshotTime?: number; - private sinceNewestSnapshot = 0; + private newestReceivedAtMs = 0; public snapshotTime = 0; public get renderTime(): number { @@ -20,7 +20,7 @@ class ServerTimeline { this.snapshotTime = timestamp; if (this.newestSnapshotTime === undefined || timestamp > this.newestSnapshotTime) { this.newestSnapshotTime = timestamp; - this.sinceNewestSnapshot = 0; + this.newestReceivedAtMs = performance.now(); } } @@ -29,10 +29,9 @@ class ServerTimeline { return; } - this.sinceNewestSnapshot += deltaTimeInSeconds; const target = this.newestSnapshotTime + - this.sinceNewestSnapshot - + (performance.now() - this.newestReceivedAtMs) / 1000 - settings.interpolationDelaySeconds; if (this.cursor === undefined || Math.abs(target - this.cursor) > resyncSeconds) { @@ -40,18 +39,16 @@ class ServerTimeline { return; } - const rate = clamp( - 1 + (target - this.cursor) * rateGain, - 1 - maxRateAdjustment, - 1 + maxRateAdjustment, - ); - this.cursor += deltaTimeInSeconds * rate; + this.cursor += deltaTimeInSeconds; + this.cursor += + deltaTimeInSeconds * + clamp((target - this.cursor) * rateGain, -maxRateAdjustment, maxRateAdjustment); } public reset() { this.cursor = undefined; this.newestSnapshotTime = undefined; - this.sinceNewestSnapshot = 0; + this.newestReceivedAtMs = 0; this.snapshotTime = 0; } } diff --git a/frontend/src/scripts/helper/storage.ts b/frontend/src/scripts/helper/storage.ts new file mode 100644 index 0000000..b68bb01 --- /dev/null +++ b/frontend/src/scripts/helper/storage.ts @@ -0,0 +1,16 @@ +export function loadStoredValue(key: string): unknown { + try { + const stored = localStorage.getItem(key); + return stored === null ? undefined : JSON.parse(stored); + } catch { + return undefined; + } +} + +export function saveStoredValue(key: string, value: unknown): void { + try { + localStorage.setItem(key, JSON.stringify(value)); + } catch { + // Preferences still apply for this visit when browser storage is unavailable. + } +} diff --git a/frontend/src/scripts/join-form-handler.ts b/frontend/src/scripts/join-form-handler.ts index c5592cb..d1eff3b 100644 --- a/frontend/src/scripts/join-form-handler.ts +++ b/frontend/src/scripts/join-form-handler.ts @@ -16,12 +16,8 @@ export class JoinFormHandler { private readonly decision: Promise; private readonly pollServersTimer: ReturnType; private servers: Array = []; - - private keyUpListener = (e: KeyboardEvent) => { - if (e.key === 'Enter' && !this.joinButton.disabled) { - this.form.requestSubmit(); - } - }; + private readonly pendingRequests = new Map(); + private destroyed = false; constructor( private readonly form: HTMLFormElement, @@ -33,14 +29,16 @@ export class JoinFormHandler { this.decision = new Promise((resolve) => { form.onsubmit = (e) => { e.preventDefault(); + if (this.joinButton.disabled) { + return; + } SoundHandler.play(Sounds.click); const data = new FormData(form); + this.destroy(); resolve({ name: String(data.get('name')), server: String(data.get('server')) }); }; }); - this.decision.then(() => this.destroy()); - addEventListener('keyup', this.keyUpListener); this.pollServersTimer = setInterval(() => this.loadServers(), pollInterval); this.loadServers(); } @@ -49,49 +47,60 @@ export class JoinFormHandler { return this.decision; } - private destroy() { - removeEventListener('keyup', this.keyUpListener); + public destroy() { + this.destroyed = true; + this.form.onsubmit = null; clearInterval(this.pollServersTimer); + this.pendingRequests.forEach((controller) => controller.abort()); this.servers.forEach((s) => s.destroy()); } private loadServers() { - servers - .filter((url) => !this.servers.some((s) => s.url === url)) - .forEach(async (url) => { - const controller = new AbortController(); - setTimeout(() => controller.abort(), pollInterval * 0.8); + for (const url of servers) { + if (!this.pendingRequests.has(url) && !this.servers.some((s) => s.url === url)) { + void this.loadServer(url); + } + } + } - let content: ServerInformation; - try { - const response = await fetch(url + serverInformationEndpoint, { - signal: controller.signal, - }); - if (!response.ok) { - return; - } - content = await response.json(); - } catch { - return; - } - - if (!this.servers.some((s) => s.url === url)) { - const server = new ServerChooserOption( - content, - url, - (s) => this.removeServer(s), - this.servers.length === 0, - ); - this.servers.push(server); - this.joinButton.disabled = false; - this.container.appendChild(server.element); - } + private async loadServer(url: string) { + const controller = new AbortController(); + this.pendingRequests.set(url, controller); + const timeout = setTimeout(() => controller.abort(), pollInterval * 0.8); + try { + const response = await fetch(url + serverInformationEndpoint, { + signal: controller.signal, }); + if (!response.ok) { + return; + } + const content: ServerInformation = await response.json(); + if (this.destroyed) { + return; + } + const server = new ServerChooserOption( + content, + url, + (s) => this.removeServer(s), + this.servers.length === 0, + ); + this.servers.push(server); + this.joinButton.disabled = false; + this.container.appendChild(server.element); + } catch { + // Unavailable servers are retried on the next poll. + } finally { + clearTimeout(timeout); + this.pendingRequests.delete(url); + } } private removeServer(server: ServerChooserOption) { this.servers = this.servers.filter((s) => s !== server); this.joinButton.disabled = this.servers.length === 0; + if (this.servers.length && !this.servers.some((s) => s.input.checked)) { + this.servers[0].input.checked = true; + } } } @@ -107,9 +116,11 @@ const roundCompletionTexts = [ class ServerChooserOption { public readonly element = document.createElement('div'); + public readonly input = document.createElement('input'); private readonly serverNameElement = document.createElement('span'); private readonly completionElement = document.createElement('span'); private readonly socket: Socket; + private readonly reconnectFailedListener = () => this.destroy(); constructor( private readonly content: ServerInformation, @@ -117,7 +128,7 @@ class ServerChooserOption { private readonly onDestroy: (v: ServerChooserOption) => unknown, isFirst: boolean, ) { - const input = document.createElement('input'); + const input = this.input; input.required = true; input.type = 'radio'; input.id = input.value = url; @@ -144,7 +155,7 @@ class ServerChooserOption { parser, } as any); - this.socket.io.on('reconnect_failed', () => this.destroy()); + this.socket.io.on('reconnect_failed', this.reconnectFailedListener); this.socket.on('connect', () => this.socket.emit(TransportEvents.SubscribeForServerInfoUpdates), ); @@ -159,6 +170,7 @@ class ServerChooserOption { } public destroy() { + this.socket.io.off('reconnect_failed', this.reconnectFailedListener); this.socket.close(); this.element.remove(); this.onDestroy(this); diff --git a/frontend/src/scripts/landing-page-background.ts b/frontend/src/scripts/landing-page-background.ts index ca1863b..68941f0 100644 --- a/frontend/src/scripts/landing-page-background.ts +++ b/frontend/src/scripts/landing-page-background.ts @@ -27,14 +27,16 @@ export class LandingPageBackground { }; constructor(canvas: HTMLCanvasElement) { - this.renderer = new Promise((r) => (this.resolveRenderer = r)); - this.start(canvas); + this.renderer = new Promise((resolve, reject) => { + this.resolveRenderer = resolve; + void this.start(canvas).catch(reject); + }); } private async start(canvas: HTMLCanvasElement): Promise { const noiseTexture = await renderNoise([256, 256], 1.2, 2); - runAnimation( + await runAnimation( canvas, [ { ...PlanetShape.descriptor, shaderCombinationSteps: [0, 1, 2] }, diff --git a/frontend/src/scripts/objects/game-object-container.ts b/frontend/src/scripts/objects/game-object-container.ts index 20cc31b..805bdd4 100644 --- a/frontend/src/scripts/objects/game-object-container.ts +++ b/frontend/src/scripts/objects/game-object-container.ts @@ -8,8 +8,8 @@ import { DeleteObjectsCommand, Id, PropertyUpdatesForObjects, + RemoteCall, RemoteCallsForObjects, - settings, UpdatePropertyCommand, } from 'shared'; import { FeedbackHud } from '../feedback-hud'; @@ -34,6 +34,8 @@ export class GameObjectContainer extends CommandReceiver { private deleteAt = new Map(); private awaitingCreateStamp: Array = []; private awaitingDeleteStamp: Array = []; + private remoteCalls: Array<{ object: View; calls: Array; at?: number }> = + []; protected commandExecutors: CommandExecutors = { [CreatePlayerCommand.name]: (c: CreatePlayerCommand) => { @@ -41,6 +43,7 @@ export class GameObjectContainer extends CommandReceiver { this.player.isMainCharacter = true; this.addObject(this.player); localCharacterPredictor.reset(); + this.game.resendMovement(); FeedbackHud.hideElimination(); this.wasLocalPlayerAlive = true; }, @@ -52,9 +55,14 @@ export class GameObjectContainer extends CommandReceiver { }), [RemoteCallsForObjects.name]: (c: RemoteCallsForObjects) => - c.callsForObjects.forEach((c) => - this.objects.get(c.id)?.processRemoteCalls(c.calls), - ), + c.callsForObjects.forEach(({ id, calls }) => { + const object = this.objects.get(id); + if (object === this.player) { + object?.processRemoteCalls(calls); + } else if (object) { + this.remoteCalls.push({ object, calls }); + } + }), [PropertyUpdatesForObjects.name]: (c: PropertyUpdatesForObjects) => { serverTimeline.onSnapshot(c.timestamp); @@ -72,7 +80,7 @@ export class GameObjectContainer extends CommandReceiver { c.ids.forEach((id: Id) => this.awaitingDeleteStamp.push(id)), }; - constructor(game: Game) { + constructor(private readonly game: Game) { super(); this.camera = new Camera(game); } @@ -86,6 +94,7 @@ export class GameObjectContainer extends CommandReceiver { this.deleteAt.clear(); this.awaitingCreateStamp = []; this.awaitingDeleteStamp = []; + this.remoteCalls = []; this.wasLocalPlayerAlive = false; } @@ -96,6 +105,16 @@ export class GameObjectContainer extends CommandReceiver { public step(deltaTimeInSeconds: number) { this.stampPending(serverTimeline.snapshotTime); this.retireDueObjects(); + this.remoteCalls = this.remoteCalls.filter(({ object, calls, at }) => { + if (this.objects.get(object.id) !== object) { + return false; + } + if (at !== undefined && at <= serverTimeline.renderTime) { + object.processRemoteCalls(calls); + return false; + } + return true; + }); this.objects.forEach((o) => o.step(deltaTimeInSeconds)); const player = this.localPlayer; @@ -107,15 +126,13 @@ export class GameObjectContainer extends CommandReceiver { if (player) { localCharacterPredictor.setAlive(alive); - localCharacterPredictor.setStrength( - player.strengthFraction * settings.playerMaxStrength, - ); + localCharacterPredictor.setStrength(player.snapshotStrength); if (localCharacterPredictor.update(this.planets)) { player.head = localCharacterPredictor.head; player.leftFoot = localCharacterPredictor.leftFoot; player.rightFoot = localCharacterPredictor.rightFoot; } - this.camera.follow(player.position, deltaTimeInSeconds); + this.camera.follow(player.position); } } @@ -144,11 +161,14 @@ export class GameObjectContainer extends CommandReceiver { this.deleteAt.set(id, timestamp); } this.awaitingDeleteStamp = []; + for (const call of this.remoteCalls) { + call.at ??= timestamp; + } } private retireDueObjects() { const renderTime = serverTimeline.renderTime; - for (const [id, at] of [...this.deleteAt]) { + for (const [id, at] of this.deleteAt) { if (renderTime >= at) { this.deleteObject(id); } @@ -172,6 +192,12 @@ export class GameObjectContainer extends CommandReceiver { } private addObject(object: View) { + // An object can re-enter the interest area before its delayed deletion is drawn. + // Retire the old view and its pending deletion before installing the replacement. + if (this.objects.has(object.id)) { + this.deleteObject(object.id); + } + this.awaitingDeleteStamp = this.awaitingDeleteStamp.filter((id) => id !== object.id); this.objects.set(object.id, object); if (object instanceof PlanetView) { this.planets.push(object); diff --git a/frontend/src/scripts/objects/types/camera.ts b/frontend/src/scripts/objects/types/camera.ts index ca768e4..cf95754 100644 --- a/frontend/src/scripts/objects/types/camera.ts +++ b/frontend/src/scripts/objects/types/camera.ts @@ -1,28 +1,17 @@ import { vec2 } from 'gl-matrix'; import { Renderer } from 'sdf-2d'; -import { calculateViewArea, followVec2, mixRgb, settings } from 'shared'; +import { calculateViewArea, mixRgb, settings } from 'shared'; import type { Game } from '../../game'; import { ScreenShake } from '../../screen-shake'; -const followSeconds = 0.08; -const snapDistance = 1500; - export class Camera { public readonly center: vec2 = vec2.create(); - private readonly previousTarget = vec2.create(); private aspectRatio?: number; constructor(private readonly game: Game) {} - public follow(target: vec2, deltaTimeInSeconds: number) { - followVec2( - this.center, - target, - this.previousTarget, - deltaTimeInSeconds, - followSeconds, - snapDistance, - ); + public follow(target: vec2) { + vec2.copy(this.center, target); } public draw(renderer: Renderer) { diff --git a/frontend/src/scripts/objects/types/character-view.ts b/frontend/src/scripts/objects/types/character-view.ts index 18e5434..4169696 100644 --- a/frontend/src/scripts/objects/types/character-view.ts +++ b/frontend/src/scripts/objects/types/character-view.ts @@ -39,6 +39,7 @@ const deathIcon = export class CharacterView extends CharacterBase implements View { public isMainCharacter = false; + public snapshotStrength = settings.playerMaxStrength; private readonly shape: CharacterShape; private readonly muzzleFlash: CircleLight; @@ -144,6 +145,7 @@ export class CharacterView extends CharacterBase implements View { } else if (propertyKey === 'rightFoot') { this.rightFootInterpolator.addFrame(propertyValue, rateOfChange); } else if (propertyKey === 'strength') { + this.snapshotStrength = propertyValue; this.strengthInterpolator.addFrame(propertyValue, rateOfChange); } } @@ -225,7 +227,6 @@ export class CharacterView extends CharacterBase implements View { 0, this.muzzleFlashIntensity - deltaTimeInSeconds / muzzleFlashDecaySeconds, ); - this.muzzleFlash.center = this.head.center; this.muzzleFlash.intensity = this.muzzleFlashIntensity; } @@ -241,7 +242,6 @@ export class CharacterView extends CharacterBase implements View { 0, this.deathBurstIntensity - deltaTimeInSeconds / deathBurstDecaySeconds, ); - this.deathBurst.center = this.bodyCenter; this.deathBurst.intensity = deathBurstMaxIntensity * this.deathBurstIntensity * this.deathBurstIntensity; } @@ -284,9 +284,11 @@ export class CharacterView extends CharacterBase implements View { renderer.addDrawable(this.shape); if (this.muzzleFlashIntensity > 0) { + this.muzzleFlash.center = this.head.center; renderer.addDrawable(this.muzzleFlash); } if (this.deathBurstIntensity > 0) { + this.deathBurst.center = this.bodyCenter; renderer.addDrawable(this.deathBurst); } } diff --git a/frontend/src/scripts/objects/types/projectile-view.ts b/frontend/src/scripts/objects/types/projectile-view.ts index be6d710..a615adb 100644 --- a/frontend/src/scripts/objects/types/projectile-view.ts +++ b/frontend/src/scripts/objects/types/projectile-view.ts @@ -8,11 +8,13 @@ import { UpdatePropertyCommand, } from 'shared'; import { Vec2Interpolator } from '../../helper/interpolators/vec2-interpolator'; +import { LinearInterpolator } from '../../helper/interpolators/linear-interpolator'; import { View } from '../view'; export class ProjectileView extends ProjectileBase implements View { private readonly light: CircleLight; private readonly centerInterpolator: Vec2Interpolator; + private readonly strengthInterpolator: LinearInterpolator; constructor( id: Id, @@ -28,15 +30,23 @@ export class ProjectileView extends ProjectileBase implements View { 0, ); this.centerInterpolator = new Vec2Interpolator(center); + this.strengthInterpolator = new LinearInterpolator(strength); } - public updateProperty({ propertyValue, rateOfChange }: UpdatePropertyCommand) { - this.centerInterpolator.addFrame(propertyValue, rateOfChange); + public updateProperty({ + propertyKey, + propertyValue, + rateOfChange, + }: UpdatePropertyCommand) { + if (propertyKey === 'center') { + this.centerInterpolator.addFrame(propertyValue, rateOfChange); + } else if (propertyKey === 'strength') { + this.strengthInterpolator.addFrame(propertyValue, rateOfChange); + } } public step(deltaTimeInSeconds: number) { - super.step(deltaTimeInSeconds); - + this.strength = Math.max(0, this.strengthInterpolator.getValue(deltaTimeInSeconds)); this.center = this.centerInterpolator.getValue(deltaTimeInSeconds); this.light.center = this.center; this.light.intensity = Math.min( diff --git a/frontend/src/scripts/options-handler.ts b/frontend/src/scripts/options-handler.ts index c5f41bf..fe64eff 100644 --- a/frontend/src/scripts/options-handler.ts +++ b/frontend/src/scripts/options-handler.ts @@ -1,5 +1,6 @@ import { SoundHandler, Sounds } from './sound-handler'; import { VibrationHandler } from './vibration-handler'; +import { loadStoredValue, saveStoredValue } from './helper/storage'; export interface Options { vibrationEnabled: boolean; @@ -13,14 +14,17 @@ export const options: Options = { musicEnabled: true, }; -const save = () => localStorage.setItem('options', JSON.stringify(options)); - export const initializeOptions = ( inputElements: Record, ) => { - const stored = localStorage.getItem('options'); - if (stored) { - Object.assign(options, JSON.parse(stored)); + const stored = loadStoredValue('options'); + if (stored && typeof stored === 'object') { + for (const key of Object.keys(inputElements) as Array) { + const value = (stored as Partial)[key]; + if (typeof value === 'boolean') { + options[key] = value; + } + } } if (options.musicEnabled) { @@ -51,7 +55,7 @@ export const initializeOptions = ( } SoundHandler.play(Sounds.click); - save(); + saveStoredValue('options', options); }); } }; diff --git a/frontend/src/scripts/sound-handler.ts b/frontend/src/scripts/sound-handler.ts index 77180d0..cdab0d6 100644 --- a/frontend/src/scripts/sound-handler.ts +++ b/frontend/src/scripts/sound-handler.ts @@ -16,7 +16,7 @@ let initialized = false; async function initializeSound(src: string): Promise { const snd = new Audio(src); snd.muted = true; - await snd.play(); + await snd.play().catch(() => undefined); snd.pause(); snd.muted = false; snd.currentTime = 0; @@ -27,20 +27,22 @@ async function initialize( onPlayKeypress: () => unknown = () => null, onPauseKeypress: () => unknown = () => null, ) { + ambient.muted = true; + ambient.volume = 0.5; + ambient.loop = true; + // Unlock every audio element during the initiating click, before awaiting any one. + const ambientReady = ambient.play().catch(() => undefined); const [hit, shoot, click] = await Promise.all( [hitSound, shootSound, clickSound].map(initializeSound), ); sounds = { hit, shoot, click }; - await ambient.play(); - ambient.muted = true; + await ambientReady; initialized = true; ambient.onpause = onPauseKeypress; ambient.onplay = onPlayKeypress; ambient.muted = false; - ambient.volume = 0.5; - ambient.loop = true; if (!isAmbientPlaying) { ambient.pause(); @@ -66,7 +68,7 @@ function play(snd: Sound, volume = 1, playbackRate = 1) { function playAmbient() { isAmbientPlaying = true; if (initialized) { - ambient.play(); + void ambient.play().catch(() => undefined); } } diff --git a/frontend/webpack.config.js b/frontend/webpack.config.js index bbbca6b..b57b543 100644 --- a/frontend/webpack.config.js +++ b/frontend/webpack.config.js @@ -114,6 +114,8 @@ module.exports = (env, argv) => { ], }, resolve: { + // Shared physics and rendering must use the same matrix array type as index.ts. + alias: { 'gl-matrix': path.resolve(__dirname, 'node_modules/gl-matrix') }, extensions: ['.ts', '.js', '.json'], }, }; diff --git a/package.json b/package.json index 8edb68a..285a535 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,9 @@ "build": "cd shared && npm run build && cd ../frontend && npm run build && cd ../backend && npm run build", "lint": "eslint ./**/src/**/*.{js,ts} --fix && prettier --write ./**/src/**/*.{js,ts,json}", "lint:check": "eslint ./**/src/**/*.{js,ts} && prettier --check ./**/src/**/*.{js,ts,json}", + "pretest": "npm --prefix shared run build", "test": "vitest run", + "pretest:watch": "npm --prefix shared run build", "test:watch": "vitest", "init": "cd shared && npm install && cd ../frontend && npm install && cd ../backend && npm install", "dev": "concurrently --kill-others-on-fail --names shared,backend,frontend \"cd shared && npm run dev\" \"cd backend && npm run dev\" \"cd frontend && npm run dev\"" diff --git a/shared/src/commands/command-receiver.ts b/shared/src/commands/command-receiver.ts index 447df6e..33f489a 100644 --- a/shared/src/commands/command-receiver.ts +++ b/shared/src/commands/command-receiver.ts @@ -12,7 +12,7 @@ export abstract class CommandReceiver { public handleCommand(command: Command) { const commandType = command.constructor.name; - if (commandType in this.commandExecutors) { + if (Object.prototype.hasOwnProperty.call(this.commandExecutors, commandType)) { this.commandExecutors[commandType]!(command); } else { this.defaultCommandExecutor(command); diff --git a/shared/src/commands/types/input-acknowledgement.ts b/shared/src/commands/types/input-acknowledgement.ts index 79f6a83..181977f 100644 --- a/shared/src/commands/types/input-acknowledgement.ts +++ b/shared/src/commands/types/input-acknowledgement.ts @@ -13,6 +13,7 @@ export class CharacterMovementSnapshot { public readonly rightFootNormal: vec2, public readonly groundPlanetId: Id | null, public readonly secondsSinceOnSurface: number, + public readonly leapCooldownRemaining: number = 0, ) {} public toArray(): Array { @@ -23,6 +24,7 @@ export class CharacterMovementSnapshot { this.rightFootNormal, this.groundPlanetId, this.secondsSinceOnSurface, + this.leapCooldownRemaining, ]; } } diff --git a/shared/src/helper/ease.ts b/shared/src/helper/ease.ts index 789ddc0..5349aae 100644 --- a/shared/src/helper/ease.ts +++ b/shared/src/helper/ease.ts @@ -1,34 +1,2 @@ -import { vec2 } from 'gl-matrix'; - export const smoothing = (deltaTimeInSeconds: number, seconds: number): number => 1 - Math.exp(-deltaTimeInSeconds / seconds); - -export const easeVec2 = ( - out: vec2, - target: vec2, - deltaTimeInSeconds: number, - seconds: number, - snapDistance: number, -): vec2 => - vec2.distance(out, target) > snapDistance - ? vec2.copy(out, target) - : vec2.lerp(out, out, target, smoothing(deltaTimeInSeconds, seconds)); - -// Follows a *moving* target. `easeVec2` alone lags one by an amount that depends on the frame -// duration, so an uneven frame — or a dropped one — changes the lag and the follower appears to -// speed up or stall. Carrying the target's own displacement first leaves only the residual to -// decay: a target at a steady speed is tracked exactly, whatever the frame times do, and a -// correction still fades out over `seconds`. -export const followVec2 = ( - out: vec2, - target: vec2, - previousTarget: vec2, - deltaTimeInSeconds: number, - seconds: number, - snapDistance: number, -): vec2 => { - out[0] += target[0] - previousTarget[0]; - out[1] += target[1] - previousTarget[1]; - vec2.copy(previousTarget, target); - return easeVec2(out, target, deltaTimeInSeconds, seconds, snapDistance); -}; diff --git a/shared/src/helper/finite.ts b/shared/src/helper/finite.ts index e659aaf..8eb7bab 100644 --- a/shared/src/helper/finite.ts +++ b/shared/src/helper/finite.ts @@ -32,16 +32,15 @@ export const finiteVec2 = (value: unknown, maxMagnitude: number): vec2 | undefin return undefined; } - const result = vec2.fromValues(x, y); - const length = vec2.length(result); + const length = Math.hypot(x, y); if (length > maxMagnitude) { - vec2.scale(result, result, maxMagnitude / length); + return vec2.fromValues((x / length) * maxMagnitude, (y / length) * maxMagnitude); } - return result; + return vec2.fromValues(x, y); }; export const sanitizeName = (value: unknown, maxLength: number): string => { - const text = typeof value === 'string' ? value : String(value ?? ''); + const text = typeof value === 'string' ? value : ''; return text.slice(0, maxLength); }; diff --git a/shared/src/helper/random.ts b/shared/src/helper/random.ts index d200cf0..654988c 100644 --- a/shared/src/helper/random.ts +++ b/shared/src/helper/random.ts @@ -1,7 +1,7 @@ -let seed = Math.random(); +let seed = (Math.random() * 0x100000000) >>> 0; function getRandom(): number { - let t = (seed += 0x6d2b79f5); + let t = (seed = (seed + 0x6d2b79f5) | 0); t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; @@ -9,7 +9,7 @@ function getRandom(): number { export const Random = { set seed(value: number) { - seed = value; + seed = value | 0; }, getRandom, getRandomInRange(from: number, to: number): number { diff --git a/shared/src/objects/game-object.ts b/shared/src/objects/game-object.ts index 6c8890c..44fba62 100644 --- a/shared/src/objects/game-object.ts +++ b/shared/src/objects/game-object.ts @@ -83,7 +83,7 @@ export abstract class GameObject { } } - public getPropertyUpdates(): PropertyUpdatesForObject | undefined { + public getPropertyUpdates(_timeScale = 1): PropertyUpdatesForObject | undefined { return undefined; } diff --git a/shared/src/physics/character-movement.ts b/shared/src/physics/character-movement.ts index 81c2b08..b314ad3 100644 --- a/shared/src/physics/character-movement.ts +++ b/shared/src/physics/character-movement.ts @@ -7,29 +7,11 @@ import { smoothing } from '../helper/ease'; export const headRadius = 50; export const feetRadius = 20; -const desiredHeadOffset = vec2.fromValues(0, 55); -const desiredLeftFootOffset = vec2.fromValues(-20, 0); -const desiredRightFootOffset = vec2.fromValues(20, 0); -const centerOfMass = vec2.scale( - vec2.create(), - vec2.add( - vec2.create(), - vec2.add(vec2.create(), desiredHeadOffset, desiredLeftFootOffset), - desiredRightFootOffset, - ), - 1 / 3, -); -export const headOffset = vec2.subtract(vec2.create(), desiredHeadOffset, centerOfMass); -export const leftFootOffset = vec2.subtract( - vec2.create(), - desiredLeftFootOffset, - centerOfMass, -); -export const rightFootOffset = vec2.subtract( - vec2.create(), - desiredRightFootOffset, - centerOfMass, -); +// Imports run before setMatrixArrayType(Array): keep these fractional constants in f64. +const centerHeight = 55 * (1 / 3); +export const headOffset: vec2 = [0, 55 - centerHeight]; +export const leftFootOffset: vec2 = [-20, -centerHeight]; +export const rightFootOffset: vec2 = [20, -centerHeight]; export const boundRadius = (headRadius + feetRadius * 2) * 2; export interface CharacterMovementState { @@ -52,11 +34,7 @@ export const applyForce = ( force: vec2, deltaTimeInSeconds: number, ) => { - vec2.add( - body.velocity, - body.velocity, - vec2.scale(vec2.create(), force, deltaTimeInSeconds), - ); + vec2.scaleAndAdd(body.velocity, body.velocity, force, deltaTimeInSeconds); }; // ((head + leftFoot) + rightFoot) / 3 — do not reassociate. @@ -128,20 +106,9 @@ const carryWithRotatingPlanet = ( return; } const angle = -planet.angularVelocity * deltaTimeInSeconds; - const center = planet.center; - state.head.center = vec2.rotate(vec2.create(), state.head.center, center, angle); - state.leftFoot.center = vec2.rotate( - vec2.create(), - state.leftFoot.center, - center, - angle, - ); - state.rightFoot.center = vec2.rotate( - vec2.create(), - state.rightFoot.center, - center, - angle, - ); + for (const body of [state.head, state.leftFoot, state.rightFoot]) { + vec2.rotate(body.center, body.center, planet.center, angle); + } }; export const applyLeapImpulse = (state: CharacterMovementState, moveDirection: vec2) => { @@ -234,10 +201,6 @@ export const decayMomentum = ( } }; -const decayBodyMomentum = (state: CharacterMovementState, deltaTimeInSeconds: number) => { - decayMomentum(state.bodyVelocity, !!state.currentPlanet, deltaTimeInSeconds); -}; - export const sumGravity = (grounds: Array, position: vec2): vec2 => grounds.reduce( (sum, ground) => vec2.add(sum, sum, ground.gravityAt(position)), @@ -335,5 +298,5 @@ export const stepCharacterMovement = ( latchGround(state, world.stepBody(state.rightFoot, deltaTimeInSeconds)); world.stepBody(state.head, deltaTimeInSeconds); - decayBodyMomentum(state, deltaTimeInSeconds); + decayMomentum(state.bodyVelocity, !!state.currentPlanet, deltaTimeInSeconds); }; diff --git a/shared/src/physics/march-circle.ts b/shared/src/physics/march-circle.ts index 4e17329..539d155 100644 --- a/shared/src/physics/march-circle.ts +++ b/shared/src/physics/march-circle.ts @@ -4,7 +4,7 @@ import { evaluateSdf } from './evaluate-sdf'; import { sdfNormal } from './sdf-normal'; export interface MarchResult { - hitSurface: boolean; + travelled: number; normal?: vec2; hitObject?: Sdf; } @@ -20,7 +20,7 @@ export const marchCircle = ( ): MarchResult => { const deltaLength = vec2.length(delta); if (!(deltaLength > 0)) { - return { hitSurface: false }; + return { travelled: 0 }; } const direction = vec2.normalize(vec2.create(), delta); @@ -35,17 +35,17 @@ export const marchCircle = ( if (minDistance < body.radius) { const intersecting = possibleIntersectors.find( - (i) => i.distance(rayEnd) <= body.radius, + (i) => i.canCollide && i.distance(rayEnd) < body.radius, )!; - onHit?.(intersecting); - vec2.scaleAndAdd(rayEnd, body.center, direction, lastFreeDistance); vec2.copy(body.center, rayEnd); + const normal = sdfNormal(rayEnd, [intersecting]); + onHit?.(intersecting); return { - hitSurface: true, - normal: sdfNormal(rayEnd, [intersecting]), + travelled: lastFreeDistance, + normal, hitObject: intersecting, }; } @@ -64,5 +64,5 @@ export const marchCircle = ( vec2.scaleAndAdd(body.center, body.center, direction, lastFreeDistance); - return { hitSurface: false }; + return { travelled: lastFreeDistance }; }; diff --git a/shared/src/physics/resolve-circle-movement.ts b/shared/src/physics/resolve-circle-movement.ts index a0cd612..c3c477c 100644 --- a/shared/src/physics/resolve-circle-movement.ts +++ b/shared/src/physics/resolve-circle-movement.ts @@ -9,35 +9,39 @@ export const resolveCircleMovement = ( possibleIntersectors: Array, onHit?: (intersecting: Sdf) => void, ): Sdf | undefined => { - let delta = vec2.scale(vec2.create(), body.velocity, deltaTimeInSeconds); - depenetrateCircle(body, possibleIntersectors); + let remainingSeconds = deltaTimeInSeconds; + let firstHit: Sdf | undefined; - const { normal, hitSurface, hitObject } = marchCircle( - body, - delta, - possibleIntersectors, - onHit, - ); - - if (hitSurface) { - vec2.copy(body.lastNormal, normal!); - - vec2.subtract( - body.velocity, - body.velocity, - vec2.scale( - normal!, - normal!, - (1 + body.restitution) * vec2.dot(normal!, body.velocity), - ), + // Resolve at most two contacts per step, spending only the time left after each hit. + for (let pass = 0; pass < 2 && remainingSeconds > 0; pass++) { + const speed = vec2.length(body.velocity); + if (speed === 0) { + break; + } + const delta = vec2.scale(vec2.create(), body.velocity, remainingSeconds); + const { normal, hitObject, travelled } = marchCircle( + body, + delta, + possibleIntersectors, + onHit, ); - - if (vec2.length(body.velocity) > 50) { - delta = vec2.scale(vec2.create(), body.velocity, deltaTimeInSeconds); - marchCircle(body, delta, possibleIntersectors, onHit); + if (!hitObject || !normal) { + break; + } + firstHit ??= hitObject; + vec2.copy(body.lastNormal, normal); + remainingSeconds -= travelled / speed; + const inwardSpeed = vec2.dot(normal, body.velocity); + if (inwardSpeed < 0) { + vec2.scaleAndAdd( + body.velocity, + body.velocity, + normal, + -(1 + body.restitution) * inwardSpeed, + ); } } - return hitObject; + return firstHit; }; diff --git a/test/__snapshots__/physics-determinism.test.mjs.snap b/test/__snapshots__/physics-determinism.test.mjs.snap index ab4977d..7972aa5 100644 --- a/test/__snapshots__/physics-determinism.test.mjs.snap +++ b/test/__snapshots__/physics-determinism.test.mjs.snap @@ -2,4 +2,4 @@ exports[`shared character simulation determinism > matches the pinned reference pose (changes only with intentional physics edits) 1`] = `"{"head":[76.14,123.412],"leftFoot":[83.214,105.036],"rightFoot":[79.974,123.01],"bodyVelocity":[0,0]}"`; -exports[`shared character simulation determinism > matches the pinned reference pose on a spinning planet 1`] = `"{"head":[320.826,-34],"leftFoot":[248.634,-50.688],"rightFoot":[281.09,-72.183],"direction":-0.314,"onSurface":true,"secondsSinceOnSurface":0}"`; +exports[`shared character simulation determinism > matches the pinned reference pose on a spinning planet 1`] = `"{"head":[321.49,-34.424],"leftFoot":[249.254,-51.093],"rightFoot":[281.714,-72.595],"direction":-0.315,"onSurface":true,"secondsSinceOnSurface":0}"`; diff --git a/test/backend-regressions.test.ts b/test/backend-regressions.test.ts index 9b3aadb..5ff105f 100644 --- a/test/backend-regressions.test.ts +++ b/test/backend-regressions.test.ts @@ -1,15 +1,32 @@ -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { createRequire } from 'node:module'; +import { performance } from 'node:perf_hooks'; import { PhysicalContainer } from '../backend/src/physics/physical-container'; import { BoundingBox } from '../backend/src/physics/bounding-box'; import { ProjectilePhysical } from '../backend/src/objects/projectile-physical'; import { CharacterPhysical } from '../backend/src/objects/character-physical'; +import { PlanetPhysical } from '../backend/src/objects/planet-physical'; +import { PlayerContainer } from '../backend/src/players/player-container'; +import { defaultOptions } from '../backend/src/options'; +import { GameServer } from '../backend/src/game-server'; const require = createRequire(import.meta.url); const shared = require('../shared/lib/main.js'); const { vec2 } = require('../shared/node_modules/gl-matrix'); -const { Random, settings, CharacterTeam } = shared; +const { + Random, + settings, + CharacterTeam, + SetAspectRatioActionCommand, + TransportEvents, + deserialize, + CreateObjectsCommand, + PropertyUpdatesForObjects, + InputAcknowledgement, + LeapActionCommand, + calculateViewArea, +} = shared; const step = settings.targetPhysicsDeltaTimeInSeconds; @@ -63,6 +80,196 @@ beforeEach(() => { Random.seed = 1; }); +it('credits posthumous kills without making a corpse appear alive to clients', () => { + const { container } = makeWorld(); + const character = spawnCharacter(container); + character.setHealth(0); + character.onDie(); + character.addKill('late projectile victim'); + + expect(character.killCount).toBe(1); + expect(character.health).toBe(0); + expect( + character.getRemoteCalls().some((call) => call.functionName === 'setHealth'), + ).toBe(false); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +const controlledServer = () => { + let nowMs = 1000; + let nextTick: () => void = () => {}; + vi.spyOn(process.hrtime, 'bigint').mockImplementation(() => BigInt(nowMs * 1e6)); + vi.spyOn(performance, 'now').mockImplementation(() => nowMs); + vi.spyOn(globalThis, 'setTimeout').mockImplementation(((callback: () => void) => { + nextTick = callback; + return 0; + }) as never); + const server = new GameServer( + { + on() {}, + to() { + return { emit() {} }; + }, + } as never, + { + ...defaultOptions, + npcCount: 0, + }, + ); + const internals = server as unknown as { + players: PlayerContainer; + objects: PhysicalContainer; + isInEndGame: boolean; + }; + server.start(); + return { + players: internals.players, + objects: internals.objects, + server, + tick(milliseconds: number) { + nowMs += milliseconds; + nextTick(); + }, + }; +}; + +describe('server snapshot cadence', () => { + it('waits for physics before acknowledging input or clearing remote calls', () => { + const { players, objects, tick } = controlledServer(); + const communicate = vi.spyOn(players, 'stepCommunication'); + const character = spawnCharacter(objects); + character.shootTowards(vec2.fromValues(1000, 0)); + + tick(1); + + expect(communicate).not.toHaveBeenCalled(); + expect( + character.getRemoteCalls().some((call) => call.functionName === 'onShoot'), + ).toBe(true); + + tick(4); + + expect(communicate).toHaveBeenCalledOnce(); + expect(character.getRemoteCalls()).toHaveLength(0); + }); + + it.each([false, true])( + 'sends 25 snapshots per simulated second (endgame: %s)', + (endGame) => { + const { players, server, tick } = controlledServer(); + (server as unknown as { isInEndGame: boolean }).isInEndGame = endGame; + const batches: unknown[] = []; + const player = players.createPlayer({ name: 'player' }, { + on() {}, + emit(event: string, batch: unknown) { + if (event === TransportEvents.ServerToPlayer) { + batches.push(batch); + } + }, + } as never); + + for (let i = 0; i < 200; i++) { + tick(5); + } + + expect(batches).toHaveLength(25); + const snapshot = deserialize(batches[24] as string).find( + (command: object) => command instanceof PropertyUpdatesForObjects, + ); + const character = snapshot.updates.find( + (object: { id: number }) => object.id === player.character!.id, + ); + const strength = character.updates.find( + (update: { propertyKey: string }) => update.propertyKey === 'strength', + ); + expect(strength.rateOfChange).toBeCloseTo( + settings.playerStrengthRegenerationPerSeconds * (endGame ? 0.5 : 1), + ); + }, + ); +}); + +describe('slow-motion extrapolation', () => { + const wallDelta = step; + const timeScale = 0.25; + + it('coasts projectiles at the same speed as the slowed server without changing momentum', () => { + const { container } = makeWorld(); + const projectile = spawnProjectile(container); + const before = vec2.clone(projectile.center); + projectile.step(wallDelta * timeScale); + const update = projectile.getPropertyUpdates(timeScale).updates[0]; + + expect( + vec2.scaleAndAdd(vec2.create(), before, update.rateOfChange, wallDelta), + ).toEqual(projectile.center); + expect(projectile.object.velocity).toEqual([3000, 0]); + const strength = projectile + .getPropertyUpdates(timeScale) + .updates.find((property) => property.propertyKey === 'strength')!; + expect(40 + strength.rateOfChange * wallDelta).toBeCloseTo(projectile.strength); + expect(strength.propertyValue).toBe(projectile.strength); + }); + + it('coasts planet rotation using wall-clock seconds', () => { + const { game } = makeWorld(); + const planet = new PlanetPhysical( + [ + [-100, -100], + [-100, 100], + [100, 100], + [100, -100], + ], + false, + game, + ); + const angularVelocity = planet.angularVelocity; + planet.step(wallDelta * timeScale); + const update = planet + .getPropertyUpdates(timeScale) + .updates.find((property) => property.propertyKey === 'rotation')!; + + expect(update.rateOfChange * wallDelta).toBeCloseTo(planet.rotation); + expect(planet.angularVelocity).toBe(angularVelocity); + }); + + it('scales character pose derivatives without mutating the stored simulation rates', () => { + const { container } = makeWorld(); + const character = spawnCharacter(container); + character.step(settings.spawnDespawnTime + step); + character.setMoveDirection(vec2.fromValues(1, 0)); + vec2.set(character.bodyVelocity, 1000, 0); + const parts = [character.head, character.leftFoot, character.rightFoot]; + const before = parts.map((part) => vec2.clone(part.center)); + character.step(wallDelta * timeScale); + const originalRates = character.getPropertyUpdates(); + const slowedRates = character.getPropertyUpdates(timeScale); + + parts.forEach((part, i) => { + const rate = slowedRates.updates[i].rateOfChange; + expect(vec2.scaleAndAdd(vec2.create(), before[i], rate.center, wallDelta)).toEqual( + part.center, + ); + }); + expect(character.getPropertyUpdates()).toEqual(originalRates); + }); + + it('coasts spawn growth using the slowed radius derivative', () => { + const { container } = makeWorld(); + const character = spawnCharacter(container); + character.step(0.1); + const before = character.head.radius; + character.step(wallDelta * timeScale); + const radiusSpeed = + character.getPropertyUpdates(timeScale).updates[0].rateOfChange.radius; + + expect(before + radiusSpeed * wallDelta).toBeCloseTo(character.head.radius); + }); +}); + describe('projectile broadphase registration', () => { it('follows the projectile instead of staying at the muzzle', () => { const { container } = makeWorld(); @@ -88,6 +295,168 @@ describe('projectile broadphase registration', () => { expect(projectile.center[0]).toBeLessThanOrEqual(15); }); + + it('does not advance a projectile destroyed earlier in the same world step', () => { + const { container } = makeWorld(); + const projectile = spawnProjectile(container); + const before = vec2.clone(projectile.center); + const character = spawnCharacter(container); + character.step = () => projectile.destroy(); + container.addObject(projectile); + + container.step(step); + + expect(projectile.center).toEqual(before); + expect(projectile.strength).toBe(40); + }); +}); + +describe('authoritative held movement', () => { + it('distinguishes no leap from an attempted leap timestamped at zero', () => { + const { container } = makeWorld(); + const players = new PlayerContainer(container, 1, 0); + const batches: string[] = []; + const player = players.createPlayer({ name: 'player' }, { + on() {}, + emit(event: string, payload: string) { + if (event === TransportEvents.ServerToPlayer) { + batches.push(payload); + } + }, + } as never); + const sendSnapshot = () => + player.stepCommunications(settings.updateMessageInterval, 1000, (object) => + object.getPropertyUpdates(), + ); + const leapAck = () => + deserialize(batches[batches.length - 1]).find( + (command: object) => command instanceof InputAcknowledgement, + ).lastLeapClientTimeMs; + + sendSnapshot(); + expect(leapAck()).toBe(-1); + + player.handleCommand(new LeapActionCommand(0)); + sendSnapshot(); + expect(leapAck()).toBe(0); + }); + + it('applies the latest direction when press and release arrive in the same tick', () => { + const { container } = makeWorld(); + const character = spawnCharacter(container); + character.step(settings.spawnDespawnTime + step); + const before = vec2.clone(character.center); + + character.setMoveDirection(vec2.fromValues(1, 0)); + character.setMoveDirection(vec2.fromValues(0, 0)); + character.step(step); + + expect(character.center).toEqual(before); + }); + + it('uses the latest held direction for a leap received in the same batch', () => { + const { container } = makeWorld(); + const character = spawnCharacter(container); + character.step(settings.spawnDespawnTime + step); + character.currentPlanet = { + center: vec2.fromValues(0, -100), + angularVelocity: 0, + } as never; + + character.setMoveDirection(vec2.fromValues(1, 0)); + character.leap(); + + expect(character.bodyVelocity[0]).toBeGreaterThan(0); + expect(character.movementSnapshot.leapCooldownRemaining).toBeCloseTo( + settings.leapCooldownSeconds, + ); + character.step(step); + expect(character.movementSnapshot.leapCooldownRemaining).toBeCloseTo( + settings.leapCooldownSeconds - step, + ); + }); +}); + +describe('planets', () => { + it('does not capture using a character killed after registering its presence', () => { + const { container, game } = makeWorld(); + const character = spawnCharacter(container); + const planet = new PlanetPhysical( + [ + [-100, -100], + [-100, 100], + [100, 100], + [100, -100], + ], + false, + game, + ); + planet.registerPresence(character); + character.onDie(); + + planet.step(step); + + expect(planet.ownership).toBe(0.5); + }); + + it.each([ + { aspect: 8, center: [0, 750] }, + { aspect: 0.2, center: [750, 0] }, + ])('sends offscreen gravity sources at aspect $aspect', ({ aspect, center }) => { + const { container, game } = makeWorld(); + const batches: string[] = []; + const socket = { + on() {}, + emit(event: string, payload: string) { + if (event === TransportEvents.ServerToPlayer) { + batches.push(payload); + } + }, + }; + const players = new PlayerContainer(container, 1, 0); + const player = players.createPlayer({ name: 'player' }, socket as never); + const [x, y] = center; + const planet = new PlanetPhysical( + [ + [x - 20, y - 20], + [x - 20, y + 20], + [x + 20, y + 20], + [x + 20, y - 20], + ], + false, + game, + ); + container.addObject(planet); + player.handleCommand(new SetAspectRatioActionCommand(aspect)); + const { topLeft, size } = calculateViewArea(player.center, aspect, 1.2); + expect( + planet.boundingBox.intersects( + new BoundingBox( + topLeft[0], + topLeft[0] + size[0], + topLeft[1] - size[1], + topLeft[1], + ), + ), + ).toBe(false); + + player.stepCommunications(settings.updateMessageInterval + step, 1000, (object) => + object.getPropertyUpdates(), + ); + + const created = deserialize(batches[0]).find( + (command: object) => command instanceof CreateObjectsCommand, + ); + expect(created.objects.map((object: { id: number }) => object.id)).toContain( + planet.id, + ); + }); +}); + +it('uses an integer default seed so separate server runs can generate different worlds', () => { + expect(Number.isInteger(defaultOptions.seed)).toBe(true); + expect(defaultOptions.seed).toBeGreaterThanOrEqual(0); + expect(defaultOptions.seed).toBeLessThan(0x100000000); }); describe('scoring', () => { @@ -124,6 +493,23 @@ describe('scoring', () => { }); }); +describe('NPC population', () => { + it('replaces a departing human without adding extra bots for humans still playing', () => { + const { container } = makeWorld(); + const players = new PlayerContainer(container, 16, 4); + const socket = { on() {} } as never; + const first = players.createPlayer({ name: 'first' }, socket); + players.createPlayer({ name: 'second' }, socket); + expect(players.players).toHaveLength(4); + + first.destroy(); + players.deletePlayer(first); + + expect(players.count).toBe(1); + expect(players.players).toHaveLength(4); + }); +}); + describe('charged shots', () => { it('scales the shot by the strength actually paid, not the requested charge', () => { const { container } = makeWorld(); diff --git a/test/backpressure.test.ts b/test/backpressure.test.ts index b82fa1d..dd45cfe 100644 --- a/test/backpressure.test.ts +++ b/test/backpressure.test.ts @@ -15,13 +15,19 @@ const { CreateObjectsCommand, DeleteObjectsCommand, PropertyUpdatesForObjects, + PropertyUpdatesForObject, + UpdatePropertyCommand, + InputAcknowledgement, UpdateMinimap, ServerAnnouncement, } = shared; class FakeSocket { public readonly sent: Array<{ event: string; payload: unknown }> = []; - public readonly conn = { transport: { socket: { bufferedAmount: 0 } } }; + public readonly conn = { + transport: { socket: { bufferedAmount: 0 } }, + writeBuffer: [] as Array<{ data: unknown }>, + }; public set bufferedAmount(bytes: number) { this.conn.transport.socket.bufferedAmount = bytes; @@ -58,7 +64,19 @@ const queueOneSnapshot = (player: ReturnType['player']) => { player.queueCommandSend(new DeleteObjectsCommand([1234])); player.queueCommandSend(new ServerAnnouncement('team blue wins')); player.queueCommandSend(new UpdateMinimap([])); - player.queueCommandSend(new PropertyUpdatesForObjects([], 1)); + player.queueCommandSend( + new PropertyUpdatesForObjects( + [ + new PropertyUpdatesForObject(1234, [ + new UpdatePropertyCommand('ownership', 1, 0), + ]), + ], + 1, + ), + ); + player.queueCommandSend( + new InputAcknowledgement(123, player.character!.movementSnapshot, 0, 0), + ); }; const typesIn = (batch: Array<{ constructor: { name: string } }>) => @@ -80,6 +98,7 @@ describe('slow-client backpressure', () => { ServerAnnouncement.name, UpdateMinimap.name, PropertyUpdatesForObjects.name, + InputAcknowledgement.name, ]); }); @@ -89,7 +108,8 @@ describe('slow-client backpressure', () => { queueOneSnapshot(player); player.sendQueuedCommandsToClient(); - expect(typesIn(socket.lastBatch)).not.toContain(PropertyUpdatesForObjects.name); + const marker = socket.lastBatch.find((c) => c instanceof PropertyUpdatesForObjects); + expect(marker).toMatchObject({ updates: [], timestamp: 1 }); }); it('sheds only the state the next tick regenerates', () => { @@ -99,7 +119,8 @@ describe('slow-client backpressure', () => { player.sendQueuedCommandsToClient(); const types = typesIn(socket.lastBatch); - expect(types).not.toContain(PropertyUpdatesForObjects.name); + expect(types).toContain(PropertyUpdatesForObjects.name); + expect(types).not.toContain(InputAcknowledgement.name); expect(types).not.toContain(UpdateMinimap.name); expect(types).toContain(CreateObjectsCommand.name); expect(types).toContain(DeleteObjectsCommand.name); @@ -115,4 +136,32 @@ describe('slow-client backpressure', () => { expect(socket.sent.length).toBe(before); }); + + it('also measures packets waiting inside Engine.IO for the transport to drain', () => { + const { player, socket } = makePlayer(); + socket.conn.writeBuffer.push({ + data: Buffer.alloc(settings.maxBufferedBytesPerClient + 1), + }); + queueOneSnapshot(player); + player.sendQueuedCommandsToClient(); + + const marker = socket.lastBatch.find((c) => c instanceof PropertyUpdatesForObjects); + expect(marker).toMatchObject({ updates: [], timestamp: 1 }); + expect(typesIn(socket.lastBatch)).not.toContain(InputAcknowledgement.name); + }); + + it('sends a fresh pose and acknowledgement after the transport drains', () => { + const { player, socket } = makePlayer(); + socket.bufferedAmount = settings.maxBufferedBytesPerClient + 1; + queueOneSnapshot(player); + player.sendQueuedCommandsToClient(); + + socket.bufferedAmount = 0; + queueOneSnapshot(player); + player.sendQueuedCommandsToClient(); + + const snapshot = socket.lastBatch.find((c) => c instanceof PropertyUpdatesForObjects); + expect(snapshot).toMatchObject({ updates: [expect.anything()], timestamp: 1 }); + expect(typesIn(socket.lastBatch)).toContain(InputAcknowledgement.name); + }); }); diff --git a/test/client-object-lifecycle.test.ts b/test/client-object-lifecycle.test.ts new file mode 100644 index 0000000..f4992c5 --- /dev/null +++ b/test/client-object-lifecycle.test.ts @@ -0,0 +1,206 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createRequire } from 'node:module'; +const { + CreateObjectsCommand, + DeleteObjectsCommand, + PropertyUpdatesForObjects, + RemoteCallsForObjects, + RemoteCallsForObject, + RemoteCall, + UpdatePropertyCommand, + CharacterTeam, +} = createRequire(import.meta.url)('../shared/lib/main.js'); +import { GameObjectContainer } from '../frontend/src/scripts/objects/game-object-container'; +import { PlanetView } from '../frontend/src/scripts/objects/types/planet-view'; +import { serverTimeline } from '../frontend/src/scripts/helper/server-timeline'; +import { LinearInterpolator } from '../frontend/src/scripts/helper/interpolators/linear-interpolator'; +import { ProjectileView } from '../frontend/src/scripts/objects/types/projectile-view'; + +vi.mock('../frontend/node_modules/sdf-2d', () => ({ + CircleLight: class { + constructor( + public center: number[], + public color: number[], + public intensity: number, + ) {} + }, +})); + +vi.mock('../frontend/src/scripts/objects/types/camera', () => ({ + Camera: class { + draw() {} + follow() {} + }, +})); +vi.mock('../frontend/src/scripts/objects/types/planet-view', () => ({ + PlanetView: class { + beforeDestroy = vi.fn(); + render = vi.fn(); + processRemoteCalls = vi.fn(); + step() {} + constructor(public id: number) {} + }, +})); +vi.mock('../frontend/src/scripts/feedback-hud', () => ({ FeedbackHud: {} })); + +let nowMs = 0; +beforeEach(() => { + nowMs = 0; + vi.spyOn(performance, 'now').mockImplementation(() => nowMs); + serverTimeline.reset(); +}); + +describe('remote effects', () => { + it('plays remote effects on the render timeline and local feedback immediately', () => { + const container = new GameObjectContainer({} as never); + const remote = new PlanetView(1, []); + const local = { + id: 2, + health: 100, + position: [0, 0], + snapshotStrength: 80, + step() {}, + processRemoteCalls: vi.fn(), + }; + container.handleCommand(new CreateObjectsCommand([remote, local])); + container.player = local as never; + const calls = [new RemoteCall('onShoot', [40])]; + container.handleCommand( + new RemoteCallsForObjects([ + new RemoteCallsForObject(remote.id, calls), + new RemoteCallsForObject(local.id, calls), + ]), + ); + container.handleCommand(new PropertyUpdatesForObjects([], 5)); + expect(local.processRemoteCalls).toHaveBeenCalledWith(calls); + step(0); + container.step(0); + expect(remote.processRemoteCalls).not.toHaveBeenCalled(); + step(0.11); + container.step(0); + expect(remote.processRemoteCalls).toHaveBeenCalledWith(calls); + }); + + it('discards queued effects when a view is replaced or the game resets', () => { + const container = new GameObjectContainer({} as never); + const original = new PlanetView(1, []); + container.handleCommand(new CreateObjectsCommand([original])); + container.handleCommand( + new RemoteCallsForObjects([ + new RemoteCallsForObject(1, [new RemoteCall('onFlipped', ['red'])]), + ]), + ); + container.handleCommand(new PropertyUpdatesForObjects([], 5)); + const replacement = new PlanetView(1, []); + container.handleCommand(new CreateObjectsCommand([replacement])); + container.handleCommand(new PropertyUpdatesForObjects([], 5.04)); + step(0.3); + container.step(0); + expect(original.processRemoteCalls).not.toHaveBeenCalled(); + expect(replacement.processRemoteCalls).not.toHaveBeenCalled(); + + container.handleCommand( + new RemoteCallsForObjects([ + new RemoteCallsForObject(1, [new RemoteCall('onFlipped', ['blue'])]), + ]), + ); + container.reset(); + container.step(0); + expect(replacement.processRemoteCalls).not.toHaveBeenCalled(); + }); +}); +afterEach(() => vi.restoreAllMocks()); + +const step = (deltaSeconds: number) => { + nowMs += deltaSeconds * 1000; + serverTimeline.step(deltaSeconds); +}; + +describe('snapshot object lifetime', () => { + it('keeps creation and deletion aligned with the delayed render timeline', () => { + const container = new GameObjectContainer({} as never); + const planet = new PlanetView(1, []); + container.handleCommand(new CreateObjectsCommand([planet])); + container.handleCommand(new PropertyUpdatesForObjects([], 5)); + step(0); + container.render({} as never, {} as never, true); + expect(planet.render).not.toHaveBeenCalled(); + + step(0.11); + container.render({} as never, {} as never, true); + expect(planet.render).toHaveBeenCalledOnce(); + + container.handleCommand(new DeleteObjectsCommand([1])); + container.handleCommand(new PropertyUpdatesForObjects([], 5.2)); + container.step(0); + expect(planet.beforeDestroy).not.toHaveBeenCalled(); + step(0.3); + container.step(0); + expect(planet.beforeDestroy).toHaveBeenCalledOnce(); + expect(container.planets).toHaveLength(0); + }); + + it('does not delete a replacement when an object re-enters before delayed retirement', () => { + const container = new GameObjectContainer({} as never); + const original = new PlanetView(1, []); + container.handleCommand(new CreateObjectsCommand([original])); + container.handleCommand(new PropertyUpdatesForObjects([], 5)); + step(0); + container.handleCommand(new DeleteObjectsCommand([1])); + container.handleCommand(new PropertyUpdatesForObjects([], 5.04)); + + const replacement = new PlanetView(1, []); + container.handleCommand(new CreateObjectsCommand([replacement])); + container.handleCommand(new PropertyUpdatesForObjects([], 5.08)); + step(0.3); + container.step(0); + container.render({} as never, {} as never, true); + + expect(original.beforeDestroy).toHaveBeenCalledOnce(); + expect(replacement.beforeDestroy).not.toHaveBeenCalled(); + expect(replacement.render).toHaveBeenCalledOnce(); + expect(container.planets).toEqual([replacement]); + }); +}); + +describe('remote render timeline', () => { + it('samples projectile strength on the server timeline, including slowed fading', () => { + const projectile = new ProjectileView(1, [0, 0] as never, 10, CharacterTeam.red, 100); + serverTimeline.onSnapshot(5); + projectile.updateProperty(new UpdatePropertyCommand('strength', 100, -10)); + serverTimeline.onSnapshot(5.2); + projectile.updateProperty(new UpdatePropertyCommand('strength', 98, -10)); + step(0); + projectile.step(2); + expect(projectile.strength).toBeCloseTo(99, 6); + expect([...projectile.center]).toEqual([0, 0]); + }); + + it('does not accumulate an extra frame of lead while chasing its target', () => { + serverTimeline.onSnapshot(10); + step(0); + for (let i = 0; i < 600; i++) step(1 / 60); + expect(serverTimeline.renderTime).toBeCloseTo(19.9, 8); + }); + + it('interpolates positions and bounds extrapolation while snapshots are missing', () => { + const position = new LinearInterpolator(0); + serverTimeline.onSnapshot(10); + position.addFrame(0, 100); + serverTimeline.onSnapshot(10.1); + position.addFrame(10, 100); + step(0); + step(0.05); + expect(position.getValue(0.05)).toBeCloseTo(5, 6); + step(0.4); + expect(position.getValue(0.4)).toBeCloseTo(16, 6); + }); + + it('ages a new snapshot from its arrival, not the beginning of the render frame', () => { + nowMs = 15; + serverTimeline.onSnapshot(10); + nowMs = 16; + serverTimeline.step(0.016); + expect(serverTimeline.renderTime).toBeCloseTo(9.901, 6); + }); +}); diff --git a/test/client-send-cadence.test.ts b/test/client-send-cadence.test.ts index b94ffce..c199b14 100644 --- a/test/client-send-cadence.test.ts +++ b/test/client-send-cadence.test.ts @@ -81,4 +81,21 @@ describe('client send cadence', () => { expect(batches).toHaveLength(0); }); + + it('discards commands from the previous connection before the next join', () => { + const batches: Array = []; + const socket = new CommandSocket({ + connected: true, + emit: (_event: string, payload: string) => batches.push(payload), + } as never); + setFrameTimeMs(1000); + socket.sendQueuedCommands(); + socket.queue(new MoveActionCommand(vec2.fromValues(1, 0), 1000)); + socket.reset(); + socket.sendQueuedCommands(); + + expect(batches).toHaveLength(2); + expect(batches[1]).toContain(ClientHeartbeatCommand.name); + expect(batches[1]).not.toContain(MoveActionCommand.name); + }); }); diff --git a/test/frontend-regressions.test.ts b/test/frontend-regressions.test.ts new file mode 100644 index 0000000..5379f5b --- /dev/null +++ b/test/frontend-regressions.test.ts @@ -0,0 +1,302 @@ +import { EventEmitter } from 'node:events'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { KeyboardListener } from '../frontend/src/scripts/commands/keyboard-listener'; +import { TouchListener } from '../frontend/src/scripts/commands/touch-listener'; +import { JoinFormHandler } from '../frontend/src/scripts/join-form-handler'; +import { initializeOptions, options } from '../frontend/src/scripts/options-handler'; +import { loadStoredValue, saveStoredValue } from '../frontend/src/scripts/helper/storage'; + +const { connect } = vi.hoisted(() => ({ connect: vi.fn() })); +vi.mock('../frontend/node_modules/socket.io-client/build/esm-debug/index.js', () => ({ + io: connect, +})); +vi.mock('../frontend/src/scripts/configuration', () => ({ + servers: ['https://first.test', 'https://second.test'], +})); +vi.mock('../frontend/src/scripts/sound-handler', () => ({ + Sounds: { click: 'click' }, + SoundHandler: { play: vi.fn(), playAmbient: vi.fn(), stopAmbient: vi.fn() }, +})); +vi.mock('../frontend/src/scripts/charge-indicator', () => ({ + ChargeIndicator: { begin: vi.fn(), end: vi.fn() }, +})); + +// Only the DOM operations exercised by the chooser and input listeners are needed. +class Element extends EventTarget { + public children: Element[] = []; + public parentElement?: Element; + public style: Record = {}; + public className = ''; + public checked = false; + public disabled = false; + public onsubmit: ((event: Event) => void) | null = null; + + public appendChild(child: Element) { + child.remove(); + this.children.push(child); + child.parentElement = this; + } + public append(...children: Element[]) { + children.forEach((child) => this.appendChild(child)); + } + public remove() { + if (this.parentElement) { + this.parentElement.children = this.parentElement.children.filter((c) => c !== this); + this.parentElement = undefined; + } + } + public querySelector() { + return this.children[0]; + } + public getBoundingClientRect() { + return { left: 0, top: 0, width: 100, height: 100 }; + } +} + +class Socket extends EventEmitter { + public io = new EventEmitter(); + public close = vi.fn(); +} + +const dispatch = (target: EventTarget, type: string, fields = {}) => { + const event = Object.assign(new Event(type, { cancelable: true }), fields); + target.dispatchEvent(event); + return event; +}; +const touch = (identifier: number, clientX = 50, clientY = 50) => ({ + identifier, + clientX, + clientY, +}); +const touchEvent = ( + target: EventTarget, + type: string, + changedTouches: unknown[], + touches = changedTouches, +) => dispatch(target, type, { changedTouches, touches }); + +let windowTarget: EventTarget; +let sockets: Socket[]; + +beforeEach(() => { + vi.useFakeTimers(); + windowTarget = new EventTarget(); + sockets = []; + vi.stubGlobal('window', windowTarget); + vi.stubGlobal('addEventListener', windowTarget.addEventListener.bind(windowTarget)); + vi.stubGlobal( + 'removeEventListener', + windowTarget.removeEventListener.bind(windowTarget), + ); + vi.stubGlobal('document', { createElement: () => new Element() }); + vi.stubGlobal('localStorage', { getItem: vi.fn(() => null), setItem: vi.fn() }); + connect.mockImplementation(() => { + const socket = new Socket(); + sockets.push(socket); + return socket; + }); +}); + +afterEach(() => { + vi.clearAllTimers(); + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.clearAllMocks(); +}); + +const chooser = () => { + const form = new Element(); + form.appendChild(new Element()); + const container = new Element(); + const handler = new JoinFormHandler(form as never, container as never); + return { form, container, handler }; +}; +const serverInfo = { + serverName: 'test', + playerCount: 1, + playerLimit: 8, + gameStatePercent: 20, +}; + +describe('server chooser lifecycle', () => { + it('aborts discovery and ignores responses completing after the chooser closes', async () => { + const signals: AbortSignal[] = []; + let resolveBody!: (value: unknown) => void; + const body = new Promise((resolve) => { + resolveBody = resolve; + }); + vi.stubGlobal( + 'fetch', + vi.fn((_url, { signal }) => { + signals.push(signal); + return Promise.resolve({ ok: true, json: () => body }); + }), + ); + const { form, container, handler } = chooser(); + await Promise.resolve(); + handler.destroy(); + resolveBody(serverInfo); + await vi.runAllTimersAsync(); + + expect(signals.every((signal) => signal.aborted)).toBe(true); + expect(sockets).toHaveLength(0); + expect(container.children).toHaveLength(0); + expect(form.onsubmit).toBeNull(); + expect(vi.getTimerCount()).toBe(0); + }); + + it('selects a remaining server when the selected connection fails and removes listeners on exit', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ ok: true, json: async () => serverInfo })), + ); + const { form, container, handler } = chooser(); + await vi.advanceTimersByTimeAsync(0); + expect(container.children).toHaveLength(2); + expect(container.children[0].children[0].checked).toBe(true); + expect(container.children[1].children[0].checked).toBe(false); + + sockets[0].io.emit('reconnect_failed'); + expect(container.children).toHaveLength(1); + expect(container.children[0].children[0].checked).toBe(true); + expect(form.children[0].disabled).toBe(false); + + handler.destroy(); + expect( + sockets.every((socket) => socket.io.listenerCount('reconnect_failed') === 0), + ).toBe(true); + expect(container.children).toHaveLength(0); + expect(vi.getTimerCount()).toBe(0); + }); +}); + +describe('movement input', () => { + it('ignores unrelated keys, prevents browser movement defaults, and resends held keys after respawn', () => { + const send = vi.fn(); + const listener = new KeyboardListener(send); + dispatch(windowTarget, 'keydown', { code: 'KeyP' }); + expect(send).not.toHaveBeenCalled(); + const key = dispatch(windowTarget, 'keydown', { code: 'ArrowRight' }); + expect(key.defaultPrevented).toBe(true); + expect(send.mock.lastCall?.[0].direction).toEqual([1, 0]); + listener.resendMovement(); + expect(send).toHaveBeenCalledTimes(2); + dispatch(windowTarget, 'blur'); + expect(send.mock.lastCall?.[0].direction).toEqual([0, 0]); + listener.resendMovement(); + expect(send).toHaveBeenCalledTimes(3); + listener.destroy(); + }); +}); + +const touchControls = () => { + const canvas = new Element(); + const overlay = new Element(); + const send = vi.fn(); + const game = { + displayToWorldCoordinates: (position: number[]) => position, + gameObjects: { + localPlayer: { bodyCenter: [0, 0], facingDirection: [1, 0], strengthFraction: 1 }, + }, + }; + const listener = new TouchListener( + canvas as never, + overlay as never, + game as never, + send, + ); + listener.update(); + return { canvas, overlay, send, listener, fire: overlay.children[0] }; +}; + +describe('touch ownership', () => { + it('keeps the first canvas gesture when a second finger lands before dragging', () => { + const { canvas, send, listener } = touchControls(); + touchEvent(canvas, 'touchstart', [touch(1, 10, 20)]); + touchEvent( + canvas, + 'touchstart', + [touch(2, 80, 90)], + [touch(1, 10, 20), touch(2, 80, 90)], + ); + touchEvent(canvas, 'touchend', [touch(2, 80, 90)], [touch(1, 10, 20)]); + expect(send).not.toHaveBeenCalled(); + touchEvent(canvas, 'touchend', [touch(1, 10, 20)], []); + expect(send).toHaveBeenCalledTimes(1); + expect(send.mock.lastCall?.[0].constructor.name).toBe('PrimaryActionCommand'); + listener.destroy(); + }); + + it('does not fire when a charge is cancelled or another finger releases', () => { + const { fire, send, listener } = touchControls(); + touchEvent(fire, 'touchstart', [touch(1)]); + touchEvent(fire, 'touchstart', [touch(2)]); + touchEvent(fire, 'touchend', [touch(2)], [touch(1)]); + expect(send).not.toHaveBeenCalled(); + touchEvent(fire, 'touchcancel', [touch(1)], []); + touchEvent(fire, 'touchend', [touch(1)], []); + expect(send).not.toHaveBeenCalled(); + touchEvent(fire, 'touchstart', [touch(3)]); + touchEvent(fire, 'touchend', [touch(3)], []); + expect(send).toHaveBeenCalledTimes(1); + listener.destroy(); + }); + + it('resends a held joystick after respawn and stops movement when focus is lost', () => { + const { canvas, overlay, send, listener } = touchControls(); + touchEvent(canvas, 'touchstart', [touch(1)]); + touchEvent(canvas, 'touchmove', [touch(1, 100, 50)]); + listener.resendMovement(); + expect(send.mock.lastCall?.[0].direction[0]).toBe(1); + expect(send.mock.lastCall?.[0].direction[1]).toBeCloseTo(0); + dispatch(windowTarget, 'blur'); + expect(send.mock.lastCall?.[0].direction).toEqual([0, 0]); + expect(overlay.children).toHaveLength(2); + touchEvent(canvas, 'touchstart', [touch(2)]); + touchEvent(canvas, 'touchmove', [touch(2, 100, 50)]); + listener.destroy(); + expect(overlay.children).toHaveLength(0); + }); +}); + +describe('stored preferences', () => { + it('ignores corrupt JSON and unavailable storage', () => { + vi.mocked(localStorage.getItem).mockReturnValue('{'); + expect(loadStoredValue('userName')).toBeUndefined(); + vi.mocked(localStorage.getItem).mockImplementation(() => { + throw new Error('disabled'); + }); + vi.mocked(localStorage.setItem).mockImplementation(() => { + throw new Error('disabled'); + }); + expect(loadStoredValue('options')).toBeUndefined(); + expect(() => saveStoredValue('userName', 'player')).not.toThrow(); + }); + + it('only loads boolean options that the game recognizes', () => { + Object.assign(options, { + soundsEnabled: true, + musicEnabled: true, + vibrationEnabled: true, + }); + vi.mocked(localStorage.getItem).mockReturnValue( + JSON.stringify({ + soundsEnabled: 'false', + musicEnabled: false, + vibrationEnabled: null, + extra: true, + }), + ); + initializeOptions({ + soundsEnabled: new Element(), + musicEnabled: new Element(), + vibrationEnabled: new Element(), + } as never); + expect(options).toEqual({ + soundsEnabled: true, + musicEnabled: false, + vibrationEnabled: true, + }); + }); +}); diff --git a/test/physics-determinism.test.mjs b/test/physics-determinism.test.mjs index bbb36f6..300f4cf 100644 --- a/test/physics-determinism.test.mjs +++ b/test/physics-determinism.test.mjs @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest'; import { createRequire } from 'node:module'; +import { execFileSync } from 'node:child_process'; const require = createRequire(import.meta.url); const shared = require('../shared/lib/main.js'); @@ -99,6 +100,41 @@ const runPlanetSimulation = () => { }; describe('shared character simulation determinism', () => { + it('does not depend on configuring vector precision before imports', () => { + const run = (configureBeforeImport) => + execFileSync( + process.execPath, + [ + '-e', + ` + const { glMatrix, vec2 } = require('./shared/node_modules/gl-matrix'); + if (${configureBeforeImport}) glMatrix.setMatrixArrayType(Array); + const s = require('./shared/lib/main.js'); + glMatrix.setMatrixArrayType(Array); + const body = (offset, radius) => ({ + center: vec2.clone(offset), radius, velocity: vec2.create(), + lastNormal: [0, 1], restitution: 0, + }); + const state = { + head: body(s.headOffset, s.headRadius), + leftFoot: body(s.leftFootOffset, s.feetRadius), + rightFoot: body(s.rightFootOffset, s.feetRadius), + direction: 0, currentPlanet: undefined, + secondsSinceOnSurface: 1, bodyVelocity: [0, 0], + }; + const world = { + groundsNear: () => [], + stepBody: (body, dt) => s.resolveCircleMovement(body, dt, []), + }; + for (let i = 0; i < 100; i++) s.stepCharacterMovement(state, world, [1, 0], 0.005); + process.stdout.write(JSON.stringify(state)); + `, + ], + { encoding: 'utf8' }, + ); + expect(run(false)).toBe(run(true)); + }); + it('produces identical output across independent runs', () => { expect(runSimulation()).toBe(runSimulation()); }); diff --git a/test/prediction-reconciliation.test.ts b/test/prediction-reconciliation.test.ts index 8ed2ba9..eed53f0 100644 --- a/test/prediction-reconciliation.test.ts +++ b/test/prediction-reconciliation.test.ts @@ -38,7 +38,7 @@ describe('local prediction reconciliation', () => { auth.rightFoot as never, ); - const used = predictor.update([], 1 / 60); + const used = predictor.update([]); expect(used).toBe(true); expect(predictor.head.center[0]).toBeCloseTo(auth.head.center[0], 5); @@ -62,7 +62,7 @@ describe('local prediction reconciliation', () => { predictor.recordInput([1, 0]); setFrameTimeMs(1100); - predictor.update([], 1 / 60); + predictor.update([]); return [ predictor.head.center[0], predictor.head.center[1], @@ -94,7 +94,7 @@ describe('local prediction reconciliation', () => { predictor.setAlive(false); setFrameTimeMs(5200); - expect(predictor.update([], 1 / 60)).toBe(false); + expect(predictor.update([])).toBe(false); }); it('replays the whole un-acknowledged span, so a longer trip predicts further', () => { @@ -113,7 +113,7 @@ describe('local prediction reconciliation', () => { auth.rightFoot as never, ); - predictor.update([], 1 / 60); + predictor.update([]); return predictor.head.center[0]; }; @@ -146,7 +146,7 @@ describe('local prediction reconciliation', () => { auth.rightFoot as never, ); - predictor.update([], 1 / 60); + predictor.update([]); return predictor.head.center[0]; }; @@ -175,7 +175,7 @@ describe('local prediction reconciliation', () => { ); setFrameTimeMs(40); - predictor.update([], 1 / 60); + predictor.update([]); return [predictor.head.center[0], predictor.head.center[1]]; }; @@ -189,3 +189,127 @@ describe('local prediction reconciliation', () => { expect(apart).toBeGreaterThan(1); }); }); + +const settledPoseAt = (x: number) => ({ + head: { center: [x, 55 - 55 / 3], radius: HEAD_RADIUS }, + leftFoot: { center: [x - 20, -55 / 3], radius: FEET_RADIUS }, + rightFoot: { center: [x + 20, -55 / 3], radius: FEET_RADIUS }, +}); + +const install = (predictor: LocalCharacterPredictor, timeMs: number, x: number) => { + const pose = settledPoseAt(x); + predictor.acknowledge(timeMs, movementState() as never, -Infinity); + predictor.setAuthoritative( + pose.head as never, + pose.leftFoot as never, + pose.rightFoot as never, + ); +}; + +describe('prediction corrections and interruptions', () => { + it('blends an authoritative correction and converges without slowing ordinary motion', () => { + const predictor = new LocalCharacterPredictor(); + setFrameTimeMs(1000); + install(predictor, 1000, 0); + predictor.update([]); + + setFrameTimeMs(1016); + install(predictor, 1016, 100); + predictor.update([]); + expect(predictor.head.center[0]).toBeCloseTo(0, 5); + + setFrameTimeMs(1032); + predictor.update([]); + expect(predictor.head.center[0]).toBeGreaterThan(0); + expect(predictor.head.center[0]).toBeLessThan(100); + + setFrameTimeMs(1516); + predictor.update([]); + expect(predictor.head.center[0]).toBeCloseTo(100, 0); + }); + + it('freezes at the replay horizon during an outage even if held input changes', () => { + const predictor = new LocalCharacterPredictor(); + setFrameTimeMs(0); + install(predictor, 0, 0); + predictor.recordInput([1, 0] as never); + setFrameTimeMs(400); + predictor.update([]); + const frozen = [...predictor.head.center]; + + setFrameTimeMs(450); + predictor.recordInput([-1, 0] as never); + setFrameTimeMs(600); + predictor.update([]); + expect([...predictor.head.center]).toEqual(frozen); + + setFrameTimeMs(2000); + predictor.recordInput([0, 0] as never); + setFrameTimeMs(2500); + predictor.update([]); + expect([...predictor.head.center]).toEqual(frozen); + }); + + it('clears visual correction on respawn and disables prediction for end-game snapshots', () => { + const predictor = new LocalCharacterPredictor(); + setFrameTimeMs(1000); + install(predictor, 1000, 0); + predictor.update([]); + install(predictor, 1000, 100); + predictor.update([]); + predictor.reset(); + install(predictor, 1000, 1000); + predictor.update([]); + expect(predictor.head.center[0]).toBe(1000); + predictor.enabled = false; + expect(predictor.update([])).toBe(false); + predictor.reset(); + install(predictor, 1000, 1000); + expect(predictor.update([])).toBe(false); + }); +}); + +const ground = { + id: 1, + vertices: Array.from({ length: 15 }, (_, i) => { + const angle = (i / 15) * -Math.PI * 2; + return [800 * Math.cos(angle), 800 * Math.sin(angle)]; + }), + snapshotRotation: 0, + snapshotRotationSpeed: 0, +}; + +const leapHeight = (cooldown: number, leap: boolean) => { + const predictor = new LocalCharacterPredictor(); + setFrameTimeMs(1000); + const pose = poseAt(0, 900); + predictor.acknowledge( + 1000, + { + ...movementState(), + groundPlanetId: 1, + secondsSinceOnSurface: 0, + leapCooldownRemaining: cooldown, + } as never, + -Infinity, + ); + predictor.setAuthoritative( + pose.head as never, + pose.leftFoot as never, + pose.rightFoot as never, + ); + if (leap) predictor.recordLeap(); + setFrameTimeMs(1002); + predictor.update([ground] as never); + return predictor.head.center[1]; +}; + +describe('leap prediction', () => { + it('applies a newly pressed leap in the very next fractional render tick', () => { + expect(leapHeight(0, true)).toBeGreaterThan(leapHeight(0, false) + 1); + }); + + it('does not predict a leap the authoritative cooldown will reject', () => { + expect(leapHeight(0.2, true)).toEqual(leapHeight(0.2, false)); + }); +}); diff --git a/test/render-smoothness.test.ts b/test/render-smoothness.test.ts index 299a4f4..2b145b9 100644 --- a/test/render-smoothness.test.ts +++ b/test/render-smoothness.test.ts @@ -10,7 +10,6 @@ import { headRadius, feetRadius, } from '../shared/src/physics/character-movement'; -import { followVec2 } from '../shared/src/helper/ease'; import { settings } from '../shared/src/settings'; const stepSeconds = settings.targetPhysicsDeltaTimeInSeconds; @@ -51,20 +50,23 @@ interface Conditions { dropRate: number; /** Server work that can delay a socket callback past the packet's arrival. */ serverBusyMs: number; + wireJitterMs?: number; + emitJitterMs?: number; + changeInput?: boolean; } interface Result { /** Peak-to-peak variation in apparent speed, over its mean. Zero means perfectly even motion. */ speedSpread: number; - /** Peak-to-peak wobble of the character's screen position about its own mean, in world units. */ - screenWobble: number; /** How far the rendered character trails the true server pose, in world units. */ lagUnits: number; + finalError: number; + maximumSpeed: number; } -// Runs a real server tick loop, a real wire, and a real render loop against the predictor, and -// reports how steady the character's *on-screen* travel is. On-screen, not in world space: the -// camera follows the character, so what the player sees is the residual between the two. +// Exercise shared movement with independent server, network, and render clocks. +// Measure world travel: the camera tracks the local character exactly, so measuring +// their relative position would always report zero even when the world visibly stutters. const measure = ({ fps, wireMs, @@ -72,6 +74,9 @@ const measure = ({ frameJitter, dropRate, serverBusyMs, + wireJitterMs = 0, + emitJitterMs = 0, + changeInput = false, }: Conditions): Result => { const world = new ClientCharacterWorld(); world.sync([planetAt(0)]); @@ -86,28 +91,30 @@ const measure = ({ bodyVelocity: [0, 0], }; - const input = [1, 0]; + let input = [1, 0]; + let serverInput = [1, 0]; const predictor = new LocalCharacterPredictor(); predictor.setStrength(settings.playerMaxStrength); // Deterministic frame-time noise: a real display never delivers a perfectly even cadence. let seed = 12345; - const random = () => ((seed = (seed * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff); + const random = () => (seed = (seed * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff; const nominalFrameMs = 1000 / fps; - const toServer: Array<{ atMs: number; clientStampMs: number }> = []; - const toClient: Array<{ atMs: number; ackAgeMs: number; clientStampMs: number; pose: any }> = []; + const toServer: Array<{ atMs: number; clientStampMs: number; direction?: number[] }> = + []; + const toClient: Array<{ + atMs: number; + ackAgeMs: number; + clientStampMs: number; + pose: any; + }> = []; const samples: Array<{ - screen: Array; world: Array; atMs: number; error: number; }> = []; - // The client's own view of the character, and the camera that chases it. - const camera = [0, planetRadius + 90]; - const cameraPreviousTarget = [0, planetRadius + 90]; - let simMs = 0; let nextSnapshotMs = 0; let nextFrameAtMs = 0; @@ -115,27 +122,33 @@ const measure = ({ let lastInputClientTimeMs = 0; let lastInputReceiptMs = 0; let hasPressed = false; - + let lastServerArrivalMs = 0; + let lastClientArrivalMs = 0; while (simMs < 8000) { tickPlanetDetachment(server, stepSeconds); - stepCharacterMovement(server, world, input.slice() as never, stepSeconds); + stepCharacterMovement(server, world, serverInput.slice() as never, stepSeconds); simMs += stepSeconds * 1000; while (toServer.length > 0 && toServer[0].atMs <= simMs) { const packet = toServer.shift()!; + if (packet.direction) serverInput = packet.direction; if (packet.clientStampMs > lastInputClientTimeMs) { lastInputClientTimeMs = packet.clientStampMs; // Socket callbacks run between physics frames, so this is the true arrival instant, // not the frame boundary — plus however long the frame in progress still had to run. - lastInputReceiptMs = packet.atMs + serverBusyMs; + lastInputReceiptMs = packet.atMs; } } if (simMs >= nextSnapshotMs) { nextSnapshotMs += snapshotMs; + lastClientArrivalMs = Math.max( + lastClientArrivalMs, + simMs + Math.max(0, wireMs + (random() * 2 - 1) * wireJitterMs), + ); toClient.push({ - atMs: simMs + wireMs, + atMs: lastClientArrivalMs, clientStampMs: lastInputClientTimeMs, ackAgeMs: lastInputReceiptMs > 0 ? Math.max(0, simMs - lastInputReceiptMs) : 0, pose: { @@ -175,8 +188,16 @@ const measure = ({ // Movement input is edge-triggered, exactly as KeyboardListener sends it: recorded once // when the key goes down, then held by both sides. - if (!hasPressed) { + const desiredInput = + changeInput && nextFrameAtMs >= 4500 + ? nextFrameAtMs >= 6000 + ? [0, 0] + : [-1, 0] + : [1, 0]; + const inputChanged = !hasPressed || desiredInput[0] !== input[0]; + if (inputChanged) { hasPressed = true; + input = desiredInput; predictor.recordInput(input as never); } @@ -185,30 +206,35 @@ const measure = ({ const frameMs = nominalFrameMs * jitter * (dropped ? 2 : 1); if (!dropped) { - predictor.update([planetAt(0)], frameMs / 1000); + predictor.update([planetAt(0)]); const head = predictor.head.center; - followVec2( - camera as never, - head, - cameraPreviousTarget as never, - frameMs / 1000, - 0.08, - 1500, - ); samples.push({ - screen: [head[0] - camera[0], head[1] - camera[1]], world: [head[0], head[1]], atMs: frameClientMs, - error: Math.hypot(head[0] - server.head.center[0], head[1] - server.head.center[1]), + error: Math.hypot( + head[0] - server.head.center[0], + head[1] - server.head.center[1], + ), }); } // Stamped at the top of the frame, emitted at the bottom. - if (frameClientMs - lastHeartbeatClientMs >= heartbeatMs - 1) { + if ( + inputChanged || + (!dropped && frameClientMs - lastHeartbeatClientMs >= heartbeatMs - 1) + ) { lastHeartbeatClientMs = frameClientMs; + lastServerArrivalMs = Math.max( + lastServerArrivalMs, + nextFrameAtMs + + serverBusyMs + + Math.max(0, emitLagMs + (random() * 2 - 1) * emitJitterMs) + + Math.max(0, wireMs + (random() * 2 - 1) * wireJitterMs), + ); toServer.push({ - atMs: nextFrameAtMs + emitLagMs + wireMs, + atMs: lastServerArrivalMs, clientStampMs: frameClientMs, + direction: inputChanged ? input.slice() : undefined, }); } @@ -229,60 +255,115 @@ const measure = ({ ); const meanSpeed = speeds.reduce((a, b) => a + b, 0) / speeds.length; - // Screen position: the camera settles to a constant lag, so anything left is visible wobble. - const centre = [0, 1].map((axis) => { - const values = tail.map((s) => s.screen[axis]); - return values.reduce((a, b) => a + b, 0) / values.length; - }); - const wobble = Math.max( - ...tail.map((s) => Math.hypot(s.screen[0] - centre[0], s.screen[1] - centre[1])), - ); - - if ((globalThis as any).__dump) { - console.log('--- apparent speed (u/ms) ---'); - speeds.slice(0, 40).forEach((v, i) => console.log(`${i} ${v.toFixed(4)}`)); - } return { speedSpread: (Math.max(...speeds) - Math.min(...speeds)) / meanSpeed, - screenWobble: wobble, lagUnits: tail.reduce((sum, s) => sum + s.error, 0) / tail.length, + finalError: tail[tail.length - 1].error, + maximumSpeed: Math.max(...speeds), }; }; const CONDITIONS: Array<[string, Conditions]> = [ - ['localhost, clean frames', { fps: 60, wireMs: 0.2, emitLagMs: 2, frameJitter: 0, dropRate: 0, serverBusyMs: 0 }], - ['localhost, 144 Hz', { fps: 144, wireMs: 0.2, emitLagMs: 2, frameJitter: 0, dropRate: 0, serverBusyMs: 0 }], - ['localhost, frame jitter', { fps: 60, wireMs: 0.2, emitLagMs: 2, frameJitter: 0.15, dropRate: 0, serverBusyMs: 0 }], - ['localhost, 2% dropped', { fps: 60, wireMs: 0.2, emitLagMs: 2, frameJitter: 0.05, dropRate: 0.02, serverBusyMs: 0 }], - ['localhost, variable CPU', { fps: 60, wireMs: 0.2, emitLagMs: 8, frameJitter: 0.15, dropRate: 0.02, serverBusyMs: 0 }], - ['30 ms link', { fps: 60, wireMs: 15, emitLagMs: 2, frameJitter: 0.05, dropRate: 0, serverBusyMs: 0 }], - ['120 ms link', { fps: 60, wireMs: 60, emitLagMs: 2, frameJitter: 0.05, dropRate: 0, serverBusyMs: 0 }], + [ + 'localhost, clean frames', + { fps: 60, wireMs: 0.2, emitLagMs: 2, frameJitter: 0, dropRate: 0, serverBusyMs: 0 }, + ], + [ + 'localhost, 144 Hz', + { fps: 144, wireMs: 0.2, emitLagMs: 2, frameJitter: 0, dropRate: 0, serverBusyMs: 0 }, + ], + [ + 'localhost, frame jitter', + { + fps: 60, + wireMs: 0.2, + emitLagMs: 2, + frameJitter: 0.15, + dropRate: 0, + serverBusyMs: 0, + }, + ], + [ + 'localhost, 2% dropped', + { + fps: 60, + wireMs: 0.2, + emitLagMs: 2, + frameJitter: 0.05, + dropRate: 0.02, + serverBusyMs: 0, + }, + ], + [ + 'localhost, slower CPU', + { + fps: 60, + wireMs: 0.2, + emitLagMs: 8, + frameJitter: 0.15, + dropRate: 0.02, + serverBusyMs: 0, + }, + ], + [ + '30 ms link', + { + fps: 60, + wireMs: 15, + emitLagMs: 2, + frameJitter: 0.05, + dropRate: 0, + serverBusyMs: 0, + }, + ], + [ + '120 ms link', + { + fps: 60, + wireMs: 60, + emitLagMs: 2, + frameJitter: 0.05, + dropRate: 0, + serverBusyMs: 0, + }, + ], ]; describe('local character render smoothness', () => { - it('keeps on-screen travel even under realistic clocks and frame pacing', () => { - ;(globalThis as any).__dump = true - measure(CONDITIONS[0][1]) - ;(globalThis as any).__dump = false - const results: Array<[string, Result]> = CONDITIONS.map(([label, c]) => [label, measure(c)]); + it.each(CONDITIONS)('keeps travel even with %s', (_label, conditions) => { + const result = measure(conditions); + expect(result.speedSpread).toBeLessThan(0.05); + if (conditions.wireMs < 1) expect(result.lagUnits).toBeLessThan(5); + }); - for (const [label, r] of results) { - console.log( - `${label.padEnd(26)} speed=${(r.speedSpread * 100).toFixed(1).padStart(6)}% ` + - `wobble=${r.screenWobble.toFixed(2).padStart(6)}u ` + - `lag=${r.lagUnits.toFixed(1).padStart(5)}u `, - ); - } + it('absorbs changing network and client CPU delay without snapshot jumps', () => { + const result = measure({ + fps: 144, + wireMs: 30, + emitLagMs: 5, + wireJitterMs: 10, + emitJitterMs: 5, + frameJitter: 0.15, + dropRate: 0.02, + serverBusyMs: 2, + }); + expect(result.speedSpread).toBeLessThan(0.4); + expect(result.lagUnits).toBeLessThan(25); + }); - for (const [label, r] of results) { - // Even travel: what the eye reads as smooth motion. - expect(r.speedSpread, label).toBeLessThan(0.05); - // No sub-pixel swimming against the camera, which follows the same character. - expect(r.screenWobble, label).toBeLessThan(1); - } - - // Prediction exists to cancel latency; a follower that lags undoes that on every link. - const localhost = results.find(([label]) => label === 'localhost, clean frames')![1]; - expect(localhost.lagUnits).toBeLessThan(5); + it('reconciles direction changes and stopping across an irregular link', () => { + const result = measure({ + fps: 144, + wireMs: 30, + emitLagMs: 5, + wireJitterMs: 10, + emitJitterMs: 5, + frameJitter: 0.15, + dropRate: 0.02, + serverBusyMs: 2, + changeInput: true, + }); + expect(result.maximumSpeed).toBeLessThan(1); + expect(result.finalError).toBeLessThan(3); }); }); diff --git a/test/serialization.test.mjs b/test/serialization.test.mjs index 5f85482..209736d 100644 --- a/test/serialization.test.mjs +++ b/test/serialization.test.mjs @@ -22,9 +22,17 @@ const { headOffset, leftFootOffset, rightFootOffset, + CharacterMovementSnapshot, } = shared; describe('serialization round-trip (built shared lib)', () => { + it('preserves the authoritative leap cooldown for prediction', () => { + const snapshot = new CharacterMovementSnapshot(0, [1, 2], [0, 1], [0, 1], 5, 0, 0.25); + const out = deserialize(serialize(snapshot)); + expect(out).toBeInstanceOf(CharacterMovementSnapshot); + expect(out.leapCooldownRemaining).toBe(0.25); + }); + it('reconstructs a ServerAnnouncement as the right class with its payload', () => { const [out] = deserialize(serialize([new ServerAnnouncement('Hello world')])); expect(out).toBeInstanceOf(ServerAnnouncement); diff --git a/test/setup.mjs b/test/setup.mjs index d55aa06..65bf24b 100644 --- a/test/setup.mjs +++ b/test/setup.mjs @@ -1,4 +1,5 @@ -// Production makes every vector an f64 Array before any module-level vector is allocated. +// Use production's f64 vector type in every package. The import-order regression separately +// verifies that physics constants also retain full precision before startup configures it. import { createRequire } from 'node:module'; const require = createRequire(import.meta.url); diff --git a/test/shared-regressions.test.ts b/test/shared-regressions.test.ts new file mode 100644 index 0000000..770ddc5 --- /dev/null +++ b/test/shared-regressions.test.ts @@ -0,0 +1,111 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { marchCircle } from '../shared/src/physics/march-circle'; +import { resolveCircleMovement } from '../shared/src/physics/resolve-circle-movement'; +import { finiteVec2, sanitizeName } from '../shared/src/helper/finite'; +import { CommandReceiver } from '../shared/src/commands/command-receiver'; +import type { PhysicsBody, Sdf } from '../shared/src/physics/sdf'; + +const bodyAt = (x: number, y: number, vx: number, vy: number): PhysicsBody => ({ + center: [x, y], + radius: 1, + velocity: [vx, vy], + lastNormal: [0, 1], + restitution: 1, +}); + +const rightWall: Sdf = { canCollide: true, distance: ([x]) => 10 - x }; + +describe('circle collision movement', () => { + it('spends only the remaining step time after a bounce', () => { + const body = bodyAt(0, 0, 100, 0); + expect(resolveCircleMovement(body, 0.2, [rightWall])).toBe(rightWall); + // 9 units to the wall, then 11 units back: 20 units of total travel. + expect(body.center[0]).toBeCloseTo(-2, 6); + expect(body.velocity[0]).toBeCloseTo(-100, 6); + }); + + it('resolves velocity at the second contact instead of leaving it aimed into a wall', () => { + const body = bodyAt(0, 0, 100, 0); + const leftWall: Sdf = { canCollide: true, distance: ([x]) => x + 5 }; + resolveCircleMovement(body, 0.3, [rightWall, leftWall]); + expect(body.center[0]).toBeCloseTo(-4, 6); + expect(body.velocity[0]).toBeCloseTo(100, 6); + }); + + it('allows slow tangential sliding along a surface', () => { + const body = { ...bodyAt(9, 0, 100, 10), restitution: 0 }; + resolveCircleMovement(body, 0.1, [rightWall]); + expect(body.center[0]).toBeCloseTo(9, 6); + expect(body.center[1]).toBeCloseTo(1, 6); + expect(body.velocity[0]).toBeCloseTo(0, 6); + }); + + it('ignores disabled objects when identifying the contacted surface', () => { + const inactive: Sdf = { canCollide: false, distance: () => -100 }; + const onHit = vi.fn(); + const body = bodyAt(0, 0, 100, 0); + const result = marchCircle(body, [20, 0], [inactive, rightWall], onHit); + expect(result.hitObject).toBe(rightWall); + expect(onHit).toHaveBeenCalledExactlyOnceWith(rightWall); + expect(result.normal).toEqual([-1, 0]); + }); + + it('samples the collision normal before the callback removes the obstacle', () => { + const wall = { ...rightWall }; + const body = bodyAt(0, 0, 100, 0); + resolveCircleMovement(body, 0.2, [wall], () => { + wall.canCollide = false; + }); + expect(body.center.every(Number.isFinite)).toBe(true); + expect(body.velocity[0]).toBeCloseTo(-100, 6); + }); +}); + +describe('network input helpers', () => { + it('clamps huge finite vectors before allocating a float vector', () => { + const direction = finiteVec2([1e300, 1e300], 1)!; + expect(direction.every(Number.isFinite)).toBe(true); + expect(Math.hypot(...direction)).toBeCloseTo(1, 6); + expect(direction[0]).toBeGreaterThan(0.7); + }); + + it('never calls methods on an untrusted player name', () => { + expect(sanitizeName({ toString: {} }, 10)).toBe(''); + expect(sanitizeName('abcdefghijk', 10)).toBe('abcdefghij'); + }); + + it('does not dispatch inherited object methods as commands', () => { + class Receiver extends CommandReceiver { + public fallback = vi.fn(); + protected defaultCommandExecutor(command: object) { + this.fallback(command); + } + } + const receiver = new Receiver(); + const command = { constructor: { name: 'constructor' } }; + receiver.handleCommand(command); + expect(receiver.fallback).toHaveBeenCalledExactlyOnceWith(command); + }); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('random world seeds', () => { + it('uses the random seed bits instead of truncating every initial seed to zero', async () => { + const random = vi.spyOn(Math, 'random'); + const firstValues: number[] = []; + for (const seed of [0.1, 0.5, 0.9]) { + random.mockReturnValue(seed); + vi.resetModules(); + const { Random } = await import('../shared/src/helper/random'); + firstValues.push(Random.getRandom()); + Random.seed = 42; + const expected = [Random.getRandom(), Random.getRandom()]; + Random.seed = 42; + expect([Random.getRandom(), Random.getRandom()]).toEqual(expected); + } + expect(new Set(firstValues).size).toBe(3); + }); +}); -- 2.54.0 From ec52e57deeb43149b48b6b2ccd09e545b574fcb8 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Mon, 14 Sep 2026 21:55:37 +0100 Subject: [PATCH 12/15] . --- .../scripts/objects/game-object-container.ts | 42 ++- test/suspended-rendering.test.ts | 278 ++++++++++++++++++ 2 files changed, 317 insertions(+), 3 deletions(-) create mode 100644 test/suspended-rendering.test.ts diff --git a/frontend/src/scripts/objects/game-object-container.ts b/frontend/src/scripts/objects/game-object-container.ts index 805bdd4..a469ff1 100644 --- a/frontend/src/scripts/objects/game-object-container.ts +++ b/frontend/src/scripts/objects/game-object-container.ts @@ -1,6 +1,7 @@ import { Renderer } from 'sdf-2d'; import { Circle, + CharacterBase, CommandExecutors, CommandReceiver, CreateObjectsCommand, @@ -21,6 +22,10 @@ import { CharacterView } from './types/character-view'; import { PlanetView } from './types/planet-view'; import { View } from './view'; +// Keep enough history for interpolation, without retaining a background tab's entire session. +const deferredHistorySeconds = 0.5; +const persistentRemoteCalls = new Set(['setKillCount', 'setLight', 'setContested']); + export class GameObjectContainer extends CommandReceiver { public player?: CharacterView; public readonly camera: Camera; @@ -28,6 +33,7 @@ export class GameObjectContainer extends CommandReceiver { private objects: Map = new Map(); private wasLocalPlayerAlive = false; + private lastStepAtMs = performance.now(); // Creates and deletes take effect on the render timeline, not on arrival. private visibleFrom = new Map(); @@ -67,6 +73,7 @@ export class GameObjectContainer extends CommandReceiver { [PropertyUpdatesForObjects.name]: (c: PropertyUpdatesForObjects) => { serverTimeline.onSnapshot(c.timestamp); this.stampPending(c.timestamp); + this.pruneDeferred(c.timestamp - deferredHistorySeconds); c.updates.forEach((u) => { const object = this.objects.get(u.id); u.updates.forEach((au) => object?.updateProperty?.(au)); @@ -96,6 +103,7 @@ export class GameObjectContainer extends CommandReceiver { this.awaitingDeleteStamp = []; this.remoteCalls = []; this.wasLocalPlayerAlive = false; + this.lastStepAtMs = performance.now(); } public get localPlayer(): CharacterView | undefined { @@ -104,6 +112,12 @@ export class GameObjectContainer extends CommandReceiver { public step(deltaTimeInSeconds: number) { this.stampPending(serverTimeline.snapshotTime); + const nowMs = performance.now(); + const resumed = nowMs - this.lastStepAtMs > deferredHistorySeconds * 1000; + this.lastStepAtMs = nowMs; + this.pruneDeferred( + serverTimeline.renderTime - (resumed ? 0 : deferredHistorySeconds), + ); this.retireDueObjects(); this.remoteCalls = this.remoteCalls.filter(({ object, calls, at }) => { if (this.objects.get(object.id) !== object) { @@ -166,8 +180,25 @@ export class GameObjectContainer extends CommandReceiver { } } - private retireDueObjects() { - const renderTime = serverTimeline.renderTime; + private pruneDeferred(cutoff: number) { + this.retireDueObjects(cutoff); + this.remoteCalls = this.remoteCalls.filter(({ object, calls, at }) => { + if (at === undefined || at > cutoff) { + return true; + } + for (const call of calls) { + if (call.functionName === 'setHealth' && object instanceof CharacterBase) { + // The view's health setter also plays hit feedback; expired damage is state only. + object.health = call.args[0]; + } else if (persistentRemoteCalls.has(call.functionName)) { + object.processRemoteCalls([call]); + } + } + return false; + }); + } + + private retireDueObjects(renderTime = serverTimeline.renderTime) { for (const [id, at] of this.deleteAt) { if (renderTime >= at) { this.deleteObject(id); @@ -205,8 +236,13 @@ export class GameObjectContainer extends CommandReceiver { } private deleteObject(id: Id) { - this.objects.get(id)?.beforeDestroy?.(); + const object = this.objects.get(id); + object?.beforeDestroy?.(); this.objects.delete(id); + this.remoteCalls = this.remoteCalls.filter((call) => call.object !== object); + if (this.player === object) { + this.player = undefined; + } this.planets = this.planets.filter((p) => p.id !== id); this.visibleFrom.delete(id); this.deleteAt.delete(id); diff --git a/test/suspended-rendering.test.ts b/test/suspended-rendering.test.ts new file mode 100644 index 0000000..a1ae8a5 --- /dev/null +++ b/test/suspended-rendering.test.ts @@ -0,0 +1,278 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + CharacterBase, + CharacterTeam, + Circle, + CreateObjectsCommand, + DeleteObjectsCommand, + GameObject, + PropertyUpdatesForObjects, + RemoteCall, + RemoteCallsForObject, + RemoteCallsForObjects, +} from '../frontend/node_modules/shared'; +import { GameObjectContainer } from '../frontend/src/scripts/objects/game-object-container'; +import { PlanetView } from '../frontend/src/scripts/objects/types/planet-view'; +import { serverTimeline } from '../frontend/src/scripts/helper/server-timeline'; + +vi.mock('../frontend/src/scripts/objects/types/camera', () => ({ + Camera: class { + draw() {} + follow() {} + }, +})); +vi.mock('../frontend/src/scripts/objects/types/planet-view', () => ({ + PlanetView: class { + beforeDestroy = vi.fn(); + render = vi.fn(); + processRemoteCalls = vi.fn(); + step() {} + constructor(public id: number) {} + }, +})); +vi.mock('../frontend/src/scripts/sound-handler', () => ({ + SoundHandler: { play: vi.fn() }, + Sounds: {}, +})); +vi.mock('../frontend/src/scripts/feedback-hud', () => ({ FeedbackHud: {} })); + +let nowMs = 0; +beforeEach(() => { + nowMs = 0; + vi.spyOn(performance, 'now').mockImplementation(() => nowMs); + serverTimeline.reset(); +}); +afterEach(() => vi.restoreAllMocks()); + +function character(id = 1) { + const pose = new Circle([0, 0], 10); + const object = new CharacterBase( + id, + 'Remote player', + 0, + 0, + CharacterTeam.red, + 100, + pose, + pose, + pose, + ); + return Object.assign(object, { + step: vi.fn(), + render: vi.fn(), + beforeDestroy: vi.fn(), + // A health setter also represents damage sound/flash feedback in CharacterView. + setHealth: vi.fn((health: number) => (object.health = health)), + onShoot: vi.fn(), + onDie: vi.fn(), + }); +} + +function calls( + container: GameObjectContainer, + id: number, + ...entries: Array<[string, ...unknown[]]> +) { + container.handleCommand( + new RemoteCallsForObjects([ + new RemoteCallsForObject( + id, + entries.map(([name, ...args]) => new RemoteCall(name, args)), + ), + ]), + ); +} + +function snapshot(container: GameObjectContainer, timestamp: number) { + nowMs = (timestamp - 5) * 1000; + container.handleCommand(new PropertyUpdatesForObjects([], timestamp)); +} + +function retained(container: GameObjectContainer) { + return container as unknown as { + objects: Map; + deleteAt: Map; + remoteCalls: Array<{ object: unknown }>; + }; +} + +describe('suspended rendering', () => { + it('bounds deleted views and effect batches as snapshots arrive without frames', () => { + const container = new GameObjectContainer({} as never); + const survivor = character(); + container.handleCommand(new CreateObjectsCommand([survivor])); + const deleted: PlanetView[] = []; + + for (let i = 0; i < 1000; i++) { + const object = new PlanetView(i + 100, []); + deleted.push(object); + container.handleCommand(new CreateObjectsCommand([object])); + calls(container, survivor.id, ['onShoot', 40]); + container.handleCommand(new DeleteObjectsCommand([object.id])); + snapshot(container, 5 + i * 0.04); + + expect(container.planets.length).toBeLessThanOrEqual(15); + expect(retained(container).objects.size).toBeLessThanOrEqual(16); + expect(retained(container).deleteAt.size).toBeLessThanOrEqual(15); + expect(retained(container).remoteCalls.length).toBeLessThanOrEqual(15); + } + + expect(deleted[0].beforeDestroy).toHaveBeenCalledOnce(); + expect(deleted[984].beforeDestroy).toHaveBeenCalledOnce(); + expect(survivor.onShoot).not.toHaveBeenCalled(); + + serverTimeline.step(0); + container.step(0); + expect(survivor.onShoot).not.toHaveBeenCalled(); + expect(retained(container).remoteCalls.length).toBeLessThanOrEqual(3); + + // With no additional effects, subsequent snapshots retire the entire old tail. + snapshot(container, 46); + expect(container.planets).toHaveLength(0); + expect(retained(container).objects.size).toBe(1); + expect(retained(container).deleteAt.size).toBe(0); + expect(retained(container).remoteCalls).toHaveLength(0); + serverTimeline.step(0); + container.step(0); + expect(survivor.onShoot).not.toHaveBeenCalled(); + }); + + it('preserves expired state silently and applies fresh health feedback in order', () => { + const container = new GameObjectContainer({} as never); + const object = character(); + container.handleCommand(new CreateObjectsCommand([object])); + calls(container, object.id, ['setHealth', 80], ['setKillCount', 1], ['onShoot', 40]); + snapshot(container, 5); + calls(container, object.id, ['setHealth', 60], ['setKillCount', 2], ['onDie']); + snapshot(container, 5.04); + snapshot(container, 5.6); + + expect(object.health).toBe(60); + expect(object.killCount).toBe(2); + expect(object.setHealth).not.toHaveBeenCalled(); + expect(object.onShoot).not.toHaveBeenCalled(); + expect(object.onDie).not.toHaveBeenCalled(); + + calls(container, object.id, ['setHealth', 40], ['setKillCount', 3], ['onShoot', 50]); + snapshot(container, 5.7); + serverTimeline.step(0); + container.step(0); + expect(object.health).toBe(60); + expect(object.setHealth).not.toHaveBeenCalled(); + + nowMs += 110; + serverTimeline.step(0.11); + container.step(0); + expect(object.health).toBe(40); + expect(object.killCount).toBe(3); + expect(object.setHealth).toHaveBeenCalledExactlyOnceWith(40); + expect(object.onShoot).toHaveBeenCalledExactlyOnceWith(50); + expect(object.onDie).not.toHaveBeenCalled(); + }); + + it('preserves final light and contested state while discarding transient planet effects', () => { + const container = new GameObjectContainer({} as never); + const object = Object.assign(new (class extends GameObject {})(1), { + contested: false, + lightness: 0, + color: [0, 0, 0], + step() {}, + render() {}, + setContested(value: boolean) { + this.contested = value; + }, + setLight(color: number[], lightness: number) { + this.color = color; + this.lightness = lightness; + }, + onFlipped: vi.fn(), + generatedPoints: vi.fn(), + }); + container.handleCommand(new CreateObjectsCommand([object])); + calls( + container, + object.id, + ['setContested', true], + ['setLight', [1, 0, 0], 2], + ['onFlipped', CharacterTeam.red], + ['generatedPoints', 10], + ); + snapshot(container, 5); + calls( + container, + object.id, + ['setContested', false], + ['setLight', [0, 0, 1], 3], + ['onFlipped', CharacterTeam.blue], + ['generatedPoints', 20], + ); + snapshot(container, 5.04); + snapshot(container, 6); + + expect(object.contested).toBe(false); + expect(object.color).toEqual([0, 0, 1]); + expect(object.lightness).toBe(3); + expect(object.onFlipped).not.toHaveBeenCalled(); + expect(object.generatedPoints).not.toHaveBeenCalled(); + expect(retained(container).remoteCalls).toHaveLength(0); + }); + + it('releases effect references immediately when a paused view is replaced', () => { + const container = new GameObjectContainer({} as never); + let object = character(); + container.handleCommand(new CreateObjectsCommand([object])); + snapshot(container, 5); + + for (let i = 0; i < 100; i++) { + calls(container, object.id, ['onShoot', 40]); + container.handleCommand(new DeleteObjectsCommand([object.id])); + const replacement = character(object.id); + container.handleCommand(new CreateObjectsCommand([replacement])); + expect(object.beforeDestroy).toHaveBeenCalledOnce(); + expect(retained(container).remoteCalls).toHaveLength(0); + expect(retained(container).objects.get(object.id)).toBe(replacement); + object = replacement; + } + + snapshot(container, 5.04); + nowMs += 200; + serverTimeline.step(0.2); + container.step(0); + expect(object.beforeDestroy).not.toHaveBeenCalled(); + }); + + it('expires the last queued effects on resume even when snapshots also stopped', () => { + const container = new GameObjectContainer({} as never); + const object = character(); + container.handleCommand(new CreateObjectsCommand([object])); + calls(container, object.id, ['setHealth', 20], ['setKillCount', 4], ['onShoot', 40]); + snapshot(container, 5); + + nowMs += 10_000; + serverTimeline.step(10); + container.step(10); + + expect(object.health).toBe(20); + expect(object.killCount).toBe(4); + expect(object.setHealth).not.toHaveBeenCalled(); + expect(object.onShoot).not.toHaveBeenCalled(); + expect(retained(container).remoteCalls).toHaveLength(0); + }); + + it('releases a deleted local player reference without waiting for a frame', () => { + const container = new GameObjectContainer({} as never); + const object = character(); + container.handleCommand(new CreateObjectsCommand([object])); + container.player = object as never; + snapshot(container, 5); + container.handleCommand(new DeleteObjectsCommand([object.id])); + snapshot(container, 5.04); + expect(container.localPlayer).toBe(object); + + snapshot(container, 6); + expect(object.beforeDestroy).toHaveBeenCalledOnce(); + expect(container.player).toBeUndefined(); + expect(container.localPlayer).toBeUndefined(); + expect(retained(container).objects.size).toBe(0); + }); +}); -- 2.54.0 From 779985c322b59422884d3ac13e8b1adb1dc13289 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Mon, 14 Sep 2026 22:13:15 +0100 Subject: [PATCH 13/15] No dumb comments --- .dockerignore | 1 - .forgejo/workflows/deploy.yml | 3 --- .vscode/launch.json | 3 --- Dockerfile | 12 +----------- frontend/src/main.scss | 9 +-------- frontend/webpack.config.js | 1 - shared/src/physics/resolve-circle-movement.ts | 1 - test/render-smoothness.test.ts | 2 -- test/suspended-rendering.test.ts | 2 -- 9 files changed, 2 insertions(+), 32 deletions(-) diff --git a/.dockerignore b/.dockerignore index 2bd8264..4c16ffc 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,4 +1,3 @@ -# The server image only needs `shared/` and `backend/`. **/node_modules **/dist **/lib diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml index 1136a06..dc0929c 100644 --- a/.forgejo/workflows/deploy.yml +++ b/.forgejo/workflows/deploy.yml @@ -13,7 +13,6 @@ concurrency: cancel-in-progress: true jobs: - # ---- Static website -> /pages mount on the runner host -------------------- website: name: Build & deploy website runs-on: docker @@ -45,11 +44,9 @@ jobs: source: frontend/dist target: declared - # ---- Server Docker image -> Forgejo container registry -------------------- server-image: name: Build & publish server image runs-on: docker - # No registry push on PRs; build validation still happens in the website job. if: github.event_name != 'pull_request' steps: - name: Checkout diff --git a/.vscode/launch.json b/.vscode/launch.json index 3b0b653..a685a4c 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,7 +1,4 @@ { - // Use IntelliSense to learn about possible attributes. - // Hover to view descriptions of existing attributes. - // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", "configurations": [ { diff --git a/Dockerfile b/Dockerfile index 327d1f2..8c283ee 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,12 +1,6 @@ # syntax=docker/dockerfile:1 -# doppler game server (the `doppler-server` package). -# The frontend is a static site and is NOT built here — see .forgejo/workflows. - -# ---- Stage 1: build the shared lib, then bundle the server ------------------- -# Node 22 (current LTS) matches the project toolchain (webpack 5 / TypeScript 6, -# see .devcontainer). The full (non-slim) image carries the build tools that -# socket.io's optional native deps (bufferutil/utf-8-validate) compile against. +# The full image carries the build tools for socket.io's optional native dependencies. FROM node:22-bookworm AS build WORKDIR /app @@ -17,7 +11,6 @@ RUN cd shared && npm install COPY shared/ shared/ RUN cd shared && npm run build -# Install the backend deps. `file:../shared` now resolves to the built /app/shared. COPY backend/package.json backend/ RUN cd backend && npm install COPY backend/ backend/ @@ -27,12 +20,10 @@ RUN cd backend && npm run build # left external (express, socket.io, cors, gl-matrix, minimist, msgpack parser). RUN cd backend && npm prune --production -# ---- Stage 2: minimal runtime ---------------------------------------------- FROM node:22-bookworm-slim WORKDIR /app ENV NODE_ENV=production -# Run as an unprivileged user. RUN groupadd -r app && useradd -r -g app -d /app app COPY --from=build /app/backend/dist ./dist @@ -41,7 +32,6 @@ COPY --from=build /app/backend/node_modules ./node_modules USER app EXPOSE 3000 -# Hits the same /state endpoint the website polls (see serverInformationEndpoint). HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ CMD node -e "require('http').get('http://localhost:3000/state',r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))" diff --git a/frontend/src/main.scss b/frontend/src/main.scss index 021ab71..b9c6a92 100644 --- a/frontend/src/main.scss +++ b/frontend/src/main.scss @@ -230,8 +230,6 @@ body { } } - // Top-down radar of the whole arena (see Minimap). The circular border is - // the world boundary; dots are painted onto the canvas. .minimap { top: $small-padding; left: $small-padding; @@ -252,7 +250,6 @@ body { z-index: 100; } - // Off-screen pointer to the keystone "Heart", tinted by its current owner. .keystone-arrow { opacity: 0.9; @@ -330,8 +327,6 @@ body { content: '\25C9'; } - // Twin-stick aim indicator: drawn from the button centre toward the - // current drag direction while aiming a shot (see TouchListener). .aim-line { position: absolute; left: 50%; @@ -735,8 +730,6 @@ body { } } - // Centred "Eliminated" overlay shown while the local player is dead; the - // respawn countdown is the server-driven "Reviving in N…" announcement. .elimination { position: absolute; top: 42%; @@ -959,4 +952,4 @@ body { color: #f2c9c7; font-size: 0.85rem; text-align: center; -} \ No newline at end of file +} diff --git a/frontend/webpack.config.js b/frontend/webpack.config.js index b57b543..4bbc556 100644 --- a/frontend/webpack.config.js +++ b/frontend/webpack.config.js @@ -75,7 +75,6 @@ module.exports = (env, argv) => { ], }, { - // svg-url-loader emits a CJS module that css-loader 7 writes out verbatim. test: /\.svg$/, type: 'asset/inline', }, diff --git a/shared/src/physics/resolve-circle-movement.ts b/shared/src/physics/resolve-circle-movement.ts index c3c477c..724c8ab 100644 --- a/shared/src/physics/resolve-circle-movement.ts +++ b/shared/src/physics/resolve-circle-movement.ts @@ -13,7 +13,6 @@ export const resolveCircleMovement = ( let remainingSeconds = deltaTimeInSeconds; let firstHit: Sdf | undefined; - // Resolve at most two contacts per step, spending only the time left after each hit. for (let pass = 0; pass < 2 && remainingSeconds > 0; pass++) { const speed = vec2.length(body.velocity); if (speed === 0) { diff --git a/test/render-smoothness.test.ts b/test/render-smoothness.test.ts index 2b145b9..1e65de9 100644 --- a/test/render-smoothness.test.ts +++ b/test/render-smoothness.test.ts @@ -64,7 +64,6 @@ interface Result { maximumSpeed: number; } -// Exercise shared movement with independent server, network, and render clocks. // Measure world travel: the camera tracks the local character exactly, so measuring // their relative position would always report zero even when the world visibly stutters. const measure = ({ @@ -96,7 +95,6 @@ const measure = ({ const predictor = new LocalCharacterPredictor(); predictor.setStrength(settings.playerMaxStrength); - // Deterministic frame-time noise: a real display never delivers a perfectly even cadence. let seed = 12345; const random = () => (seed = (seed * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff; diff --git a/test/suspended-rendering.test.ts b/test/suspended-rendering.test.ts index a1ae8a5..92b777b 100644 --- a/test/suspended-rendering.test.ts +++ b/test/suspended-rendering.test.ts @@ -61,7 +61,6 @@ function character(id = 1) { step: vi.fn(), render: vi.fn(), beforeDestroy: vi.fn(), - // A health setter also represents damage sound/flash feedback in CharacterView. setHealth: vi.fn((health: number) => (object.health = health)), onShoot: vi.fn(), onDie: vi.fn(), @@ -126,7 +125,6 @@ describe('suspended rendering', () => { expect(survivor.onShoot).not.toHaveBeenCalled(); expect(retained(container).remoteCalls.length).toBeLessThanOrEqual(3); - // With no additional effects, subsequent snapshots retire the entire old tail. snapshot(container, 46); expect(container.planets).toHaveLength(0); expect(retained(container).objects.size).toBe(1); -- 2.54.0 From 3980d9948b667c1281e9c16d2f10b2173a1db0fe Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Mon, 14 Sep 2026 22:19:49 +0100 Subject: [PATCH 14/15] . --- .forgejo/workflows/deploy.yml | 7 +- .gitignore | 1 - Dockerfile | 14 +- backend/package-lock.json | 3635 +++++++++++++++++++++++++++++++++ frontend/package-lock.json | 54 +- package-lock.json | 2805 +++++++++++++++++++++++++ shared/package-lock.json | 2196 ++++++++++++++++++++ 7 files changed, 8665 insertions(+), 47 deletions(-) create mode 100644 backend/package-lock.json create mode 100644 package-lock.json create mode 100644 shared/package-lock.json diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml index dc0929c..4d72944 100644 --- a/.forgejo/workflows/deploy.yml +++ b/.forgejo/workflows/deploy.yml @@ -25,8 +25,11 @@ jobs: node-version-file: .nvmrc - name: Install dependencies - # No lockfiles are committed (see .gitignore), so use `npm install`. - run: npm install && npm run init + run: | + npm ci + npm --prefix shared ci + npm --prefix frontend ci + npm --prefix backend ci - name: Build (shared -> frontend -> backend) run: npm run build diff --git a/.gitignore b/.gitignore index 6e9e967..fd7f6c5 100644 --- a/.gitignore +++ b/.gitignore @@ -4,5 +4,4 @@ node_modules .firebase yarm-error.log yarn.lock -package-lock.json .DS_Store diff --git a/Dockerfile b/Dockerfile index 8c283ee..909413b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,19 +6,19 @@ WORKDIR /app # `shared` is consumed by the backend as `file:../shared` and is bundled into # the server bundle by webpack, so it must be installed and built first. -COPY shared/package.json shared/ -RUN cd shared && npm install +COPY shared/package.json shared/package-lock.json shared/ +RUN npm --prefix shared ci COPY shared/ shared/ -RUN cd shared && npm run build +RUN npm --prefix shared run build -COPY backend/package.json backend/ -RUN cd backend && npm install +COPY backend/package.json backend/package-lock.json backend/ +RUN npm --prefix backend ci COPY backend/ backend/ -RUN cd backend && npm run build +RUN npm --prefix backend run build # Drop devDependencies; the runtime only needs the production deps that webpack # left external (express, socket.io, cors, gl-matrix, minimist, msgpack parser). -RUN cd backend && npm prune --production +RUN npm --prefix backend prune --production FROM node:22-bookworm-slim WORKDIR /app diff --git a/backend/package-lock.json b/backend/package-lock.json new file mode 100644 index 0000000..5c45e81 --- /dev/null +++ b/backend/package-lock.json @@ -0,0 +1,3635 @@ +{ + "name": "doppler-server", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "doppler-server", + "version": "0.1.0", + "dependencies": { + "cors": "^2.8.6", + "express": "^5.2.1", + "gl-matrix": "3.3.0", + "minimist": "^1.2.8", + "socket.io": "^4.8.3", + "socket.io-msgpack-parser": "^3.0.2" + }, + "bin": { + "doppler-server": "dist/main.js" + }, + "devDependencies": { + "@types/cors": "^2.8.19", + "@types/express": "^5.0.6", + "@types/minimist": "^1.2.5", + "@types/node": "^22.0.0", + "clean-webpack-plugin": "^4.0.0", + "concurrently": "^10.0.3", + "nodemon": "^3.1.14", + "shared": "file:../shared", + "terser-webpack-plugin": "^5.6.1", + "ts-loader": "^9.6.0", + "typescript": "^6.0.3", + "webpack": "^5.107.2", + "webpack-cli": "^7.0.3", + "webpack-node-externals": "^3.0.0" + } + }, + "../shared": { + "version": "0.0.0", + "dev": true, + "devDependencies": { + "clean-webpack-plugin": "^4.0.0", + "file-loader": "^6.2.0", + "gl-matrix": "^3.3.0", + "prettier": "^3.8.3", + "terser-webpack-plugin": "^5.6.1", + "ts-loader": "^9.6.0", + "typescript": "^6.0.3", + "webpack": "^5.107.2", + "webpack-cli": "^7.0.3" + } + }, + "node_modules/@discoveryjs/json-ext": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-1.1.0.tgz", + "integrity": "sha512-Xc3VhU02wqZ1HvHRJUwL09HkZSTvidqY5Ya0NXBSYOxAp+Ln9dcJr9fySI+CkONzP3PekQo9WdzCv0PGER/mOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.17.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/express": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz", + "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/glob": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@types/glob/-/glob-7.2.0.tgz", + "integrity": "sha512-ZUxbzKl0IfJILTS6t7ip5fQQM/J3TJYubDm3nMbgubNNYS62eXeUpoLUC8/7fJNiFYHTrGPQn7hspDUzIHX3UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/minimatch": "*", + "@types/node": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/minimatch": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-5.1.2.tgz", + "integrity": "sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/minimist": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.5.tgz", + "integrity": "sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.19", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", + "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*" + } + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@webassemblyjs/ast": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", + "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/helper-numbers": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2" + } + }, + "node_modules/@webassemblyjs/floating-point-hex-parser": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", + "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-api-error": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", + "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-buffer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", + "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-numbers": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", + "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/floating-point-hex-parser": "1.13.2", + "@webassemblyjs/helper-api-error": "1.13.2", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/helper-wasm-bytecode": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", + "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-wasm-section": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", + "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/wasm-gen": "1.14.1" + } + }, + "node_modules/@webassemblyjs/ieee754": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", + "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "node_modules/@webassemblyjs/leb128": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", + "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/utf8": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", + "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/wasm-edit": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", + "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/helper-wasm-section": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-opt": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1", + "@webassemblyjs/wast-printer": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-gen": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", + "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wasm-opt": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", + "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-parser": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", + "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-api-error": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wast-printer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", + "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@xtuc/ieee754": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@xtuc/long": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-import-phases": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", + "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + }, + "peerDependencies": { + "acorn": "^8.14.0" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/array-union": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-1.0.2.tgz", + "integrity": "sha512-Dxr6QJj/RdU/hCaBjOfxW+q6lyuVE6JFWIrAUpuOOhoJJoQ99cUn3igRaHVB5P9WrgFVN0FfArM3x0cueOU8ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-uniq": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/array-uniq": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/array-uniq/-/array-uniq-1.0.3.tgz", + "integrity": "sha512-MNha4BWQ6JbwhFhj03YK552f7cb3AzoE8SzeljgChvL1dl3IcvggXVz1DilzySZkCja+CXuZbdW7yATchWn8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/base64id": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", + "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==", + "license": "MIT", + "engines": { + "node": "^4.5.0 || >= 5.9" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.33", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.33.tgz", + "integrity": "sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001793", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", + "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chrome-trace-event": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", + "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0" + } + }, + "node_modules/clean-webpack-plugin": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/clean-webpack-plugin/-/clean-webpack-plugin-4.0.0.tgz", + "integrity": "sha512-WuWE1nyTNAyW5T7oNyys2EN0cfP2fdRxhxnIQWiAp0bMabPdHhoGxM8A6YL2GhqwgrPnnaemVE7nv5XJ2Fhh2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "del": "^4.1.1" + }, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "webpack": ">=4.0.0 <6.0.0" + } + }, + "node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/clone-deep": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz", + "integrity": "sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-plain-object": "^2.0.4", + "kind-of": "^6.0.2", + "shallow-clone": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/concurrently": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.3.tgz", + "integrity": "sha512-hc3LH4UaKWd/bbyDK/IGVa4RB6PtQ3CUYwtrkzqHn+wIG3Hr5fhpRlk0L/gCa8ZE1L/Ufj50Zho69cI5w8SQBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "5.6.2", + "rxjs": "7.8.2", + "shell-quote": "1.8.4", + "supports-color": "10.2.2", + "tree-kill": "1.2.2", + "yargs": "18.0.0" + }, + "bin": { + "conc": "dist/bin/index.js", + "concurrently": "dist/bin/index.js" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/del": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/del/-/del-4.1.1.tgz", + "integrity": "sha512-QwGuEUouP2kVwQenAsOof5Fv8K9t3D8Ca8NxcXKrIpEHjTXK5J2nXLdP+ALI1cgv8wj7KuwBhTwBkOZSJKM5XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/glob": "^7.1.1", + "globby": "^6.1.0", + "is-path-cwd": "^2.0.0", + "is-path-in-cwd": "^2.0.0", + "p-map": "^2.0.0", + "pify": "^4.0.1", + "rimraf": "^2.6.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.366", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.366.tgz", + "integrity": "sha512-OlRuhb688YTCzzU3gXPLn6nGyd+F+53INE1qaKKlu6kETErE8FYsyDh0XqXEU+uBRn0MpCzz2vfNwORhkap8qg==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/engine.io": { + "version": "6.6.8", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.8.tgz", + "integrity": "sha512-2agL3ueZhqxoVrfmntO8yuVj+uNSlIOnhykYHk3Cq0ShYPdUjjUiSJrQvXjq01I9jAuI0Zl2YO8Evv5Mqytm5g==", + "license": "MIT", + "dependencies": { + "@types/cors": "^2.8.12", + "@types/node": ">=10.0.0", + "@types/ws": "^8.5.12", + "accepts": "~1.3.4", + "base64id": "2.0.0", + "cookie": "~0.7.2", + "cors": "~2.8.5", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.20.1" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/engine.io/node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/engine.io/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/engine.io/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/engine.io/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.22.2", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.22.2.tgz", + "integrity": "sha512-0rxICaFZ7NQho/sHely2bvOPRP0Eu2B0NZ9zM54YvRvWMn7jfz3DmnOZDR9LlXDdDcqntAVc6Hfy4gr/tdH/Ag==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/envinfo": { + "version": "7.21.0", + "resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.21.0.tgz", + "integrity": "sha512-Lw7I8Zp5YKHFCXL7+Dz95g4CcbMEpgvqZNNq3AmlT5XAV6CgAAk6gyAMqn2zjw08K9BHfcNuKrMiCPLByGafow==", + "dev": true, + "license": "MIT", + "bin": { + "envinfo": "dist/cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esrecurse/node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/flat": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", + "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", + "dev": true, + "license": "BSD-3-Clause", + "bin": { + "flat": "cli.js" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gl-matrix": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.3.0.tgz", + "integrity": "sha512-COb7LDz+SXaHtl/h4LeaFcNdJdAQSDeVqjiIihSXNrkWObZLhDI4hIkZC11Aeqp7bcE72clzB0BnDXr2SmslRA==", + "license": "MIT" + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/globby": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-6.1.0.tgz", + "integrity": "sha512-KVbFv2TQtbzCoxAnfD6JcHZTYCzyliEaaeM/gH8qQdkKr5s0OP9scEgvdcngyk7AVdY6YVW/TJHd+lQ/Df3Daw==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-union": "^1.0.1", + "glob": "^7.0.3", + "object-assign": "^4.0.1", + "pify": "^2.0.0", + "pinkie-promise": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/globby/node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true, + "license": "ISC" + }, + "node_modules/import-local": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/interpret": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-3.1.1.tgz", + "integrity": "sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-path-cwd": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-path-cwd/-/is-path-cwd-2.2.0.tgz", + "integrity": "sha512-w942bTcih8fdJPJmQHFzkS76NEP8Kzzvmw92cXsazb8intwLqPibPPdXf4ANdKV3rYMuuQYGIWtvz9JilB3NFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/is-path-in-cwd": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-path-in-cwd/-/is-path-in-cwd-2.1.0.tgz", + "integrity": "sha512-rNocXHgipO+rvnP6dk3zI20RpOtrAM/kzbB258Uw5BWr3TpXi861yzjo16Dn4hUox07iw5AyeMLHWsujkjzvRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-path-inside": "^2.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/is-path-inside": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-2.1.0.tgz", + "integrity": "sha512-wiyhTzfDWsvwAW53OBWF5zuvaOGlZ6PwYxAbPVDhpm+gM09xKQGjBq/8uYN12aDvMxnAnq3dxTyoSoRNmg5YFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-is-inside": "^1.0.2" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "isobject": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/loader-runner": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.2.tgz", + "integrity": "sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.11.5" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.47", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", + "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/nodemon": { + "version": "3.1.14", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", + "integrity": "sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^10.2.1", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/nodemon/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/nodemon/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/nodemon/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/nodemon/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/nodemon/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/notepack.io": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/notepack.io/-/notepack.io-2.2.0.tgz", + "integrity": "sha512-9b5w3t5VSH6ZPosoYnyDONnUTF8o0UkBw7JLA6eBlYJWyGT1Q3vQa8Hmuj1/X6RYvHjjygBDgw6fJhe0JEojfw==", + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-map": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-2.1.0.tgz", + "integrity": "sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-is-inside": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/path-is-inside/-/path-is-inside-1.0.2.tgz", + "integrity": "sha512-DUWJr3+ULp4zXmol/SZkFf3JGsS9/SIv+Y3Rt93/UjPpDpklB5f1er4O3POIbUuUJ3FXgqte2Q7SrU6zAqwk8w==", + "dev": true, + "license": "(WTFPL OR MIT)" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", + "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/pinkie": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/pinkie/-/pinkie-2.0.4.tgz", + "integrity": "sha512-MnUuEycAemtSaeFSjXKW/aroV7akBbY+Sv+RkyqFjgAe73F+MR0TBWKBRDkmfWq/HiFmdavfZ1G7h4SPZXaCSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pinkie-promise": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pinkie-promise/-/pinkie-promise-2.0.1.tgz", + "integrity": "sha512-0Gni6D4UcLTbv9c57DfxDGdr41XfgUjqWZu492f0cIGr16zDU06BWP/RAEvOuo7CQ0CNjHaLlM59YJJFm3NWlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "pinkie": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true, + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/rechoir": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz", + "integrity": "sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve": "^1.20.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/schema-utils": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/semver": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shallow-clone": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz", + "integrity": "sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==", + "dev": true, + "license": "MIT", + "dependencies": { + "kind-of": "^6.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shared": { + "resolved": "../shared", + "link": true + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/shell-quote": { + "version": "1.8.4", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz", + "integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/socket.io": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.3.tgz", + "integrity": "sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.4", + "base64id": "~2.0.0", + "cors": "~2.8.5", + "debug": "~4.4.1", + "engine.io": "~6.6.0", + "socket.io-adapter": "~2.5.2", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/socket.io-adapter": { + "version": "2.5.7", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.7.tgz", + "integrity": "sha512-e0LyK91f3cUxTmv95/KzoLg47+zF+s/sbxRGDNsyG4dmIP8ZSX8ax6byOxfJXeNNtS/8AZlfD+uP7gBeR7DLlg==", + "license": "MIT", + "dependencies": { + "debug": "~4.4.1", + "ws": "~8.20.1" + } + }, + "node_modules/socket.io-msgpack-parser": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/socket.io-msgpack-parser/-/socket.io-msgpack-parser-3.0.2.tgz", + "integrity": "sha512-1e76bJ1PCKi9H+JiYk+S29PBJvknHjQWM7Mtj0hjF2KxDA6b6rQxv3rTsnwBoz/haZOhlCDIMQvPATbqYeuMxg==", + "license": "MIT", + "dependencies": { + "component-emitter": "~1.3.0", + "notepack.io": "~2.2.0" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz", + "integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io/node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/socket.io/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/socket.io/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/socket.io/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/terser": { + "version": "5.48.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.48.0.tgz", + "integrity": "sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser-webpack-plugin": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.6.1.tgz", + "integrity": "sha512-201R5j+sJpK8nFWwKVyNfZot8FaJbLZDq5evriVzbV1wDtSXDjRUDRfJzHpAaxFDMEhsZL1QkeqM61wgsS3KaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "jest-worker": "^27.4.5", + "schema-utils": "^4.3.0", + "terser": "^5.31.1" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@minify-html/node": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "@swc/css": { + "optional": true + }, + "@swc/html": { + "optional": true + }, + "clean-css": { + "optional": true + }, + "cssnano": { + "optional": true + }, + "csso": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "html-minifier-terser": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "postcss": { + "optional": true + }, + "uglify-js": { + "optional": true + } + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/touch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", + "dev": true, + "license": "ISC", + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/ts-loader": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/ts-loader/-/ts-loader-9.6.0.tgz", + "integrity": "sha512-dsJO0S+T7grTDWTc4a0nTygXGjKncVUpx8Y+af8EvI/D5WgTJby5UEk5eoMCB9EcLQmnvitqh99MqtjtHgAwFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "enhanced-resolve": "^5.0.0", + "micromatch": "^4.0.0", + "semver": "^7.3.4", + "source-map": "^0.7.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "loader-utils": "*", + "typescript": "*", + "webpack": "^4.0.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "loader-utils": { + "optional": true + } + } + }, + "node_modules/ts-loader/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ts-loader/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/ts-loader/node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ts-loader/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/watchpack": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz", + "integrity": "sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack": { + "version": "5.107.2", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.107.2.tgz", + "integrity": "sha512-v7RhXaJbpMlV0D7hC7lb2EbnxkoeUqf9qhKr6lozx3Q48pmFrqqNRmZFUEGmi7pSwm6fCQ2H1IjvCkHqdpVdjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.8", + "@types/json-schema": "^7.0.15", + "@webassemblyjs/ast": "^1.14.1", + "@webassemblyjs/wasm-edit": "^1.14.1", + "@webassemblyjs/wasm-parser": "^1.14.1", + "acorn": "^8.16.0", + "acorn-import-phases": "^1.0.3", + "browserslist": "^4.28.1", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.22.0", + "es-module-lexer": "^2.1.0", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.2.11", + "loader-runner": "^4.3.2", + "mime-db": "^1.54.0", + "neo-async": "^2.6.2", + "schema-utils": "^4.3.3", + "tapable": "^2.3.0", + "terser-webpack-plugin": "^5.5.0", + "watchpack": "^2.5.1", + "webpack-sources": "^3.5.0" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-cli": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-7.0.3.tgz", + "integrity": "sha512-2E2C6A1e2El7791zQgTH7LPIuwLjRliow9OHS/qlJc9pwhZlCoL/uiwqd/1WSlXT83wJfmfDbkcqHXuXoPJZ3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@discoveryjs/json-ext": "^1.1.0", + "commander": "^14.0.3", + "cross-spawn": "^7.0.6", + "envinfo": "^7.14.0", + "import-local": "^3.0.2", + "interpret": "^3.1.1", + "rechoir": "^0.8.0", + "webpack-merge": "^6.0.1" + }, + "bin": { + "webpack-cli": "bin/cli.js" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.101.0", + "webpack-bundle-analyzer": "^4.0.0 || ^5.0.0", + "webpack-dev-server": "^5.0.0" + }, + "peerDependenciesMeta": { + "webpack-bundle-analyzer": { + "optional": true + }, + "webpack-dev-server": { + "optional": true + } + } + }, + "node_modules/webpack-cli/node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/webpack-merge": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-6.0.1.tgz", + "integrity": "sha512-hXXvrjtx2PLYx4qruKl+kyRSLc52V+cCvMxRjmKwoA+CBbbF5GfIBtR6kCvl0fYGqTUPKB+1ktVmTHqMOzgCBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone-deep": "^4.0.1", + "flat": "^5.0.2", + "wildcard": "^2.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/webpack-node-externals": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/webpack-node-externals/-/webpack-node-externals-3.0.0.tgz", + "integrity": "sha512-LnL6Z3GGDPht/AigwRh2dvL9PQPFQ8skEpVrWZXLWBYmqcaojHNN0onvHzie6rq7EWKrrBfPYqNEzTJgiwEQDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/webpack-sources": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.5.0.tgz", + "integrity": "sha512-HPuy+uuoTCaaoEoI1LQ3JN9+vrPBvEesnnX1jADHy728cHSMlq4wUc4afYqahq2B1mhQVZxCXOkNTnXltr+2vQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wildcard": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.1.tgz", + "integrity": "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.20.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", + "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", + "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^7.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + } + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index bfa9f49..6c253d6 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -23,7 +23,7 @@ "resize-observer-polyfill": "^1.5.1", "sass": "^1.100.0", "sass-loader": "^17.0.0", - "sdf-2d": "file:../../sdf-2d", + "sdf-2d": "^0.8.0", "shared": "file:../shared", "socket.io-client": "^4.8.3", "socket.io-msgpack-parser": "^3.0.2", @@ -36,40 +36,6 @@ "webpack-dev-server": "^5.2.4" } }, - "../../sdf-2d": { - "version": "0.7.6", - "dev": true, - "license": "ISC", - "dependencies": { - "gl-matrix": "^3.4.4", - "resize-observer-polyfill": "^1.5.1" - }, - "devDependencies": { - "@eslint/js": "^10.0.1", - "@typescript-eslint/eslint-plugin": "^8.60.1", - "@typescript-eslint/parser": "^8.60.1", - "eslint": "^10.4.1", - "eslint-config-prettier": "^10.1.8", - "eslint-plugin-prettier": "^5.5.6", - "eslint-plugin-unused-imports": "^4.4.1", - "globals": "^17.6.0", - "prettier": "^3.8.3", - "raw-loader": "^4.0.2", - "terser-webpack-plugin": "^5.6.1", - "ts-loader": "^9.6.0", - "typedoc": "^0.28.19", - "typedoc-plugin-extras": "^4.0.1", - "typescript": "^6.0.3", - "webpack": "^5.107.2", - "webpack-cli": "^7.0.3" - } - }, - "../../sdf-2d/dist": { - "extraneous": true - }, - "../../sdf-2d/lib": { - "extraneous": true - }, "../shared": { "version": "0.0.0", "dev": true, @@ -5443,8 +5409,22 @@ } }, "node_modules/sdf-2d": { - "resolved": "../../sdf-2d", - "link": true + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/sdf-2d/-/sdf-2d-0.8.0.tgz", + "integrity": "sha512-rqM3RMm71ytlLmlzC9lwsUFOv9uvodgtgRRZrv69IV5nh9OlGKk25RJRe2sRLK3u3LDvt0h+4KwthQu7GVRyJg==", + "dev": true, + "license": "ISC", + "dependencies": { + "gl-matrix": "^3.4.4", + "resize-observer-polyfill": "^1.5.1" + } + }, + "node_modules/sdf-2d/node_modules/gl-matrix": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz", + "integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==", + "dev": true, + "license": "MIT" }, "node_modules/select-hose": { "version": "2.0.0", diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..65637fd --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2805 @@ +{ + "name": "root", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "root", + "devDependencies": { + "concurrently": "^10.0.3", + "eslint": "^10.4.1", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-prettier": "^5.5.6", + "eslint-plugin-unused-imports": "^4.4.1", + "prettier": "^3.8.3", + "typescript": "^6.0.3", + "typescript-eslint": "^8.60.1", + "vitest": "^4.1.9" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", + "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", + "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", + "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@pkgr/core": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.3.6.tgz", + "integrity": "sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/pkgr" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", + "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", + "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", + "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", + "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", + "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", + "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", + "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", + "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", + "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", + "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", + "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", + "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", + "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", + "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", + "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.1.tgz", + "integrity": "sha512-JQ4S5GB0tfjO8BuJ4fcX+HodkzJjYBV+7OJ+wLygaX7OGQ7FudyHL4NSCA6ob+w3Yn+5MkKIozOwQhXeM7opVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.60.1", + "@typescript-eslint/type-utils": "8.60.1", + "@typescript-eslint/utils": "8.60.1", + "@typescript-eslint/visitor-keys": "8.60.1", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.60.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.60.1.tgz", + "integrity": "sha512-A0M6ua6H252bVjPvvtSgl2QA4+ET9S5Mtkb2GDyTxIhH/C4qDItT7RQNO5PhMC6NXGYXOR9dIalcDDgBKT7oFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.60.1", + "@typescript-eslint/types": "8.60.1", + "@typescript-eslint/typescript-estree": "8.60.1", + "@typescript-eslint/visitor-keys": "8.60.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.60.1.tgz", + "integrity": "sha512-eXkTH2bxmXlqD1RnOPmLZ9ZM9D3VwSx04JOwBnP9RQ+yUA5a2Mu7SfW8uaV2Aon53NJzZlZYuX7tn91Izf+xaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.60.1", + "@typescript-eslint/types": "^8.60.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.60.1.tgz", + "integrity": "sha512-gvI5OQoptnxQnchOirukCuQ55svJSTuD/4k5+pC267xyBtYry748R9/c3tYUzb/iE6RZfllRz2lVulLCHkTm4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.60.1", + "@typescript-eslint/visitor-keys": "8.60.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.60.1.tgz", + "integrity": "sha512-nh8w4qAteiKuZu3pSSzG/yGKpw0OlkrKnzFmbVRenKaD4qc+7i1GrmZaLVkr8rk4uipiPGMOW4YsM6WmKZ5CvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.60.1.tgz", + "integrity": "sha512-sdwTrpjosW7ANQYJ39ZBF1ZyEMEGVB2UsikrserVM/30a/F1dTLnu9bGxEdosugyu5caigjLrR2qiD11asjI1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.60.1", + "@typescript-eslint/typescript-estree": "8.60.1", + "@typescript-eslint/utils": "8.60.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.60.1.tgz", + "integrity": "sha512-4h0tY8ppCkdCzcrl2YM5M3my0xsE1Tf8om3owEu5oPWmXwkKRmk0j0LGDzYBGUcAlesEbxBhazqu/K4cu3Ug7w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.60.1.tgz", + "integrity": "sha512-alpRkfG8hlVE5kdJW2GkfgDgXxold3e8e4l6EnmhRmRLbekgAPCCGDVD++sABy9FcgPFroq+uFcCSM1vR57Cew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.60.1", + "@typescript-eslint/tsconfig-utils": "8.60.1", + "@typescript-eslint/types": "8.60.1", + "@typescript-eslint/visitor-keys": "8.60.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.60.1.tgz", + "integrity": "sha512-h2MPBLoNtjc3qZWfY3Tl51yPorQ2McHn8pJfcMNTcIvrrZrr90Ykffit0yjrPFWQcRcUxzH20+6OcVdW4yHtUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.60.1", + "@typescript-eslint/types": "8.60.1", + "@typescript-eslint/typescript-estree": "8.60.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.60.1.tgz", + "integrity": "sha512-EbGRQg4FhrmwLodl+t3JNAnXHWVr9Vp+Zl1QBZVPY4ByfkzIT8cX3K6QWODHtkIZqqJVEWvhHSx3v5PDHsaQag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.60.1", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", + "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", + "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", + "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", + "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.9", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", + "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "@vitest/utils": "4.1.9", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", + "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", + "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/concurrently": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.3.tgz", + "integrity": "sha512-hc3LH4UaKWd/bbyDK/IGVa4RB6PtQ3CUYwtrkzqHn+wIG3Hr5fhpRlk0L/gCa8ZE1L/Ufj50Zho69cI5w8SQBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "5.6.2", + "rxjs": "7.8.2", + "shell-quote": "1.8.4", + "supports-color": "10.2.2", + "tree-kill": "1.2.2", + "yargs": "18.0.0" + }, + "bin": { + "conc": "dist/bin/index.js", + "concurrently": "dist/bin/index.js" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.4.1.tgz", + "integrity": "sha512-AyIKhnOBuOAdueD7RB3xB+YeAWScb9jHsJBgH2Hcde8InP5JYhqrRR6iTMHyTEwgENK54Cp44e4v8BwNhsuHuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.6.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-plugin-prettier": { + "version": "5.5.6", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.6.tgz", + "integrity": "sha512-ifetmTcxWfz+4qRW3pH/ujdTq2jQIj59AxJMIN26K5avYgU8dxycUETQonWiW+wPrYXA0j3Try0l1CnwVQtDqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prettier-linter-helpers": "^1.0.1", + "synckit": "^0.11.13" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-plugin-prettier" + }, + "peerDependencies": { + "@types/eslint": ">=8.0.0", + "eslint": ">=8.0.0", + "eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0", + "prettier": ">=3.0.0" + }, + "peerDependenciesMeta": { + "@types/eslint": { + "optional": true + }, + "eslint-config-prettier": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-unused-imports": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-unused-imports/-/eslint-plugin-unused-imports-4.4.1.tgz", + "integrity": "sha512-oZGYUz1X3sRMGUB+0cZyK2VcvRX5lm/vB56PgNNcU+7ficUCKm66oZWKUubXWnOuPjQ8PvmXtCViXBMONPe7tQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@typescript-eslint/eslint-plugin": "^8.0.0-0 || ^7.0.0 || ^6.0.0 || ^5.0.0", + "eslint": "^10.0.0 || ^9.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/eslint-plugin": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-diff": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", + "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", + "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-linter-helpers": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.1.tgz", + "integrity": "sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-diff": "^1.1.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/rolldown": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", + "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.133.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.3", + "@rolldown/binding-darwin-arm64": "1.0.3", + "@rolldown/binding-darwin-x64": "1.0.3", + "@rolldown/binding-freebsd-x64": "1.0.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", + "@rolldown/binding-linux-arm64-gnu": "1.0.3", + "@rolldown/binding-linux-arm64-musl": "1.0.3", + "@rolldown/binding-linux-ppc64-gnu": "1.0.3", + "@rolldown/binding-linux-s390x-gnu": "1.0.3", + "@rolldown/binding-linux-x64-gnu": "1.0.3", + "@rolldown/binding-linux-x64-musl": "1.0.3", + "@rolldown/binding-openharmony-arm64": "1.0.3", + "@rolldown/binding-wasm32-wasi": "1.0.3", + "@rolldown/binding-win32-arm64-msvc": "1.0.3", + "@rolldown/binding-win32-x64-msvc": "1.0.3" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/semver": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/shell-quote": { + "version": "1.8.4", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz", + "integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/synckit": { + "version": "0.11.13", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.13.tgz", + "integrity": "sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pkgr/core": "^0.3.6" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/synckit" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.60.1.tgz", + "integrity": "sha512-6m5hkkRAp8lKvhVpcprAIn5KkehQEh+47oHH2VGnExEh7dhNxXlg6GPAOIu6TxbVQxhebrJDvjl3020ooiWCMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.60.1", + "@typescript-eslint/parser": "8.60.1", + "@typescript-eslint/typescript-estree": "8.60.1", + "@typescript-eslint/utils": "8.60.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "8.0.16", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", + "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "1.0.3", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", + "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.9", + "@vitest/mocker": "4.1.9", + "@vitest/pretty-format": "4.1.9", + "@vitest/runner": "4.1.9", + "@vitest/snapshot": "4.1.9", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.9", + "@vitest/browser-preview": "4.1.9", + "@vitest/browser-webdriverio": "4.1.9", + "@vitest/coverage-istanbul": "4.1.9", + "@vitest/coverage-v8": "4.1.9", + "@vitest/ui": "4.1.9", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", + "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^7.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/shared/package-lock.json b/shared/package-lock.json new file mode 100644 index 0000000..5e024b9 --- /dev/null +++ b/shared/package-lock.json @@ -0,0 +1,2196 @@ +{ + "name": "shared", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "shared", + "version": "0.0.0", + "devDependencies": { + "clean-webpack-plugin": "^4.0.0", + "file-loader": "^6.2.0", + "gl-matrix": "^3.3.0", + "prettier": "^3.8.3", + "terser-webpack-plugin": "^5.6.1", + "ts-loader": "^9.6.0", + "typescript": "^6.0.3", + "webpack": "^5.107.2", + "webpack-cli": "^7.0.3" + } + }, + "node_modules/@discoveryjs/json-ext": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-1.1.0.tgz", + "integrity": "sha512-Xc3VhU02wqZ1HvHRJUwL09HkZSTvidqY5Ya0NXBSYOxAp+Ln9dcJr9fySI+CkONzP3PekQo9WdzCv0PGER/mOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.17.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/glob": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@types/glob/-/glob-7.2.0.tgz", + "integrity": "sha512-ZUxbzKl0IfJILTS6t7ip5fQQM/J3TJYubDm3nMbgubNNYS62eXeUpoLUC8/7fJNiFYHTrGPQn7hspDUzIHX3UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/minimatch": "*", + "@types/node": "*" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/minimatch": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-5.1.2.tgz", + "integrity": "sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.9.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz", + "integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@webassemblyjs/ast": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", + "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/helper-numbers": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2" + } + }, + "node_modules/@webassemblyjs/floating-point-hex-parser": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", + "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-api-error": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", + "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-buffer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", + "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-numbers": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", + "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/floating-point-hex-parser": "1.13.2", + "@webassemblyjs/helper-api-error": "1.13.2", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/helper-wasm-bytecode": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", + "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-wasm-section": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", + "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/wasm-gen": "1.14.1" + } + }, + "node_modules/@webassemblyjs/ieee754": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", + "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "node_modules/@webassemblyjs/leb128": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", + "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/utf8": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", + "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/wasm-edit": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", + "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/helper-wasm-section": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-opt": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1", + "@webassemblyjs/wast-printer": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-gen": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", + "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wasm-opt": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", + "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-parser": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", + "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-api-error": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wast-printer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", + "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@xtuc/ieee754": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@xtuc/long": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-import-phases": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", + "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + }, + "peerDependencies": { + "acorn": "^8.14.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/array-union": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-1.0.2.tgz", + "integrity": "sha512-Dxr6QJj/RdU/hCaBjOfxW+q6lyuVE6JFWIrAUpuOOhoJJoQ99cUn3igRaHVB5P9WrgFVN0FfArM3x0cueOU8ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-uniq": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/array-uniq": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/array-uniq/-/array-uniq-1.0.3.tgz", + "integrity": "sha512-MNha4BWQ6JbwhFhj03YK552f7cb3AzoE8SzeljgChvL1dl3IcvggXVz1DilzySZkCja+CXuZbdW7yATchWn8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.33", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.33.tgz", + "integrity": "sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/big.js": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", + "integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001793", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", + "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/chrome-trace-event": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", + "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0" + } + }, + "node_modules/clean-webpack-plugin": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/clean-webpack-plugin/-/clean-webpack-plugin-4.0.0.tgz", + "integrity": "sha512-WuWE1nyTNAyW5T7oNyys2EN0cfP2fdRxhxnIQWiAp0bMabPdHhoGxM8A6YL2GhqwgrPnnaemVE7nv5XJ2Fhh2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "del": "^4.1.1" + }, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "webpack": ">=4.0.0 <6.0.0" + } + }, + "node_modules/clone-deep": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz", + "integrity": "sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-plain-object": "^2.0.4", + "kind-of": "^6.0.2", + "shallow-clone": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/del": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/del/-/del-4.1.1.tgz", + "integrity": "sha512-QwGuEUouP2kVwQenAsOof5Fv8K9t3D8Ca8NxcXKrIpEHjTXK5J2nXLdP+ALI1cgv8wj7KuwBhTwBkOZSJKM5XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/glob": "^7.1.1", + "globby": "^6.1.0", + "is-path-cwd": "^2.0.0", + "is-path-in-cwd": "^2.0.0", + "p-map": "^2.0.0", + "pify": "^4.0.1", + "rimraf": "^2.6.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.366", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.366.tgz", + "integrity": "sha512-OlRuhb688YTCzzU3gXPLn6nGyd+F+53INE1qaKKlu6kETErE8FYsyDh0XqXEU+uBRn0MpCzz2vfNwORhkap8qg==", + "dev": true, + "license": "ISC" + }, + "node_modules/emojis-list": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz", + "integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.22.2", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.22.2.tgz", + "integrity": "sha512-0rxICaFZ7NQho/sHely2bvOPRP0Eu2B0NZ9zM54YvRvWMn7jfz3DmnOZDR9LlXDdDcqntAVc6Hfy4gr/tdH/Ag==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/envinfo": { + "version": "7.21.0", + "resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.21.0.tgz", + "integrity": "sha512-Lw7I8Zp5YKHFCXL7+Dz95g4CcbMEpgvqZNNq3AmlT5XAV6CgAAk6gyAMqn2zjw08K9BHfcNuKrMiCPLByGafow==", + "dev": true, + "license": "MIT", + "bin": { + "envinfo": "dist/cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esrecurse/node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/file-loader": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/file-loader/-/file-loader-6.2.0.tgz", + "integrity": "sha512-qo3glqyTa61Ytg4u73GultjHGjdRyig3tG6lPtyX/jOEJvHif9uB0/OCI2Kif6ctF3caQTW2G5gym21oAsI4pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "loader-utils": "^2.0.0", + "schema-utils": "^3.0.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^4.0.0 || ^5.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/flat": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", + "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", + "dev": true, + "license": "BSD-3-Clause", + "bin": { + "flat": "cli.js" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gl-matrix": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.3.0.tgz", + "integrity": "sha512-COb7LDz+SXaHtl/h4LeaFcNdJdAQSDeVqjiIihSXNrkWObZLhDI4hIkZC11Aeqp7bcE72clzB0BnDXr2SmslRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/globby": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-6.1.0.tgz", + "integrity": "sha512-KVbFv2TQtbzCoxAnfD6JcHZTYCzyliEaaeM/gH8qQdkKr5s0OP9scEgvdcngyk7AVdY6YVW/TJHd+lQ/Df3Daw==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-union": "^1.0.1", + "glob": "^7.0.3", + "object-assign": "^4.0.1", + "pify": "^2.0.0", + "pinkie-promise": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/globby/node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/import-local": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/interpret": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-3.1.1.tgz", + "integrity": "sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-path-cwd": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-path-cwd/-/is-path-cwd-2.2.0.tgz", + "integrity": "sha512-w942bTcih8fdJPJmQHFzkS76NEP8Kzzvmw92cXsazb8intwLqPibPPdXf4ANdKV3rYMuuQYGIWtvz9JilB3NFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/is-path-in-cwd": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-path-in-cwd/-/is-path-in-cwd-2.1.0.tgz", + "integrity": "sha512-rNocXHgipO+rvnP6dk3zI20RpOtrAM/kzbB258Uw5BWr3TpXi861yzjo16Dn4hUox07iw5AyeMLHWsujkjzvRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-path-inside": "^2.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/is-path-inside": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-2.1.0.tgz", + "integrity": "sha512-wiyhTzfDWsvwAW53OBWF5zuvaOGlZ6PwYxAbPVDhpm+gM09xKQGjBq/8uYN12aDvMxnAnq3dxTyoSoRNmg5YFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-is-inside": "^1.0.2" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "isobject": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/loader-runner": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.2.tgz", + "integrity": "sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.11.5" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/loader-utils": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz", + "integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^2.1.2" + }, + "engines": { + "node": ">=8.9.0" + } + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.47", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", + "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-map": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-2.1.0.tgz", + "integrity": "sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-is-inside": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/path-is-inside/-/path-is-inside-1.0.2.tgz", + "integrity": "sha512-DUWJr3+ULp4zXmol/SZkFf3JGsS9/SIv+Y3Rt93/UjPpDpklB5f1er4O3POIbUuUJ3FXgqte2Q7SrU6zAqwk8w==", + "dev": true, + "license": "(WTFPL OR MIT)" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", + "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/pinkie": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/pinkie/-/pinkie-2.0.4.tgz", + "integrity": "sha512-MnUuEycAemtSaeFSjXKW/aroV7akBbY+Sv+RkyqFjgAe73F+MR0TBWKBRDkmfWq/HiFmdavfZ1G7h4SPZXaCSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pinkie-promise": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pinkie-promise/-/pinkie-promise-2.0.1.tgz", + "integrity": "sha512-0Gni6D4UcLTbv9c57DfxDGdr41XfgUjqWZu492f0cIGr16zDU06BWP/RAEvOuo7CQ0CNjHaLlM59YJJFm3NWlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "pinkie": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/prettier": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", + "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/rechoir": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz", + "integrity": "sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve": "^1.20.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, + "node_modules/schema-utils": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", + "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/semver": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shallow-clone": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz", + "integrity": "sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==", + "dev": true, + "license": "MIT", + "dependencies": { + "kind-of": "^6.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/terser": { + "version": "5.48.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.48.0.tgz", + "integrity": "sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser-webpack-plugin": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.6.1.tgz", + "integrity": "sha512-201R5j+sJpK8nFWwKVyNfZot8FaJbLZDq5evriVzbV1wDtSXDjRUDRfJzHpAaxFDMEhsZL1QkeqM61wgsS3KaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "jest-worker": "^27.4.5", + "schema-utils": "^4.3.0", + "terser": "^5.31.1" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@minify-html/node": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "@swc/css": { + "optional": true + }, + "@swc/html": { + "optional": true + }, + "clean-css": { + "optional": true + }, + "cssnano": { + "optional": true + }, + "csso": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "html-minifier-terser": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "postcss": { + "optional": true + }, + "uglify-js": { + "optional": true + } + } + }, + "node_modules/terser-webpack-plugin/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/terser-webpack-plugin/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/terser-webpack-plugin/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/terser-webpack-plugin/node_modules/schema-utils": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-loader": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/ts-loader/-/ts-loader-9.6.0.tgz", + "integrity": "sha512-dsJO0S+T7grTDWTc4a0nTygXGjKncVUpx8Y+af8EvI/D5WgTJby5UEk5eoMCB9EcLQmnvitqh99MqtjtHgAwFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "enhanced-resolve": "^5.0.0", + "micromatch": "^4.0.0", + "semver": "^7.3.4", + "source-map": "^0.7.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "loader-utils": "*", + "typescript": "*", + "webpack": "^4.0.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "loader-utils": { + "optional": true + } + } + }, + "node_modules/ts-loader/node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/watchpack": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz", + "integrity": "sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack": { + "version": "5.107.2", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.107.2.tgz", + "integrity": "sha512-v7RhXaJbpMlV0D7hC7lb2EbnxkoeUqf9qhKr6lozx3Q48pmFrqqNRmZFUEGmi7pSwm6fCQ2H1IjvCkHqdpVdjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.8", + "@types/json-schema": "^7.0.15", + "@webassemblyjs/ast": "^1.14.1", + "@webassemblyjs/wasm-edit": "^1.14.1", + "@webassemblyjs/wasm-parser": "^1.14.1", + "acorn": "^8.16.0", + "acorn-import-phases": "^1.0.3", + "browserslist": "^4.28.1", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.22.0", + "es-module-lexer": "^2.1.0", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.2.11", + "loader-runner": "^4.3.2", + "mime-db": "^1.54.0", + "neo-async": "^2.6.2", + "schema-utils": "^4.3.3", + "tapable": "^2.3.0", + "terser-webpack-plugin": "^5.5.0", + "watchpack": "^2.5.1", + "webpack-sources": "^3.5.0" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-cli": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-7.0.3.tgz", + "integrity": "sha512-2E2C6A1e2El7791zQgTH7LPIuwLjRliow9OHS/qlJc9pwhZlCoL/uiwqd/1WSlXT83wJfmfDbkcqHXuXoPJZ3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@discoveryjs/json-ext": "^1.1.0", + "commander": "^14.0.3", + "cross-spawn": "^7.0.6", + "envinfo": "^7.14.0", + "import-local": "^3.0.2", + "interpret": "^3.1.1", + "rechoir": "^0.8.0", + "webpack-merge": "^6.0.1" + }, + "bin": { + "webpack-cli": "bin/cli.js" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.101.0", + "webpack-bundle-analyzer": "^4.0.0 || ^5.0.0", + "webpack-dev-server": "^5.0.0" + }, + "peerDependenciesMeta": { + "webpack-bundle-analyzer": { + "optional": true + }, + "webpack-dev-server": { + "optional": true + } + } + }, + "node_modules/webpack-cli/node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/webpack-merge": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-6.0.1.tgz", + "integrity": "sha512-hXXvrjtx2PLYx4qruKl+kyRSLc52V+cCvMxRjmKwoA+CBbbF5GfIBtR6kCvl0fYGqTUPKB+1ktVmTHqMOzgCBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone-deep": "^4.0.1", + "flat": "^5.0.2", + "wildcard": "^2.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/webpack-sources": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.5.0.tgz", + "integrity": "sha512-HPuy+uuoTCaaoEoI1LQ3JN9+vrPBvEesnnX1jADHy728cHSMlq4wUc4afYqahq2B1mhQVZxCXOkNTnXltr+2vQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/webpack/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/webpack/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/webpack/node_modules/schema-utils": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wildcard": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.1.tgz", + "integrity": "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + } + } +} -- 2.54.0 From e2fe09fcb2517583e374229c96f3f180edcd414f Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Tue, 15 Sep 2026 07:56:39 +0100 Subject: [PATCH 15/15] readme --- README.md | 37 ------------------------------------- 1 file changed, 37 deletions(-) diff --git a/README.md b/README.md index 3ae3aca..1a77fd5 100644 --- a/README.md +++ b/README.md @@ -15,43 +15,6 @@ Run `npm install && npm run init` once, then `npm run dev`. Run `npm test` for regressions, `npm run lint:check` for formatting and lint, and `npm run build` for all production bundles. Tests rebuild the shared library before running. -## Movement and networking - -The server owns physics and combat. It simulates at 200 Hz, keeps each player's -latest held movement direction, and sends snapshots at 25 Hz. Clients send input -changes immediately on their next frame and send idle heartbeats at 30 Hz. - -The local player replays input from the latest authoritative pose using the same -physics as the server. The acknowledgement includes movement state, remaining leap -cooldown, and the age of the acknowledged input, so the client can align replay -without synchronizing wall clocks. Nearby planets are included even when offscreen -because their gravity affects prediction. Replay stops after 400 ms without a fresh -anchor; only reconciliation corrections are eased over 80 ms. The camera follows -the displayed player directly. - -Other objects interpolate snapshots on a server timeline delayed by 100 ms, with -at most 60 ms of extrapolation. Creation and deletion use that same timeline. -Remote effects use that timeline too; feedback for the local player is immediate. -End-of-round slow motion comes from server snapshots and disables local prediction; -outgoing velocity and projectile fade rates are expressed per wall-clock second. -When a connection backs up, regenerable state can be skipped; lifecycle messages -keep their timestamps. The frontend bundles one `gl-matrix` instance so prediction -and server physics both use JavaScript's full numeric precision. - -## Deployment - -CI/CD runs on Forgejo Actions (`.forgejo/workflows/deploy.yml`). On a push to -`main` it: - -- builds the static frontend and rsyncs `frontend/dist/` to the `/pages/declared` - mount on the runner host (the mount keeps its pre-rebrand name), and -- builds the server image from the root `Dockerfile` and pushes it to the Forgejo - container registry as `//-server`. - -The registry job needs a `FORGEJO_PACKAGE_TOKEN` secret (with package write -scope) and, optionally, a `CONTAINER_REGISTRY_HOST` variable to override the -registry host. - The website's server list is hardcoded in [`frontend/src/scripts/configuration.ts`](frontend/src/scripts/configuration.ts) — edit it to add or remove game-server origins. -- 2.54.0