diff --git a/README.md b/README.md index ce57285..3ae3aca 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,35 @@ A 2-dimensional multiplayer game utilising ray tracing. For optimised 2D ray tracing, [SDF-2D](https://github.com/schmelczerandras/sdf-2d) is used. +## Development + +Run `npm install && npm run init` once, then `npm run dev`. Run `npm test` for +regressions, `npm run lint:check` for formatting and lint, and `npm run build` for +all production bundles. Tests rebuild the shared library before running. + +## Movement and networking + +The server owns physics and combat. It simulates at 200 Hz, keeps each player's +latest held movement direction, and sends snapshots at 25 Hz. Clients send input +changes immediately on their next frame and send idle heartbeats at 30 Hz. + +The local player replays input from the latest authoritative pose using the same +physics as the server. The acknowledgement includes movement state, remaining leap +cooldown, and the age of the acknowledged input, so the client can align replay +without synchronizing wall clocks. Nearby planets are included even when offscreen +because their gravity affects prediction. Replay stops after 400 ms without a fresh +anchor; only reconciliation corrections are eased over 80 ms. The camera follows +the displayed player directly. + +Other objects interpolate snapshots on a server timeline delayed by 100 ms, with +at most 60 ms of extrapolation. Creation and deletion use that same timeline. +Remote effects use that timeline too; feedback for the local player is immediate. +End-of-round slow motion comes from server snapshots and disables local prediction; +outgoing velocity and projectile fade rates are expressed per wall-clock second. +When a connection backs up, regenerable state can be skipped; lifecycle messages +keep their timestamps. The frontend bundles one `gl-matrix` instance so prediction +and server physics both use JavaScript's full numeric precision. + ## Deployment CI/CD runs on Forgejo Actions (`.forgejo/workflows/deploy.yml`). On a push to diff --git a/backend/src/game-server.ts b/backend/src/game-server.ts index 7c081c6..5e8d20c 100644 --- a/backend/src/game-server.ts +++ b/backend/src/game-server.ts @@ -109,6 +109,9 @@ export class GameServer implements GameEvents { this.matchPointAnnounced = {}; this.isInEndGame = false; this.timeScaling = 1; + this.physicsAccumulator = 0; + this.timeSinceLastServerStateUpdate = 0; + this.timeSinceLastPointUpdate = 0; previousPlayers?.queueCommandForEachClient(new GameStartCommand()); previousPlayers?.sendQueuedCommands(); } @@ -139,15 +142,18 @@ export class GameServer implements GameEvents { const player = this.players.createPlayer(playerInfo, socket, carried); - const onPlayerToServer = (json: string) => { + const onPlayerToServer = (json: unknown) => { try { - if (json.length > settings.maxInboundMessageBytes) { + if (typeof json !== 'string' || json.length > settings.maxInboundMessageBytes) { return; } if (!this.allowInboundMessage(socket)) { return; } const commands: Array = deserialize(json); + if (!Array.isArray(commands)) { + return; + } commands.forEach((c) => player.handleCommand(c)); } catch (e) { console.error('Error while processing command', e); @@ -354,14 +360,20 @@ export class GameServer implements GameEvents { // of noise on every interpolated position and on the client's replay anchor. const simulatedThroughMs = frameStartMs - this.physicsAccumulator * 1000; - const propertyUpdates = new Map(); - this.players.stepCommunication(delta, simulatedThroughMs, (object) => { - if (!propertyUpdates.has(object)) { - propertyUpdates.set(object, object.getPropertyUpdates()); - } - return propertyUpdates.get(object); - }); - this.objects.resetRemoteCalls(); + if (substeps > 0) { + const propertyUpdates = new Map(); + this.players.stepCommunication( + substeps * fixedDelta, + simulatedThroughMs, + (object) => { + if (!propertyUpdates.has(object)) { + propertyUpdates.set(object, object.getPropertyUpdates(1 / this.timeScaling)); + } + return propertyUpdates.get(object); + }, + ); + this.objects.resetRemoteCalls(); + } const elapsed = Number(process.hrtime.bigint() - frameStart) / 1e9; setTimeout(() => this.handlePhysics(), Math.max(0, fixedDelta - elapsed) * 1000); diff --git a/backend/src/objects/character-physical.ts b/backend/src/objects/character-physical.ts index 4d847e6..d38b1da 100644 --- a/backend/src/objects/character-physical.ts +++ b/backend/src/objects/character-physical.ts @@ -62,8 +62,7 @@ export class CharacterPhysical extends CharacterBase implements Physical { private lastSyncedHealth = settings.playerMaxHealth; private killStreak = 0; - private movementDirections: Array = []; - private lastMovementDirection = vec2.create(); + private readonly movementDirection = vec2.create(); private readonly previousPose = [0, 1, 2].map(() => new Circle(vec2.create(), 0)); private readonly velocities = [0, 1, 2].map(() => new Circle(vec2.create(), 0)); @@ -136,6 +135,7 @@ export class CharacterPhysical extends CharacterBase implements Physical { vec2.clone(this.rightFoot.lastNormal), this.groundPlanet?.id ?? null, this.secondsSinceOnSurface, + Math.max(0, settings.leapCooldownSeconds - this.timeSinceLastLeap), ); } @@ -162,7 +162,7 @@ export class CharacterPhysical extends CharacterBase implements Physical { } public setMoveDirection(direction: vec2) { - this.movementDirections.push(direction); + vec2.normalize(this.movementDirection, direction); } public addKill(victimName: string, charge = 0) { @@ -170,11 +170,13 @@ export class CharacterPhysical extends CharacterBase implements Physical { this.killStreak++; this.remoteCall('setKillCount', this.killCount); - this.health = Math.min( - settings.playerMaxHealth, - this.health + settings.playerKillHealthReward, - ); - this.syncHealth(); + if (this.isAlive) { + this.health = Math.min( + settings.playerMaxHealth, + this.health + settings.playerKillHealthReward, + ); + this.syncHealth(); + } this.remoteCall('onKillConfirmed', victimName, this.killStreak, charge); } @@ -293,7 +295,7 @@ export class CharacterPhysical extends CharacterBase implements Physical { this.timeSinceLastLeap = 0; this.projectileStrength -= settings.leapStrengthCost; - applyLeapImpulse(this, this.lastMovementDirection); + applyLeapImpulse(this, this.movementDirection); this.remoteCall('onLeap'); } @@ -311,8 +313,14 @@ export class CharacterPhysical extends CharacterBase implements Physical { } } - public getPropertyUpdates(): PropertyUpdatesForObject { - const [headVelocity, leftFootVelocity, rightFootVelocity] = this.velocities; + public getPropertyUpdates(timeScale = 1): PropertyUpdatesForObject { + const [headVelocity, leftFootVelocity, rightFootVelocity] = this.velocities.map( + (velocity) => + new Circle( + vec2.scale(vec2.create(), velocity.center, timeScale), + velocity.radius * timeScale, + ), + ); return new PropertyUpdatesForObject(this.id, [ new UpdatePropertyCommand('head', this.head, headVelocity), new UpdatePropertyCommand('leftFoot', this.leftFoot, leftFootVelocity), @@ -320,7 +328,7 @@ export class CharacterPhysical extends CharacterBase implements Physical { new UpdatePropertyCommand( 'strength', this.projectileStrength, - settings.playerStrengthRegenerationPerSeconds, + settings.playerStrengthRegenerationPerSeconds * timeScale, ), ]); } @@ -387,27 +395,11 @@ export class CharacterPhysical extends CharacterBase implements Physical { stepCharacterMovement( this, this.movementWorld, - this.averageAndResetMovementDirections(), + this.movementDirection, deltaTimeInSeconds, ); } - private averageAndResetMovementDirections(): vec2 { - const direction = vec2.create(); - if (this.movementDirections.length === 0) { - vec2.copy(direction, this.lastMovementDirection); - } else { - for (const d of this.movementDirections) { - vec2.add(direction, direction, d); - } - vec2.scale(direction, direction, 1 / this.movementDirections.length); - this.lastMovementDirection = - this.movementDirections[this.movementDirections.length - 1]; - this.movementDirections = []; - } - return vec2.normalize(direction, direction); - } - private animateScaling(q: number) { this.head.radius = headRadius * q; this.leftFoot.radius = this.rightFoot.radius = feetRadius * q; diff --git a/backend/src/objects/planet-physical.ts b/backend/src/objects/planet-physical.ts index 7afdc3d..4c77ba4 100644 --- a/backend/src/objects/planet-physical.ts +++ b/backend/src/objects/planet-physical.ts @@ -95,6 +95,9 @@ export class PlanetPhysical extends PlanetBase implements Physical { let blue = 0; let red = 0; for (const c of this.presentCharacters) { + if (!c.isAlive) { + continue; + } if (c.team === CharacterTeam.blue) { blue++; } else if (c.team === CharacterTeam.red) { @@ -172,10 +175,14 @@ export class PlanetPhysical extends PlanetBase implements Physical { this.ownership = clamp01(this.ownership); } - public getPropertyUpdates(): PropertyUpdatesForObject { + public getPropertyUpdates(timeScale = 1): PropertyUpdatesForObject { return new PropertyUpdatesForObject(this.id, [ new UpdatePropertyCommand('ownership', this.ownership, 0), - new UpdatePropertyCommand('rotation', this.rotation, this.angularVelocity), + new UpdatePropertyCommand( + 'rotation', + this.rotation, + this.angularVelocity * timeScale, + ), ]); } } diff --git a/backend/src/objects/projectile-physical.ts b/backend/src/objects/projectile-physical.ts index ce0e2bd..6ced842 100644 --- a/backend/src/objects/projectile-physical.ts +++ b/backend/src/objects/projectile-physical.ts @@ -93,13 +93,25 @@ export class ProjectilePhysical extends ProjectileBase implements Physical { } } - public getPropertyUpdates(): PropertyUpdatesForObject { + public getPropertyUpdates(timeScale = 1): PropertyUpdatesForObject { return new PropertyUpdatesForObject(this.id, [ - new UpdatePropertyCommand('center', this.center, this.object.velocity), + new UpdatePropertyCommand( + 'center', + this.center, + vec2.scale(vec2.create(), this.object.velocity, timeScale), + ), + new UpdatePropertyCommand( + 'strength', + this.strength, + -settings.projectileFadeSpeed * timeScale, + ), ]); } public step(deltaTimeInSeconds: number) { + if (!this.isAlive) { + return; + } super.step(deltaTimeInSeconds); if (this.strength <= 0) { diff --git a/backend/src/options.ts b/backend/src/options.ts index 99482e3..e3b2dab 100644 --- a/backend/src/options.ts +++ b/backend/src/options.ts @@ -14,6 +14,6 @@ export const defaultOptions: Options = { name: isDevelopment ? 'Dev server' : 'Doppler server', playerLimit: 16, npcCount: 8, - seed: Math.random(), + seed: (Math.random() * 0x100000000) >>> 0, scoreLimit: 2500, }; diff --git a/backend/src/players/player-container.ts b/backend/src/players/player-container.ts index 7b5bac9..0232e9f 100644 --- a/backend/src/players/player-container.ts +++ b/backend/src/players/player-container.ts @@ -30,13 +30,15 @@ export class PlayerContainer { } private createNPCs() { - const newNpcCount = Math.min( - this.playerMaxCount - this._players.length - this.npcs.length, - this.npcMaxCount - this.npcs.length, - ); + const newNpcCount = + Math.min(this.playerMaxCount, this.npcMaxCount) - + this._players.length - + this.npcs.length; for (let i = 0; i < newNpcCount; i++) { const name = `🤖 ${Random.choose(settings.npcNames)}`; - this.npcs.push(new NPC({ name }, this, this.objects, this.getTeamOfNextNpc())); + this.npcs.push( + new NPC({ name }, this, this.objects, this.teamOfNext(this.players)), + ); } } @@ -45,7 +47,7 @@ export class PlayerContainer { socket: Socket, carried?: CarriedScore, ): Player { - const team = carried ? carried.team : this.getTeamOfNextPlayer(); + const team = carried ? carried.team : this.teamOfNext(this._players); const npcToReplace = this.npcs.find((n) => n.team === team) ?? this.npcs.find((n) => n.team !== team); @@ -100,14 +102,6 @@ export class PlayerContainer { this._players.forEach((p) => p.sendQueuedCommandsToClient()); } - private getTeamOfNextPlayer(): CharacterTeam { - return this.teamOfNext(this._players); - } - - private getTeamOfNextNpc(): CharacterTeam { - return this.teamOfNext(this.players); - } - private teamOfNext(players: Array): CharacterTeam { const blueCount = players.filter((p) => p.team === CharacterTeam.blue).length; const redCount = players.filter((p) => p.team === CharacterTeam.red).length; diff --git a/backend/src/players/player.ts b/backend/src/players/player.ts index e29951d..20912ca 100644 --- a/backend/src/players/player.ts +++ b/backend/src/players/player.ts @@ -29,6 +29,7 @@ import { finiteInRange, finiteVec2, isFiniteNumber, + boundRadius, } from 'shared'; import { Socket } from 'socket.io'; import { BoundingBox } from '../physics/bounding-box'; @@ -36,6 +37,7 @@ import { PhysicalContainer } from '../physics/physical-container'; import { PlayerContainer } from './player-container'; import { PlayerBase, Score } from './player-base'; import { CharacterPhysical } from '../objects/character-physical'; +import { planetsNear } from '../objects/planet-physical'; const pingIntervalSeconds = 1; const minimumAspectRatio = 0.2; @@ -59,7 +61,7 @@ export class Player extends PlayerBase { private lastInputClientTimeMs = 0; private lastInputReceiptMs = 0; - private lastLeapClientTimeMs = 0; + private lastLeapClientTimeMs = -1; private timeSinceLastPing = 0; private lastPingSentMs = 0; @@ -178,7 +180,7 @@ export class Player extends PlayerBase { } // Subtracted, not zeroed: zeroing would stretch every interval to the next physics frame. - if ((this.timeSinceLastMessage += deltaTime) > settings.updateMessageInterval) { + if ((this.timeSinceLastMessage += deltaTime) >= settings.updateMessageInterval) { this.timeSinceLastMessage = Math.min( this.timeSinceLastMessage - settings.updateMessageInterval, settings.updateMessageInterval, @@ -206,6 +208,14 @@ export class Player extends PlayerBase { ); if (this.character) { inViewArea.add(this.character); + // Prediction needs every gravity source, including planets outside a narrow viewport. + for (const planet of planetsNear( + this.objectContainer, + this.character.center, + boundRadius + settings.maxGravityDistance, + )) { + inViewArea.add(planet); + } } const created = [...inViewArea].filter((o) => !this.objectsInViewArea.has(o)); @@ -258,19 +268,45 @@ export class Player extends PlayerBase { } private get bufferedBytes(): number { - const conn = this.socket.conn as - | { transport?: { socket?: { bufferedAmount?: unknown } } } + const conn = this.socket.conn as unknown as + | { + transport?: { socket?: { bufferedAmount?: unknown } }; + writeBuffer?: Array<{ data?: unknown }>; + } | undefined; const buffered = conn?.transport?.socket?.bufferedAmount; - return typeof buffered === 'number' ? buffered : 0; + let bytes = typeof buffered === 'number' ? buffered : 0; + // Engine.IO queues packets here while the transport is waiting for its write callback. + for (const { data } of conn?.writeBuffer ?? []) { + if (typeof data === 'string') { + bytes += Buffer.byteLength(data); + } else if (ArrayBuffer.isView(data) || data instanceof ArrayBuffer) { + bytes += data.byteLength; + } + } + return bytes; } public sendQueuedCommandsToClient() { // Only state the next snapshot regenerates may be dropped; create/delete are one-shot. if (this.bufferedBytes > settings.maxBufferedBytesPerClient) { - this.commandsToBeSent = this.commandsToBeSent.filter( + const hasReliableCommands = this.commandsToBeSent.some( (c) => !sheddableWhenBackedUp.includes(c.constructor.name), ); + this.commandsToBeSent = hasReliableCommands + ? this.commandsToBeSent.flatMap((c): Array => { + // Lifecycle events still need their snapshot time to enter the render timeline. + if (c.constructor.name === PropertyUpdatesForObjects.name) { + return [ + new PropertyUpdatesForObjects( + [], + (c as PropertyUpdatesForObjects).timestamp, + ), + ]; + } + return sheddableWhenBackedUp.includes(c.constructor.name) ? [] : [c]; + }) + : []; } if (this.commandsToBeSent.length === 0) { return; diff --git a/frontend/src/index.ts b/frontend/src/index.ts index ea8ff1d..4c7780f 100644 --- a/frontend/src/index.ts +++ b/frontend/src/index.ts @@ -15,7 +15,7 @@ import '../static/favicons/favicon-32x32.png'; import '../static/favicons/favicon.ico'; import ResizeObserver from 'resize-observer-polyfill'; import { LandingPageBackground } from './scripts/landing-page-background'; -import { JoinFormHandler } from './scripts/join-form-handler'; +import { JoinFormHandler, PlayerDecision } from './scripts/join-form-handler'; import { handleFullScreen } from './scripts/helper/handle-full-screen'; import { Game } from './scripts/game'; import { initializeOptions } from './scripts/options-handler'; @@ -25,6 +25,7 @@ import { CharacterView } from './scripts/objects/types/character-view'; import { LampView } from './scripts/objects/types/lamp-view'; import { PlanetView } from './scripts/objects/types/planet-view'; import { ProjectileView } from './scripts/objects/types/projectile-view'; +import { loadStoredValue, saveStoredValue } from './scripts/helper/storage'; glMatrix.setMatrixArrayType(Array); @@ -88,9 +89,9 @@ const applyServerContainerShadows = () => { const main = async () => { let game: Game | undefined; - const storedUserName = localStorage.getItem('userName'); - if (storedUserName) { - nameInput.value = JSON.parse(storedUserName); + const storedUserName = loadStoredValue('userName'); + if (typeof storedUserName === 'string') { + nameInput.value = storedUserName; } const firstClickListener = () => { @@ -138,12 +139,17 @@ const main = async () => { const background = new LandingPageBackground(canvas); const joinHandler = new JoinFormHandler(joinGameForm, serverContainer); - await background.renderer; - setVisible(spinner, false); + let playerDecision: PlayerDecision; + try { + await background.renderer; + setVisible(spinner, false); + playerDecision = await joinHandler.getPlayerDecision(); + } finally { + background.destroy(); + joinHandler.destroy(); + } - const playerDecision = await joinHandler.getPlayerDecision(); - - localStorage.setItem('userName', JSON.stringify(playerDecision.name)); + saveStoredValue('userName', playerDecision.name); if (!history.state) { history.pushState(true, ''); @@ -151,7 +157,6 @@ const main = async () => { setDisplayed(landingUI, null); setVisible(spinner, true); - background.destroy(); game = new Game(playerDecision, canvas, overlay); const gameOver = game.start(); await game.started; @@ -168,6 +173,7 @@ const main = async () => { main().catch((error) => { console.error(error); setVisible(spinner, false); + setDisplayed(landingUI, 'flex'); joinNotice.innerText = 'Something went wrong. Please reload the page.'; setDisplayed(joinNotice, 'block'); }); diff --git a/frontend/src/scripts/commands/command-socket.ts b/frontend/src/scripts/commands/command-socket.ts index b68484d..aa94639 100644 --- a/frontend/src/scripts/commands/command-socket.ts +++ b/frontend/src/scripts/commands/command-socket.ts @@ -22,6 +22,11 @@ export class CommandSocket { this.commandQueue.push(command); } + public reset() { + this.commandQueue = []; + this.lastSendMs = -Infinity; + } + public sendQueuedCommands() { // socket.io-client would buffer emits made while disconnected and flush them before the re-join. if (!this.socket.connected) { diff --git a/frontend/src/scripts/commands/keyboard-listener.ts b/frontend/src/scripts/commands/keyboard-listener.ts index 7674247..b6b0938 100644 --- a/frontend/src/scripts/commands/keyboard-listener.ts +++ b/frontend/src/scripts/commands/keyboard-listener.ts @@ -2,6 +2,18 @@ import { vec2 } from 'gl-matrix'; import type { Command } from 'shared'; import { InputGenerator } from './input-generator'; +const movementKeys = new Set([ + 'KeyW', + 'KeyA', + 'KeyS', + 'KeyD', + 'ArrowUp', + 'ArrowLeft', + 'ArrowDown', + 'ArrowRight', +]); +const leapKeys = new Set(['Space', 'ShiftLeft', 'ShiftRight']); + export class KeyboardListener extends InputGenerator { private keysDown: Set = new Set(); @@ -14,18 +26,23 @@ export class KeyboardListener extends InputGenerator { private keyDownListener = (event: KeyboardEvent) => { const key = event.code; + if (!movementKeys.has(key) && !leapKeys.has(key)) { + return; + } + event.preventDefault(); if (this.keysDown.has(key)) { return; } this.keysDown.add(key); - if (key === 'Space' || key === 'ShiftLeft' || key === 'ShiftRight') { + if (leapKeys.has(key)) { this.sendLeap(); + } else { + this.sendMovement(); } - this.sendMovement(); }; private keyUpListener = (event: KeyboardEvent) => { - if (this.keysDown.delete(event.code)) { + if (this.keysDown.delete(event.code) && movementKeys.has(event.code)) { this.sendMovement(); } }; @@ -35,6 +52,12 @@ export class KeyboardListener extends InputGenerator { this.sendMovement(); }; + public resendMovement() { + if ([...this.keysDown].some((key) => movementKeys.has(key))) { + this.sendMovement(); + } + } + private sendMovement() { const down = (...codes: Array) => codes.some((c) => this.keysDown.has(c)) ? 1 : 0; diff --git a/frontend/src/scripts/commands/mouse-listener.ts b/frontend/src/scripts/commands/mouse-listener.ts index 5055c67..611c9e9 100644 --- a/frontend/src/scripts/commands/mouse-listener.ts +++ b/frontend/src/scripts/commands/mouse-listener.ts @@ -28,6 +28,7 @@ export class MouseListener extends InputGenerator { private mouseDownListener = (event: MouseEvent) => { if (event.button === 0) { + this.mouseMoveListener(event); this.primaryDownAt = performance.now(); ChargeIndicator.begin(event.clientX, event.clientY, true); } diff --git a/frontend/src/scripts/commands/touch-listener.ts b/frontend/src/scripts/commands/touch-listener.ts index 31aa502..3af1895 100644 --- a/frontend/src/scripts/commands/touch-listener.ts +++ b/frontend/src/scripts/commands/touch-listener.ts @@ -21,12 +21,13 @@ export class TouchListener extends InputGenerator { private isJoystickActive = false; private touchStartPosition = vec2.create(); + private movement = vec2.create(); private primaryDownAt: number | null = null; private gestureTouchId: number | null = null; private fireDownAt: number | null = null; + private fireTouchId: number | null = null; private fireButtonCenter = vec2.create(); - private fireAim: vec2 | null = null; constructor( private readonly target: HTMLElement, @@ -46,6 +47,7 @@ export class TouchListener extends InputGenerator { this.fireButton.addEventListener('touchstart', this.fireButtonDownListener); this.fireButton.addEventListener('touchmove', this.fireButtonMoveListener); this.fireButton.addEventListener('touchend', this.fireButtonUpListener); + this.fireButton.addEventListener('touchcancel', this.fireButtonCancelListener); this.leapButton.className = 'touch-button leap'; this.leapButton.addEventListener('touchstart', this.leapButtonListener); @@ -54,11 +56,12 @@ export class TouchListener extends InputGenerator { target.addEventListener('touchmove', this.touchMoveListener); target.addEventListener('touchend', this.touchEndListener); target.addEventListener('touchcancel', this.touchCancelListener); + window.addEventListener('blur', this.cancelTouches); } - private findGestureTouch(list: TouchList): Touch | undefined { + private findTouch(list: TouchList, id: number | null): Touch | undefined { for (let i = 0; i < list.length; i++) { - if (list[i].identifier === this.gestureTouchId) { + if (list[i].identifier === id) { return list[i]; } } @@ -78,7 +81,7 @@ export class TouchListener extends InputGenerator { ), 0, ); - } else { + } else if (this.gestureTouchId === null) { this.gestureTouchId = touch.identifier; vec2.set(this.touchStartPosition, touch.clientX, touch.clientY); this.primaryDownAt = performance.now(); @@ -89,7 +92,7 @@ export class TouchListener extends InputGenerator { private touchMoveListener = (event: TouchEvent) => { event.preventDefault(); - const touch = this.findGestureTouch(event.touches); + const touch = this.findTouch(event.touches, this.gestureTouchId); if (!touch) { return; } @@ -117,16 +120,17 @@ export class TouchListener extends InputGenerator { if (deltaLength > deadZone) { vec2.set(delta, delta[0], -delta[1]); - this.sendMove(vec2.normalize(delta, delta)); + vec2.normalize(this.movement, delta); } else { - this.sendMove(vec2.create()); + vec2.zero(this.movement); } + this.sendMove(vec2.clone(this.movement)); }; private touchEndListener = (event: TouchEvent) => { event.preventDefault(); - const touch = this.findGestureTouch(event.changedTouches); + const touch = this.findTouch(event.changedTouches, this.gestureTouchId); if (!touch) { return; } @@ -147,7 +151,7 @@ export class TouchListener extends InputGenerator { }; private touchCancelListener = (event: TouchEvent) => { - if (!this.findGestureTouch(event.changedTouches)) { + if (!this.findTouch(event.changedTouches, this.gestureTouchId)) { return; } this.gestureTouchId = null; @@ -161,7 +165,30 @@ export class TouchListener extends InputGenerator { private releaseJoystick() { this.isJoystickActive = false; this.joystick.remove(); - this.sendMove(vec2.create()); + vec2.zero(this.movement); + this.sendMove(vec2.clone(this.movement)); + } + + public resendMovement() { + if (this.isJoystickActive) { + this.sendMove(vec2.clone(this.movement)); + } + } + + private cancelTouches = () => { + this.gestureTouchId = null; + this.primaryDownAt = null; + this.cancelFire(); + if (this.isJoystickActive) { + this.releaseJoystick(); + } + }; + + private cancelFire() { + this.fireTouchId = null; + this.fireDownAt = null; + this.fireAimLine.style.opacity = '0'; + ChargeIndicator.end(); } private swallowTouch(event: TouchEvent) { @@ -176,6 +203,11 @@ export class TouchListener extends InputGenerator { private fireButtonDownListener = (event: TouchEvent) => { this.swallowTouch(event); + const touch = event.changedTouches[0]; + if (this.fireTouchId !== null || !touch) { + return; + } + this.fireTouchId = touch.identifier; this.fireDownAt = performance.now(); const rect = this.fireButton.getBoundingClientRect(); vec2.set( @@ -183,18 +215,16 @@ export class TouchListener extends InputGenerator { rect.left + rect.width / 2, rect.top + rect.height / 2, ); - this.fireAim = null; ChargeIndicator.begin(this.fireButtonCenter[0], this.fireButtonCenter[1]); }; private fireButtonMoveListener = (event: TouchEvent) => { this.swallowTouch(event); - const touch = event.targetTouches[0] ?? event.changedTouches[0]; + const touch = this.findTouch(event.touches, this.fireTouchId); if (this.fireDownAt === null || !touch) { return; } const aim = this.aimFromTouch(touch); - this.fireAim = aim; if (aim) { this.fireAimLine.style.opacity = '1'; this.fireAimLine.style.transform = `translateY(-50%) rotate(${Math.atan2(-aim[1], aim[0])}rad)`; @@ -215,22 +245,21 @@ export class TouchListener extends InputGenerator { private fireButtonUpListener = (event: TouchEvent) => { this.swallowTouch(event); - ChargeIndicator.end(); - this.fireAimLine.style.opacity = '0'; - if (this.fireDownAt === null) { + const touch = this.findTouch(event.changedTouches, this.fireTouchId); + if (this.fireDownAt === null || !touch) { return; } const charge = chargeHeldSince(this.fireDownAt); - this.fireDownAt = null; + const aim = this.aimFromTouch(touch); + this.cancelFire(); const character = this.game.gameObjects.localPlayer; if (!character) { return; } - const direction = this.fireAim ?? character.facingDirection; - this.fireAim = null; + const direction = aim ?? character.facingDirection; this.sendPrimary( vec2.scaleAndAdd( vec2.create(), @@ -242,10 +271,20 @@ export class TouchListener extends InputGenerator { ); }; + private fireButtonCancelListener = (event: TouchEvent) => { + this.swallowTouch(event); + if (this.findTouch(event.changedTouches, this.fireTouchId)) { + this.cancelFire(); + } + }; + public update() { if (!this.fireButton.parentElement) { this.overlay.append(this.fireButton, this.leapButton); } + if (this.isJoystickActive && !this.joystick.parentElement) { + this.overlay.appendChild(this.joystick); + } const character = this.game.gameObjects.localPlayer; if (character) { @@ -261,13 +300,16 @@ export class TouchListener extends InputGenerator { this.target.removeEventListener('touchmove', this.touchMoveListener); this.target.removeEventListener('touchend', this.touchEndListener); this.target.removeEventListener('touchcancel', this.touchCancelListener); + window.removeEventListener('blur', this.cancelTouches); this.fireButton.removeEventListener('touchstart', this.fireButtonDownListener); this.fireButton.removeEventListener('touchmove', this.fireButtonMoveListener); this.fireButton.removeEventListener('touchend', this.fireButtonUpListener); + this.fireButton.removeEventListener('touchcancel', this.fireButtonCancelListener); this.leapButton.removeEventListener('touchstart', this.leapButtonListener); this.fireButton.remove(); this.leapButton.remove(); + this.joystick.remove(); } } diff --git a/frontend/src/scripts/game.ts b/frontend/src/scripts/game.ts index 2267d64..e76fd80 100644 --- a/frontend/src/scripts/game.ts +++ b/frontend/src/scripts/game.ts @@ -59,8 +59,6 @@ export class Game extends CommandReceiver { private resolveStarted!: () => void; private isBetweenGames = false; private isActive = true; - private isEnding = false; - private timeScaling = 1; private readonly keyboardListener: KeyboardListener; private readonly mouseListener: MouseListener; @@ -92,7 +90,7 @@ export class Game extends CommandReceiver { c.lastLeapClientTimeMs, c.ackAgeMs, ), - [GameEndCommand.name]: () => (this.isEnding = true), + [GameEndCommand.name]: () => (localCharacterPredictor.enabled = false), [UpdateMinimap.name]: (c: UpdateMinimap) => (this.lastMinimap = c), [GameStartCommand.name]: () => this.initialize(), }; @@ -125,6 +123,7 @@ export class Game extends CommandReceiver { this.socket?.close(); serverTimeline.reset(); localCharacterPredictor.reset(); + localCharacterPredictor.enabled = true; ScreenShake.reset(); this.gameObjects.reset(); this.gameObjects = new GameObjectContainer(this); @@ -132,10 +131,8 @@ export class Game extends CommandReceiver { this.keystoneArrow = undefined; this.connectionBanner = undefined; this.lastMinimap = undefined; - this.isEnding = false; this.lastAnnouncementText = ''; this.announcementText.innerText = ''; - this.timeScaling = 1; this.overlay.append( this.scoreboard.element, this.minimap.element, @@ -172,6 +169,7 @@ export class Game extends CommandReceiver { serverTimeline.reset(); localCharacterPredictor.reset(); this.gameObjects.reset(); + this.socketReceiver.reset(); this.socket.emit(TransportEvents.PlayerJoining, { ...this.playerDecision, reconnectToken: this.reconnectToken, @@ -220,49 +218,64 @@ export class Game extends CommandReceiver { } public async start(): Promise { - this.initialize(); - this.resolveStarted(); - const noiseTexture = await renderNoise([256, 256], 2, 1); + setFrameTimeMs(0); + try { + this.initialize(); + this.resolveStarted(); + let renderError: unknown; + const noiseTexture = await renderNoise([256, 256], 2, 1); - await runAnimation( - this.canvas, - [ - PlanetShape.descriptor, - CharacterShape.descriptor, - { - ...CircleLight.descriptor, - shaderCombinationSteps: [0, 1, 2, 4, 8, 16], + await runAnimation( + this.canvas, + [ + PlanetShape.descriptor, + CharacterShape.descriptor, + { + ...CircleLight.descriptor, + shaderCombinationSteps: [0, 1, 2, 4, 8, 16], + }, + ], + (renderer, currentTime, deltaTime) => { + try { + return this.gameLoop(renderer, currentTime, deltaTime); + } catch (error) { + renderError = error; + return false; + } }, - ], - this.gameLoop.bind(this), - { - shadowTraceCount: 16, - paletteSize: settings.paletteDim.length, - colorPalette: settings.paletteDim, - enableHighDpiRendering: true, - lightCutoffDistance: settings.lightCutoffDistance, - lightOverlapReduction: settings.lightOverlapReduction, - textures: { - noiseTexture: { - source: noiseTexture, - overrides: { - maxFilter: FilteringOptions.LINEAR, - wrapS: WrapOptions.MIRRORED_REPEAT, - wrapT: WrapOptions.MIRRORED_REPEAT, + { + shadowTraceCount: 16, + paletteSize: settings.paletteDim.length, + colorPalette: settings.paletteDim, + enableHighDpiRendering: true, + lightCutoffDistance: settings.lightCutoffDistance, + lightOverlapReduction: settings.lightOverlapReduction, + textures: { + noiseTexture: { + source: noiseTexture, + overrides: { + maxFilter: FilteringOptions.LINEAR, + wrapS: WrapOptions.MIRRORED_REPEAT, + wrapT: WrapOptions.MIRRORED_REPEAT, + }, }, }, }, - }, - ); - - this.socket.close(); - this.gameObjects.reset(); - this.overlay.innerHTML = ''; - this.hideConnectionBanner(); - FeedbackHud.reset(); - this.keyboardListener.destroy(); - this.mouseListener.destroy(); - this.touchListener.destroy(); + ); + if (renderError) { + throw renderError; + } + } finally { + this.resolveStarted(); + this.socket?.close(); + this.gameObjects.reset(); + this.overlay.innerHTML = ''; + this.hideConnectionBanner(); + FeedbackHud.reset(); + this.keyboardListener.destroy(); + this.mouseListener.destroy(); + this.touchListener.destroy(); + } } public static rejectionText(reason: JoinRejectionReason): string { @@ -307,6 +320,11 @@ export class Game extends CommandReceiver { this.isActive = false; } + public resendMovement() { + this.keyboardListener.resendMovement(); + this.touchListener.resendMovement(); + } + private gameLoop( renderer: Renderer, currentTime: DOMHighResTimeStamp, @@ -333,11 +351,6 @@ export class Game extends CommandReceiver { this.lastAnnouncementText = ''; } - if (this.isEnding) { - this.timeScaling *= Math.pow(settings.endGameDeltaScaling, deltaTime); - deltaTime /= this.timeScaling; - } - this.gameObjects.step(deltaTime); this.gameObjects.render(renderer, this.overlay, shouldChangeLayout); this.touchListener.update(); diff --git a/frontend/src/scripts/helper/handle-full-screen.ts b/frontend/src/scripts/helper/handle-full-screen.ts index c5b414c..e0ab2b4 100644 --- a/frontend/src/scripts/helper/handle-full-screen.ts +++ b/frontend/src/scripts/helper/handle-full-screen.ts @@ -18,32 +18,25 @@ export const handleFullScreen = ( }; showButtons(); - - let currentWindowHeight = innerHeight; - - const followToggle = () => { - SoundHandler.play(Sounds.click); - showButtons(); - currentWindowHeight = innerHeight; - }; + document.addEventListener('fullscreenchange', showButtons); const triggerToggle = async () => { - await (isInFullScreen() - ? document.exitFullscreen() - : document.body.requestFullscreen()); - followToggle(); + try { + await (isInFullScreen() + ? document.exitFullscreen() + : document.body.requestFullscreen()); + SoundHandler.play(Sounds.click); + } catch { + // The browser may deny fullscreen (for example, when embedded). + } }; addEventListener('keydown', (e) => { if (e.key === 'F11') { - triggerToggle(); e.preventDefault(); - } - }); - - addEventListener('resize', () => { - if (isInFullScreen() && currentWindowHeight > innerHeight) { - followToggle(); + if (!e.repeat) { + void triggerToggle(); + } } }); diff --git a/frontend/src/scripts/helper/interpolators/linear-interpolator.ts b/frontend/src/scripts/helper/interpolators/linear-interpolator.ts index 133a4bf..6a43cd3 100644 --- a/frontend/src/scripts/helper/interpolators/linear-interpolator.ts +++ b/frontend/src/scripts/helper/interpolators/linear-interpolator.ts @@ -28,7 +28,10 @@ export class LinearInterpolator { const newest = this.frames.length > 0 ? this.frames[this.frames.length - 1] : null; const wasCoasting = this.isCoasting; - if (newest && time <= newest.time) { + if (newest && time < newest.time) { + return; + } + if (newest && time === newest.time) { this.frames[this.frames.length - 1] = { time: newest.time, value, diff --git a/frontend/src/scripts/helper/prediction/client-character-world.ts b/frontend/src/scripts/helper/prediction/client-character-world.ts index 4668cbd..1fb19b3 100644 --- a/frontend/src/scripts/helper/prediction/client-character-world.ts +++ b/frontend/src/scripts/helper/prediction/client-character-world.ts @@ -9,11 +9,16 @@ import { } from 'shared'; import { PlanetView } from '../../objects/types/planet-view'; +export type PlanetSnapshot = Pick< + PlanetView, + 'id' | 'vertices' | 'snapshotRotation' | 'snapshotRotationSpeed' +>; + // Planets only, re-seeded from the newest snapshot every frame; never dispatches collisions. export class ClientCharacterWorld implements CharacterWorld { private surfaces: Array = []; - public sync(planets: Array) { + public sync(planets: Array) { this.surfaces = planets .map((planet) => { const surface = new PlanetBase(planet.id, planet.vertices); diff --git a/frontend/src/scripts/helper/prediction/local-character-predictor.ts b/frontend/src/scripts/helper/prediction/local-character-predictor.ts index b3e290f..ccf248b 100644 --- a/frontend/src/scripts/helper/prediction/local-character-predictor.ts +++ b/frontend/src/scripts/helper/prediction/local-character-predictor.ts @@ -11,7 +11,7 @@ import { feetRadius, headRadius, } from 'shared'; -import { ClientCharacterWorld } from './client-character-world'; +import { ClientCharacterWorld, PlanetSnapshot } from './client-character-world'; import type { PlanetView } from '../../objects/types/planet-view'; import { InputHistory } from './input-history'; @@ -25,6 +25,18 @@ export const setFrameTimeMs = (timeMs: number): void => { export const clientTimeMs = (): number => frameTimeMs; const maxReplayMs = 400; +const correctionSeconds = 0.08; +const snapDistance = 1500; + +interface ReplaySnapshot { + pose: { head: Circle; leftFoot: Circle; rightFoot: Circle }; + movement: CharacterMovementSnapshot; + anchorMs: number; + leapAckMs: number; + strength: number; + planets: Array; + horizonState?: CharacterMovementState; +} const upwards = vec2.fromValues(0, 1); @@ -37,6 +49,7 @@ const makeBody = (center: vec2, radius: number, normal: vec2): PhysicsBody => ({ }); export class LocalCharacterPredictor { + public enabled = true; private readonly inputHistory = new InputHistory(); private readonly world = new ClientCharacterWorld(); @@ -48,6 +61,9 @@ export class LocalCharacterPredictor { private currentStrength = settings.playerMaxStrength; private alive = true; + private previousSnapshot?: ReplaySnapshot; + private lastUpdateMs?: number; + private readonly correction = vec2.create(); private renderHead = new Circle(vec2.create(), headRadius); private renderLeftFoot = new Circle(vec2.create(), feetRadius); @@ -75,10 +91,7 @@ export class LocalCharacterPredictor { lastLeapClientTimeMs: number, ackAgeMs = 0, ): void { - const anchor = clientTimeMs + Math.max(0, ackAgeMs); - if (this.replayAnchorMs === undefined || anchor > this.replayAnchorMs) { - this.replayAnchorMs = anchor; - } + this.replayAnchorMs = clientTimeMs + Math.max(0, ackAgeMs); this.movement = movement; this.lastLeapAckMs = lastLeapClientTimeMs; } @@ -114,6 +127,9 @@ export class LocalCharacterPredictor { this.movement = undefined; this.currentStrength = settings.playerMaxStrength; this.alive = true; + this.previousSnapshot = undefined; + this.lastUpdateMs = undefined; + vec2.zero(this.correction); } public get canPredict(): boolean { @@ -132,29 +148,77 @@ export class LocalCharacterPredictor { } public update(planets: Array): boolean { - if (!this.alive || !this.canPredict || this.isAnimatingInOrOut) { + if (!this.enabled || !this.alive || !this.canPredict || this.isAnimatingInOrOut) { + this.previousSnapshot = undefined; + this.lastUpdateMs = undefined; + vec2.zero(this.correction); return false; } - this.world.sync(planets); - const predicted = this.simulate(); + const now = clientTimeMs(); + const deltaSeconds = + this.lastUpdateMs === undefined ? 0 : (now - this.lastUpdateMs) / 1000; + vec2.scale( + this.correction, + this.correction, + Math.exp(-deltaSeconds / correctionSeconds), + ); + const snapshot: ReplaySnapshot = + this.previousSnapshot && this.previousSnapshot.pose === this.authoritative + ? this.previousSnapshot + : { + pose: this.authoritative!, + movement: this.movement!, + anchorMs: this.replayAnchorMs!, + leapAckMs: this.lastLeapAckMs, + strength: this.currentStrength, + planets: planets.map( + ({ id, vertices, snapshotRotation, snapshotRotationSpeed }) => ({ + id, + vertices, + snapshotRotation, + snapshotRotationSpeed, + }), + ), + }; + const predicted = this.simulate(snapshot); - vec2.copy(this.renderHead.center, predicted.head.center); - this.renderHead.radius = predicted.head.radius; - vec2.copy(this.renderLeftFoot.center, predicted.leftFoot.center); - this.renderLeftFoot.radius = predicted.leftFoot.radius; - vec2.copy(this.renderRightFoot.center, predicted.rightFoot.center); - this.renderRightFoot.radius = predicted.rightFoot.radius; + if (this.previousSnapshot && this.previousSnapshot.pose !== snapshot.pose) { + // Compare both snapshots at the same instant: smooth only the correction, + // leaving fresh input and ordinary movement immediate. + const previous = this.simulate(this.previousSnapshot); + vec2.add( + this.correction, + this.correction, + vec2.subtract(vec2.create(), previous.head.center, predicted.head.center), + ); + if (vec2.length(this.correction) > snapDistance) { + vec2.zero(this.correction); + } + } + for (const [render, body] of [ + [this.renderHead, predicted.head], + [this.renderLeftFoot, predicted.leftFoot], + [this.renderRightFoot, predicted.rightFoot], + ]) { + vec2.add(render.center, body.center, this.correction); + render.radius = body.radius; + } + this.previousSnapshot = snapshot; + this.lastUpdateMs = now; return true; } - private simulate(): CharacterMovementState { - const auth = this.authoritative!; - const movement = this.movement!; + private simulate(snapshot: ReplaySnapshot): CharacterMovementState { + if (snapshot.horizonState) { + return snapshot.horizonState; + } + this.world.sync(snapshot.planets); + const { pose: auth, movement } = snapshot; const now = clientTimeMs(); - const startMs = Math.max(this.replayAnchorMs!, now - maxReplayMs); - const windowMs = Math.max(0, now - startMs); + const startMs = snapshot.anchorMs; + const windowMs = Math.min(maxReplayMs, Math.max(0, now - startMs)); const steps = Math.floor(windowMs / stepMs); const partialStep = (windowMs - steps * stepMs) / stepMs; @@ -176,39 +240,32 @@ export class LocalCharacterPredictor { bodyVelocity: vec2.fromValues(movement.bodyVelocity[0], movement.bodyVelocity[1]), }; - const cooldownMs = settings.leapCooldownSeconds * 1000; - let availableStrength = this.currentStrength; - let lastLeapMs = -Infinity; + let availableStrength = snapshot.strength; + let cooldown = movement.leapCooldownRemaining ?? 0; let t = startMs; - for (let i = 0; i < steps; i++) { - const input = this.inputHistory.directionAt(t); - this.world.advance(stepSeconds); - tickPlanetDetachment(state, stepSeconds); - stepCharacterMovement(state, this.world, input, stepSeconds); - + for (let i = 0; i < steps + (partialStep > 0 ? 1 : 0); i++) { + const fraction = i < steps ? 1 : partialStep; + const from = + fraction < 1 + ? [state.head, state.leftFoot, state.rightFoot].map((body) => + vec2.clone(body.center), + ) + : undefined; for (const leapMs of this.leapHistory) { if ( leapMs >= t && - leapMs < t + stepMs && - leapMs > this.lastLeapAckMs && + leapMs < t + stepMs * fraction && + leapMs > snapshot.leapAckMs && state.currentPlanet && - leapMs - lastLeapMs >= cooldownMs && + cooldown <= 0 && availableStrength >= settings.leapStrengthCost ) { applyLeapImpulse(state, this.inputHistory.directionAt(leapMs)); availableStrength -= settings.leapStrengthCost; - lastLeapMs = leapMs; + cooldown = settings.leapCooldownSeconds; } } - - t += stepMs; - } - - if (partialStep > 0) { - const from = [state.head, state.leftFoot, state.rightFoot].map((b) => - vec2.clone(b.center), - ); this.world.advance(stepSeconds); tickPlanetDetachment(state, stepSeconds); stepCharacterMovement( @@ -217,11 +274,22 @@ export class LocalCharacterPredictor { this.inputHistory.directionAt(t), stepSeconds, ); - [state.head, state.leftFoot, state.rightFoot].forEach((body, i) => - vec2.lerp(body.center, from[i], body.center, partialStep), + cooldown = Math.max(0, cooldown - stepSeconds); + availableStrength = Math.min( + settings.playerMaxStrength, + availableStrength + settings.playerStrengthRegenerationPerSeconds * stepSeconds, ); + if (from) { + [state.head, state.leftFoot, state.rightFoot].forEach((body, index) => + vec2.lerp(body.center, from[index], body.center, fraction), + ); + } + t += stepMs; } + if (windowMs === maxReplayMs) { + snapshot.horizonState = state; + } return state; } } diff --git a/frontend/src/scripts/helper/server-timeline.ts b/frontend/src/scripts/helper/server-timeline.ts index 9ac449f..d4b17fc 100644 --- a/frontend/src/scripts/helper/server-timeline.ts +++ b/frontend/src/scripts/helper/server-timeline.ts @@ -9,7 +9,7 @@ const resyncSeconds = 0.3; class ServerTimeline { private cursor?: number; private newestSnapshotTime?: number; - private sinceNewestSnapshot = 0; + private newestReceivedAtMs = 0; public snapshotTime = 0; public get renderTime(): number { @@ -20,7 +20,7 @@ class ServerTimeline { this.snapshotTime = timestamp; if (this.newestSnapshotTime === undefined || timestamp > this.newestSnapshotTime) { this.newestSnapshotTime = timestamp; - this.sinceNewestSnapshot = 0; + this.newestReceivedAtMs = performance.now(); } } @@ -29,10 +29,9 @@ class ServerTimeline { return; } - this.sinceNewestSnapshot += deltaTimeInSeconds; const target = this.newestSnapshotTime + - this.sinceNewestSnapshot - + (performance.now() - this.newestReceivedAtMs) / 1000 - settings.interpolationDelaySeconds; if (this.cursor === undefined || Math.abs(target - this.cursor) > resyncSeconds) { @@ -40,18 +39,16 @@ class ServerTimeline { return; } - const rate = clamp( - 1 + (target - this.cursor) * rateGain, - 1 - maxRateAdjustment, - 1 + maxRateAdjustment, - ); - this.cursor += deltaTimeInSeconds * rate; + this.cursor += deltaTimeInSeconds; + this.cursor += + deltaTimeInSeconds * + clamp((target - this.cursor) * rateGain, -maxRateAdjustment, maxRateAdjustment); } public reset() { this.cursor = undefined; this.newestSnapshotTime = undefined; - this.sinceNewestSnapshot = 0; + this.newestReceivedAtMs = 0; this.snapshotTime = 0; } } diff --git a/frontend/src/scripts/helper/storage.ts b/frontend/src/scripts/helper/storage.ts new file mode 100644 index 0000000..b68bb01 --- /dev/null +++ b/frontend/src/scripts/helper/storage.ts @@ -0,0 +1,16 @@ +export function loadStoredValue(key: string): unknown { + try { + const stored = localStorage.getItem(key); + return stored === null ? undefined : JSON.parse(stored); + } catch { + return undefined; + } +} + +export function saveStoredValue(key: string, value: unknown): void { + try { + localStorage.setItem(key, JSON.stringify(value)); + } catch { + // Preferences still apply for this visit when browser storage is unavailable. + } +} diff --git a/frontend/src/scripts/join-form-handler.ts b/frontend/src/scripts/join-form-handler.ts index c5592cb..d1eff3b 100644 --- a/frontend/src/scripts/join-form-handler.ts +++ b/frontend/src/scripts/join-form-handler.ts @@ -16,12 +16,8 @@ export class JoinFormHandler { private readonly decision: Promise; private readonly pollServersTimer: ReturnType; private servers: Array = []; - - private keyUpListener = (e: KeyboardEvent) => { - if (e.key === 'Enter' && !this.joinButton.disabled) { - this.form.requestSubmit(); - } - }; + private readonly pendingRequests = new Map(); + private destroyed = false; constructor( private readonly form: HTMLFormElement, @@ -33,14 +29,16 @@ export class JoinFormHandler { this.decision = new Promise((resolve) => { form.onsubmit = (e) => { e.preventDefault(); + if (this.joinButton.disabled) { + return; + } SoundHandler.play(Sounds.click); const data = new FormData(form); + this.destroy(); resolve({ name: String(data.get('name')), server: String(data.get('server')) }); }; }); - this.decision.then(() => this.destroy()); - addEventListener('keyup', this.keyUpListener); this.pollServersTimer = setInterval(() => this.loadServers(), pollInterval); this.loadServers(); } @@ -49,49 +47,60 @@ export class JoinFormHandler { return this.decision; } - private destroy() { - removeEventListener('keyup', this.keyUpListener); + public destroy() { + this.destroyed = true; + this.form.onsubmit = null; clearInterval(this.pollServersTimer); + this.pendingRequests.forEach((controller) => controller.abort()); this.servers.forEach((s) => s.destroy()); } private loadServers() { - servers - .filter((url) => !this.servers.some((s) => s.url === url)) - .forEach(async (url) => { - const controller = new AbortController(); - setTimeout(() => controller.abort(), pollInterval * 0.8); + for (const url of servers) { + if (!this.pendingRequests.has(url) && !this.servers.some((s) => s.url === url)) { + void this.loadServer(url); + } + } + } - let content: ServerInformation; - try { - const response = await fetch(url + serverInformationEndpoint, { - signal: controller.signal, - }); - if (!response.ok) { - return; - } - content = await response.json(); - } catch { - return; - } - - if (!this.servers.some((s) => s.url === url)) { - const server = new ServerChooserOption( - content, - url, - (s) => this.removeServer(s), - this.servers.length === 0, - ); - this.servers.push(server); - this.joinButton.disabled = false; - this.container.appendChild(server.element); - } + private async loadServer(url: string) { + const controller = new AbortController(); + this.pendingRequests.set(url, controller); + const timeout = setTimeout(() => controller.abort(), pollInterval * 0.8); + try { + const response = await fetch(url + serverInformationEndpoint, { + signal: controller.signal, }); + if (!response.ok) { + return; + } + const content: ServerInformation = await response.json(); + if (this.destroyed) { + return; + } + const server = new ServerChooserOption( + content, + url, + (s) => this.removeServer(s), + this.servers.length === 0, + ); + this.servers.push(server); + this.joinButton.disabled = false; + this.container.appendChild(server.element); + } catch { + // Unavailable servers are retried on the next poll. + } finally { + clearTimeout(timeout); + this.pendingRequests.delete(url); + } } private removeServer(server: ServerChooserOption) { this.servers = this.servers.filter((s) => s !== server); this.joinButton.disabled = this.servers.length === 0; + if (this.servers.length && !this.servers.some((s) => s.input.checked)) { + this.servers[0].input.checked = true; + } } } @@ -107,9 +116,11 @@ const roundCompletionTexts = [ class ServerChooserOption { public readonly element = document.createElement('div'); + public readonly input = document.createElement('input'); private readonly serverNameElement = document.createElement('span'); private readonly completionElement = document.createElement('span'); private readonly socket: Socket; + private readonly reconnectFailedListener = () => this.destroy(); constructor( private readonly content: ServerInformation, @@ -117,7 +128,7 @@ class ServerChooserOption { private readonly onDestroy: (v: ServerChooserOption) => unknown, isFirst: boolean, ) { - const input = document.createElement('input'); + const input = this.input; input.required = true; input.type = 'radio'; input.id = input.value = url; @@ -144,7 +155,7 @@ class ServerChooserOption { parser, } as any); - this.socket.io.on('reconnect_failed', () => this.destroy()); + this.socket.io.on('reconnect_failed', this.reconnectFailedListener); this.socket.on('connect', () => this.socket.emit(TransportEvents.SubscribeForServerInfoUpdates), ); @@ -159,6 +170,7 @@ class ServerChooserOption { } public destroy() { + this.socket.io.off('reconnect_failed', this.reconnectFailedListener); this.socket.close(); this.element.remove(); this.onDestroy(this); diff --git a/frontend/src/scripts/landing-page-background.ts b/frontend/src/scripts/landing-page-background.ts index ca1863b..68941f0 100644 --- a/frontend/src/scripts/landing-page-background.ts +++ b/frontend/src/scripts/landing-page-background.ts @@ -27,14 +27,16 @@ export class LandingPageBackground { }; constructor(canvas: HTMLCanvasElement) { - this.renderer = new Promise((r) => (this.resolveRenderer = r)); - this.start(canvas); + this.renderer = new Promise((resolve, reject) => { + this.resolveRenderer = resolve; + void this.start(canvas).catch(reject); + }); } private async start(canvas: HTMLCanvasElement): Promise { const noiseTexture = await renderNoise([256, 256], 1.2, 2); - runAnimation( + await runAnimation( canvas, [ { ...PlanetShape.descriptor, shaderCombinationSteps: [0, 1, 2] }, diff --git a/frontend/src/scripts/objects/game-object-container.ts b/frontend/src/scripts/objects/game-object-container.ts index 20cc31b..805bdd4 100644 --- a/frontend/src/scripts/objects/game-object-container.ts +++ b/frontend/src/scripts/objects/game-object-container.ts @@ -8,8 +8,8 @@ import { DeleteObjectsCommand, Id, PropertyUpdatesForObjects, + RemoteCall, RemoteCallsForObjects, - settings, UpdatePropertyCommand, } from 'shared'; import { FeedbackHud } from '../feedback-hud'; @@ -34,6 +34,8 @@ export class GameObjectContainer extends CommandReceiver { private deleteAt = new Map(); private awaitingCreateStamp: Array = []; private awaitingDeleteStamp: Array = []; + private remoteCalls: Array<{ object: View; calls: Array; at?: number }> = + []; protected commandExecutors: CommandExecutors = { [CreatePlayerCommand.name]: (c: CreatePlayerCommand) => { @@ -41,6 +43,7 @@ export class GameObjectContainer extends CommandReceiver { this.player.isMainCharacter = true; this.addObject(this.player); localCharacterPredictor.reset(); + this.game.resendMovement(); FeedbackHud.hideElimination(); this.wasLocalPlayerAlive = true; }, @@ -52,9 +55,14 @@ export class GameObjectContainer extends CommandReceiver { }), [RemoteCallsForObjects.name]: (c: RemoteCallsForObjects) => - c.callsForObjects.forEach((c) => - this.objects.get(c.id)?.processRemoteCalls(c.calls), - ), + c.callsForObjects.forEach(({ id, calls }) => { + const object = this.objects.get(id); + if (object === this.player) { + object?.processRemoteCalls(calls); + } else if (object) { + this.remoteCalls.push({ object, calls }); + } + }), [PropertyUpdatesForObjects.name]: (c: PropertyUpdatesForObjects) => { serverTimeline.onSnapshot(c.timestamp); @@ -72,7 +80,7 @@ export class GameObjectContainer extends CommandReceiver { c.ids.forEach((id: Id) => this.awaitingDeleteStamp.push(id)), }; - constructor(game: Game) { + constructor(private readonly game: Game) { super(); this.camera = new Camera(game); } @@ -86,6 +94,7 @@ export class GameObjectContainer extends CommandReceiver { this.deleteAt.clear(); this.awaitingCreateStamp = []; this.awaitingDeleteStamp = []; + this.remoteCalls = []; this.wasLocalPlayerAlive = false; } @@ -96,6 +105,16 @@ export class GameObjectContainer extends CommandReceiver { public step(deltaTimeInSeconds: number) { this.stampPending(serverTimeline.snapshotTime); this.retireDueObjects(); + this.remoteCalls = this.remoteCalls.filter(({ object, calls, at }) => { + if (this.objects.get(object.id) !== object) { + return false; + } + if (at !== undefined && at <= serverTimeline.renderTime) { + object.processRemoteCalls(calls); + return false; + } + return true; + }); this.objects.forEach((o) => o.step(deltaTimeInSeconds)); const player = this.localPlayer; @@ -107,15 +126,13 @@ export class GameObjectContainer extends CommandReceiver { if (player) { localCharacterPredictor.setAlive(alive); - localCharacterPredictor.setStrength( - player.strengthFraction * settings.playerMaxStrength, - ); + localCharacterPredictor.setStrength(player.snapshotStrength); if (localCharacterPredictor.update(this.planets)) { player.head = localCharacterPredictor.head; player.leftFoot = localCharacterPredictor.leftFoot; player.rightFoot = localCharacterPredictor.rightFoot; } - this.camera.follow(player.position, deltaTimeInSeconds); + this.camera.follow(player.position); } } @@ -144,11 +161,14 @@ export class GameObjectContainer extends CommandReceiver { this.deleteAt.set(id, timestamp); } this.awaitingDeleteStamp = []; + for (const call of this.remoteCalls) { + call.at ??= timestamp; + } } private retireDueObjects() { const renderTime = serverTimeline.renderTime; - for (const [id, at] of [...this.deleteAt]) { + for (const [id, at] of this.deleteAt) { if (renderTime >= at) { this.deleteObject(id); } @@ -172,6 +192,12 @@ export class GameObjectContainer extends CommandReceiver { } private addObject(object: View) { + // An object can re-enter the interest area before its delayed deletion is drawn. + // Retire the old view and its pending deletion before installing the replacement. + if (this.objects.has(object.id)) { + this.deleteObject(object.id); + } + this.awaitingDeleteStamp = this.awaitingDeleteStamp.filter((id) => id !== object.id); this.objects.set(object.id, object); if (object instanceof PlanetView) { this.planets.push(object); diff --git a/frontend/src/scripts/objects/types/camera.ts b/frontend/src/scripts/objects/types/camera.ts index ca768e4..cf95754 100644 --- a/frontend/src/scripts/objects/types/camera.ts +++ b/frontend/src/scripts/objects/types/camera.ts @@ -1,28 +1,17 @@ import { vec2 } from 'gl-matrix'; import { Renderer } from 'sdf-2d'; -import { calculateViewArea, followVec2, mixRgb, settings } from 'shared'; +import { calculateViewArea, mixRgb, settings } from 'shared'; import type { Game } from '../../game'; import { ScreenShake } from '../../screen-shake'; -const followSeconds = 0.08; -const snapDistance = 1500; - export class Camera { public readonly center: vec2 = vec2.create(); - private readonly previousTarget = vec2.create(); private aspectRatio?: number; constructor(private readonly game: Game) {} - public follow(target: vec2, deltaTimeInSeconds: number) { - followVec2( - this.center, - target, - this.previousTarget, - deltaTimeInSeconds, - followSeconds, - snapDistance, - ); + public follow(target: vec2) { + vec2.copy(this.center, target); } public draw(renderer: Renderer) { diff --git a/frontend/src/scripts/objects/types/character-view.ts b/frontend/src/scripts/objects/types/character-view.ts index 18e5434..4169696 100644 --- a/frontend/src/scripts/objects/types/character-view.ts +++ b/frontend/src/scripts/objects/types/character-view.ts @@ -39,6 +39,7 @@ const deathIcon = export class CharacterView extends CharacterBase implements View { public isMainCharacter = false; + public snapshotStrength = settings.playerMaxStrength; private readonly shape: CharacterShape; private readonly muzzleFlash: CircleLight; @@ -144,6 +145,7 @@ export class CharacterView extends CharacterBase implements View { } else if (propertyKey === 'rightFoot') { this.rightFootInterpolator.addFrame(propertyValue, rateOfChange); } else if (propertyKey === 'strength') { + this.snapshotStrength = propertyValue; this.strengthInterpolator.addFrame(propertyValue, rateOfChange); } } @@ -225,7 +227,6 @@ export class CharacterView extends CharacterBase implements View { 0, this.muzzleFlashIntensity - deltaTimeInSeconds / muzzleFlashDecaySeconds, ); - this.muzzleFlash.center = this.head.center; this.muzzleFlash.intensity = this.muzzleFlashIntensity; } @@ -241,7 +242,6 @@ export class CharacterView extends CharacterBase implements View { 0, this.deathBurstIntensity - deltaTimeInSeconds / deathBurstDecaySeconds, ); - this.deathBurst.center = this.bodyCenter; this.deathBurst.intensity = deathBurstMaxIntensity * this.deathBurstIntensity * this.deathBurstIntensity; } @@ -284,9 +284,11 @@ export class CharacterView extends CharacterBase implements View { renderer.addDrawable(this.shape); if (this.muzzleFlashIntensity > 0) { + this.muzzleFlash.center = this.head.center; renderer.addDrawable(this.muzzleFlash); } if (this.deathBurstIntensity > 0) { + this.deathBurst.center = this.bodyCenter; renderer.addDrawable(this.deathBurst); } } diff --git a/frontend/src/scripts/objects/types/projectile-view.ts b/frontend/src/scripts/objects/types/projectile-view.ts index be6d710..a615adb 100644 --- a/frontend/src/scripts/objects/types/projectile-view.ts +++ b/frontend/src/scripts/objects/types/projectile-view.ts @@ -8,11 +8,13 @@ import { UpdatePropertyCommand, } from 'shared'; import { Vec2Interpolator } from '../../helper/interpolators/vec2-interpolator'; +import { LinearInterpolator } from '../../helper/interpolators/linear-interpolator'; import { View } from '../view'; export class ProjectileView extends ProjectileBase implements View { private readonly light: CircleLight; private readonly centerInterpolator: Vec2Interpolator; + private readonly strengthInterpolator: LinearInterpolator; constructor( id: Id, @@ -28,15 +30,23 @@ export class ProjectileView extends ProjectileBase implements View { 0, ); this.centerInterpolator = new Vec2Interpolator(center); + this.strengthInterpolator = new LinearInterpolator(strength); } - public updateProperty({ propertyValue, rateOfChange }: UpdatePropertyCommand) { - this.centerInterpolator.addFrame(propertyValue, rateOfChange); + public updateProperty({ + propertyKey, + propertyValue, + rateOfChange, + }: UpdatePropertyCommand) { + if (propertyKey === 'center') { + this.centerInterpolator.addFrame(propertyValue, rateOfChange); + } else if (propertyKey === 'strength') { + this.strengthInterpolator.addFrame(propertyValue, rateOfChange); + } } public step(deltaTimeInSeconds: number) { - super.step(deltaTimeInSeconds); - + this.strength = Math.max(0, this.strengthInterpolator.getValue(deltaTimeInSeconds)); this.center = this.centerInterpolator.getValue(deltaTimeInSeconds); this.light.center = this.center; this.light.intensity = Math.min( diff --git a/frontend/src/scripts/options-handler.ts b/frontend/src/scripts/options-handler.ts index c5f41bf..fe64eff 100644 --- a/frontend/src/scripts/options-handler.ts +++ b/frontend/src/scripts/options-handler.ts @@ -1,5 +1,6 @@ import { SoundHandler, Sounds } from './sound-handler'; import { VibrationHandler } from './vibration-handler'; +import { loadStoredValue, saveStoredValue } from './helper/storage'; export interface Options { vibrationEnabled: boolean; @@ -13,14 +14,17 @@ export const options: Options = { musicEnabled: true, }; -const save = () => localStorage.setItem('options', JSON.stringify(options)); - export const initializeOptions = ( inputElements: Record, ) => { - const stored = localStorage.getItem('options'); - if (stored) { - Object.assign(options, JSON.parse(stored)); + const stored = loadStoredValue('options'); + if (stored && typeof stored === 'object') { + for (const key of Object.keys(inputElements) as Array) { + const value = (stored as Partial)[key]; + if (typeof value === 'boolean') { + options[key] = value; + } + } } if (options.musicEnabled) { @@ -51,7 +55,7 @@ export const initializeOptions = ( } SoundHandler.play(Sounds.click); - save(); + saveStoredValue('options', options); }); } }; diff --git a/frontend/src/scripts/sound-handler.ts b/frontend/src/scripts/sound-handler.ts index 77180d0..cdab0d6 100644 --- a/frontend/src/scripts/sound-handler.ts +++ b/frontend/src/scripts/sound-handler.ts @@ -16,7 +16,7 @@ let initialized = false; async function initializeSound(src: string): Promise { const snd = new Audio(src); snd.muted = true; - await snd.play(); + await snd.play().catch(() => undefined); snd.pause(); snd.muted = false; snd.currentTime = 0; @@ -27,20 +27,22 @@ async function initialize( onPlayKeypress: () => unknown = () => null, onPauseKeypress: () => unknown = () => null, ) { + ambient.muted = true; + ambient.volume = 0.5; + ambient.loop = true; + // Unlock every audio element during the initiating click, before awaiting any one. + const ambientReady = ambient.play().catch(() => undefined); const [hit, shoot, click] = await Promise.all( [hitSound, shootSound, clickSound].map(initializeSound), ); sounds = { hit, shoot, click }; - await ambient.play(); - ambient.muted = true; + await ambientReady; initialized = true; ambient.onpause = onPauseKeypress; ambient.onplay = onPlayKeypress; ambient.muted = false; - ambient.volume = 0.5; - ambient.loop = true; if (!isAmbientPlaying) { ambient.pause(); @@ -66,7 +68,7 @@ function play(snd: Sound, volume = 1, playbackRate = 1) { function playAmbient() { isAmbientPlaying = true; if (initialized) { - ambient.play(); + void ambient.play().catch(() => undefined); } } diff --git a/frontend/webpack.config.js b/frontend/webpack.config.js index bbbca6b..b57b543 100644 --- a/frontend/webpack.config.js +++ b/frontend/webpack.config.js @@ -114,6 +114,8 @@ module.exports = (env, argv) => { ], }, resolve: { + // Shared physics and rendering must use the same matrix array type as index.ts. + alias: { 'gl-matrix': path.resolve(__dirname, 'node_modules/gl-matrix') }, extensions: ['.ts', '.js', '.json'], }, }; diff --git a/package.json b/package.json index 8edb68a..285a535 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,9 @@ "build": "cd shared && npm run build && cd ../frontend && npm run build && cd ../backend && npm run build", "lint": "eslint ./**/src/**/*.{js,ts} --fix && prettier --write ./**/src/**/*.{js,ts,json}", "lint:check": "eslint ./**/src/**/*.{js,ts} && prettier --check ./**/src/**/*.{js,ts,json}", + "pretest": "npm --prefix shared run build", "test": "vitest run", + "pretest:watch": "npm --prefix shared run build", "test:watch": "vitest", "init": "cd shared && npm install && cd ../frontend && npm install && cd ../backend && npm install", "dev": "concurrently --kill-others-on-fail --names shared,backend,frontend \"cd shared && npm run dev\" \"cd backend && npm run dev\" \"cd frontend && npm run dev\"" diff --git a/shared/src/commands/command-receiver.ts b/shared/src/commands/command-receiver.ts index 447df6e..33f489a 100644 --- a/shared/src/commands/command-receiver.ts +++ b/shared/src/commands/command-receiver.ts @@ -12,7 +12,7 @@ export abstract class CommandReceiver { public handleCommand(command: Command) { const commandType = command.constructor.name; - if (commandType in this.commandExecutors) { + if (Object.prototype.hasOwnProperty.call(this.commandExecutors, commandType)) { this.commandExecutors[commandType]!(command); } else { this.defaultCommandExecutor(command); diff --git a/shared/src/commands/types/input-acknowledgement.ts b/shared/src/commands/types/input-acknowledgement.ts index 79f6a83..181977f 100644 --- a/shared/src/commands/types/input-acknowledgement.ts +++ b/shared/src/commands/types/input-acknowledgement.ts @@ -13,6 +13,7 @@ export class CharacterMovementSnapshot { public readonly rightFootNormal: vec2, public readonly groundPlanetId: Id | null, public readonly secondsSinceOnSurface: number, + public readonly leapCooldownRemaining: number = 0, ) {} public toArray(): Array { @@ -23,6 +24,7 @@ export class CharacterMovementSnapshot { this.rightFootNormal, this.groundPlanetId, this.secondsSinceOnSurface, + this.leapCooldownRemaining, ]; } } diff --git a/shared/src/helper/ease.ts b/shared/src/helper/ease.ts index 789ddc0..5349aae 100644 --- a/shared/src/helper/ease.ts +++ b/shared/src/helper/ease.ts @@ -1,34 +1,2 @@ -import { vec2 } from 'gl-matrix'; - export const smoothing = (deltaTimeInSeconds: number, seconds: number): number => 1 - Math.exp(-deltaTimeInSeconds / seconds); - -export const easeVec2 = ( - out: vec2, - target: vec2, - deltaTimeInSeconds: number, - seconds: number, - snapDistance: number, -): vec2 => - vec2.distance(out, target) > snapDistance - ? vec2.copy(out, target) - : vec2.lerp(out, out, target, smoothing(deltaTimeInSeconds, seconds)); - -// Follows a *moving* target. `easeVec2` alone lags one by an amount that depends on the frame -// duration, so an uneven frame — or a dropped one — changes the lag and the follower appears to -// speed up or stall. Carrying the target's own displacement first leaves only the residual to -// decay: a target at a steady speed is tracked exactly, whatever the frame times do, and a -// correction still fades out over `seconds`. -export const followVec2 = ( - out: vec2, - target: vec2, - previousTarget: vec2, - deltaTimeInSeconds: number, - seconds: number, - snapDistance: number, -): vec2 => { - out[0] += target[0] - previousTarget[0]; - out[1] += target[1] - previousTarget[1]; - vec2.copy(previousTarget, target); - return easeVec2(out, target, deltaTimeInSeconds, seconds, snapDistance); -}; diff --git a/shared/src/helper/finite.ts b/shared/src/helper/finite.ts index e659aaf..8eb7bab 100644 --- a/shared/src/helper/finite.ts +++ b/shared/src/helper/finite.ts @@ -32,16 +32,15 @@ export const finiteVec2 = (value: unknown, maxMagnitude: number): vec2 | undefin return undefined; } - const result = vec2.fromValues(x, y); - const length = vec2.length(result); + const length = Math.hypot(x, y); if (length > maxMagnitude) { - vec2.scale(result, result, maxMagnitude / length); + return vec2.fromValues((x / length) * maxMagnitude, (y / length) * maxMagnitude); } - return result; + return vec2.fromValues(x, y); }; export const sanitizeName = (value: unknown, maxLength: number): string => { - const text = typeof value === 'string' ? value : String(value ?? ''); + const text = typeof value === 'string' ? value : ''; return text.slice(0, maxLength); }; diff --git a/shared/src/helper/random.ts b/shared/src/helper/random.ts index d200cf0..654988c 100644 --- a/shared/src/helper/random.ts +++ b/shared/src/helper/random.ts @@ -1,7 +1,7 @@ -let seed = Math.random(); +let seed = (Math.random() * 0x100000000) >>> 0; function getRandom(): number { - let t = (seed += 0x6d2b79f5); + let t = (seed = (seed + 0x6d2b79f5) | 0); t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; @@ -9,7 +9,7 @@ function getRandom(): number { export const Random = { set seed(value: number) { - seed = value; + seed = value | 0; }, getRandom, getRandomInRange(from: number, to: number): number { diff --git a/shared/src/objects/game-object.ts b/shared/src/objects/game-object.ts index 6c8890c..44fba62 100644 --- a/shared/src/objects/game-object.ts +++ b/shared/src/objects/game-object.ts @@ -83,7 +83,7 @@ export abstract class GameObject { } } - public getPropertyUpdates(): PropertyUpdatesForObject | undefined { + public getPropertyUpdates(_timeScale = 1): PropertyUpdatesForObject | undefined { return undefined; } diff --git a/shared/src/physics/character-movement.ts b/shared/src/physics/character-movement.ts index 81c2b08..b314ad3 100644 --- a/shared/src/physics/character-movement.ts +++ b/shared/src/physics/character-movement.ts @@ -7,29 +7,11 @@ import { smoothing } from '../helper/ease'; export const headRadius = 50; export const feetRadius = 20; -const desiredHeadOffset = vec2.fromValues(0, 55); -const desiredLeftFootOffset = vec2.fromValues(-20, 0); -const desiredRightFootOffset = vec2.fromValues(20, 0); -const centerOfMass = vec2.scale( - vec2.create(), - vec2.add( - vec2.create(), - vec2.add(vec2.create(), desiredHeadOffset, desiredLeftFootOffset), - desiredRightFootOffset, - ), - 1 / 3, -); -export const headOffset = vec2.subtract(vec2.create(), desiredHeadOffset, centerOfMass); -export const leftFootOffset = vec2.subtract( - vec2.create(), - desiredLeftFootOffset, - centerOfMass, -); -export const rightFootOffset = vec2.subtract( - vec2.create(), - desiredRightFootOffset, - centerOfMass, -); +// Imports run before setMatrixArrayType(Array): keep these fractional constants in f64. +const centerHeight = 55 * (1 / 3); +export const headOffset: vec2 = [0, 55 - centerHeight]; +export const leftFootOffset: vec2 = [-20, -centerHeight]; +export const rightFootOffset: vec2 = [20, -centerHeight]; export const boundRadius = (headRadius + feetRadius * 2) * 2; export interface CharacterMovementState { @@ -52,11 +34,7 @@ export const applyForce = ( force: vec2, deltaTimeInSeconds: number, ) => { - vec2.add( - body.velocity, - body.velocity, - vec2.scale(vec2.create(), force, deltaTimeInSeconds), - ); + vec2.scaleAndAdd(body.velocity, body.velocity, force, deltaTimeInSeconds); }; // ((head + leftFoot) + rightFoot) / 3 — do not reassociate. @@ -128,20 +106,9 @@ const carryWithRotatingPlanet = ( return; } const angle = -planet.angularVelocity * deltaTimeInSeconds; - const center = planet.center; - state.head.center = vec2.rotate(vec2.create(), state.head.center, center, angle); - state.leftFoot.center = vec2.rotate( - vec2.create(), - state.leftFoot.center, - center, - angle, - ); - state.rightFoot.center = vec2.rotate( - vec2.create(), - state.rightFoot.center, - center, - angle, - ); + for (const body of [state.head, state.leftFoot, state.rightFoot]) { + vec2.rotate(body.center, body.center, planet.center, angle); + } }; export const applyLeapImpulse = (state: CharacterMovementState, moveDirection: vec2) => { @@ -234,10 +201,6 @@ export const decayMomentum = ( } }; -const decayBodyMomentum = (state: CharacterMovementState, deltaTimeInSeconds: number) => { - decayMomentum(state.bodyVelocity, !!state.currentPlanet, deltaTimeInSeconds); -}; - export const sumGravity = (grounds: Array, position: vec2): vec2 => grounds.reduce( (sum, ground) => vec2.add(sum, sum, ground.gravityAt(position)), @@ -335,5 +298,5 @@ export const stepCharacterMovement = ( latchGround(state, world.stepBody(state.rightFoot, deltaTimeInSeconds)); world.stepBody(state.head, deltaTimeInSeconds); - decayBodyMomentum(state, deltaTimeInSeconds); + decayMomentum(state.bodyVelocity, !!state.currentPlanet, deltaTimeInSeconds); }; diff --git a/shared/src/physics/march-circle.ts b/shared/src/physics/march-circle.ts index 4e17329..539d155 100644 --- a/shared/src/physics/march-circle.ts +++ b/shared/src/physics/march-circle.ts @@ -4,7 +4,7 @@ import { evaluateSdf } from './evaluate-sdf'; import { sdfNormal } from './sdf-normal'; export interface MarchResult { - hitSurface: boolean; + travelled: number; normal?: vec2; hitObject?: Sdf; } @@ -20,7 +20,7 @@ export const marchCircle = ( ): MarchResult => { const deltaLength = vec2.length(delta); if (!(deltaLength > 0)) { - return { hitSurface: false }; + return { travelled: 0 }; } const direction = vec2.normalize(vec2.create(), delta); @@ -35,17 +35,17 @@ export const marchCircle = ( if (minDistance < body.radius) { const intersecting = possibleIntersectors.find( - (i) => i.distance(rayEnd) <= body.radius, + (i) => i.canCollide && i.distance(rayEnd) < body.radius, )!; - onHit?.(intersecting); - vec2.scaleAndAdd(rayEnd, body.center, direction, lastFreeDistance); vec2.copy(body.center, rayEnd); + const normal = sdfNormal(rayEnd, [intersecting]); + onHit?.(intersecting); return { - hitSurface: true, - normal: sdfNormal(rayEnd, [intersecting]), + travelled: lastFreeDistance, + normal, hitObject: intersecting, }; } @@ -64,5 +64,5 @@ export const marchCircle = ( vec2.scaleAndAdd(body.center, body.center, direction, lastFreeDistance); - return { hitSurface: false }; + return { travelled: lastFreeDistance }; }; diff --git a/shared/src/physics/resolve-circle-movement.ts b/shared/src/physics/resolve-circle-movement.ts index a0cd612..c3c477c 100644 --- a/shared/src/physics/resolve-circle-movement.ts +++ b/shared/src/physics/resolve-circle-movement.ts @@ -9,35 +9,39 @@ export const resolveCircleMovement = ( possibleIntersectors: Array, onHit?: (intersecting: Sdf) => void, ): Sdf | undefined => { - let delta = vec2.scale(vec2.create(), body.velocity, deltaTimeInSeconds); - depenetrateCircle(body, possibleIntersectors); + let remainingSeconds = deltaTimeInSeconds; + let firstHit: Sdf | undefined; - const { normal, hitSurface, hitObject } = marchCircle( - body, - delta, - possibleIntersectors, - onHit, - ); - - if (hitSurface) { - vec2.copy(body.lastNormal, normal!); - - vec2.subtract( - body.velocity, - body.velocity, - vec2.scale( - normal!, - normal!, - (1 + body.restitution) * vec2.dot(normal!, body.velocity), - ), + // Resolve at most two contacts per step, spending only the time left after each hit. + for (let pass = 0; pass < 2 && remainingSeconds > 0; pass++) { + const speed = vec2.length(body.velocity); + if (speed === 0) { + break; + } + const delta = vec2.scale(vec2.create(), body.velocity, remainingSeconds); + const { normal, hitObject, travelled } = marchCircle( + body, + delta, + possibleIntersectors, + onHit, ); - - if (vec2.length(body.velocity) > 50) { - delta = vec2.scale(vec2.create(), body.velocity, deltaTimeInSeconds); - marchCircle(body, delta, possibleIntersectors, onHit); + if (!hitObject || !normal) { + break; + } + firstHit ??= hitObject; + vec2.copy(body.lastNormal, normal); + remainingSeconds -= travelled / speed; + const inwardSpeed = vec2.dot(normal, body.velocity); + if (inwardSpeed < 0) { + vec2.scaleAndAdd( + body.velocity, + body.velocity, + normal, + -(1 + body.restitution) * inwardSpeed, + ); } } - return hitObject; + return firstHit; }; diff --git a/test/__snapshots__/physics-determinism.test.mjs.snap b/test/__snapshots__/physics-determinism.test.mjs.snap index ab4977d..7972aa5 100644 --- a/test/__snapshots__/physics-determinism.test.mjs.snap +++ b/test/__snapshots__/physics-determinism.test.mjs.snap @@ -2,4 +2,4 @@ exports[`shared character simulation determinism > matches the pinned reference pose (changes only with intentional physics edits) 1`] = `"{"head":[76.14,123.412],"leftFoot":[83.214,105.036],"rightFoot":[79.974,123.01],"bodyVelocity":[0,0]}"`; -exports[`shared character simulation determinism > matches the pinned reference pose on a spinning planet 1`] = `"{"head":[320.826,-34],"leftFoot":[248.634,-50.688],"rightFoot":[281.09,-72.183],"direction":-0.314,"onSurface":true,"secondsSinceOnSurface":0}"`; +exports[`shared character simulation determinism > matches the pinned reference pose on a spinning planet 1`] = `"{"head":[321.49,-34.424],"leftFoot":[249.254,-51.093],"rightFoot":[281.714,-72.595],"direction":-0.315,"onSurface":true,"secondsSinceOnSurface":0}"`; diff --git a/test/backend-regressions.test.ts b/test/backend-regressions.test.ts index 9b3aadb..5ff105f 100644 --- a/test/backend-regressions.test.ts +++ b/test/backend-regressions.test.ts @@ -1,15 +1,32 @@ -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { createRequire } from 'node:module'; +import { performance } from 'node:perf_hooks'; import { PhysicalContainer } from '../backend/src/physics/physical-container'; import { BoundingBox } from '../backend/src/physics/bounding-box'; import { ProjectilePhysical } from '../backend/src/objects/projectile-physical'; import { CharacterPhysical } from '../backend/src/objects/character-physical'; +import { PlanetPhysical } from '../backend/src/objects/planet-physical'; +import { PlayerContainer } from '../backend/src/players/player-container'; +import { defaultOptions } from '../backend/src/options'; +import { GameServer } from '../backend/src/game-server'; const require = createRequire(import.meta.url); const shared = require('../shared/lib/main.js'); const { vec2 } = require('../shared/node_modules/gl-matrix'); -const { Random, settings, CharacterTeam } = shared; +const { + Random, + settings, + CharacterTeam, + SetAspectRatioActionCommand, + TransportEvents, + deserialize, + CreateObjectsCommand, + PropertyUpdatesForObjects, + InputAcknowledgement, + LeapActionCommand, + calculateViewArea, +} = shared; const step = settings.targetPhysicsDeltaTimeInSeconds; @@ -63,6 +80,196 @@ beforeEach(() => { Random.seed = 1; }); +it('credits posthumous kills without making a corpse appear alive to clients', () => { + const { container } = makeWorld(); + const character = spawnCharacter(container); + character.setHealth(0); + character.onDie(); + character.addKill('late projectile victim'); + + expect(character.killCount).toBe(1); + expect(character.health).toBe(0); + expect( + character.getRemoteCalls().some((call) => call.functionName === 'setHealth'), + ).toBe(false); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +const controlledServer = () => { + let nowMs = 1000; + let nextTick: () => void = () => {}; + vi.spyOn(process.hrtime, 'bigint').mockImplementation(() => BigInt(nowMs * 1e6)); + vi.spyOn(performance, 'now').mockImplementation(() => nowMs); + vi.spyOn(globalThis, 'setTimeout').mockImplementation(((callback: () => void) => { + nextTick = callback; + return 0; + }) as never); + const server = new GameServer( + { + on() {}, + to() { + return { emit() {} }; + }, + } as never, + { + ...defaultOptions, + npcCount: 0, + }, + ); + const internals = server as unknown as { + players: PlayerContainer; + objects: PhysicalContainer; + isInEndGame: boolean; + }; + server.start(); + return { + players: internals.players, + objects: internals.objects, + server, + tick(milliseconds: number) { + nowMs += milliseconds; + nextTick(); + }, + }; +}; + +describe('server snapshot cadence', () => { + it('waits for physics before acknowledging input or clearing remote calls', () => { + const { players, objects, tick } = controlledServer(); + const communicate = vi.spyOn(players, 'stepCommunication'); + const character = spawnCharacter(objects); + character.shootTowards(vec2.fromValues(1000, 0)); + + tick(1); + + expect(communicate).not.toHaveBeenCalled(); + expect( + character.getRemoteCalls().some((call) => call.functionName === 'onShoot'), + ).toBe(true); + + tick(4); + + expect(communicate).toHaveBeenCalledOnce(); + expect(character.getRemoteCalls()).toHaveLength(0); + }); + + it.each([false, true])( + 'sends 25 snapshots per simulated second (endgame: %s)', + (endGame) => { + const { players, server, tick } = controlledServer(); + (server as unknown as { isInEndGame: boolean }).isInEndGame = endGame; + const batches: unknown[] = []; + const player = players.createPlayer({ name: 'player' }, { + on() {}, + emit(event: string, batch: unknown) { + if (event === TransportEvents.ServerToPlayer) { + batches.push(batch); + } + }, + } as never); + + for (let i = 0; i < 200; i++) { + tick(5); + } + + expect(batches).toHaveLength(25); + const snapshot = deserialize(batches[24] as string).find( + (command: object) => command instanceof PropertyUpdatesForObjects, + ); + const character = snapshot.updates.find( + (object: { id: number }) => object.id === player.character!.id, + ); + const strength = character.updates.find( + (update: { propertyKey: string }) => update.propertyKey === 'strength', + ); + expect(strength.rateOfChange).toBeCloseTo( + settings.playerStrengthRegenerationPerSeconds * (endGame ? 0.5 : 1), + ); + }, + ); +}); + +describe('slow-motion extrapolation', () => { + const wallDelta = step; + const timeScale = 0.25; + + it('coasts projectiles at the same speed as the slowed server without changing momentum', () => { + const { container } = makeWorld(); + const projectile = spawnProjectile(container); + const before = vec2.clone(projectile.center); + projectile.step(wallDelta * timeScale); + const update = projectile.getPropertyUpdates(timeScale).updates[0]; + + expect( + vec2.scaleAndAdd(vec2.create(), before, update.rateOfChange, wallDelta), + ).toEqual(projectile.center); + expect(projectile.object.velocity).toEqual([3000, 0]); + const strength = projectile + .getPropertyUpdates(timeScale) + .updates.find((property) => property.propertyKey === 'strength')!; + expect(40 + strength.rateOfChange * wallDelta).toBeCloseTo(projectile.strength); + expect(strength.propertyValue).toBe(projectile.strength); + }); + + it('coasts planet rotation using wall-clock seconds', () => { + const { game } = makeWorld(); + const planet = new PlanetPhysical( + [ + [-100, -100], + [-100, 100], + [100, 100], + [100, -100], + ], + false, + game, + ); + const angularVelocity = planet.angularVelocity; + planet.step(wallDelta * timeScale); + const update = planet + .getPropertyUpdates(timeScale) + .updates.find((property) => property.propertyKey === 'rotation')!; + + expect(update.rateOfChange * wallDelta).toBeCloseTo(planet.rotation); + expect(planet.angularVelocity).toBe(angularVelocity); + }); + + it('scales character pose derivatives without mutating the stored simulation rates', () => { + const { container } = makeWorld(); + const character = spawnCharacter(container); + character.step(settings.spawnDespawnTime + step); + character.setMoveDirection(vec2.fromValues(1, 0)); + vec2.set(character.bodyVelocity, 1000, 0); + const parts = [character.head, character.leftFoot, character.rightFoot]; + const before = parts.map((part) => vec2.clone(part.center)); + character.step(wallDelta * timeScale); + const originalRates = character.getPropertyUpdates(); + const slowedRates = character.getPropertyUpdates(timeScale); + + parts.forEach((part, i) => { + const rate = slowedRates.updates[i].rateOfChange; + expect(vec2.scaleAndAdd(vec2.create(), before[i], rate.center, wallDelta)).toEqual( + part.center, + ); + }); + expect(character.getPropertyUpdates()).toEqual(originalRates); + }); + + it('coasts spawn growth using the slowed radius derivative', () => { + const { container } = makeWorld(); + const character = spawnCharacter(container); + character.step(0.1); + const before = character.head.radius; + character.step(wallDelta * timeScale); + const radiusSpeed = + character.getPropertyUpdates(timeScale).updates[0].rateOfChange.radius; + + expect(before + radiusSpeed * wallDelta).toBeCloseTo(character.head.radius); + }); +}); + describe('projectile broadphase registration', () => { it('follows the projectile instead of staying at the muzzle', () => { const { container } = makeWorld(); @@ -88,6 +295,168 @@ describe('projectile broadphase registration', () => { expect(projectile.center[0]).toBeLessThanOrEqual(15); }); + + it('does not advance a projectile destroyed earlier in the same world step', () => { + const { container } = makeWorld(); + const projectile = spawnProjectile(container); + const before = vec2.clone(projectile.center); + const character = spawnCharacter(container); + character.step = () => projectile.destroy(); + container.addObject(projectile); + + container.step(step); + + expect(projectile.center).toEqual(before); + expect(projectile.strength).toBe(40); + }); +}); + +describe('authoritative held movement', () => { + it('distinguishes no leap from an attempted leap timestamped at zero', () => { + const { container } = makeWorld(); + const players = new PlayerContainer(container, 1, 0); + const batches: string[] = []; + const player = players.createPlayer({ name: 'player' }, { + on() {}, + emit(event: string, payload: string) { + if (event === TransportEvents.ServerToPlayer) { + batches.push(payload); + } + }, + } as never); + const sendSnapshot = () => + player.stepCommunications(settings.updateMessageInterval, 1000, (object) => + object.getPropertyUpdates(), + ); + const leapAck = () => + deserialize(batches[batches.length - 1]).find( + (command: object) => command instanceof InputAcknowledgement, + ).lastLeapClientTimeMs; + + sendSnapshot(); + expect(leapAck()).toBe(-1); + + player.handleCommand(new LeapActionCommand(0)); + sendSnapshot(); + expect(leapAck()).toBe(0); + }); + + it('applies the latest direction when press and release arrive in the same tick', () => { + const { container } = makeWorld(); + const character = spawnCharacter(container); + character.step(settings.spawnDespawnTime + step); + const before = vec2.clone(character.center); + + character.setMoveDirection(vec2.fromValues(1, 0)); + character.setMoveDirection(vec2.fromValues(0, 0)); + character.step(step); + + expect(character.center).toEqual(before); + }); + + it('uses the latest held direction for a leap received in the same batch', () => { + const { container } = makeWorld(); + const character = spawnCharacter(container); + character.step(settings.spawnDespawnTime + step); + character.currentPlanet = { + center: vec2.fromValues(0, -100), + angularVelocity: 0, + } as never; + + character.setMoveDirection(vec2.fromValues(1, 0)); + character.leap(); + + expect(character.bodyVelocity[0]).toBeGreaterThan(0); + expect(character.movementSnapshot.leapCooldownRemaining).toBeCloseTo( + settings.leapCooldownSeconds, + ); + character.step(step); + expect(character.movementSnapshot.leapCooldownRemaining).toBeCloseTo( + settings.leapCooldownSeconds - step, + ); + }); +}); + +describe('planets', () => { + it('does not capture using a character killed after registering its presence', () => { + const { container, game } = makeWorld(); + const character = spawnCharacter(container); + const planet = new PlanetPhysical( + [ + [-100, -100], + [-100, 100], + [100, 100], + [100, -100], + ], + false, + game, + ); + planet.registerPresence(character); + character.onDie(); + + planet.step(step); + + expect(planet.ownership).toBe(0.5); + }); + + it.each([ + { aspect: 8, center: [0, 750] }, + { aspect: 0.2, center: [750, 0] }, + ])('sends offscreen gravity sources at aspect $aspect', ({ aspect, center }) => { + const { container, game } = makeWorld(); + const batches: string[] = []; + const socket = { + on() {}, + emit(event: string, payload: string) { + if (event === TransportEvents.ServerToPlayer) { + batches.push(payload); + } + }, + }; + const players = new PlayerContainer(container, 1, 0); + const player = players.createPlayer({ name: 'player' }, socket as never); + const [x, y] = center; + const planet = new PlanetPhysical( + [ + [x - 20, y - 20], + [x - 20, y + 20], + [x + 20, y + 20], + [x + 20, y - 20], + ], + false, + game, + ); + container.addObject(planet); + player.handleCommand(new SetAspectRatioActionCommand(aspect)); + const { topLeft, size } = calculateViewArea(player.center, aspect, 1.2); + expect( + planet.boundingBox.intersects( + new BoundingBox( + topLeft[0], + topLeft[0] + size[0], + topLeft[1] - size[1], + topLeft[1], + ), + ), + ).toBe(false); + + player.stepCommunications(settings.updateMessageInterval + step, 1000, (object) => + object.getPropertyUpdates(), + ); + + const created = deserialize(batches[0]).find( + (command: object) => command instanceof CreateObjectsCommand, + ); + expect(created.objects.map((object: { id: number }) => object.id)).toContain( + planet.id, + ); + }); +}); + +it('uses an integer default seed so separate server runs can generate different worlds', () => { + expect(Number.isInteger(defaultOptions.seed)).toBe(true); + expect(defaultOptions.seed).toBeGreaterThanOrEqual(0); + expect(defaultOptions.seed).toBeLessThan(0x100000000); }); describe('scoring', () => { @@ -124,6 +493,23 @@ describe('scoring', () => { }); }); +describe('NPC population', () => { + it('replaces a departing human without adding extra bots for humans still playing', () => { + const { container } = makeWorld(); + const players = new PlayerContainer(container, 16, 4); + const socket = { on() {} } as never; + const first = players.createPlayer({ name: 'first' }, socket); + players.createPlayer({ name: 'second' }, socket); + expect(players.players).toHaveLength(4); + + first.destroy(); + players.deletePlayer(first); + + expect(players.count).toBe(1); + expect(players.players).toHaveLength(4); + }); +}); + describe('charged shots', () => { it('scales the shot by the strength actually paid, not the requested charge', () => { const { container } = makeWorld(); diff --git a/test/backpressure.test.ts b/test/backpressure.test.ts index b82fa1d..dd45cfe 100644 --- a/test/backpressure.test.ts +++ b/test/backpressure.test.ts @@ -15,13 +15,19 @@ const { CreateObjectsCommand, DeleteObjectsCommand, PropertyUpdatesForObjects, + PropertyUpdatesForObject, + UpdatePropertyCommand, + InputAcknowledgement, UpdateMinimap, ServerAnnouncement, } = shared; class FakeSocket { public readonly sent: Array<{ event: string; payload: unknown }> = []; - public readonly conn = { transport: { socket: { bufferedAmount: 0 } } }; + public readonly conn = { + transport: { socket: { bufferedAmount: 0 } }, + writeBuffer: [] as Array<{ data: unknown }>, + }; public set bufferedAmount(bytes: number) { this.conn.transport.socket.bufferedAmount = bytes; @@ -58,7 +64,19 @@ const queueOneSnapshot = (player: ReturnType['player']) => { player.queueCommandSend(new DeleteObjectsCommand([1234])); player.queueCommandSend(new ServerAnnouncement('team blue wins')); player.queueCommandSend(new UpdateMinimap([])); - player.queueCommandSend(new PropertyUpdatesForObjects([], 1)); + player.queueCommandSend( + new PropertyUpdatesForObjects( + [ + new PropertyUpdatesForObject(1234, [ + new UpdatePropertyCommand('ownership', 1, 0), + ]), + ], + 1, + ), + ); + player.queueCommandSend( + new InputAcknowledgement(123, player.character!.movementSnapshot, 0, 0), + ); }; const typesIn = (batch: Array<{ constructor: { name: string } }>) => @@ -80,6 +98,7 @@ describe('slow-client backpressure', () => { ServerAnnouncement.name, UpdateMinimap.name, PropertyUpdatesForObjects.name, + InputAcknowledgement.name, ]); }); @@ -89,7 +108,8 @@ describe('slow-client backpressure', () => { queueOneSnapshot(player); player.sendQueuedCommandsToClient(); - expect(typesIn(socket.lastBatch)).not.toContain(PropertyUpdatesForObjects.name); + const marker = socket.lastBatch.find((c) => c instanceof PropertyUpdatesForObjects); + expect(marker).toMatchObject({ updates: [], timestamp: 1 }); }); it('sheds only the state the next tick regenerates', () => { @@ -99,7 +119,8 @@ describe('slow-client backpressure', () => { player.sendQueuedCommandsToClient(); const types = typesIn(socket.lastBatch); - expect(types).not.toContain(PropertyUpdatesForObjects.name); + expect(types).toContain(PropertyUpdatesForObjects.name); + expect(types).not.toContain(InputAcknowledgement.name); expect(types).not.toContain(UpdateMinimap.name); expect(types).toContain(CreateObjectsCommand.name); expect(types).toContain(DeleteObjectsCommand.name); @@ -115,4 +136,32 @@ describe('slow-client backpressure', () => { expect(socket.sent.length).toBe(before); }); + + it('also measures packets waiting inside Engine.IO for the transport to drain', () => { + const { player, socket } = makePlayer(); + socket.conn.writeBuffer.push({ + data: Buffer.alloc(settings.maxBufferedBytesPerClient + 1), + }); + queueOneSnapshot(player); + player.sendQueuedCommandsToClient(); + + const marker = socket.lastBatch.find((c) => c instanceof PropertyUpdatesForObjects); + expect(marker).toMatchObject({ updates: [], timestamp: 1 }); + expect(typesIn(socket.lastBatch)).not.toContain(InputAcknowledgement.name); + }); + + it('sends a fresh pose and acknowledgement after the transport drains', () => { + const { player, socket } = makePlayer(); + socket.bufferedAmount = settings.maxBufferedBytesPerClient + 1; + queueOneSnapshot(player); + player.sendQueuedCommandsToClient(); + + socket.bufferedAmount = 0; + queueOneSnapshot(player); + player.sendQueuedCommandsToClient(); + + const snapshot = socket.lastBatch.find((c) => c instanceof PropertyUpdatesForObjects); + expect(snapshot).toMatchObject({ updates: [expect.anything()], timestamp: 1 }); + expect(typesIn(socket.lastBatch)).toContain(InputAcknowledgement.name); + }); }); diff --git a/test/client-object-lifecycle.test.ts b/test/client-object-lifecycle.test.ts new file mode 100644 index 0000000..f4992c5 --- /dev/null +++ b/test/client-object-lifecycle.test.ts @@ -0,0 +1,206 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createRequire } from 'node:module'; +const { + CreateObjectsCommand, + DeleteObjectsCommand, + PropertyUpdatesForObjects, + RemoteCallsForObjects, + RemoteCallsForObject, + RemoteCall, + UpdatePropertyCommand, + CharacterTeam, +} = createRequire(import.meta.url)('../shared/lib/main.js'); +import { GameObjectContainer } from '../frontend/src/scripts/objects/game-object-container'; +import { PlanetView } from '../frontend/src/scripts/objects/types/planet-view'; +import { serverTimeline } from '../frontend/src/scripts/helper/server-timeline'; +import { LinearInterpolator } from '../frontend/src/scripts/helper/interpolators/linear-interpolator'; +import { ProjectileView } from '../frontend/src/scripts/objects/types/projectile-view'; + +vi.mock('../frontend/node_modules/sdf-2d', () => ({ + CircleLight: class { + constructor( + public center: number[], + public color: number[], + public intensity: number, + ) {} + }, +})); + +vi.mock('../frontend/src/scripts/objects/types/camera', () => ({ + Camera: class { + draw() {} + follow() {} + }, +})); +vi.mock('../frontend/src/scripts/objects/types/planet-view', () => ({ + PlanetView: class { + beforeDestroy = vi.fn(); + render = vi.fn(); + processRemoteCalls = vi.fn(); + step() {} + constructor(public id: number) {} + }, +})); +vi.mock('../frontend/src/scripts/feedback-hud', () => ({ FeedbackHud: {} })); + +let nowMs = 0; +beforeEach(() => { + nowMs = 0; + vi.spyOn(performance, 'now').mockImplementation(() => nowMs); + serverTimeline.reset(); +}); + +describe('remote effects', () => { + it('plays remote effects on the render timeline and local feedback immediately', () => { + const container = new GameObjectContainer({} as never); + const remote = new PlanetView(1, []); + const local = { + id: 2, + health: 100, + position: [0, 0], + snapshotStrength: 80, + step() {}, + processRemoteCalls: vi.fn(), + }; + container.handleCommand(new CreateObjectsCommand([remote, local])); + container.player = local as never; + const calls = [new RemoteCall('onShoot', [40])]; + container.handleCommand( + new RemoteCallsForObjects([ + new RemoteCallsForObject(remote.id, calls), + new RemoteCallsForObject(local.id, calls), + ]), + ); + container.handleCommand(new PropertyUpdatesForObjects([], 5)); + expect(local.processRemoteCalls).toHaveBeenCalledWith(calls); + step(0); + container.step(0); + expect(remote.processRemoteCalls).not.toHaveBeenCalled(); + step(0.11); + container.step(0); + expect(remote.processRemoteCalls).toHaveBeenCalledWith(calls); + }); + + it('discards queued effects when a view is replaced or the game resets', () => { + const container = new GameObjectContainer({} as never); + const original = new PlanetView(1, []); + container.handleCommand(new CreateObjectsCommand([original])); + container.handleCommand( + new RemoteCallsForObjects([ + new RemoteCallsForObject(1, [new RemoteCall('onFlipped', ['red'])]), + ]), + ); + container.handleCommand(new PropertyUpdatesForObjects([], 5)); + const replacement = new PlanetView(1, []); + container.handleCommand(new CreateObjectsCommand([replacement])); + container.handleCommand(new PropertyUpdatesForObjects([], 5.04)); + step(0.3); + container.step(0); + expect(original.processRemoteCalls).not.toHaveBeenCalled(); + expect(replacement.processRemoteCalls).not.toHaveBeenCalled(); + + container.handleCommand( + new RemoteCallsForObjects([ + new RemoteCallsForObject(1, [new RemoteCall('onFlipped', ['blue'])]), + ]), + ); + container.reset(); + container.step(0); + expect(replacement.processRemoteCalls).not.toHaveBeenCalled(); + }); +}); +afterEach(() => vi.restoreAllMocks()); + +const step = (deltaSeconds: number) => { + nowMs += deltaSeconds * 1000; + serverTimeline.step(deltaSeconds); +}; + +describe('snapshot object lifetime', () => { + it('keeps creation and deletion aligned with the delayed render timeline', () => { + const container = new GameObjectContainer({} as never); + const planet = new PlanetView(1, []); + container.handleCommand(new CreateObjectsCommand([planet])); + container.handleCommand(new PropertyUpdatesForObjects([], 5)); + step(0); + container.render({} as never, {} as never, true); + expect(planet.render).not.toHaveBeenCalled(); + + step(0.11); + container.render({} as never, {} as never, true); + expect(planet.render).toHaveBeenCalledOnce(); + + container.handleCommand(new DeleteObjectsCommand([1])); + container.handleCommand(new PropertyUpdatesForObjects([], 5.2)); + container.step(0); + expect(planet.beforeDestroy).not.toHaveBeenCalled(); + step(0.3); + container.step(0); + expect(planet.beforeDestroy).toHaveBeenCalledOnce(); + expect(container.planets).toHaveLength(0); + }); + + it('does not delete a replacement when an object re-enters before delayed retirement', () => { + const container = new GameObjectContainer({} as never); + const original = new PlanetView(1, []); + container.handleCommand(new CreateObjectsCommand([original])); + container.handleCommand(new PropertyUpdatesForObjects([], 5)); + step(0); + container.handleCommand(new DeleteObjectsCommand([1])); + container.handleCommand(new PropertyUpdatesForObjects([], 5.04)); + + const replacement = new PlanetView(1, []); + container.handleCommand(new CreateObjectsCommand([replacement])); + container.handleCommand(new PropertyUpdatesForObjects([], 5.08)); + step(0.3); + container.step(0); + container.render({} as never, {} as never, true); + + expect(original.beforeDestroy).toHaveBeenCalledOnce(); + expect(replacement.beforeDestroy).not.toHaveBeenCalled(); + expect(replacement.render).toHaveBeenCalledOnce(); + expect(container.planets).toEqual([replacement]); + }); +}); + +describe('remote render timeline', () => { + it('samples projectile strength on the server timeline, including slowed fading', () => { + const projectile = new ProjectileView(1, [0, 0] as never, 10, CharacterTeam.red, 100); + serverTimeline.onSnapshot(5); + projectile.updateProperty(new UpdatePropertyCommand('strength', 100, -10)); + serverTimeline.onSnapshot(5.2); + projectile.updateProperty(new UpdatePropertyCommand('strength', 98, -10)); + step(0); + projectile.step(2); + expect(projectile.strength).toBeCloseTo(99, 6); + expect([...projectile.center]).toEqual([0, 0]); + }); + + it('does not accumulate an extra frame of lead while chasing its target', () => { + serverTimeline.onSnapshot(10); + step(0); + for (let i = 0; i < 600; i++) step(1 / 60); + expect(serverTimeline.renderTime).toBeCloseTo(19.9, 8); + }); + + it('interpolates positions and bounds extrapolation while snapshots are missing', () => { + const position = new LinearInterpolator(0); + serverTimeline.onSnapshot(10); + position.addFrame(0, 100); + serverTimeline.onSnapshot(10.1); + position.addFrame(10, 100); + step(0); + step(0.05); + expect(position.getValue(0.05)).toBeCloseTo(5, 6); + step(0.4); + expect(position.getValue(0.4)).toBeCloseTo(16, 6); + }); + + it('ages a new snapshot from its arrival, not the beginning of the render frame', () => { + nowMs = 15; + serverTimeline.onSnapshot(10); + nowMs = 16; + serverTimeline.step(0.016); + expect(serverTimeline.renderTime).toBeCloseTo(9.901, 6); + }); +}); diff --git a/test/client-send-cadence.test.ts b/test/client-send-cadence.test.ts index b94ffce..c199b14 100644 --- a/test/client-send-cadence.test.ts +++ b/test/client-send-cadence.test.ts @@ -81,4 +81,21 @@ describe('client send cadence', () => { expect(batches).toHaveLength(0); }); + + it('discards commands from the previous connection before the next join', () => { + const batches: Array = []; + const socket = new CommandSocket({ + connected: true, + emit: (_event: string, payload: string) => batches.push(payload), + } as never); + setFrameTimeMs(1000); + socket.sendQueuedCommands(); + socket.queue(new MoveActionCommand(vec2.fromValues(1, 0), 1000)); + socket.reset(); + socket.sendQueuedCommands(); + + expect(batches).toHaveLength(2); + expect(batches[1]).toContain(ClientHeartbeatCommand.name); + expect(batches[1]).not.toContain(MoveActionCommand.name); + }); }); diff --git a/test/frontend-regressions.test.ts b/test/frontend-regressions.test.ts new file mode 100644 index 0000000..5379f5b --- /dev/null +++ b/test/frontend-regressions.test.ts @@ -0,0 +1,302 @@ +import { EventEmitter } from 'node:events'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { KeyboardListener } from '../frontend/src/scripts/commands/keyboard-listener'; +import { TouchListener } from '../frontend/src/scripts/commands/touch-listener'; +import { JoinFormHandler } from '../frontend/src/scripts/join-form-handler'; +import { initializeOptions, options } from '../frontend/src/scripts/options-handler'; +import { loadStoredValue, saveStoredValue } from '../frontend/src/scripts/helper/storage'; + +const { connect } = vi.hoisted(() => ({ connect: vi.fn() })); +vi.mock('../frontend/node_modules/socket.io-client/build/esm-debug/index.js', () => ({ + io: connect, +})); +vi.mock('../frontend/src/scripts/configuration', () => ({ + servers: ['https://first.test', 'https://second.test'], +})); +vi.mock('../frontend/src/scripts/sound-handler', () => ({ + Sounds: { click: 'click' }, + SoundHandler: { play: vi.fn(), playAmbient: vi.fn(), stopAmbient: vi.fn() }, +})); +vi.mock('../frontend/src/scripts/charge-indicator', () => ({ + ChargeIndicator: { begin: vi.fn(), end: vi.fn() }, +})); + +// Only the DOM operations exercised by the chooser and input listeners are needed. +class Element extends EventTarget { + public children: Element[] = []; + public parentElement?: Element; + public style: Record = {}; + public className = ''; + public checked = false; + public disabled = false; + public onsubmit: ((event: Event) => void) | null = null; + + public appendChild(child: Element) { + child.remove(); + this.children.push(child); + child.parentElement = this; + } + public append(...children: Element[]) { + children.forEach((child) => this.appendChild(child)); + } + public remove() { + if (this.parentElement) { + this.parentElement.children = this.parentElement.children.filter((c) => c !== this); + this.parentElement = undefined; + } + } + public querySelector() { + return this.children[0]; + } + public getBoundingClientRect() { + return { left: 0, top: 0, width: 100, height: 100 }; + } +} + +class Socket extends EventEmitter { + public io = new EventEmitter(); + public close = vi.fn(); +} + +const dispatch = (target: EventTarget, type: string, fields = {}) => { + const event = Object.assign(new Event(type, { cancelable: true }), fields); + target.dispatchEvent(event); + return event; +}; +const touch = (identifier: number, clientX = 50, clientY = 50) => ({ + identifier, + clientX, + clientY, +}); +const touchEvent = ( + target: EventTarget, + type: string, + changedTouches: unknown[], + touches = changedTouches, +) => dispatch(target, type, { changedTouches, touches }); + +let windowTarget: EventTarget; +let sockets: Socket[]; + +beforeEach(() => { + vi.useFakeTimers(); + windowTarget = new EventTarget(); + sockets = []; + vi.stubGlobal('window', windowTarget); + vi.stubGlobal('addEventListener', windowTarget.addEventListener.bind(windowTarget)); + vi.stubGlobal( + 'removeEventListener', + windowTarget.removeEventListener.bind(windowTarget), + ); + vi.stubGlobal('document', { createElement: () => new Element() }); + vi.stubGlobal('localStorage', { getItem: vi.fn(() => null), setItem: vi.fn() }); + connect.mockImplementation(() => { + const socket = new Socket(); + sockets.push(socket); + return socket; + }); +}); + +afterEach(() => { + vi.clearAllTimers(); + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.clearAllMocks(); +}); + +const chooser = () => { + const form = new Element(); + form.appendChild(new Element()); + const container = new Element(); + const handler = new JoinFormHandler(form as never, container as never); + return { form, container, handler }; +}; +const serverInfo = { + serverName: 'test', + playerCount: 1, + playerLimit: 8, + gameStatePercent: 20, +}; + +describe('server chooser lifecycle', () => { + it('aborts discovery and ignores responses completing after the chooser closes', async () => { + const signals: AbortSignal[] = []; + let resolveBody!: (value: unknown) => void; + const body = new Promise((resolve) => { + resolveBody = resolve; + }); + vi.stubGlobal( + 'fetch', + vi.fn((_url, { signal }) => { + signals.push(signal); + return Promise.resolve({ ok: true, json: () => body }); + }), + ); + const { form, container, handler } = chooser(); + await Promise.resolve(); + handler.destroy(); + resolveBody(serverInfo); + await vi.runAllTimersAsync(); + + expect(signals.every((signal) => signal.aborted)).toBe(true); + expect(sockets).toHaveLength(0); + expect(container.children).toHaveLength(0); + expect(form.onsubmit).toBeNull(); + expect(vi.getTimerCount()).toBe(0); + }); + + it('selects a remaining server when the selected connection fails and removes listeners on exit', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ ok: true, json: async () => serverInfo })), + ); + const { form, container, handler } = chooser(); + await vi.advanceTimersByTimeAsync(0); + expect(container.children).toHaveLength(2); + expect(container.children[0].children[0].checked).toBe(true); + expect(container.children[1].children[0].checked).toBe(false); + + sockets[0].io.emit('reconnect_failed'); + expect(container.children).toHaveLength(1); + expect(container.children[0].children[0].checked).toBe(true); + expect(form.children[0].disabled).toBe(false); + + handler.destroy(); + expect( + sockets.every((socket) => socket.io.listenerCount('reconnect_failed') === 0), + ).toBe(true); + expect(container.children).toHaveLength(0); + expect(vi.getTimerCount()).toBe(0); + }); +}); + +describe('movement input', () => { + it('ignores unrelated keys, prevents browser movement defaults, and resends held keys after respawn', () => { + const send = vi.fn(); + const listener = new KeyboardListener(send); + dispatch(windowTarget, 'keydown', { code: 'KeyP' }); + expect(send).not.toHaveBeenCalled(); + const key = dispatch(windowTarget, 'keydown', { code: 'ArrowRight' }); + expect(key.defaultPrevented).toBe(true); + expect(send.mock.lastCall?.[0].direction).toEqual([1, 0]); + listener.resendMovement(); + expect(send).toHaveBeenCalledTimes(2); + dispatch(windowTarget, 'blur'); + expect(send.mock.lastCall?.[0].direction).toEqual([0, 0]); + listener.resendMovement(); + expect(send).toHaveBeenCalledTimes(3); + listener.destroy(); + }); +}); + +const touchControls = () => { + const canvas = new Element(); + const overlay = new Element(); + const send = vi.fn(); + const game = { + displayToWorldCoordinates: (position: number[]) => position, + gameObjects: { + localPlayer: { bodyCenter: [0, 0], facingDirection: [1, 0], strengthFraction: 1 }, + }, + }; + const listener = new TouchListener( + canvas as never, + overlay as never, + game as never, + send, + ); + listener.update(); + return { canvas, overlay, send, listener, fire: overlay.children[0] }; +}; + +describe('touch ownership', () => { + it('keeps the first canvas gesture when a second finger lands before dragging', () => { + const { canvas, send, listener } = touchControls(); + touchEvent(canvas, 'touchstart', [touch(1, 10, 20)]); + touchEvent( + canvas, + 'touchstart', + [touch(2, 80, 90)], + [touch(1, 10, 20), touch(2, 80, 90)], + ); + touchEvent(canvas, 'touchend', [touch(2, 80, 90)], [touch(1, 10, 20)]); + expect(send).not.toHaveBeenCalled(); + touchEvent(canvas, 'touchend', [touch(1, 10, 20)], []); + expect(send).toHaveBeenCalledTimes(1); + expect(send.mock.lastCall?.[0].constructor.name).toBe('PrimaryActionCommand'); + listener.destroy(); + }); + + it('does not fire when a charge is cancelled or another finger releases', () => { + const { fire, send, listener } = touchControls(); + touchEvent(fire, 'touchstart', [touch(1)]); + touchEvent(fire, 'touchstart', [touch(2)]); + touchEvent(fire, 'touchend', [touch(2)], [touch(1)]); + expect(send).not.toHaveBeenCalled(); + touchEvent(fire, 'touchcancel', [touch(1)], []); + touchEvent(fire, 'touchend', [touch(1)], []); + expect(send).not.toHaveBeenCalled(); + touchEvent(fire, 'touchstart', [touch(3)]); + touchEvent(fire, 'touchend', [touch(3)], []); + expect(send).toHaveBeenCalledTimes(1); + listener.destroy(); + }); + + it('resends a held joystick after respawn and stops movement when focus is lost', () => { + const { canvas, overlay, send, listener } = touchControls(); + touchEvent(canvas, 'touchstart', [touch(1)]); + touchEvent(canvas, 'touchmove', [touch(1, 100, 50)]); + listener.resendMovement(); + expect(send.mock.lastCall?.[0].direction[0]).toBe(1); + expect(send.mock.lastCall?.[0].direction[1]).toBeCloseTo(0); + dispatch(windowTarget, 'blur'); + expect(send.mock.lastCall?.[0].direction).toEqual([0, 0]); + expect(overlay.children).toHaveLength(2); + touchEvent(canvas, 'touchstart', [touch(2)]); + touchEvent(canvas, 'touchmove', [touch(2, 100, 50)]); + listener.destroy(); + expect(overlay.children).toHaveLength(0); + }); +}); + +describe('stored preferences', () => { + it('ignores corrupt JSON and unavailable storage', () => { + vi.mocked(localStorage.getItem).mockReturnValue('{'); + expect(loadStoredValue('userName')).toBeUndefined(); + vi.mocked(localStorage.getItem).mockImplementation(() => { + throw new Error('disabled'); + }); + vi.mocked(localStorage.setItem).mockImplementation(() => { + throw new Error('disabled'); + }); + expect(loadStoredValue('options')).toBeUndefined(); + expect(() => saveStoredValue('userName', 'player')).not.toThrow(); + }); + + it('only loads boolean options that the game recognizes', () => { + Object.assign(options, { + soundsEnabled: true, + musicEnabled: true, + vibrationEnabled: true, + }); + vi.mocked(localStorage.getItem).mockReturnValue( + JSON.stringify({ + soundsEnabled: 'false', + musicEnabled: false, + vibrationEnabled: null, + extra: true, + }), + ); + initializeOptions({ + soundsEnabled: new Element(), + musicEnabled: new Element(), + vibrationEnabled: new Element(), + } as never); + expect(options).toEqual({ + soundsEnabled: true, + musicEnabled: false, + vibrationEnabled: true, + }); + }); +}); diff --git a/test/physics-determinism.test.mjs b/test/physics-determinism.test.mjs index bbb36f6..300f4cf 100644 --- a/test/physics-determinism.test.mjs +++ b/test/physics-determinism.test.mjs @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest'; import { createRequire } from 'node:module'; +import { execFileSync } from 'node:child_process'; const require = createRequire(import.meta.url); const shared = require('../shared/lib/main.js'); @@ -99,6 +100,41 @@ const runPlanetSimulation = () => { }; describe('shared character simulation determinism', () => { + it('does not depend on configuring vector precision before imports', () => { + const run = (configureBeforeImport) => + execFileSync( + process.execPath, + [ + '-e', + ` + const { glMatrix, vec2 } = require('./shared/node_modules/gl-matrix'); + if (${configureBeforeImport}) glMatrix.setMatrixArrayType(Array); + const s = require('./shared/lib/main.js'); + glMatrix.setMatrixArrayType(Array); + const body = (offset, radius) => ({ + center: vec2.clone(offset), radius, velocity: vec2.create(), + lastNormal: [0, 1], restitution: 0, + }); + const state = { + head: body(s.headOffset, s.headRadius), + leftFoot: body(s.leftFootOffset, s.feetRadius), + rightFoot: body(s.rightFootOffset, s.feetRadius), + direction: 0, currentPlanet: undefined, + secondsSinceOnSurface: 1, bodyVelocity: [0, 0], + }; + const world = { + groundsNear: () => [], + stepBody: (body, dt) => s.resolveCircleMovement(body, dt, []), + }; + for (let i = 0; i < 100; i++) s.stepCharacterMovement(state, world, [1, 0], 0.005); + process.stdout.write(JSON.stringify(state)); + `, + ], + { encoding: 'utf8' }, + ); + expect(run(false)).toBe(run(true)); + }); + it('produces identical output across independent runs', () => { expect(runSimulation()).toBe(runSimulation()); }); diff --git a/test/prediction-reconciliation.test.ts b/test/prediction-reconciliation.test.ts index 8ed2ba9..eed53f0 100644 --- a/test/prediction-reconciliation.test.ts +++ b/test/prediction-reconciliation.test.ts @@ -38,7 +38,7 @@ describe('local prediction reconciliation', () => { auth.rightFoot as never, ); - const used = predictor.update([], 1 / 60); + const used = predictor.update([]); expect(used).toBe(true); expect(predictor.head.center[0]).toBeCloseTo(auth.head.center[0], 5); @@ -62,7 +62,7 @@ describe('local prediction reconciliation', () => { predictor.recordInput([1, 0]); setFrameTimeMs(1100); - predictor.update([], 1 / 60); + predictor.update([]); return [ predictor.head.center[0], predictor.head.center[1], @@ -94,7 +94,7 @@ describe('local prediction reconciliation', () => { predictor.setAlive(false); setFrameTimeMs(5200); - expect(predictor.update([], 1 / 60)).toBe(false); + expect(predictor.update([])).toBe(false); }); it('replays the whole un-acknowledged span, so a longer trip predicts further', () => { @@ -113,7 +113,7 @@ describe('local prediction reconciliation', () => { auth.rightFoot as never, ); - predictor.update([], 1 / 60); + predictor.update([]); return predictor.head.center[0]; }; @@ -146,7 +146,7 @@ describe('local prediction reconciliation', () => { auth.rightFoot as never, ); - predictor.update([], 1 / 60); + predictor.update([]); return predictor.head.center[0]; }; @@ -175,7 +175,7 @@ describe('local prediction reconciliation', () => { ); setFrameTimeMs(40); - predictor.update([], 1 / 60); + predictor.update([]); return [predictor.head.center[0], predictor.head.center[1]]; }; @@ -189,3 +189,127 @@ describe('local prediction reconciliation', () => { expect(apart).toBeGreaterThan(1); }); }); + +const settledPoseAt = (x: number) => ({ + head: { center: [x, 55 - 55 / 3], radius: HEAD_RADIUS }, + leftFoot: { center: [x - 20, -55 / 3], radius: FEET_RADIUS }, + rightFoot: { center: [x + 20, -55 / 3], radius: FEET_RADIUS }, +}); + +const install = (predictor: LocalCharacterPredictor, timeMs: number, x: number) => { + const pose = settledPoseAt(x); + predictor.acknowledge(timeMs, movementState() as never, -Infinity); + predictor.setAuthoritative( + pose.head as never, + pose.leftFoot as never, + pose.rightFoot as never, + ); +}; + +describe('prediction corrections and interruptions', () => { + it('blends an authoritative correction and converges without slowing ordinary motion', () => { + const predictor = new LocalCharacterPredictor(); + setFrameTimeMs(1000); + install(predictor, 1000, 0); + predictor.update([]); + + setFrameTimeMs(1016); + install(predictor, 1016, 100); + predictor.update([]); + expect(predictor.head.center[0]).toBeCloseTo(0, 5); + + setFrameTimeMs(1032); + predictor.update([]); + expect(predictor.head.center[0]).toBeGreaterThan(0); + expect(predictor.head.center[0]).toBeLessThan(100); + + setFrameTimeMs(1516); + predictor.update([]); + expect(predictor.head.center[0]).toBeCloseTo(100, 0); + }); + + it('freezes at the replay horizon during an outage even if held input changes', () => { + const predictor = new LocalCharacterPredictor(); + setFrameTimeMs(0); + install(predictor, 0, 0); + predictor.recordInput([1, 0] as never); + setFrameTimeMs(400); + predictor.update([]); + const frozen = [...predictor.head.center]; + + setFrameTimeMs(450); + predictor.recordInput([-1, 0] as never); + setFrameTimeMs(600); + predictor.update([]); + expect([...predictor.head.center]).toEqual(frozen); + + setFrameTimeMs(2000); + predictor.recordInput([0, 0] as never); + setFrameTimeMs(2500); + predictor.update([]); + expect([...predictor.head.center]).toEqual(frozen); + }); + + it('clears visual correction on respawn and disables prediction for end-game snapshots', () => { + const predictor = new LocalCharacterPredictor(); + setFrameTimeMs(1000); + install(predictor, 1000, 0); + predictor.update([]); + install(predictor, 1000, 100); + predictor.update([]); + predictor.reset(); + install(predictor, 1000, 1000); + predictor.update([]); + expect(predictor.head.center[0]).toBe(1000); + predictor.enabled = false; + expect(predictor.update([])).toBe(false); + predictor.reset(); + install(predictor, 1000, 1000); + expect(predictor.update([])).toBe(false); + }); +}); + +const ground = { + id: 1, + vertices: Array.from({ length: 15 }, (_, i) => { + const angle = (i / 15) * -Math.PI * 2; + return [800 * Math.cos(angle), 800 * Math.sin(angle)]; + }), + snapshotRotation: 0, + snapshotRotationSpeed: 0, +}; + +const leapHeight = (cooldown: number, leap: boolean) => { + const predictor = new LocalCharacterPredictor(); + setFrameTimeMs(1000); + const pose = poseAt(0, 900); + predictor.acknowledge( + 1000, + { + ...movementState(), + groundPlanetId: 1, + secondsSinceOnSurface: 0, + leapCooldownRemaining: cooldown, + } as never, + -Infinity, + ); + predictor.setAuthoritative( + pose.head as never, + pose.leftFoot as never, + pose.rightFoot as never, + ); + if (leap) predictor.recordLeap(); + setFrameTimeMs(1002); + predictor.update([ground] as never); + return predictor.head.center[1]; +}; + +describe('leap prediction', () => { + it('applies a newly pressed leap in the very next fractional render tick', () => { + expect(leapHeight(0, true)).toBeGreaterThan(leapHeight(0, false) + 1); + }); + + it('does not predict a leap the authoritative cooldown will reject', () => { + expect(leapHeight(0.2, true)).toEqual(leapHeight(0.2, false)); + }); +}); diff --git a/test/render-smoothness.test.ts b/test/render-smoothness.test.ts index 299a4f4..2b145b9 100644 --- a/test/render-smoothness.test.ts +++ b/test/render-smoothness.test.ts @@ -10,7 +10,6 @@ import { headRadius, feetRadius, } from '../shared/src/physics/character-movement'; -import { followVec2 } from '../shared/src/helper/ease'; import { settings } from '../shared/src/settings'; const stepSeconds = settings.targetPhysicsDeltaTimeInSeconds; @@ -51,20 +50,23 @@ interface Conditions { dropRate: number; /** Server work that can delay a socket callback past the packet's arrival. */ serverBusyMs: number; + wireJitterMs?: number; + emitJitterMs?: number; + changeInput?: boolean; } interface Result { /** Peak-to-peak variation in apparent speed, over its mean. Zero means perfectly even motion. */ speedSpread: number; - /** Peak-to-peak wobble of the character's screen position about its own mean, in world units. */ - screenWobble: number; /** How far the rendered character trails the true server pose, in world units. */ lagUnits: number; + finalError: number; + maximumSpeed: number; } -// Runs a real server tick loop, a real wire, and a real render loop against the predictor, and -// reports how steady the character's *on-screen* travel is. On-screen, not in world space: the -// camera follows the character, so what the player sees is the residual between the two. +// Exercise shared movement with independent server, network, and render clocks. +// Measure world travel: the camera tracks the local character exactly, so measuring +// their relative position would always report zero even when the world visibly stutters. const measure = ({ fps, wireMs, @@ -72,6 +74,9 @@ const measure = ({ frameJitter, dropRate, serverBusyMs, + wireJitterMs = 0, + emitJitterMs = 0, + changeInput = false, }: Conditions): Result => { const world = new ClientCharacterWorld(); world.sync([planetAt(0)]); @@ -86,28 +91,30 @@ const measure = ({ bodyVelocity: [0, 0], }; - const input = [1, 0]; + let input = [1, 0]; + let serverInput = [1, 0]; const predictor = new LocalCharacterPredictor(); predictor.setStrength(settings.playerMaxStrength); // Deterministic frame-time noise: a real display never delivers a perfectly even cadence. let seed = 12345; - const random = () => ((seed = (seed * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff); + const random = () => (seed = (seed * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff; const nominalFrameMs = 1000 / fps; - const toServer: Array<{ atMs: number; clientStampMs: number }> = []; - const toClient: Array<{ atMs: number; ackAgeMs: number; clientStampMs: number; pose: any }> = []; + const toServer: Array<{ atMs: number; clientStampMs: number; direction?: number[] }> = + []; + const toClient: Array<{ + atMs: number; + ackAgeMs: number; + clientStampMs: number; + pose: any; + }> = []; const samples: Array<{ - screen: Array; world: Array; atMs: number; error: number; }> = []; - // The client's own view of the character, and the camera that chases it. - const camera = [0, planetRadius + 90]; - const cameraPreviousTarget = [0, planetRadius + 90]; - let simMs = 0; let nextSnapshotMs = 0; let nextFrameAtMs = 0; @@ -115,27 +122,33 @@ const measure = ({ let lastInputClientTimeMs = 0; let lastInputReceiptMs = 0; let hasPressed = false; - + let lastServerArrivalMs = 0; + let lastClientArrivalMs = 0; while (simMs < 8000) { tickPlanetDetachment(server, stepSeconds); - stepCharacterMovement(server, world, input.slice() as never, stepSeconds); + stepCharacterMovement(server, world, serverInput.slice() as never, stepSeconds); simMs += stepSeconds * 1000; while (toServer.length > 0 && toServer[0].atMs <= simMs) { const packet = toServer.shift()!; + if (packet.direction) serverInput = packet.direction; if (packet.clientStampMs > lastInputClientTimeMs) { lastInputClientTimeMs = packet.clientStampMs; // Socket callbacks run between physics frames, so this is the true arrival instant, // not the frame boundary — plus however long the frame in progress still had to run. - lastInputReceiptMs = packet.atMs + serverBusyMs; + lastInputReceiptMs = packet.atMs; } } if (simMs >= nextSnapshotMs) { nextSnapshotMs += snapshotMs; + lastClientArrivalMs = Math.max( + lastClientArrivalMs, + simMs + Math.max(0, wireMs + (random() * 2 - 1) * wireJitterMs), + ); toClient.push({ - atMs: simMs + wireMs, + atMs: lastClientArrivalMs, clientStampMs: lastInputClientTimeMs, ackAgeMs: lastInputReceiptMs > 0 ? Math.max(0, simMs - lastInputReceiptMs) : 0, pose: { @@ -175,8 +188,16 @@ const measure = ({ // Movement input is edge-triggered, exactly as KeyboardListener sends it: recorded once // when the key goes down, then held by both sides. - if (!hasPressed) { + const desiredInput = + changeInput && nextFrameAtMs >= 4500 + ? nextFrameAtMs >= 6000 + ? [0, 0] + : [-1, 0] + : [1, 0]; + const inputChanged = !hasPressed || desiredInput[0] !== input[0]; + if (inputChanged) { hasPressed = true; + input = desiredInput; predictor.recordInput(input as never); } @@ -185,30 +206,35 @@ const measure = ({ const frameMs = nominalFrameMs * jitter * (dropped ? 2 : 1); if (!dropped) { - predictor.update([planetAt(0)], frameMs / 1000); + predictor.update([planetAt(0)]); const head = predictor.head.center; - followVec2( - camera as never, - head, - cameraPreviousTarget as never, - frameMs / 1000, - 0.08, - 1500, - ); samples.push({ - screen: [head[0] - camera[0], head[1] - camera[1]], world: [head[0], head[1]], atMs: frameClientMs, - error: Math.hypot(head[0] - server.head.center[0], head[1] - server.head.center[1]), + error: Math.hypot( + head[0] - server.head.center[0], + head[1] - server.head.center[1], + ), }); } // Stamped at the top of the frame, emitted at the bottom. - if (frameClientMs - lastHeartbeatClientMs >= heartbeatMs - 1) { + if ( + inputChanged || + (!dropped && frameClientMs - lastHeartbeatClientMs >= heartbeatMs - 1) + ) { lastHeartbeatClientMs = frameClientMs; + lastServerArrivalMs = Math.max( + lastServerArrivalMs, + nextFrameAtMs + + serverBusyMs + + Math.max(0, emitLagMs + (random() * 2 - 1) * emitJitterMs) + + Math.max(0, wireMs + (random() * 2 - 1) * wireJitterMs), + ); toServer.push({ - atMs: nextFrameAtMs + emitLagMs + wireMs, + atMs: lastServerArrivalMs, clientStampMs: frameClientMs, + direction: inputChanged ? input.slice() : undefined, }); } @@ -229,60 +255,115 @@ const measure = ({ ); const meanSpeed = speeds.reduce((a, b) => a + b, 0) / speeds.length; - // Screen position: the camera settles to a constant lag, so anything left is visible wobble. - const centre = [0, 1].map((axis) => { - const values = tail.map((s) => s.screen[axis]); - return values.reduce((a, b) => a + b, 0) / values.length; - }); - const wobble = Math.max( - ...tail.map((s) => Math.hypot(s.screen[0] - centre[0], s.screen[1] - centre[1])), - ); - - if ((globalThis as any).__dump) { - console.log('--- apparent speed (u/ms) ---'); - speeds.slice(0, 40).forEach((v, i) => console.log(`${i} ${v.toFixed(4)}`)); - } return { speedSpread: (Math.max(...speeds) - Math.min(...speeds)) / meanSpeed, - screenWobble: wobble, lagUnits: tail.reduce((sum, s) => sum + s.error, 0) / tail.length, + finalError: tail[tail.length - 1].error, + maximumSpeed: Math.max(...speeds), }; }; const CONDITIONS: Array<[string, Conditions]> = [ - ['localhost, clean frames', { fps: 60, wireMs: 0.2, emitLagMs: 2, frameJitter: 0, dropRate: 0, serverBusyMs: 0 }], - ['localhost, 144 Hz', { fps: 144, wireMs: 0.2, emitLagMs: 2, frameJitter: 0, dropRate: 0, serverBusyMs: 0 }], - ['localhost, frame jitter', { fps: 60, wireMs: 0.2, emitLagMs: 2, frameJitter: 0.15, dropRate: 0, serverBusyMs: 0 }], - ['localhost, 2% dropped', { fps: 60, wireMs: 0.2, emitLagMs: 2, frameJitter: 0.05, dropRate: 0.02, serverBusyMs: 0 }], - ['localhost, variable CPU', { fps: 60, wireMs: 0.2, emitLagMs: 8, frameJitter: 0.15, dropRate: 0.02, serverBusyMs: 0 }], - ['30 ms link', { fps: 60, wireMs: 15, emitLagMs: 2, frameJitter: 0.05, dropRate: 0, serverBusyMs: 0 }], - ['120 ms link', { fps: 60, wireMs: 60, emitLagMs: 2, frameJitter: 0.05, dropRate: 0, serverBusyMs: 0 }], + [ + 'localhost, clean frames', + { fps: 60, wireMs: 0.2, emitLagMs: 2, frameJitter: 0, dropRate: 0, serverBusyMs: 0 }, + ], + [ + 'localhost, 144 Hz', + { fps: 144, wireMs: 0.2, emitLagMs: 2, frameJitter: 0, dropRate: 0, serverBusyMs: 0 }, + ], + [ + 'localhost, frame jitter', + { + fps: 60, + wireMs: 0.2, + emitLagMs: 2, + frameJitter: 0.15, + dropRate: 0, + serverBusyMs: 0, + }, + ], + [ + 'localhost, 2% dropped', + { + fps: 60, + wireMs: 0.2, + emitLagMs: 2, + frameJitter: 0.05, + dropRate: 0.02, + serverBusyMs: 0, + }, + ], + [ + 'localhost, slower CPU', + { + fps: 60, + wireMs: 0.2, + emitLagMs: 8, + frameJitter: 0.15, + dropRate: 0.02, + serverBusyMs: 0, + }, + ], + [ + '30 ms link', + { + fps: 60, + wireMs: 15, + emitLagMs: 2, + frameJitter: 0.05, + dropRate: 0, + serverBusyMs: 0, + }, + ], + [ + '120 ms link', + { + fps: 60, + wireMs: 60, + emitLagMs: 2, + frameJitter: 0.05, + dropRate: 0, + serverBusyMs: 0, + }, + ], ]; describe('local character render smoothness', () => { - it('keeps on-screen travel even under realistic clocks and frame pacing', () => { - ;(globalThis as any).__dump = true - measure(CONDITIONS[0][1]) - ;(globalThis as any).__dump = false - const results: Array<[string, Result]> = CONDITIONS.map(([label, c]) => [label, measure(c)]); + it.each(CONDITIONS)('keeps travel even with %s', (_label, conditions) => { + const result = measure(conditions); + expect(result.speedSpread).toBeLessThan(0.05); + if (conditions.wireMs < 1) expect(result.lagUnits).toBeLessThan(5); + }); - for (const [label, r] of results) { - console.log( - `${label.padEnd(26)} speed=${(r.speedSpread * 100).toFixed(1).padStart(6)}% ` + - `wobble=${r.screenWobble.toFixed(2).padStart(6)}u ` + - `lag=${r.lagUnits.toFixed(1).padStart(5)}u `, - ); - } + it('absorbs changing network and client CPU delay without snapshot jumps', () => { + const result = measure({ + fps: 144, + wireMs: 30, + emitLagMs: 5, + wireJitterMs: 10, + emitJitterMs: 5, + frameJitter: 0.15, + dropRate: 0.02, + serverBusyMs: 2, + }); + expect(result.speedSpread).toBeLessThan(0.4); + expect(result.lagUnits).toBeLessThan(25); + }); - for (const [label, r] of results) { - // Even travel: what the eye reads as smooth motion. - expect(r.speedSpread, label).toBeLessThan(0.05); - // No sub-pixel swimming against the camera, which follows the same character. - expect(r.screenWobble, label).toBeLessThan(1); - } - - // Prediction exists to cancel latency; a follower that lags undoes that on every link. - const localhost = results.find(([label]) => label === 'localhost, clean frames')![1]; - expect(localhost.lagUnits).toBeLessThan(5); + it('reconciles direction changes and stopping across an irregular link', () => { + const result = measure({ + fps: 144, + wireMs: 30, + emitLagMs: 5, + wireJitterMs: 10, + emitJitterMs: 5, + frameJitter: 0.15, + dropRate: 0.02, + serverBusyMs: 2, + changeInput: true, + }); + expect(result.maximumSpeed).toBeLessThan(1); + expect(result.finalError).toBeLessThan(3); }); }); diff --git a/test/serialization.test.mjs b/test/serialization.test.mjs index 5f85482..209736d 100644 --- a/test/serialization.test.mjs +++ b/test/serialization.test.mjs @@ -22,9 +22,17 @@ const { headOffset, leftFootOffset, rightFootOffset, + CharacterMovementSnapshot, } = shared; describe('serialization round-trip (built shared lib)', () => { + it('preserves the authoritative leap cooldown for prediction', () => { + const snapshot = new CharacterMovementSnapshot(0, [1, 2], [0, 1], [0, 1], 5, 0, 0.25); + const out = deserialize(serialize(snapshot)); + expect(out).toBeInstanceOf(CharacterMovementSnapshot); + expect(out.leapCooldownRemaining).toBe(0.25); + }); + it('reconstructs a ServerAnnouncement as the right class with its payload', () => { const [out] = deserialize(serialize([new ServerAnnouncement('Hello world')])); expect(out).toBeInstanceOf(ServerAnnouncement); diff --git a/test/setup.mjs b/test/setup.mjs index d55aa06..65bf24b 100644 --- a/test/setup.mjs +++ b/test/setup.mjs @@ -1,4 +1,5 @@ -// Production makes every vector an f64 Array before any module-level vector is allocated. +// Use production's f64 vector type in every package. The import-order regression separately +// verifies that physics constants also retain full precision before startup configures it. import { createRequire } from 'node:module'; const require = createRequire(import.meta.url); diff --git a/test/shared-regressions.test.ts b/test/shared-regressions.test.ts new file mode 100644 index 0000000..770ddc5 --- /dev/null +++ b/test/shared-regressions.test.ts @@ -0,0 +1,111 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { marchCircle } from '../shared/src/physics/march-circle'; +import { resolveCircleMovement } from '../shared/src/physics/resolve-circle-movement'; +import { finiteVec2, sanitizeName } from '../shared/src/helper/finite'; +import { CommandReceiver } from '../shared/src/commands/command-receiver'; +import type { PhysicsBody, Sdf } from '../shared/src/physics/sdf'; + +const bodyAt = (x: number, y: number, vx: number, vy: number): PhysicsBody => ({ + center: [x, y], + radius: 1, + velocity: [vx, vy], + lastNormal: [0, 1], + restitution: 1, +}); + +const rightWall: Sdf = { canCollide: true, distance: ([x]) => 10 - x }; + +describe('circle collision movement', () => { + it('spends only the remaining step time after a bounce', () => { + const body = bodyAt(0, 0, 100, 0); + expect(resolveCircleMovement(body, 0.2, [rightWall])).toBe(rightWall); + // 9 units to the wall, then 11 units back: 20 units of total travel. + expect(body.center[0]).toBeCloseTo(-2, 6); + expect(body.velocity[0]).toBeCloseTo(-100, 6); + }); + + it('resolves velocity at the second contact instead of leaving it aimed into a wall', () => { + const body = bodyAt(0, 0, 100, 0); + const leftWall: Sdf = { canCollide: true, distance: ([x]) => x + 5 }; + resolveCircleMovement(body, 0.3, [rightWall, leftWall]); + expect(body.center[0]).toBeCloseTo(-4, 6); + expect(body.velocity[0]).toBeCloseTo(100, 6); + }); + + it('allows slow tangential sliding along a surface', () => { + const body = { ...bodyAt(9, 0, 100, 10), restitution: 0 }; + resolveCircleMovement(body, 0.1, [rightWall]); + expect(body.center[0]).toBeCloseTo(9, 6); + expect(body.center[1]).toBeCloseTo(1, 6); + expect(body.velocity[0]).toBeCloseTo(0, 6); + }); + + it('ignores disabled objects when identifying the contacted surface', () => { + const inactive: Sdf = { canCollide: false, distance: () => -100 }; + const onHit = vi.fn(); + const body = bodyAt(0, 0, 100, 0); + const result = marchCircle(body, [20, 0], [inactive, rightWall], onHit); + expect(result.hitObject).toBe(rightWall); + expect(onHit).toHaveBeenCalledExactlyOnceWith(rightWall); + expect(result.normal).toEqual([-1, 0]); + }); + + it('samples the collision normal before the callback removes the obstacle', () => { + const wall = { ...rightWall }; + const body = bodyAt(0, 0, 100, 0); + resolveCircleMovement(body, 0.2, [wall], () => { + wall.canCollide = false; + }); + expect(body.center.every(Number.isFinite)).toBe(true); + expect(body.velocity[0]).toBeCloseTo(-100, 6); + }); +}); + +describe('network input helpers', () => { + it('clamps huge finite vectors before allocating a float vector', () => { + const direction = finiteVec2([1e300, 1e300], 1)!; + expect(direction.every(Number.isFinite)).toBe(true); + expect(Math.hypot(...direction)).toBeCloseTo(1, 6); + expect(direction[0]).toBeGreaterThan(0.7); + }); + + it('never calls methods on an untrusted player name', () => { + expect(sanitizeName({ toString: {} }, 10)).toBe(''); + expect(sanitizeName('abcdefghijk', 10)).toBe('abcdefghij'); + }); + + it('does not dispatch inherited object methods as commands', () => { + class Receiver extends CommandReceiver { + public fallback = vi.fn(); + protected defaultCommandExecutor(command: object) { + this.fallback(command); + } + } + const receiver = new Receiver(); + const command = { constructor: { name: 'constructor' } }; + receiver.handleCommand(command); + expect(receiver.fallback).toHaveBeenCalledExactlyOnceWith(command); + }); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('random world seeds', () => { + it('uses the random seed bits instead of truncating every initial seed to zero', async () => { + const random = vi.spyOn(Math, 'random'); + const firstValues: number[] = []; + for (const seed of [0.1, 0.5, 0.9]) { + random.mockReturnValue(seed); + vi.resetModules(); + const { Random } = await import('../shared/src/helper/random'); + firstValues.push(Random.getRandom()); + Random.seed = 42; + const expected = [Random.getRandom(), Random.getRandom()]; + Random.seed = 42; + expect([Random.getRandom(), Random.getRandom()]).toEqual(expected); + } + expect(new Set(firstValues).size).toBe(3); + }); +});