diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml index 762d92c..4e0cc2f 100644 --- a/.forgejo/workflows/deploy.yml +++ b/.forgejo/workflows/deploy.yml @@ -32,12 +32,6 @@ 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 5e92f3b..e321737 100644 --- a/backend/src/game-server.ts +++ b/backend/src/game-server.ts @@ -99,8 +99,7 @@ export class GameServer extends CommandReceiver { this.players.deletePlayer(player); this.sendServerStateUpdate(); }); - } catch (e) { - console.error('Failed to register joining player; disconnecting socket', e); + } catch { socket.disconnect(); } }); @@ -169,9 +168,6 @@ 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 }); @@ -193,24 +189,12 @@ 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; } @@ -253,23 +237,6 @@ 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 11990d9..2f60597 100644 --- a/backend/src/objects/character-physical.ts +++ b/backend/src/objects/character-physical.ts @@ -20,12 +20,6 @@ import { CharacterMovementState, CharacterWorld, GroundSurface, - headRadius, - feetRadius, - headOffset, - leftFootOffset, - rightFootOffset, - boundRadius, } from 'shared'; import { DynamicPhysical } from '../physics/physicals/dynamic-physical'; import { CirclePhysical } from './circle-physical'; @@ -44,14 +38,48 @@ 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; - // 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; + // 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; private timeSinceDying = 0; private isDestroyed = false; @@ -101,20 +129,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, headOffset), - headRadius, + vec2.add(vec2.create(), startPosition, CharacterPhysical.headOffset), + CharacterPhysical.headRadius, this, container, ); this.leftFoot = new CirclePhysical( - vec2.add(vec2.create(), startPosition, leftFootOffset), - feetRadius, + vec2.add(vec2.create(), startPosition, CharacterPhysical.leftFootOffset), + CharacterPhysical.feetRadius, this, container, ); this.rightFoot = new CirclePhysical( - vec2.add(vec2.create(), startPosition, rightFootOffset), - feetRadius, + vec2.add(vec2.create(), startPosition, CharacterPhysical.rightFootOffset), + CharacterPhysical.feetRadius, this, container, ); @@ -126,9 +154,6 @@ 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, @@ -386,8 +411,8 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical } private animateScaling(q: number) { - this.head.radius = headRadius * q; - this.leftFoot.radius = this.rightFoot.radius = feetRadius * q; + this.head.radius = CharacterPhysical.headRadius * q; + this.leftFoot.radius = this.rightFoot.radius = CharacterPhysical.feetRadius * q; } public getPropertyUpdates(): PropertyUpdatesForObject { diff --git a/backend/src/players/npc.ts b/backend/src/players/npc.ts index 655ade7..23177b6 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 6de34cd..3d6a561 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 01a1c96..afc67bb 100644 --- a/backend/src/players/player-container.ts +++ b/backend/src/players/player-container.ts @@ -57,12 +57,6 @@ 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 923be65..5081f48 100644 --- a/backend/src/players/player.ts +++ b/backend/src/players/player.ts @@ -32,9 +32,6 @@ 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; @@ -44,12 +41,6 @@ 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), @@ -80,15 +71,6 @@ 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() { @@ -209,12 +191,6 @@ 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 3ab46d2..c8aab8f 100644 --- a/frontend/src/main.scss +++ b/frontend/src/main.scss @@ -332,26 +332,6 @@ 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 { @@ -614,8 +594,8 @@ body { .hitmarker { position: absolute; transform: translate(-50%, -50%); - @include square(34px); - opacity: 0.95; + @include square(26px); + opacity: 0.9; &::before, &::after { @@ -623,13 +603,10 @@ body { position: absolute; top: 50%; left: 50%; - width: 16px; - height: 3px; - border-radius: 2px; + width: 12px; + height: 2px; background-color: white; - box-shadow: - 0 0 6px rgba(255, 255, 255, 0.9), - 0 0 3px rgba(0, 0, 0, 0.9); + box-shadow: 0 0 4px rgba(0, 0, 0, 0.8); transform-origin: center; } @@ -641,58 +618,31 @@ body { transform: translate(-50%, -50%) rotate(-45deg); } - animation: hitmarker-pop 220ms cubic-bezier(0.2, 0.9, 0.3, 1) forwards; + animation: hitmarker-pop 250ms ease-out forwards; &.charged { - @include square(44px); &::before, &::after { - width: 24px; - height: 4px; + width: 18px; + height: 3px; 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: 800; - font-size: 1.7rem; + font-weight: 700; + font-size: 1.4rem; white-space: nowrap; color: $bright-neutral; - 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; + text-shadow: 0 0 6px rgba(0, 0, 0, 0.9); + animation: kill-popup-rise 1200ms ease-out forwards; &.charged { color: $accent; - text-shadow: - 0 0 10px rgba(0, 0, 0, 0.9), - 0 0 18px rgba($accent, 0.6); } .heal { @@ -705,14 +655,12 @@ body { top: 33%; left: 50%; transform: translateX(-50%); - font-weight: 800; - font-size: 3rem; + font-weight: 700; + font-size: 2.6rem; color: $accent; - text-shadow: - 0 0 12px rgba(0, 0, 0, 0.9), - 0 0 24px rgba($accent, 0.7); + text-shadow: 0 0 12px rgba(0, 0, 0, 0.9); white-space: nowrap; - animation: streak-pop 1400ms cubic-bezier(0.18, 0.9, 0.3, 1) forwards; + animation: streak-pop 1400ms ease-out forwards; } .killfeed { @@ -737,34 +685,6 @@ body { } } } - - // Centred "Eliminated" overlay shown while the local player is dead; the - // respawn countdown is the server-driven "Reviving in N…" announcement. - .elimination { - position: absolute; - top: 42%; - 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 { @@ -828,72 +748,46 @@ body { @keyframes hitmarker-pop { 0% { opacity: 0; - transform: translate(-50%, -50%) scale(2); + transform: translate(-50%, -50%) scale(1.6); } - 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% { + 25% { 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%, -20%) scale(0.6); + transform: translate(-50%, -30%) scale(0.8); } - 12% { + 20% { opacity: 1; - transform: translate(-50%, -56%) scale(1.25); - } - - 28% { - transform: translate(-50%, -62%) scale(1); + transform: translate(-50%, -60%) scale(1); } 100% { opacity: 0; - transform: translate(-50%, -170%) scale(1); + transform: translate(-50%, -160%) scale(1); } } @keyframes streak-pop { 0% { opacity: 0; - transform: translateX(-50%) scale(0.5); + transform: translateX(-50%) scale(0.6); } - 16% { + 20% { opacity: 1; - transform: translateX(-50%) scale(1.25); - } - - 32% { - transform: translateX(-50%) scale(0.95); + transform: translateX(-50%) scale(1.1); } 70% { @@ -925,16 +819,4 @@ 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 239e33b..7b44718 100644 --- a/frontend/src/scripts/charge-indicator.ts +++ b/frontend/src/scripts/charge-indicator.ts @@ -1,6 +1,7 @@ import { holdDurationToCharge } from 'shared'; import { Pointer } from './helper/pointer'; + export abstract class ChargeIndicator { private static element?: HTMLElement; private static heldSince = 0; @@ -40,9 +41,8 @@ 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 ab91ce8..9dd2cfa 100644 --- a/frontend/src/scripts/commands/touch-listener.ts +++ b/frontend/src/scripts/commands/touch-listener.ts @@ -15,9 +15,6 @@ 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; @@ -27,12 +24,9 @@ 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, @@ -51,12 +45,8 @@ 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'); @@ -169,41 +159,12 @@ export class TouchListener extends CommandGenerator { this.swallowTouch(event); this.fireDownAt = performance.now(); const rect = this.fireButton.getBoundingClientRect(); - 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'; - } + ChargeIndicator.begin(rect.left + rect.width / 2, rect.top + rect.height / 2); }; private fireButtonUpListener = (event: TouchEvent) => { this.swallowTouch(event); ChargeIndicator.end(); - this.fireAimLine.style.opacity = '0'; if (this.fireDownAt === null) { return; } @@ -213,28 +174,12 @@ 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, - direction, + character.facingDirection, settings.touchAimRange, ); this.sendCommandToSubscribers(new PrimaryActionCommand(aim, charge)); @@ -250,9 +195,8 @@ export class TouchListener extends CommandGenerator { const character = this.game.gameObjects.player; if (character) { - this.fireStrengthRing.style.background = `conic-gradient(rgba(255, 255, 255, 0.75) ${ - character.strengthFraction * 360 - }deg, transparent 0deg)`; + this.fireStrengthRing.style.background = `conic-gradient(rgba(255, 255, 255, 0.75) ${character.strengthFraction * 360 + }deg, transparent 0deg)`; } } @@ -263,7 +207,6 @@ 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 adbf068..316aecf 100644 --- a/frontend/src/scripts/feedback-hud.ts +++ b/frontend/src/scripts/feedback-hud.ts @@ -4,7 +4,6 @@ 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) { @@ -48,11 +47,6 @@ 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')}`; @@ -76,29 +70,6 @@ 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 4b8678f..a212d5a 100644 --- a/frontend/src/scripts/game.ts +++ b/frontend/src/scripts/game.ts @@ -39,7 +39,6 @@ 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); @@ -81,9 +80,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 = ''; this.keystoneArrow = undefined; @@ -236,11 +232,6 @@ 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 5d23c1c..c147477 100644 --- a/frontend/src/scripts/helper/prediction/local-character-predictor.ts +++ b/frontend/src/scripts/helper/prediction/local-character-predictor.ts @@ -17,14 +17,6 @@ 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. @@ -82,12 +74,6 @@ 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 @@ -114,7 +100,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(nowMs()); + const timeMs = Math.round(performance.now()); this.inputHistory.record(direction, timeMs); return timeMs; } @@ -126,10 +112,7 @@ 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]); @@ -138,7 +121,7 @@ export class LocalCharacterPredictor { public setAuthoritative(head: Circle, leftFoot: Circle, rightFoot: Circle): void { this.authoritative = { head, leftFoot, rightFoot }; - this.authReceiptMs = Math.round(nowMs()); + this.authReceiptMs = Math.round(performance.now()); } // The player pressed leap; remember when, so the replay applies the same @@ -146,7 +129,7 @@ export class LocalCharacterPredictor { // it — a rejected leap (no strength/cooldown) self-corrects via the streamed // authoritative momentum. public recordLeap(): number { - const timeMs = Math.round(nowMs()); + const timeMs = Math.round(performance.now()); this.leapHistory.push(timeMs); const cutoff = timeMs - 1500; while (this.leapHistory.length > 0 && this.leapHistory[0] <= cutoff) { @@ -159,10 +142,6 @@ export class LocalCharacterPredictor { this.currentStrength = strength; } - public setAlive(alive: boolean): void { - this.alive = alive; - } - public reset(): void { this.inputHistory.reset(); this.leapHistory = []; @@ -175,7 +154,6 @@ export class LocalCharacterPredictor { this.carriedPlanetId = undefined; this.carriedSecondsSinceSurface = 1; this.hasRender = false; - this.alive = true; } public get canPredict(): boolean { @@ -197,7 +175,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.alive || !this.canPredict || this.isAnimatingInOrOut) { + if (!this.canPredict || this.isAnimatingInOrOut) { // Resume from the authoritative pose with a snap rather than gliding from // a stale rendered one. this.hasRender = false; @@ -231,7 +209,7 @@ export class LocalCharacterPredictor { private simulate(): CharacterMovementState { const auth = this.authoritative!; - const now = Math.round(nowMs()); + const now = Math.round(performance.now()); // 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 c2bd37f..e9c2b71 100644 --- a/frontend/src/scripts/join-form-handler.ts +++ b/frontend/src/scripts/join-form-handler.ts @@ -16,12 +16,8 @@ export class JoinFormHandler { private 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. - if (e.key === 'Enter' && !this.joinButton.disabled) { - this.form.requestSubmit(); + if (e.key === 'enter') { + this.form.submit(); } }; diff --git a/frontend/src/scripts/landing-page-background.ts b/frontend/src/scripts/landing-page-background.ts index bafe559..0e2be9a 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'; -// 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; +const landingPageVertexCount = 9; +const LandingPagePolygon = NoisyPolygonFactory( + landingPageVertexCount, + rgb(0.5, 0.4, 0.7), +); export class LandingPageBackground { private isActive = true; @@ -34,7 +34,7 @@ export class LandingPageBackground { canvas, [ { - ...PlanetShape.descriptor, + ...LandingPagePolygon.descriptor, shaderCombinationSteps: [0, 1, 2], }, { @@ -74,41 +74,34 @@ export class LandingPageBackground { 0.7 * renderer.canvasSize.y, ); - const topPlanet = new PlanetShape( + const topPlanet = new LandingPagePolygon( PlanetBase.createPlanetVertices( topPlanetPosition, Random.getRandomInRange(150, 400), Random.getRandomInRange(150, 400), Random.getRandomInRange(10, 20), + landingPageVertexCount, ), - 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; + (topPlanet as any).randomOffset = 0.5 + time / 3500; const bottomPlanetPosition = vec2.fromValues( 0.3 * renderer.canvasSize.x, 0.3 * renderer.canvasSize.y, ); - const bottomPlanet = new PlanetShape( + const bottomPlanet = new LandingPagePolygon( PlanetBase.createPlanetVertices( bottomPlanetPosition, Random.getRandomInRange(150, 800), Random.getRandomInRange(150, 400), Random.getRandomInRange(10, 40), + landingPageVertexCount, ), - bluePlanet, ); - bottomPlanet.randomOffset = Random.getRandom(); - bottomPlanet.rotation = (time / 1000) * -0.06; + (bottomPlanet as any).randomOffset = time / 2500; 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 2b03836..fa48f08 100644 --- a/frontend/src/scripts/objects/game-object-container.ts +++ b/frontend/src/scripts/objects/game-object-container.ts @@ -16,7 +16,6 @@ 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'; @@ -29,7 +28,6 @@ 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) => { @@ -39,9 +37,6 @@ 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) => @@ -50,32 +45,15 @@ export class GameObjectContainer extends CommandReceiver { [StepCommand.type]: (c: StepCommand) => { 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. - 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) { + if (this.player) { // 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. - localCharacterPredictor.setAlive(alive); + // one so it responds to input immediately. Remote objects keep + // interpolating. A large correction (respawn / death) snaps inside the + // predictor rather than rubber-banding. 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 c607c38..f1cf670 100644 --- a/frontend/src/scripts/objects/types/camera.ts +++ b/frontend/src/scripts/objects/types/camera.ts @@ -8,7 +8,6 @@ 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(); @@ -47,16 +46,7 @@ export class Camera extends CommandReceiver { this.game.aspectRatioChanged(canvasAspectRatio); } - // Shake displaces only the rendered view centre and the zoom-punch shrinks - // only the rendered view area — neither touches the followed position, so - // impacts jolt the frame without nudging the camera off the player. Passing - // the zoom as oversizeRatio scales the area about the (shaken) centre. - const shakenCenter = vec2.fromValues( - this.center[0] + ScreenShake.offsetX, - this.center[1] + ScreenShake.offsetY, - ); - const scale = ScreenShake.viewScale; - const viewArea = calculateViewArea(shakenCenter, canvasAspectRatio, scale * scale); + const viewArea = calculateViewArea(this.center, canvasAspectRatio); 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 5c0d751..1806993 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, vec3 } from 'gl-matrix'; +import { vec2 } from 'gl-matrix'; import { CircleLight, Renderer } from 'sdf-2d'; import { @@ -23,18 +23,10 @@ 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 = ''; @@ -47,8 +39,6 @@ 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'); @@ -89,7 +79,6 @@ 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!); @@ -177,9 +166,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,23 +174,17 @@ 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; } - // 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))); + // 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); + } FeedbackHud.hitMarker(charge); } @@ -212,14 +192,8 @@ 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. - 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))]); + SoundHandler.play(Sounds.click, 1, mix(0.7, 0.5, charge)); + VibrationHandler.vibrate(mix(60, 110, charge)); FeedbackHud.killConfirmed(victimName, streak, charge); } @@ -256,18 +230,6 @@ 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) { @@ -311,10 +273,6 @@ 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 deleted file mode 100644 index 75c5bce..0000000 --- a/frontend/src/scripts/screen-shake.ts +++ /dev/null @@ -1,84 +0,0 @@ -// 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 ceb2e35..1d1ec9e 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,7 +141,10 @@ 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 3ae8420..951efaa 100644 --- a/frontend/src/scripts/vibration-handler.ts +++ b/frontend/src/scripts/vibration-handler.ts @@ -1,11 +1,9 @@ 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 { + public static vibrate(time: number): void { if (OptionsHandler.options.vibrationEnabled && this.isVibrationEnabled) { - navigator.vibrate(pattern); + navigator.vibrate(time); } } diff --git a/package.json b/package.json index 3e123bd..b1364f0 100644 --- a/package.json +++ b/package.json @@ -12,15 +12,11 @@ "eslint-plugin-unused-imports": "^4.4.1", "prettier": "^3.8.3", "typescript": "^6.0.3", - "typescript-eslint": "^8.60.1", - "vitest": "^4.1.9" + "typescript-eslint": "^8.60.1" }, "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 bf2597b..67ebd4c 100644 --- a/shared/package.json +++ b/shared/package.json @@ -7,9 +7,6 @@ "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 f851137..93ab8c2 100644 --- a/shared/src/objects/game-object.ts +++ b/shared/src/objects/game-object.ts @@ -15,24 +15,10 @@ 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: SyncPropertyKey, + public readonly propertyKey: string, public readonly propertyValue: any, public readonly rateOfChange: any, ) { @@ -59,40 +45,16 @@ 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) => { - 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); - } - }); + remoteCalls.forEach((r) => + (this[r.functionName as keyof this] as unknown as (...args: Array) => unknown)( + ...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 f6cad5c..b461127 100644 --- a/shared/src/objects/types/character-base.ts +++ b/shared/src/objects/types/character-base.ts @@ -1,7 +1,6 @@ import { Id } from '../../communication/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 { @@ -12,18 +11,6 @@ export enum CharacterTeam { @serializable export class CharacterBase extends GameObject { - private static readonly serializedFields = [ - 'id', - 'name', - 'killCount', - 'deathCount', - 'team', - 'health', - 'head', - 'leftFoot', - 'rightFoot', - ] as const; - constructor( id: Id, public name: string, @@ -39,27 +26,37 @@ 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 toArrayFromFields(this, CharacterBase.serializedFields); + return [ + this.id, + this.name, + this.killCount, + this.deathCount, + this.team, + this.health, + this.head, + this.leftFoot, + this.rightFoot, + ]; } } diff --git a/shared/src/objects/types/lamp-base.ts b/shared/src/objects/types/lamp-base.ts index 9f86ca6..794e75b 100644 --- a/shared/src/objects/types/lamp-base.ts +++ b/shared/src/objects/types/lamp-base.ts @@ -1,17 +1,9 @@ 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, @@ -27,6 +19,7 @@ export class LampBase extends GameObject { public setLight(color: vec3, lightness: number) {} public toArray(): Array { - return toArrayFromFields(this, LampBase.serializedFields); + const { id, center, color, lightness } = this; + return [id, center, color, lightness]; } } diff --git a/shared/src/objects/types/planet-base.ts b/shared/src/objects/types/planet-base.ts index bafea38..e3835e1 100644 --- a/shared/src/objects/types/planet-base.ts +++ b/shared/src/objects/types/planet-base.ts @@ -2,22 +2,12 @@ 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; @@ -67,6 +57,6 @@ export class PlanetBase extends GameObject { } public toArray(): Array { - return toArrayFromFields(this, PlanetBase.serializedFields); + return [this.id, this.vertices, this.ownership, this.isKeystone]; } } diff --git a/shared/src/objects/types/projectile-base.ts b/shared/src/objects/types/projectile-base.ts index f96fd94..c334cb8 100644 --- a/shared/src/objects/types/projectile-base.ts +++ b/shared/src/objects/types/projectile-base.ts @@ -1,21 +1,12 @@ import { vec2 } from 'gl-matrix'; import { settings } from '../../settings'; import { serializable } from '../../serialization/serializable'; -import { toArrayFromFields } from '../../serialization/serialized-fields'; import { GameObject } from '../game-object'; import { Id } from '../../communication/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, @@ -32,6 +23,6 @@ export class ProjectileBase extends GameObject { } public toArray(): Array { - return toArrayFromFields(this, ProjectileBase.serializedFields); + return [this.id, this.center, this.radius, this.team, this.strength]; } } diff --git a/shared/src/physics/character-movement.ts b/shared/src/physics/character-movement.ts index 21355a5..a5744b9 100644 --- a/shared/src/physics/character-movement.ts +++ b/shared/src/physics/character-movement.ts @@ -107,13 +107,7 @@ 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, @@ -139,12 +133,7 @@ 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, @@ -159,7 +148,10 @@ 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; @@ -214,9 +206,7 @@ export const tickPlanetDetachment = ( state: CharacterMovementState, deltaTimeInSeconds: number, ) => { - if ( - (state.secondsSinceOnSurface += deltaTimeInSeconds) > settings.planetDetachmentSeconds - ) { + if ((state.secondsSinceOnSurface += deltaTimeInSeconds) > settings.planetDetachmentSeconds) { state.currentPlanet = undefined; } }; @@ -265,7 +255,10 @@ export const decayMomentum = ( } }; -const decayBodyMomentum = (state: CharacterMovementState, deltaTimeInSeconds: number) => { +const decayBodyMomentum = ( + state: CharacterMovementState, + deltaTimeInSeconds: number, +) => { decayMomentum(state.bodyVelocity, !!state.currentPlanet, deltaTimeInSeconds); }; @@ -300,11 +293,7 @@ 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 deleted file mode 100644 index 48733e8..0000000 --- a/shared/src/serialization/serialized-fields.ts +++ /dev/null @@ -1,13 +0,0 @@ -// 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 deleted file mode 100644 index 50ab1c3..0000000 --- a/test/__snapshots__/physics-determinism.test.mjs.snap +++ /dev/null @@ -1,3 +0,0 @@ -// 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 deleted file mode 100644 index d0fee1a..0000000 --- a/test/physics-determinism.test.mjs +++ /dev/null @@ -1,83 +0,0 @@ -// 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 deleted file mode 100644 index 98ffc1d..0000000 --- a/test/prediction-reconciliation.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -// 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 deleted file mode 100644 index 9c7bc11..0000000 --- a/test/serialization.test.mjs +++ /dev/null @@ -1,159 +0,0 @@ -// 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); - }); -});