diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml index 4e0cc2f..762d92c 100644 --- a/.forgejo/workflows/deploy.yml +++ b/.forgejo/workflows/deploy.yml @@ -32,6 +32,12 @@ jobs: - name: Build (shared -> frontend -> backend) run: npm run build + - name: Test + run: npm test + + - name: Lint + run: npm run lint:check + - name: Deploy to host pages mount if: github.event_name == 'push' && github.ref == 'refs/heads/main' run: | diff --git a/backend/src/game-server.ts b/backend/src/game-server.ts index e321737..5e92f3b 100644 --- a/backend/src/game-server.ts +++ b/backend/src/game-server.ts @@ -99,7 +99,8 @@ export class GameServer extends CommandReceiver { this.players.deletePlayer(player); this.sendServerStateUpdate(); }); - } catch { + } catch (e) { + console.error('Failed to register joining player; disconnecting socket', e); socket.disconnect(); } }); @@ -168,6 +169,9 @@ export class GameServer extends CommandReceiver { 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 }); @@ -189,12 +193,24 @@ 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, + maxBacklogSeconds, + ); substeps = maxSubstepsPerFrame; - this.physicsAccumulator = 0; } else { this.physicsAccumulator -= substeps * fixedDelta; } @@ -237,6 +253,23 @@ export class GameServer extends CommandReceiver { 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.saturatedFrames > 0) { + console.warn( + `Physics saturated on ${this.saturatedFrames} frame(s) since last report — shedding backlog`, + ); + this.saturatedFrames = 0; + } + this.deltaTimes = []; } } diff --git a/backend/src/objects/character-physical.ts b/backend/src/objects/character-physical.ts index 2f60597..11990d9 100644 --- a/backend/src/objects/character-physical.ts +++ b/backend/src/objects/character-physical.ts @@ -20,6 +20,12 @@ import { CharacterMovementState, CharacterWorld, GroundSurface, + headRadius, + feetRadius, + headOffset, + leftFootOffset, + rightFootOffset, + boundRadius, } from 'shared'; import { DynamicPhysical } from '../physics/physicals/dynamic-physical'; import { CirclePhysical } from './circle-physical'; @@ -38,48 +44,14 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical public readonly canCollide = true; public readonly canMove = true; - private static readonly headRadius = 50; - private static readonly feetRadius = 20; private projectileStrength = settings.playerMaxStrength; - // offsets are measured from (0, 0). The head sits this far above the feet, - // which sets the leg length: kept short so the body reads as a compact head on - // stubby legs rather than a long-legged strider. - private static readonly desiredHeadOffset = vec2.fromValues(0, 55); - private static readonly desiredLeftFootOffset = vec2.fromValues(-20, 0); - private static readonly desiredRightFootOffset = vec2.fromValues(20, 0); - private static readonly centerOfMass = vec2.scale( - vec2.create(), - vec2.add( - vec2.create(), - vec2.add( - vec2.create(), - CharacterPhysical.desiredHeadOffset, - CharacterPhysical.desiredLeftFootOffset, - ), - CharacterPhysical.desiredRightFootOffset, - ), - 1 / 3, - ); - - private static readonly headOffset = vec2.subtract( - vec2.create(), - CharacterPhysical.desiredHeadOffset, - CharacterPhysical.centerOfMass, - ); - private static readonly leftFootOffset = vec2.subtract( - vec2.create(), - CharacterPhysical.desiredLeftFootOffset, - CharacterPhysical.centerOfMass, - ); - private static readonly rightFootOffset = vec2.subtract( - vec2.create(), - CharacterPhysical.desiredRightFootOffset, - CharacterPhysical.centerOfMass, - ); - - public static readonly boundRadius = - (CharacterPhysical.headRadius + CharacterPhysical.feetRadius * 2) * 2; + // Body geometry (head/foot radii, posture offsets, bound radius) is defined + // once in the shared movement module and imported here, so the authoritative + // body and the client's predicted body are bit-identical by construction + // instead of by a hand-synced "copied verbatim" duplicate. Re-exposed as a + // static only because external callers reference CharacterPhysical.boundRadius. + public static readonly boundRadius = boundRadius; private timeSinceDying = 0; private isDestroyed = false; @@ -129,20 +101,20 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical ) { super(id(), name, killCount, deathCount, team, settings.playerMaxHealth); this.head = new CirclePhysical( - vec2.add(vec2.create(), startPosition, CharacterPhysical.headOffset), - CharacterPhysical.headRadius, + vec2.add(vec2.create(), startPosition, headOffset), + headRadius, this, container, ); this.leftFoot = new CirclePhysical( - vec2.add(vec2.create(), startPosition, CharacterPhysical.leftFootOffset), - CharacterPhysical.feetRadius, + vec2.add(vec2.create(), startPosition, leftFootOffset), + feetRadius, this, container, ); this.rightFoot = new CirclePhysical( - vec2.add(vec2.create(), startPosition, CharacterPhysical.rightFootOffset), - CharacterPhysical.feetRadius, + vec2.add(vec2.create(), startPosition, rightFootOffset), + feetRadius, this, container, ); @@ -154,6 +126,9 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical } private initMovementBridge() { + // The movementState object-literal getters/setters below can't use `this` + // (it would bind to the literal), so alias the character instance. + // eslint-disable-next-line @typescript-eslint/no-this-alias const self = this; this.movementState = { head: this.head, @@ -411,8 +386,8 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical } private animateScaling(q: number) { - this.head.radius = CharacterPhysical.headRadius * q; - this.leftFoot.radius = this.rightFoot.radius = CharacterPhysical.feetRadius * q; + this.head.radius = headRadius * q; + this.leftFoot.radius = this.rightFoot.radius = feetRadius * q; } public getPropertyUpdates(): PropertyUpdatesForObject { diff --git a/backend/src/players/npc.ts b/backend/src/players/npc.ts index 23177b6..655ade7 100644 --- a/backend/src/players/npc.ts +++ b/backend/src/players/npc.ts @@ -315,7 +315,7 @@ export class NPC extends PlayerBase { const charge = distance > npcTuning.chargeRangeThreshold && - Random.getRandom() < + Random.getRandom() < npcTuning.chargeBaseChance + npcTuning.chargeAggressionChance * this.aggression ? Random.getRandomInRange(npcTuning.chargeMin, 1) : 0; diff --git a/backend/src/players/player-base.ts b/backend/src/players/player-base.ts index 3d6a561..6de34cd 100644 --- a/backend/src/players/player-base.ts +++ b/backend/src/players/player-base.ts @@ -75,7 +75,7 @@ export abstract class PlayerBase extends CommandReceiver { protected findEmptyPositionForPlayer(preferredCenter: vec2): vec2 { let rotation = 0; let radius = 0; - for (; ;) { + for (;;) { const playerPosition = vec2.fromValues( radius * Math.cos(rotation) + preferredCenter.x, radius * Math.sin(rotation) + preferredCenter.y, diff --git a/backend/src/players/player-container.ts b/backend/src/players/player-container.ts index afc67bb..01a1c96 100644 --- a/backend/src/players/player-container.ts +++ b/backend/src/players/player-container.ts @@ -57,6 +57,12 @@ export class PlayerContainer { return this._players.length; } + // Measured round-trip times (ms) of the real connected players, for + // server-side latency stats. NPCs have no socket and are excluded. + public get connectedPlayerRttsMs(): Array { + return this._players.map((p) => p.rttMs); + } + public step(deltaTimeInSeconds: number) { this.players.forEach((p) => p.step(deltaTimeInSeconds)); } diff --git a/backend/src/players/player.ts b/backend/src/players/player.ts index 5081f48..923be65 100644 --- a/backend/src/players/player.ts +++ b/backend/src/players/player.ts @@ -32,6 +32,9 @@ import { PhysicalContainer } from '../physics/containers/physical-container'; import { PlayerContainer } from './player-container'; import { PlayerBase } from './player-base'; +// How often the server pings each client to measure round-trip time. +const pingIntervalSeconds = 1; + export class Player extends PlayerBase { // default, until the clients sends its real value private aspectRatio: number = 16 / 9; @@ -41,6 +44,12 @@ export class Player extends PlayerBase { private lastInputClientTimeMs = 0; private lastLeapClientTimeMs = 0; + // Measured round-trip time to this client (ms) — the latency primitive that + // lag compensation, a latency HUD, and adaptive interpolation build on. + public rttMs = 0; + private timeSinceLastPing = 0; + private lastPingSentMs = 0; + protected commandExecutors: CommandExecutors = { [SetAspectRatioActionCommand.type]: (v: SetAspectRatioActionCommand) => (this.aspectRatio = v.aspectRatio), @@ -71,6 +80,15 @@ export class Player extends PlayerBase { super(playerInfo, playerContainer, objectContainer, team); 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. + this.socket.on(TransportEvents.Pong, () => { + if (this.lastPingSentMs > 0) { + this.rttMs = performance.now() - this.lastPingSentMs; + } + }); } protected createCharacter() { @@ -191,6 +209,12 @@ export class Player extends PlayerBase { this.queueCommandSend(new RemoteCallsForObjects(remoteCalls)); } + if ((this.timeSinceLastPing += deltaTime) > pingIntervalSeconds) { + this.timeSinceLastPing = 0; + this.lastPingSentMs = performance.now(); + this.socket.emit(TransportEvents.Ping); + } + if ((this.timeSinceLastMessage += deltaTime) > settings.updateMessageInterval) { this.handleAnnouncements(); this.handleViewAreaUpdate(); diff --git a/frontend/src/main.scss b/frontend/src/main.scss index c8aab8f..3ab46d2 100644 --- a/frontend/src/main.scss +++ b/frontend/src/main.scss @@ -332,6 +332,26 @@ body { &::after { content: '\25C9'; } + + // Twin-stick aim indicator: drawn from the button centre toward the + // current drag direction while aiming a shot (see TouchListener). + .aim-line { + position: absolute; + left: 50%; + top: 50%; + width: 130px; + height: 3px; + border-radius: 2px; + transform-origin: left center; + transform: translateY(-50%) rotate(0rad); + background: linear-gradient(90deg, + rgba(255, 255, 255, 0.85), + rgba(255, 255, 255, 0)); + box-shadow: 0 0 6px rgba(0, 0, 0, 0.6); + opacity: 0; + pointer-events: none; + transition: opacity 80ms; + } } &.leap { @@ -594,8 +614,8 @@ body { .hitmarker { position: absolute; transform: translate(-50%, -50%); - @include square(26px); - opacity: 0.9; + @include square(34px); + opacity: 0.95; &::before, &::after { @@ -603,10 +623,13 @@ body { position: absolute; top: 50%; left: 50%; - width: 12px; - height: 2px; + width: 16px; + height: 3px; + border-radius: 2px; background-color: white; - box-shadow: 0 0 4px rgba(0, 0, 0, 0.8); + box-shadow: + 0 0 6px rgba(255, 255, 255, 0.9), + 0 0 3px rgba(0, 0, 0, 0.9); transform-origin: center; } @@ -618,31 +641,58 @@ body { transform: translate(-50%, -50%) rotate(-45deg); } - animation: hitmarker-pop 250ms ease-out forwards; + animation: hitmarker-pop 220ms cubic-bezier(0.2, 0.9, 0.3, 1) forwards; &.charged { + @include square(44px); &::before, &::after { - width: 18px; - height: 3px; + width: 24px; + height: 4px; background-color: $accent; + box-shadow: + 0 0 10px rgba($accent, 0.9), + 0 0 4px rgba(0, 0, 0, 0.9); } } } + .kill-flash { + position: absolute; + inset: 0; + pointer-events: none; + background: radial-gradient(ellipse at center, + transparent 42%, + rgba($accent, 0) 56%, + rgba($accent, 0.55) 100%); + animation: kill-flash 420ms ease-out forwards; + + &.charged { + background: radial-gradient(ellipse at center, + transparent 36%, + rgba($accent, 0) 50%, + rgba($accent, 0.8) 100%); + } + } + .kill-popup { position: absolute; transform: translate(-50%, -50%); - font-weight: 700; - font-size: 1.4rem; + font-weight: 800; + font-size: 1.7rem; white-space: nowrap; color: $bright-neutral; - text-shadow: 0 0 6px rgba(0, 0, 0, 0.9); - animation: kill-popup-rise 1200ms ease-out forwards; + text-shadow: + 0 0 10px rgba(0, 0, 0, 0.9), + 0 0 18px rgba(255, 255, 255, 0.35); + animation: kill-popup-rise 1200ms cubic-bezier(0.18, 0.9, 0.3, 1) forwards; &.charged { color: $accent; + text-shadow: + 0 0 10px rgba(0, 0, 0, 0.9), + 0 0 18px rgba($accent, 0.6); } .heal { @@ -655,12 +705,14 @@ body { top: 33%; left: 50%; transform: translateX(-50%); - font-weight: 700; - font-size: 2.6rem; + font-weight: 800; + font-size: 3rem; color: $accent; - text-shadow: 0 0 12px rgba(0, 0, 0, 0.9); + text-shadow: + 0 0 12px rgba(0, 0, 0, 0.9), + 0 0 24px rgba($accent, 0.7); white-space: nowrap; - animation: streak-pop 1400ms ease-out forwards; + animation: streak-pop 1400ms cubic-bezier(0.18, 0.9, 0.3, 1) forwards; } .killfeed { @@ -685,6 +737,34 @@ body { } } } + + // Centred "Eliminated" overlay shown while the local player is dead; the + // respawn countdown is the server-driven "Reviving in N…" announcement. + .elimination { + position: absolute; + top: 42%; + left: 50%; + transform: translate(-50%, -50%); + text-align: center; + animation: elimination-in 220ms ease-out; + + .elimination-title { + font-weight: 800; + font-size: 3.4rem; + letter-spacing: 0.04em; + color: $accent; + text-shadow: + 0 0 12px rgba(0, 0, 0, 0.9), + 0 0 28px rgba($accent, 0.6); + } + + .elimination-sub { + margin-top: 6px; + font-size: 1.3rem; + opacity: 0.85; + text-shadow: 0 0 8px rgba(0, 0, 0, 0.9); + } + } } @keyframes contested-pulse { @@ -748,46 +828,72 @@ body { @keyframes hitmarker-pop { 0% { opacity: 0; - transform: translate(-50%, -50%) scale(1.6); + transform: translate(-50%, -50%) scale(2); } - 25% { + 18% { + opacity: 1; + transform: translate(-50%, -50%) scale(0.82); + } + + 36% { + transform: translate(-50%, -50%) scale(1.06); + } + + 100% { + opacity: 0; + transform: translate(-50%, -50%) scale(0.95); + } +} + +@keyframes kill-flash { + 0% { + opacity: 0; + } + + 12% { opacity: 1; - transform: translate(-50%, -50%) scale(1); } 100% { opacity: 0; - transform: translate(-50%, -50%) scale(0.9); } } @keyframes kill-popup-rise { 0% { opacity: 0; - transform: translate(-50%, -30%) scale(0.8); + transform: translate(-50%, -20%) scale(0.6); } - 20% { + 12% { opacity: 1; - transform: translate(-50%, -60%) scale(1); + transform: translate(-50%, -56%) scale(1.25); + } + + 28% { + transform: translate(-50%, -62%) scale(1); } 100% { opacity: 0; - transform: translate(-50%, -160%) scale(1); + transform: translate(-50%, -170%) scale(1); } } @keyframes streak-pop { 0% { opacity: 0; - transform: translateX(-50%) scale(0.6); + transform: translateX(-50%) scale(0.5); } - 20% { + 16% { opacity: 1; - transform: translateX(-50%) scale(1.1); + transform: translateX(-50%) scale(1.25); + } + + 32% { + transform: translateX(-50%) scale(0.95); } 70% { @@ -819,4 +925,16 @@ body { 100% { opacity: 0; } +} + +@keyframes elimination-in { + 0% { + opacity: 0; + transform: translate(-50%, -42%) scale(0.9); + } + + 100% { + opacity: 1; + transform: translate(-50%, -50%) scale(1); + } } \ No newline at end of file diff --git a/frontend/src/scripts/charge-indicator.ts b/frontend/src/scripts/charge-indicator.ts index 7b44718..239e33b 100644 --- a/frontend/src/scripts/charge-indicator.ts +++ b/frontend/src/scripts/charge-indicator.ts @@ -1,7 +1,6 @@ import { holdDurationToCharge } from 'shared'; import { Pointer } from './helper/pointer'; - export abstract class ChargeIndicator { private static element?: HTMLElement; private static heldSince = 0; @@ -41,8 +40,9 @@ export abstract class ChargeIndicator { (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.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) { diff --git a/frontend/src/scripts/commands/touch-listener.ts b/frontend/src/scripts/commands/touch-listener.ts index 9dd2cfa..ab91ce8 100644 --- a/frontend/src/scripts/commands/touch-listener.ts +++ b/frontend/src/scripts/commands/touch-listener.ts @@ -15,6 +15,9 @@ import { localCharacterPredictor } from '../helper/prediction/local-character-pr 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; private joystickButton: HTMLElement; @@ -24,9 +27,12 @@ export class TouchListener extends CommandGenerator { private fireButton: HTMLElement; private fireStrengthRing: HTMLElement; + private fireAimLine!: HTMLElement; private leapButton: HTMLElement; private fireDownAt: number | null = null; + private fireButtonCenter: vec2 | null = null; + private fireAimScreen: vec2 | null = null; constructor( private target: HTMLElement, @@ -45,8 +51,12 @@ export class TouchListener extends CommandGenerator { this.fireStrengthRing = document.createElement('div'); this.fireStrengthRing.className = 'strength-ring'; this.fireButton.appendChild(this.fireStrengthRing); + this.fireAimLine = document.createElement('div'); + this.fireAimLine.className = 'aim-line'; + this.fireButton.appendChild(this.fireAimLine); this.fireButton.addEventListener('touchstart', this.fireButtonDownListener); + this.fireButton.addEventListener('touchmove', this.fireButtonMoveListener); this.fireButton.addEventListener('touchend', this.fireButtonUpListener); this.leapButton = document.createElement('div'); @@ -159,12 +169,41 @@ export class TouchListener extends CommandGenerator { this.swallowTouch(event); this.fireDownAt = performance.now(); const rect = this.fireButton.getBoundingClientRect(); - ChargeIndicator.begin(rect.left + rect.width / 2, rect.top + rect.height / 2); + this.fireButtonCenter = vec2.fromValues( + rect.left + rect.width / 2, + rect.top + rect.height / 2, + ); + this.fireAimScreen = null; + 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) { + return; + } + const touch = event.targetTouches[0] ?? event.changedTouches[0]; + if (!touch) { + return; + } + this.fireAimScreen = vec2.fromValues(touch.clientX, touch.clientY); + const dx = this.fireAimScreen[0] - this.fireButtonCenter[0]; + const dy = this.fireAimScreen[1] - this.fireButtonCenter[1]; + if (dx * dx + dy * dy > TouchListener.aimDeadZone * TouchListener.aimDeadZone) { + this.fireAimLine.style.opacity = '1'; + this.fireAimLine.style.transform = `translateY(-50%) rotate(${Math.atan2(dy, dx)}rad)`; + } else { + this.fireAimLine.style.opacity = '0'; + } }; private fireButtonUpListener = (event: TouchEvent) => { this.swallowTouch(event); ChargeIndicator.end(); + this.fireAimLine.style.opacity = '0'; if (this.fireDownAt === null) { return; } @@ -174,12 +213,28 @@ export class TouchListener extends CommandGenerator { const character = this.game.gameObjects.player; if (!character) { + this.fireButtonCenter = null; + this.fireAimScreen = null; 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. + let direction = character.facingDirection; + if (this.fireButtonCenter && this.fireAimScreen) { + const dx = this.fireAimScreen[0] - this.fireButtonCenter[0]; + const dy = this.fireAimScreen[1] - this.fireButtonCenter[1]; + if (dx * dx + dy * dy > TouchListener.aimDeadZone * TouchListener.aimDeadZone) { + direction = vec2.normalize(vec2.create(), vec2.fromValues(dx, -dy)); + } + } + this.fireButtonCenter = null; + this.fireAimScreen = null; + const aim = vec2.scaleAndAdd( vec2.create(), character.bodyCenter, - character.facingDirection, + direction, settings.touchAimRange, ); this.sendCommandToSubscribers(new PrimaryActionCommand(aim, charge)); @@ -195,8 +250,9 @@ export class TouchListener extends CommandGenerator { const character = this.game.gameObjects.player; if (character) { - this.fireStrengthRing.style.background = `conic-gradient(rgba(255, 255, 255, 0.75) ${character.strengthFraction * 360 - }deg, transparent 0deg)`; + this.fireStrengthRing.style.background = `conic-gradient(rgba(255, 255, 255, 0.75) ${ + character.strengthFraction * 360 + }deg, transparent 0deg)`; } } @@ -207,6 +263,7 @@ export class TouchListener extends CommandGenerator { this.target.removeEventListener('touchend', this.touchEndListener); this.fireButton.removeEventListener('touchstart', this.fireButtonDownListener); + this.fireButton.removeEventListener('touchmove', this.fireButtonMoveListener); this.fireButton.removeEventListener('touchend', this.fireButtonUpListener); this.leapButton.removeEventListener('touchstart', this.leapButtonListener); diff --git a/frontend/src/scripts/feedback-hud.ts b/frontend/src/scripts/feedback-hud.ts index 316aecf..adbf068 100644 --- a/frontend/src/scripts/feedback-hud.ts +++ b/frontend/src/scripts/feedback-hud.ts @@ -4,6 +4,7 @@ import { Pointer } from './helper/pointer'; export abstract class FeedbackHud { private static root?: HTMLElement; private static killfeed?: HTMLElement; + private static elimination?: HTMLElement; private static ensureRoot(): { root: HTMLElement; killfeed: HTMLElement } { if (!this.root || !this.killfeed) { @@ -47,6 +48,11 @@ export abstract class FeedbackHud { const { killfeed } = this.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 entry = document.createElement('div'); entry.className = 'kill-entry'; entry.innerHTML = `Eliminated ${this.escape(victimName ?? 'enemy')}`; @@ -70,6 +76,29 @@ export abstract class FeedbackHud { } } + // 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 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; + } + private static streakName(streak: number): string | undefined { switch (streak) { case 2: diff --git a/frontend/src/scripts/game.ts b/frontend/src/scripts/game.ts index a212d5a..4b8678f 100644 --- a/frontend/src/scripts/game.ts +++ b/frontend/src/scripts/game.ts @@ -39,6 +39,7 @@ import { localCharacterPredictor } from './helper/prediction/local-character-pre import { Tutorial } from './tutorial'; import { Scoreboard } from './scoreboard'; import { Minimap } from './minimap'; +import { ScreenShake } from './screen-shake'; export class Game extends CommandReceiver { public gameObjects = new GameObjectContainer(this); @@ -80,6 +81,9 @@ 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 = ''; this.keystoneArrow = undefined; @@ -232,6 +236,11 @@ export class Game extends CommandReceiver { this.resolveStarted(); 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. + 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. diff --git a/frontend/src/scripts/helper/prediction/local-character-predictor.ts b/frontend/src/scripts/helper/prediction/local-character-predictor.ts index c147477..5d23c1c 100644 --- a/frontend/src/scripts/helper/prediction/local-character-predictor.ts +++ b/frontend/src/scripts/helper/prediction/local-character-predictor.ts @@ -17,6 +17,14 @@ import { InputHistory } from './input-history'; const stepMs = 1000 / 200; // match the server's 200 Hz fixed tick const stepSeconds = 1 / 200; +// Clock source for the predictor. Injectable so deterministic reconciliation +// tests can drive prediction without real time passing; defaults to the +// browser wall clock in production. +let nowMs: () => number = () => performance.now(); +export const setPredictorClockForTesting = (clock: () => number): void => { + nowMs = clock; +}; + // Don't replay more than this far back: if the last acknowledged input is older // (a stall, or a backgrounded tab catching up) snap to the authoritative pose // instead of grinding through hundreds of steps. @@ -74,6 +82,12 @@ export class LocalCharacterPredictor { // 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. + private alive = true; + // Continuous state carried between replays (the snapshot carries only poses). // The facing direction is NOT carried — it is re-derived from the pose each // frame (see directionFromPose / simulate); only the latched planet and the @@ -100,7 +114,7 @@ export class LocalCharacterPredictor { // Stamp a movement command and record it for replay. Returns the wall-clock // time the command should carry so the server can echo it back. public recordInput(direction: vec2): number { - const timeMs = Math.round(performance.now()); + const timeMs = Math.round(nowMs()); this.inputHistory.record(direction, timeMs); return timeMs; } @@ -112,7 +126,10 @@ export class LocalCharacterPredictor { ): void { // Inputs only advance the acknowledgement forward; the launch momentum and // leap boundary always adopt the latest authoritative values. - if (this.lastAckClientTimeMs === undefined || clientTimeMs > this.lastAckClientTimeMs) { + if ( + this.lastAckClientTimeMs === undefined || + clientTimeMs > this.lastAckClientTimeMs + ) { this.lastAckClientTimeMs = clientTimeMs; } vec2.set(this.authoritativeBodyVelocity, bodyVelocity[0], bodyVelocity[1]); @@ -121,7 +138,7 @@ export class LocalCharacterPredictor { public setAuthoritative(head: Circle, leftFoot: Circle, rightFoot: Circle): void { this.authoritative = { head, leftFoot, rightFoot }; - this.authReceiptMs = Math.round(performance.now()); + this.authReceiptMs = Math.round(nowMs()); } // The player pressed leap; remember when, so the replay applies the same @@ -129,7 +146,7 @@ export class LocalCharacterPredictor { // it — a rejected leap (no strength/cooldown) self-corrects via the streamed // authoritative momentum. public recordLeap(): number { - const timeMs = Math.round(performance.now()); + const timeMs = Math.round(nowMs()); this.leapHistory.push(timeMs); const cutoff = timeMs - 1500; while (this.leapHistory.length > 0 && this.leapHistory[0] <= cutoff) { @@ -142,6 +159,10 @@ export class LocalCharacterPredictor { this.currentStrength = strength; } + public setAlive(alive: boolean): void { + this.alive = alive; + } + public reset(): void { this.inputHistory.reset(); this.leapHistory = []; @@ -154,6 +175,7 @@ export class LocalCharacterPredictor { this.carriedPlanetId = undefined; this.carriedSecondsSinceSurface = 1; this.hasRender = false; + this.alive = true; } public get canPredict(): boolean { @@ -175,7 +197,7 @@ export class LocalCharacterPredictor { // 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.canPredict || this.isAnimatingInOrOut) { + 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; @@ -209,7 +231,7 @@ export class LocalCharacterPredictor { private simulate(): CharacterMovementState { const auth = this.authoritative!; - const now = Math.round(performance.now()); + const now = Math.round(nowMs()); // Predict forward from the latest snapshot by its age, clamped so a stall // (or a backgrounded tab catching up) snaps instead of grinding hundreds of // steps. This advances with wall-clock time even while a held key sends no diff --git a/frontend/src/scripts/join-form-handler.ts b/frontend/src/scripts/join-form-handler.ts index e9c2b71..c2bd37f 100644 --- a/frontend/src/scripts/join-form-handler.ts +++ b/frontend/src/scripts/join-form-handler.ts @@ -16,8 +16,12 @@ export class JoinFormHandler { private resolvePlayerDecision!: (d: PlayerDecision) => void; private pollServersTimer: any; private keyUpListener = (e: KeyboardEvent) => { - if (e.key === 'enter') { - this.form.submit(); + // 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. + if (e.key === 'Enter' && !this.joinButton.disabled) { + this.form.requestSubmit(); } }; diff --git a/frontend/src/scripts/landing-page-background.ts b/frontend/src/scripts/landing-page-background.ts index 0e2be9a..bafe559 100644 --- a/frontend/src/scripts/landing-page-background.ts +++ b/frontend/src/scripts/landing-page-background.ts @@ -3,19 +3,19 @@ import { CircleLight, FilteringOptions, hsl, - NoisyPolygonFactory, Renderer, renderNoise, runAnimation, WrapOptions, } from 'sdf-2d'; import { settings, rgb, PlanetBase, Random } from 'shared'; +import { PlanetShape } from './shapes/planet-shape'; -const landingPageVertexCount = 9; -const LandingPagePolygon = NoisyPolygonFactory( - landingPageVertexCount, - rgb(0.5, 0.4, 0.7), -); +// 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; export class LandingPageBackground { private isActive = true; @@ -34,7 +34,7 @@ export class LandingPageBackground { canvas, [ { - ...LandingPagePolygon.descriptor, + ...PlanetShape.descriptor, shaderCombinationSteps: [0, 1, 2], }, { @@ -74,34 +74,41 @@ export class LandingPageBackground { 0.7 * renderer.canvasSize.y, ); - const topPlanet = new LandingPagePolygon( + const topPlanet = new PlanetShape( PlanetBase.createPlanetVertices( topPlanetPosition, Random.getRandomInRange(150, 400), Random.getRandomInRange(150, 400), Random.getRandomInRange(10, 20), - landingPageVertexCount, ), + redPlanet, ); - (topPlanet as any).randomOffset = 0.5 + time / 3500; + // 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; const bottomPlanetPosition = vec2.fromValues( 0.3 * renderer.canvasSize.x, 0.3 * renderer.canvasSize.y, ); - const bottomPlanet = new LandingPagePolygon( + const bottomPlanet = new PlanetShape( PlanetBase.createPlanetVertices( bottomPlanetPosition, Random.getRandomInRange(150, 800), Random.getRandomInRange(150, 400), Random.getRandomInRange(10, 40), - landingPageVertexCount, ), + bluePlanet, ); - (bottomPlanet as any).randomOffset = time / 2500; + bottomPlanet.randomOffset = Random.getRandom(); + bottomPlanet.rotation = (time / 1000) * -0.06; const planetDistance = vec2.subtract( vec2.create(), diff --git a/frontend/src/scripts/objects/game-object-container.ts b/frontend/src/scripts/objects/game-object-container.ts index fa48f08..2b03836 100644 --- a/frontend/src/scripts/objects/game-object-container.ts +++ b/frontend/src/scripts/objects/game-object-container.ts @@ -16,6 +16,7 @@ import { } from 'shared'; import { BeforeDestroyCommand } from '../commands/types/before-destroy'; import { StepCommand } from '../commands/types/step'; +import { FeedbackHud } from '../feedback-hud'; import { Game } from '../game'; import { serverTimeline } from '../helper/server-timeline'; import { PredictablePlanet } from '../helper/prediction/client-character-world'; @@ -28,6 +29,7 @@ export class GameObjectContainer extends CommandReceiver { protected objects: Map = new Map(); public player!: CharacterView; public camera: Camera = new Camera(this.game); + private wasLocalPlayerAlive = false; protected commandExecutors: CommandExecutors = { [CreatePlayerCommand.type]: (c: CreatePlayerCommand) => { @@ -37,6 +39,9 @@ export class GameObjectContainer extends CommandReceiver { // 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; }, [CreateObjectsCommand.type]: (c: CreateObjectsCommand) => @@ -45,15 +50,32 @@ export class GameObjectContainer extends CommandReceiver { [StepCommand.type]: (c: StepCommand) => { this.defaultCommandExecutor(c); - if (this.player) { + // 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. + 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. Remote objects keep - // interpolating. A large correction (respawn / death) snaps inside the - // predictor rather than rubber-banding. + // 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. + localCharacterPredictor.setAlive(alive); localCharacterPredictor.setStrength( this.player.strengthFraction * settings.playerMaxStrength, ); - if (localCharacterPredictor.update(this.predictablePlanets(), c.deltaTimeInSeconds)) { + if ( + localCharacterPredictor.update(this.predictablePlanets(), c.deltaTimeInSeconds) + ) { this.player.head = localCharacterPredictor.head; this.player.leftFoot = localCharacterPredictor.leftFoot; this.player.rightFoot = localCharacterPredictor.rightFoot; diff --git a/frontend/src/scripts/objects/types/camera.ts b/frontend/src/scripts/objects/types/camera.ts index f1cf670..c607c38 100644 --- a/frontend/src/scripts/objects/types/camera.ts +++ b/frontend/src/scripts/objects/types/camera.ts @@ -8,6 +8,7 @@ import { } from 'shared'; import { RenderCommand } from '../../commands/types/render'; import { Game } from '../../game'; +import { ScreenShake } from '../../screen-shake'; export class Camera extends CommandReceiver { public center: vec2 = vec2.create(); @@ -46,7 +47,16 @@ export class Camera extends CommandReceiver { this.game.aspectRatioChanged(canvasAspectRatio); } - const viewArea = calculateViewArea(this.center, canvasAspectRatio); + // Shake displaces only the rendered view centre and the zoom-punch shrinks + // only the rendered view area — neither touches the followed position, so + // impacts jolt the frame without nudging the camera off the player. Passing + // the zoom as oversizeRatio scales the area about the (shaken) centre. + const shakenCenter = vec2.fromValues( + this.center[0] + ScreenShake.offsetX, + this.center[1] + ScreenShake.offsetY, + ); + const scale = ScreenShake.viewScale; + const viewArea = calculateViewArea(shakenCenter, canvasAspectRatio, scale * scale); renderer.setViewArea(viewArea.topLeft, viewArea.size); renderer.setRuntimeSettings({ diff --git a/frontend/src/scripts/objects/types/character-view.ts b/frontend/src/scripts/objects/types/character-view.ts index 1806993..5c0d751 100644 --- a/frontend/src/scripts/objects/types/character-view.ts +++ b/frontend/src/scripts/objects/types/character-view.ts @@ -1,4 +1,4 @@ -import { vec2 } from 'gl-matrix'; +import { vec2, vec3 } from 'gl-matrix'; import { CircleLight, Renderer } from 'sdf-2d'; import { @@ -23,10 +23,18 @@ import { CharacterShape } from '../../shapes/character-shape'; import { SoundHandler, Sounds } from '../../sound-handler'; import { VibrationHandler } from '../../vibration-handler'; import { FeedbackHud } from '../../feedback-hud'; +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. +const deathBurstDecaySeconds = 0.42; +const deathBurstMaxIntensity = 1.7; +const deathBurstColor = vec3.fromValues(2.5, 2.3, 2.1); + const killIcon = ''; @@ -39,6 +47,8 @@ export class CharacterView extends CharacterBase { private muzzleFlash: CircleLight; private muzzleFlashIntensity = 0; private hitFlashIntensity = 0; + private deathBurst: CircleLight; + private deathBurstIntensity = 0; private strength = settings.playerMaxStrength; private strengthInterpolator = new LinearInterpolator(settings.playerMaxStrength); private nameElement: HTMLElement = document.createElement('div'); @@ -79,6 +89,7 @@ export class CharacterView extends CharacterBase { settings.paletteDim[settings.colorIndices[team]], 0, ); + this.deathBurst = new CircleLight(vec2.clone(this.head!.center), deathBurstColor, 0); this.leftFootInterpolator = new CircleInterpolator(this.leftFoot!); this.rightFootInterpolator = new CircleInterpolator(this.rightFoot!); @@ -166,6 +177,9 @@ 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)); } } } @@ -174,17 +188,23 @@ 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. + this.deathBurstIntensity = 1; + this.hitFlashIntensity = 1; } public onHitConfirmed(charge = 0) { if (!this.isMainCharacter) { return; } - // A charged hit lands lower and harder than a panic tap. - SoundHandler.play(Sounds.click, mix(0.4, 0.75, charge), mix(1.7, 1.05, charge)); - if (charge >= settings.chargedHitThreshold) { - VibrationHandler.vibrate(25); - } + // Layer a meaty thud under the crisp confirmation tick; a charged hit lands + // lower and harder than a panic tap. + 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)); + VibrationHandler.vibrate(Math.round(mix(12, 35, charge))); FeedbackHud.hitMarker(charge); } @@ -192,8 +212,14 @@ export class CharacterView extends CharacterBase { if (!this.isMainCharacter) { return; } - SoundHandler.play(Sounds.click, 1, mix(0.7, 0.5, charge)); - VibrationHandler.vibrate(mix(60, 110, charge)); + // 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. + 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)); + ScreenShake.addPunch(mix(0.7, 1, charge)); + VibrationHandler.vibrate([45, 35, Math.round(mix(80, 130, charge))]); FeedbackHud.killConfirmed(victimName, streak, charge); } @@ -230,6 +256,18 @@ export class CharacterView extends CharacterBase { this.hitFlashIntensity - deltaTimeInSeconds / hitFlashDecaySeconds, ); } + + if (this.deathBurstIntensity > 0) { + this.deathBurstIntensity = Math.max( + 0, + this.deathBurstIntensity - deltaTimeInSeconds / deathBurstDecaySeconds, + ); + this.deathBurst.center = this.bodyCenter; + // Square the envelope so the flash blooms then drops off sharply rather + // than fading out in a flat ramp. + this.deathBurst.intensity = + deathBurstMaxIntensity * this.deathBurstIntensity * this.deathBurstIntensity; + } } public onShoot(strength: number) { @@ -273,6 +311,10 @@ export class CharacterView extends CharacterBase { if (this.muzzleFlashIntensity > 0) { renderer.addDrawable(this.muzzleFlash); } + + if (this.deathBurstIntensity > 0) { + renderer.addDrawable(this.deathBurst); + } } private calculateGazeTarget(renderer: Renderer): vec2 { diff --git a/frontend/src/scripts/screen-shake.ts b/frontend/src/scripts/screen-shake.ts new file mode 100644 index 0000000..75c5bce --- /dev/null +++ b/frontend/src/scripts/screen-shake.ts @@ -0,0 +1,84 @@ +// 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; + + // 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; + + private static offsetXValue = 0; + private static 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; + } +} diff --git a/frontend/src/scripts/shapes/planet-shape.ts b/frontend/src/scripts/shapes/planet-shape.ts index 1d1ec9e..ceb2e35 100644 --- a/frontend/src/scripts/shapes/planet-shape.ts +++ b/frontend/src/scripts/shapes/planet-shape.ts @@ -103,8 +103,8 @@ export class PlanetShape extends PolygonFactory(settings.planetEdgeCount, 0) { if (dist < minDistance) { minDistance = dist; color = mix(${colorToString(settings.bluePlanetColor)}, ${colorToString( - settings.redPlanetColor, - )}, planetColorMixQ[j]); + settings.redPlanetColor, + )}, planetColorMixQ[j]); } } @@ -141,10 +141,7 @@ export class PlanetShape extends PolygonFactory(settings.planetEdgeCount, 0) { ) { super(vertices); - this.cullCenter = vertices.reduce( - (sum, v) => vec2.add(sum, sum, v), - vec2.create(), - ); + this.cullCenter = vertices.reduce((sum, v) => vec2.add(sum, sum, v), vec2.create()); vec2.scale(this.cullCenter, this.cullCenter, 1 / vertices.length); this.cullRadius = vertices.reduce( diff --git a/frontend/src/scripts/vibration-handler.ts b/frontend/src/scripts/vibration-handler.ts index 951efaa..3ae8420 100644 --- a/frontend/src/scripts/vibration-handler.ts +++ b/frontend/src/scripts/vibration-handler.ts @@ -1,9 +1,11 @@ import { OptionsHandler } from './options-handler'; export abstract class VibrationHandler { - public static vibrate(time: number): void { + // 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) { - navigator.vibrate(time); + navigator.vibrate(pattern); } } diff --git a/package.json b/package.json index b1364f0..3e123bd 100644 --- a/package.json +++ b/package.json @@ -12,11 +12,15 @@ "eslint-plugin-unused-imports": "^4.4.1", "prettier": "^3.8.3", "typescript": "^6.0.3", - "typescript-eslint": "^8.60.1" + "typescript-eslint": "^8.60.1", + "vitest": "^4.1.9" }, "scripts": { "build": "cd shared && npm run build && cd ../frontend && npm run build && cd ../backend && npm run build", "lint": "eslint ./**/src/**/*.{js,ts} --fix && prettier --write ./**/src/**/*.{js,ts,json}", + "lint:check": "eslint ./**/src/**/*.{js,ts} && prettier --check ./**/src/**/*.{js,ts,json}", + "test": "vitest run", + "test:watch": "vitest", "init": "cd shared && npm install && cd ../frontend && npm install && cd ../backend && npm install", "dev": "concurrently --kill-others-on-fail \"cd shared && npm dev\" \"cd backend && npm dev\" \"cd frontend && npm dev\"" } diff --git a/shared/package.json b/shared/package.json index 67ebd4c..bf2597b 100644 --- a/shared/package.json +++ b/shared/package.json @@ -7,6 +7,9 @@ "files": [ "lib" ], + "engines": { + "node": ">=20" + }, "scripts": { "build": "npx webpack --mode production", "dev": "npx webpack --mode development --watch", diff --git a/shared/src/objects/game-object.ts b/shared/src/objects/game-object.ts index 93ab8c2..f851137 100644 --- a/shared/src/objects/game-object.ts +++ b/shared/src/objects/game-object.ts @@ -15,10 +15,24 @@ 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. +export type SyncPropertyKey = + | 'head' + | 'leftFoot' + | 'rightFoot' + | 'strength' + | 'center' + | 'ownership' + | 'rotation'; + @serializable export class UpdatePropertyCommand extends Command { constructor( - public readonly propertyKey: string, + public readonly propertyKey: SyncPropertyKey, public readonly propertyValue: any, public readonly rateOfChange: any, ) { @@ -45,16 +59,40 @@ export class PropertyUpdatesForObject { export abstract class GameObject extends CommandReceiver { private remoteCalls: Array = []; + // 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. + private static readonly allowedRemoteCalls: ReadonlySet = new Set([ + 'onShoot', + 'onLeap', + 'onDie', + 'onHitConfirmed', + 'onKillConfirmed', + 'setHealth', + 'setKillCount', + 'onFlipped', + 'setContested', + 'generatedPoints', + 'setLight', + ]); + constructor(public readonly id: Id) { super(); } public processRemoteCalls(remoteCalls: Array) { - remoteCalls.forEach((r) => - (this[r.functionName as keyof this] as unknown as (...args: Array) => unknown)( - ...r.args, - ), - ); + remoteCalls.forEach((r) => { + if (!GameObject.allowedRemoteCalls.has(r.functionName)) { + console.warn(`Dropped disallowed remote call: ${r.functionName}`); + return; + } + const fn = this[r.functionName as keyof this]; + if (typeof fn === 'function') { + (fn as (...args: Array) => unknown).apply(this, r.args); + } + }); } public getPropertyUpdates(): PropertyUpdatesForObject | void {} diff --git a/shared/src/objects/types/character-base.ts b/shared/src/objects/types/character-base.ts index b461127..f6cad5c 100644 --- a/shared/src/objects/types/character-base.ts +++ b/shared/src/objects/types/character-base.ts @@ -1,6 +1,7 @@ import { Id } from '../../communication/id'; import { Circle } from '../../helper/circle'; import { serializable } from '../../serialization/serializable'; +import { toArrayFromFields } from '../../serialization/serialized-fields'; import { GameObject } from '../game-object'; export enum CharacterTeam { @@ -11,6 +12,18 @@ export enum CharacterTeam { @serializable export class CharacterBase extends GameObject { + private static readonly serializedFields = [ + 'id', + 'name', + 'killCount', + 'deathCount', + 'team', + 'health', + 'head', + 'leftFoot', + 'rightFoot', + ] as const; + constructor( id: Id, public name: string, @@ -26,37 +39,27 @@ export class CharacterBase extends GameObject { } // eslint-disable-next-line @typescript-eslint/no-unused-vars - public onShoot(strength: number) { } + public onShoot(strength: number) {} - public onLeap() { } + public onLeap() {} // eslint-disable-next-line @typescript-eslint/no-unused-vars - public onHitConfirmed(charge?: number) { } + public onHitConfirmed(charge?: number) {} // eslint-disable-next-line @typescript-eslint/no-unused-vars - public onKillConfirmed(victimName?: string, streak?: number, charge?: number) { } + public onKillConfirmed(victimName?: string, streak?: number, charge?: number) {} public setHealth(health: number) { this.health = health; } - public onDie() { } + public onDie() {} public setKillCount(killCount: number) { this.killCount = killCount; } public toArray(): Array { - return [ - this.id, - this.name, - this.killCount, - this.deathCount, - this.team, - this.health, - this.head, - this.leftFoot, - this.rightFoot, - ]; + return toArrayFromFields(this, CharacterBase.serializedFields); } } diff --git a/shared/src/objects/types/lamp-base.ts b/shared/src/objects/types/lamp-base.ts index 794e75b..9f86ca6 100644 --- a/shared/src/objects/types/lamp-base.ts +++ b/shared/src/objects/types/lamp-base.ts @@ -1,9 +1,17 @@ import { vec2, vec3 } from 'gl-matrix'; import { Id, serializable } from '../../main'; +import { toArrayFromFields } from '../../serialization/serialized-fields'; import { GameObject } from '../game-object'; @serializable export class LampBase extends GameObject { + private static readonly serializedFields = [ + 'id', + 'center', + 'color', + 'lightness', + ] as const; + constructor( id: Id, public center: vec2, @@ -19,7 +27,6 @@ export class LampBase extends GameObject { public setLight(color: vec3, lightness: number) {} public toArray(): Array { - const { id, center, color, lightness } = this; - return [id, center, color, lightness]; + return toArrayFromFields(this, LampBase.serializedFields); } } diff --git a/shared/src/objects/types/planet-base.ts b/shared/src/objects/types/planet-base.ts index e3835e1..bafea38 100644 --- a/shared/src/objects/types/planet-base.ts +++ b/shared/src/objects/types/planet-base.ts @@ -2,12 +2,22 @@ import { vec2 } from 'gl-matrix'; import { Random } from '../../helper/random'; import { settings } from '../../settings'; import { serializable } from '../../serialization/serializable'; +import { toArrayFromFields } from '../../serialization/serialized-fields'; import { GameObject } from '../game-object'; import { Id } from '../../communication/id'; 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. + private static readonly serializedFields = [ + 'id', + 'vertices', + 'ownership', + 'isKeystone', + ] as const; + public readonly center: vec2; public readonly radius: number; @@ -57,6 +67,6 @@ export class PlanetBase extends GameObject { } public toArray(): Array { - return [this.id, this.vertices, this.ownership, this.isKeystone]; + return toArrayFromFields(this, PlanetBase.serializedFields); } } diff --git a/shared/src/objects/types/projectile-base.ts b/shared/src/objects/types/projectile-base.ts index c334cb8..f96fd94 100644 --- a/shared/src/objects/types/projectile-base.ts +++ b/shared/src/objects/types/projectile-base.ts @@ -1,12 +1,21 @@ import { vec2 } from 'gl-matrix'; import { settings } from '../../settings'; import { serializable } from '../../serialization/serializable'; +import { toArrayFromFields } from '../../serialization/serialized-fields'; import { GameObject } from '../game-object'; import { Id } from '../../communication/id'; import { CharacterTeam } from './character-base'; @serializable export class ProjectileBase extends GameObject { + private static readonly serializedFields = [ + 'id', + 'center', + 'radius', + 'team', + 'strength', + ] as const; + constructor( id: Id, public center: vec2, @@ -23,6 +32,6 @@ export class ProjectileBase extends GameObject { } public toArray(): Array { - return [this.id, this.center, this.radius, this.team, this.strength]; + return toArrayFromFields(this, ProjectileBase.serializedFields); } } diff --git a/shared/src/physics/character-movement.ts b/shared/src/physics/character-movement.ts index a5744b9..21355a5 100644 --- a/shared/src/physics/character-movement.ts +++ b/shared/src/physics/character-movement.ts @@ -107,7 +107,13 @@ const springMove = ( const keepPosture = (state: CharacterMovementState) => { const center = characterCenter(state); - springMove(state, state.leftFoot, center, leftFootOffset, settings.postureFeetStiffness); + springMove( + state, + state.leftFoot, + center, + leftFootOffset, + settings.postureFeetStiffness, + ); springMove( state, state.rightFoot, @@ -133,7 +139,12 @@ const carryWithRotatingPlanet = ( const angle = -planet.angularVelocity * deltaTimeInSeconds; const center = planet.center; state.head.center = vec2.rotate(vec2.create(), state.head.center, center, angle); - state.leftFoot.center = vec2.rotate(vec2.create(), state.leftFoot.center, center, angle); + state.leftFoot.center = vec2.rotate( + vec2.create(), + state.leftFoot.center, + center, + angle, + ); state.rightFoot.center = vec2.rotate( vec2.create(), state.rightFoot.center, @@ -148,10 +159,7 @@ const carryWithRotatingPlanet = ( // 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. -export const applyLeapImpulse = ( - state: CharacterMovementState, - moveDirection: vec2, -) => { +export const applyLeapImpulse = (state: CharacterMovementState, moveDirection: vec2) => { const planet = state.currentPlanet; if (!planet) { return; @@ -206,7 +214,9 @@ export const tickPlanetDetachment = ( state: CharacterMovementState, deltaTimeInSeconds: number, ) => { - if ((state.secondsSinceOnSurface += deltaTimeInSeconds) > settings.planetDetachmentSeconds) { + if ( + (state.secondsSinceOnSurface += deltaTimeInSeconds) > settings.planetDetachmentSeconds + ) { state.currentPlanet = undefined; } }; @@ -255,10 +265,7 @@ export const decayMomentum = ( } }; -const decayBodyMomentum = ( - state: CharacterMovementState, - deltaTimeInSeconds: number, -) => { +const decayBodyMomentum = (state: CharacterMovementState, deltaTimeInSeconds: number) => { decayMomentum(state.bodyVelocity, !!state.currentPlanet, deltaTimeInSeconds); }; @@ -293,7 +300,11 @@ export const stepCharacterMovement = ( const center = characterCenter(state); const grounds = world.groundsNear(center, boundRadius + settings.maxGravityDistance); - const movementForce = vec2.scale(inputDirection, inputDirection, settings.maxAcceleration); + const movementForce = vec2.scale( + inputDirection, + inputDirection, + settings.maxAcceleration, + ); applyForce(state.leftFoot, movementForce, deltaTimeInSeconds); applyForce(state.rightFoot, movementForce, deltaTimeInSeconds); diff --git a/shared/src/serialization/serialized-fields.ts b/shared/src/serialization/serialized-fields.ts new file mode 100644 index 0000000..48733e8 --- /dev/null +++ b/shared/src/serialization/serialized-fields.ts @@ -0,0 +1,13 @@ +// 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. +export const toArrayFromFields = ( + object: any, + fields: ReadonlyArray, +): Array => fields.map((field) => object[field]); diff --git a/test/__snapshots__/physics-determinism.test.mjs.snap b/test/__snapshots__/physics-determinism.test.mjs.snap new file mode 100644 index 0000000..50ab1c3 --- /dev/null +++ b/test/__snapshots__/physics-determinism.test.mjs.snap @@ -0,0 +1,3 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`shared character simulation determinism > matches the pinned reference pose (changes only with intentional physics edits) 1`] = `"{"head":[76.14,123.412],"leftFoot":[83.214,105.036],"rightFoot":[79.974,123.01],"bodyVelocity":[0,0]}"`; diff --git a/test/physics-determinism.test.mjs b/test/physics-determinism.test.mjs new file mode 100644 index 0000000..d0fee1a --- /dev/null +++ b/test/physics-determinism.test.mjs @@ -0,0 +1,83 @@ +// 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. +import { describe, it, expect } from 'vitest'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const shared = require('../shared/lib/main.js'); +const { vec2 } = require('../shared/node_modules/gl-matrix'); + +const { + stepCharacterMovement, + resolveCircleMovement, + headRadius, + feetRadius, + headOffset, + leftFootOffset, + rightFootOffset, +} = shared; + +const makeBody = (center, radius) => ({ + center: vec2.clone(center), + radius, + velocity: vec2.create(), + lastNormal: vec2.fromValues(0, 1), + 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. +const emptyWorld = { + groundsNear: () => [], + stepBody: (body, dt) => { + resolveCircleMovement(body, dt, []); + return undefined; + }, +}; + +const stepSeconds = 1 / 200; + +// 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 = { + head: makeBody(vec2.add(vec2.create(), start, headOffset), headRadius), + leftFoot: makeBody(vec2.add(vec2.create(), start, leftFootOffset), feetRadius), + rightFoot: makeBody(vec2.add(vec2.create(), start, rightFootOffset), feetRadius), + direction: 0, + currentPlanet: undefined, + secondsSinceOnSurface: 1, + bodyVelocity: vec2.create(), + }; + + for (let i = 0; i < 300; i++) { + const angle = i * 0.1; + const input = vec2.fromValues(Math.cos(angle), Math.sin(angle)); + stepCharacterMovement(state, emptyWorld, input, stepSeconds); + } + + const round = (v) => Math.round(v * 1000) / 1000; + return JSON.stringify({ + head: [round(state.head.center[0]), round(state.head.center[1])], + leftFoot: [round(state.leftFoot.center[0]), round(state.leftFoot.center[1])], + rightFoot: [round(state.rightFoot.center[0]), round(state.rightFoot.center[1])], + bodyVelocity: [round(state.bodyVelocity[0]), round(state.bodyVelocity[1])], + }); +}; + +describe('shared character simulation determinism', () => { + it('produces identical output across independent runs', () => { + expect(runSimulation()).toBe(runSimulation()); + }); + + it('matches the pinned reference pose (changes only with intentional physics edits)', () => { + // Regression pin — update deliberately when physics behaviour changes. + expect(runSimulation()).toMatchSnapshot(); + }); +}); diff --git a/test/prediction-reconciliation.test.ts b/test/prediction-reconciliation.test.ts new file mode 100644 index 0000000..98ffc1d --- /dev/null +++ b/test/prediction-reconciliation.test.ts @@ -0,0 +1,96 @@ +// Reconciliation guard for the REAL client predictor. It exercises +// LocalCharacterPredictor end-to-end with an injected clock, verifying the two +// properties reconciliation depends on: +// 1. a fully-acknowledged snapshot reproduces the authoritative pose exactly +// (no spurious drift on top of server truth), and +// 2. un-acknowledged input is replayed forward deterministically. +// This is the net for prediction-touching changes (the death-while-dead fix, +// future netcode work) — a regression shows up as drift or non-determinism. +import { describe, it, expect } from 'vitest'; +import { + LocalCharacterPredictor, + setPredictorClockForTesting, +} from '../frontend/src/scripts/helper/prediction/local-character-predictor'; + +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 }, +}); + +describe('local prediction reconciliation', () => { + it('reproduces the authoritative pose exactly when all input is acknowledged', () => { + let clock = 1000; + setPredictorClockForTesting(() => clock); + + const predictor = new LocalCharacterPredictor(); + const auth = poseAt(500, 500); + + const t = predictor.recordInput([1, 0]); + predictor.acknowledge(t, [0, 0], -Infinity); // server has consumed this input + predictor.setStrength(80); + predictor.setAuthoritative(auth.head as never, auth.leftFoot as never, auth.rightFoot as never); + + // No clock advance → zero replay window → predicted pose == authoritative. + const used = predictor.update([], 1 / 60); + + expect(used).toBe(true); + expect(predictor.head.center[0]).toBeCloseTo(auth.head.center[0], 5); + expect(predictor.head.center[1]).toBeCloseTo(auth.head.center[1], 5); + expect(predictor.leftFoot.center[0]).toBeCloseTo(auth.leftFoot.center[0], 5); + expect(predictor.rightFoot.center[0]).toBeCloseTo(auth.rightFoot.center[0], 5); + }); + + it('replays un-acknowledged input deterministically', () => { + const drive = () => { + let clock = 0; + setPredictorClockForTesting(() => clock); + + const predictor = new LocalCharacterPredictor(); + clock = 1000; + predictor.acknowledge(900, [0, 0], -Infinity); // baseline ack (older than the input below) + predictor.setStrength(80); + const auth = poseAt(0, 0); + predictor.setAuthoritative(auth.head as never, auth.leftFoot as never, auth.rightFoot as never); + predictor.recordInput([1, 0]); // unacked rightward input at t=1000 + + clock = 1100; // replay ~100 ms forward + predictor.update([], 1 / 60); + return [ + predictor.head.center[0], + predictor.head.center[1], + predictor.leftFoot.center[0], + predictor.rightFoot.center[0], + ]; + }; + + const a = drive(); + const b = drive(); + expect(a).toEqual(b); // deterministic replay + expect(a.every((n) => Number.isFinite(n))).toBe(true); + expect(a[0]).toBeGreaterThan(0.5); // rightward input actually moved the body + }); + + it('suppresses prediction while the local player is dead', () => { + let clock = 5000; + setPredictorClockForTesting(() => clock); + + const predictor = new LocalCharacterPredictor(); + const auth = poseAt(200, 200); + const t = predictor.recordInput([1, 0]); + predictor.acknowledge(t, [0, 0], -Infinity); + predictor.setStrength(80); + predictor.setAuthoritative(auth.head as never, auth.leftFoot as never, auth.rightFoot as never); + predictor.setAlive(false); // dead, awaiting respawn + + clock = 5200; // input + elapsed time that would otherwise be replayed forward + // Even with a valid authoritative pose and pending input, a dead body must + // not be predicted/moved — that was the "move while dead" bug. + expect(predictor.update([], 1 / 60)).toBe(false); + }); +}); diff --git a/test/serialization.test.mjs b/test/serialization.test.mjs new file mode 100644 index 0000000..9c7bc11 --- /dev/null +++ b/test/serialization.test.mjs @@ -0,0 +1,159 @@ +// 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. +import { describe, it, expect } from 'vitest'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const shared = require('../shared/lib/main.js'); + +const { + serialize, + deserialize, + Circle, + ServerAnnouncement, + MoveActionCommand, + UpdatePropertyCommand, + PropertyUpdatesForObject, + // networked entity bases — their toArray() must mirror their constructor order + CharacterBase, + PlanetBase, + ProjectileBase, + LampBase, + // geometry — single source of truth shared by server & client prediction + headRadius, + feetRadius, + boundRadius, + headOffset, + leftFootOffset, + rightFootOffset, +} = shared; + +describe('serialization round-trip (built shared lib)', () => { + it('reconstructs a ServerAnnouncement as the right class with its payload', () => { + const [out] = deserialize(serialize([new ServerAnnouncement('Hello world')])); + expect(out).toBeInstanceOf(ServerAnnouncement); + expect(out.text).toBe('Hello world'); + }); + + it('round-trips a Circle and rounds floats to 3 decimals', () => { + const out = deserialize(serialize(new Circle([12.34567, -7.1], 5.43219))); + expect(out).toBeInstanceOf(Circle); + // serialize.ts rounds every float via toFixed(3) + expect(out.center[0]).toBeCloseTo(12.346, 6); + expect(out.center[1]).toBeCloseTo(-7.1, 6); + expect(out.radius).toBeCloseTo(5.432, 6); + }); + + it('keeps integer clientTimeMs exact (the predictor matches on it)', () => { + const t = 1718000000123; + const out = deserialize(serialize(new MoveActionCommand([1, 0], t))); + expect(out).toBeInstanceOf(MoveActionCommand); + expect(out.clientTimeMs).toBe(t); + }); + + it('round-trips nested property-update commands', () => { + const out = deserialize( + serialize( + new PropertyUpdatesForObject(42, [new UpdatePropertyCommand('strength', 80, 8)]), + ), + ); + expect(out).toBeInstanceOf(PropertyUpdatesForObject); + expect(out.id).toBe(42); + expect(out.updates[0]).toBeInstanceOf(UpdatePropertyCommand); + expect(out.updates[0].propertyKey).toBe('strength'); + expect(out.updates[0].propertyValue).toBe(80); + }); + + 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. + const batch = [ + new ServerAnnouncement('a'), + new MoveActionCommand([0, 1], 5), + new Circle([1, 2], 3), + ]; + const out = deserialize(serialize(batch)); + expect(out[0]).toBeInstanceOf(ServerAnnouncement); + expect(out[1]).toBeInstanceOf(MoveActionCommand); + expect(out[2]).toBeInstanceOf(Circle); + }); +}); + +describe('networked entity round-trips (toArray ↔ constructor contract)', () => { + it('round-trips a ProjectileBase', () => { + const out = deserialize(serialize(new ProjectileBase(7, [10, 20], 30, 'blue', 40))); + expect(out).toBeInstanceOf(ProjectileBase); + expect(out.id).toBe(7); + expect(out.center[0]).toBeCloseTo(10, 5); + expect(out.center[1]).toBeCloseTo(20, 5); + expect(out.radius).toBe(30); + expect(out.team).toBe('blue'); + expect(out.strength).toBe(40); + }); + + it('round-trips a CharacterBase with its nested body Circles', () => { + const out = deserialize( + serialize( + new CharacterBase( + 8, + 'Bob', + 2, + 1, + 'red', + 100, + new Circle([1, 2], 50), + new Circle([3, 4], 20), + new Circle([5, 6], 20), + ), + ), + ); + expect(out).toBeInstanceOf(CharacterBase); + expect(out.id).toBe(8); + expect(out.name).toBe('Bob'); + expect(out.killCount).toBe(2); + expect(out.deathCount).toBe(1); + expect(out.team).toBe('red'); + expect(out.health).toBe(100); + expect(out.head).toBeInstanceOf(Circle); + expect(out.head.center[0]).toBeCloseTo(1, 5); + expect(out.head.radius).toBe(50); + }); + + it('round-trips a LampBase', () => { + const out = deserialize(serialize(new LampBase(9, [7, 8], [1, 0.5, 0.2], 0.8))); + expect(out).toBeInstanceOf(LampBase); + expect(out.center[1]).toBeCloseTo(8, 5); + expect(out.color[2]).toBeCloseTo(0.2, 5); + expect(out.lightness).toBeCloseTo(0.8, 5); + }); + + it('round-trips a PlanetBase (derived centre/radius recomputed from vertices)', () => { + const out = deserialize( + serialize(new PlanetBase(10, [[0, 0], [100, 0], [50, 100]], 0.7, true)), + ); + expect(out).toBeInstanceOf(PlanetBase); + expect(out.id).toBe(10); + expect(out.vertices).toHaveLength(3); + expect(out.ownership).toBeCloseTo(0.7, 5); + expect(out.isKeystone).toBe(true); + expect(out.center[0]).toBeCloseTo(50, 5); // recomputed by the constructor + }); +}); + +describe('shared character geometry — single source of truth', () => { + it('matches the known body layout', () => { + expect(headRadius).toBe(50); + expect(feetRadius).toBe(20); + expect(boundRadius).toBe((headRadius + feetRadius * 2) * 2); // 180 + }); + + it('posture offsets are measured from the centre of mass (they sum to ~0)', () => { + const sx = headOffset[0] + leftFootOffset[0] + rightFootOffset[0]; + const sy = headOffset[1] + leftFootOffset[1] + rightFootOffset[1]; + expect(sx).toBeCloseTo(0, 4); + expect(sy).toBeCloseTo(0, 4); + }); +});