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"] } }