Improve netcode and simplify #4

Merged
andras merged 15 commits from fix/audit-findings into main 2026-09-15 07:57:58 +01:00
51 changed files with 2080 additions and 518 deletions
Showing only changes of commit 244620105a - Show all commits
Andras Schmelczer 2026-09-14 21:29:18 +01:00

View file

@ -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. 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 ## Deployment
CI/CD runs on Forgejo Actions (`.forgejo/workflows/deploy.yml`). On a push to CI/CD runs on Forgejo Actions (`.forgejo/workflows/deploy.yml`). On a push to

View file

@ -109,6 +109,9 @@ export class GameServer implements GameEvents {
this.matchPointAnnounced = {}; this.matchPointAnnounced = {};
this.isInEndGame = false; this.isInEndGame = false;
this.timeScaling = 1; this.timeScaling = 1;
this.physicsAccumulator = 0;
this.timeSinceLastServerStateUpdate = 0;
this.timeSinceLastPointUpdate = 0;
previousPlayers?.queueCommandForEachClient(new GameStartCommand()); previousPlayers?.queueCommandForEachClient(new GameStartCommand());
previousPlayers?.sendQueuedCommands(); previousPlayers?.sendQueuedCommands();
} }
@ -139,15 +142,18 @@ export class GameServer implements GameEvents {
const player = this.players.createPlayer(playerInfo, socket, carried); const player = this.players.createPlayer(playerInfo, socket, carried);
const onPlayerToServer = (json: string) => { const onPlayerToServer = (json: unknown) => {
try { try {
if (json.length > settings.maxInboundMessageBytes) { if (typeof json !== 'string' || json.length > settings.maxInboundMessageBytes) {
return; return;
} }
if (!this.allowInboundMessage(socket)) { if (!this.allowInboundMessage(socket)) {
return; return;
} }
const commands: Array<Command> = deserialize(json); const commands: Array<Command> = deserialize(json);
if (!Array.isArray(commands)) {
return;
}
commands.forEach((c) => player.handleCommand(c)); commands.forEach((c) => player.handleCommand(c));
} catch (e) { } catch (e) {
console.error('Error while processing command', 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. // of noise on every interpolated position and on the client's replay anchor.
const simulatedThroughMs = frameStartMs - this.physicsAccumulator * 1000; const simulatedThroughMs = frameStartMs - this.physicsAccumulator * 1000;
const propertyUpdates = new Map<GameObject, PropertyUpdatesForObject | undefined>(); if (substeps > 0) {
this.players.stepCommunication(delta, simulatedThroughMs, (object) => { const propertyUpdates = new Map<GameObject, PropertyUpdatesForObject | undefined>();
if (!propertyUpdates.has(object)) { this.players.stepCommunication(
propertyUpdates.set(object, object.getPropertyUpdates()); substeps * fixedDelta,
} simulatedThroughMs,
return propertyUpdates.get(object); (object) => {
}); if (!propertyUpdates.has(object)) {
this.objects.resetRemoteCalls(); propertyUpdates.set(object, object.getPropertyUpdates(1 / this.timeScaling));
}
return propertyUpdates.get(object);
},
);
this.objects.resetRemoteCalls();
}
const elapsed = Number(process.hrtime.bigint() - frameStart) / 1e9; const elapsed = Number(process.hrtime.bigint() - frameStart) / 1e9;
setTimeout(() => this.handlePhysics(), Math.max(0, fixedDelta - elapsed) * 1000); setTimeout(() => this.handlePhysics(), Math.max(0, fixedDelta - elapsed) * 1000);

View file

@ -62,8 +62,7 @@ export class CharacterPhysical extends CharacterBase implements Physical {
private lastSyncedHealth = settings.playerMaxHealth; private lastSyncedHealth = settings.playerMaxHealth;
private killStreak = 0; private killStreak = 0;
private movementDirections: Array<vec2> = []; private readonly movementDirection = vec2.create();
private lastMovementDirection = vec2.create();
private readonly previousPose = [0, 1, 2].map(() => new Circle(vec2.create(), 0)); private readonly previousPose = [0, 1, 2].map(() => new Circle(vec2.create(), 0));
private readonly velocities = [0, 1, 2].map(() => new Circle(vec2.create(), 0)); private readonly 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), vec2.clone(this.rightFoot.lastNormal),
this.groundPlanet?.id ?? null, this.groundPlanet?.id ?? null,
this.secondsSinceOnSurface, this.secondsSinceOnSurface,
Math.max(0, settings.leapCooldownSeconds - this.timeSinceLastLeap),
); );
} }
@ -162,7 +162,7 @@ export class CharacterPhysical extends CharacterBase implements Physical {
} }
public setMoveDirection(direction: vec2) { public setMoveDirection(direction: vec2) {
this.movementDirections.push(direction); vec2.normalize(this.movementDirection, direction);
} }
public addKill(victimName: string, charge = 0) { public addKill(victimName: string, charge = 0) {
@ -170,11 +170,13 @@ export class CharacterPhysical extends CharacterBase implements Physical {
this.killStreak++; this.killStreak++;
this.remoteCall('setKillCount', this.killCount); this.remoteCall('setKillCount', this.killCount);
this.health = Math.min( if (this.isAlive) {
settings.playerMaxHealth, this.health = Math.min(
this.health + settings.playerKillHealthReward, settings.playerMaxHealth,
); this.health + settings.playerKillHealthReward,
this.syncHealth(); );
this.syncHealth();
}
this.remoteCall('onKillConfirmed', victimName, this.killStreak, charge); this.remoteCall('onKillConfirmed', victimName, this.killStreak, charge);
} }
@ -293,7 +295,7 @@ export class CharacterPhysical extends CharacterBase implements Physical {
this.timeSinceLastLeap = 0; this.timeSinceLastLeap = 0;
this.projectileStrength -= settings.leapStrengthCost; this.projectileStrength -= settings.leapStrengthCost;
applyLeapImpulse(this, this.lastMovementDirection); applyLeapImpulse(this, this.movementDirection);
this.remoteCall('onLeap'); this.remoteCall('onLeap');
} }
@ -311,8 +313,14 @@ export class CharacterPhysical extends CharacterBase implements Physical {
} }
} }
public getPropertyUpdates(): PropertyUpdatesForObject { public getPropertyUpdates(timeScale = 1): PropertyUpdatesForObject {
const [headVelocity, leftFootVelocity, rightFootVelocity] = this.velocities; 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, [ return new PropertyUpdatesForObject(this.id, [
new UpdatePropertyCommand('head', this.head, headVelocity), new UpdatePropertyCommand('head', this.head, headVelocity),
new UpdatePropertyCommand('leftFoot', this.leftFoot, leftFootVelocity), new UpdatePropertyCommand('leftFoot', this.leftFoot, leftFootVelocity),
@ -320,7 +328,7 @@ export class CharacterPhysical extends CharacterBase implements Physical {
new UpdatePropertyCommand( new UpdatePropertyCommand(
'strength', 'strength',
this.projectileStrength, this.projectileStrength,
settings.playerStrengthRegenerationPerSeconds, settings.playerStrengthRegenerationPerSeconds * timeScale,
), ),
]); ]);
} }
@ -387,27 +395,11 @@ export class CharacterPhysical extends CharacterBase implements Physical {
stepCharacterMovement( stepCharacterMovement(
this, this,
this.movementWorld, this.movementWorld,
this.averageAndResetMovementDirections(), this.movementDirection,
deltaTimeInSeconds, 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) { private animateScaling(q: number) {
this.head.radius = headRadius * q; this.head.radius = headRadius * q;
this.leftFoot.radius = this.rightFoot.radius = feetRadius * q; this.leftFoot.radius = this.rightFoot.radius = feetRadius * q;

View file

@ -95,6 +95,9 @@ export class PlanetPhysical extends PlanetBase implements Physical {
let blue = 0; let blue = 0;
let red = 0; let red = 0;
for (const c of this.presentCharacters) { for (const c of this.presentCharacters) {
if (!c.isAlive) {
continue;
}
if (c.team === CharacterTeam.blue) { if (c.team === CharacterTeam.blue) {
blue++; blue++;
} else if (c.team === CharacterTeam.red) { } else if (c.team === CharacterTeam.red) {
@ -172,10 +175,14 @@ export class PlanetPhysical extends PlanetBase implements Physical {
this.ownership = clamp01(this.ownership); this.ownership = clamp01(this.ownership);
} }
public getPropertyUpdates(): PropertyUpdatesForObject { public getPropertyUpdates(timeScale = 1): PropertyUpdatesForObject {
return new PropertyUpdatesForObject(this.id, [ return new PropertyUpdatesForObject(this.id, [
new UpdatePropertyCommand('ownership', this.ownership, 0), new UpdatePropertyCommand('ownership', this.ownership, 0),
new UpdatePropertyCommand('rotation', this.rotation, this.angularVelocity), new UpdatePropertyCommand(
'rotation',
this.rotation,
this.angularVelocity * timeScale,
),
]); ]);
} }
} }

View file

@ -93,13 +93,25 @@ export class ProjectilePhysical extends ProjectileBase implements Physical {
} }
} }
public getPropertyUpdates(): PropertyUpdatesForObject { public getPropertyUpdates(timeScale = 1): PropertyUpdatesForObject {
return new PropertyUpdatesForObject(this.id, [ 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) { public step(deltaTimeInSeconds: number) {
if (!this.isAlive) {
return;
}
super.step(deltaTimeInSeconds); super.step(deltaTimeInSeconds);
if (this.strength <= 0) { if (this.strength <= 0) {

View file

@ -14,6 +14,6 @@ export const defaultOptions: Options = {
name: isDevelopment ? 'Dev server' : 'Doppler server', name: isDevelopment ? 'Dev server' : 'Doppler server',
playerLimit: 16, playerLimit: 16,
npcCount: 8, npcCount: 8,
seed: Math.random(), seed: (Math.random() * 0x100000000) >>> 0,
scoreLimit: 2500, scoreLimit: 2500,
}; };

View file

@ -30,13 +30,15 @@ export class PlayerContainer {
} }
private createNPCs() { private createNPCs() {
const newNpcCount = Math.min( const newNpcCount =
this.playerMaxCount - this._players.length - this.npcs.length, Math.min(this.playerMaxCount, this.npcMaxCount) -
this.npcMaxCount - this.npcs.length, this._players.length -
); this.npcs.length;
for (let i = 0; i < newNpcCount; i++) { for (let i = 0; i < newNpcCount; i++) {
const name = `🤖 ${Random.choose(settings.npcNames)}`; 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, socket: Socket,
carried?: CarriedScore, carried?: CarriedScore,
): Player { ): Player {
const team = carried ? carried.team : this.getTeamOfNextPlayer(); const team = carried ? carried.team : this.teamOfNext(this._players);
const npcToReplace = const npcToReplace =
this.npcs.find((n) => n.team === team) ?? this.npcs.find((n) => n.team !== team); this.npcs.find((n) => n.team === team) ?? this.npcs.find((n) => n.team !== team);
@ -100,14 +102,6 @@ export class PlayerContainer {
this._players.forEach((p) => p.sendQueuedCommandsToClient()); 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<PlayerBase>): CharacterTeam { private teamOfNext(players: Array<PlayerBase>): CharacterTeam {
const blueCount = players.filter((p) => p.team === CharacterTeam.blue).length; const blueCount = players.filter((p) => p.team === CharacterTeam.blue).length;
const redCount = players.filter((p) => p.team === CharacterTeam.red).length; const redCount = players.filter((p) => p.team === CharacterTeam.red).length;

View file

@ -29,6 +29,7 @@ import {
finiteInRange, finiteInRange,
finiteVec2, finiteVec2,
isFiniteNumber, isFiniteNumber,
boundRadius,
} from 'shared'; } from 'shared';
import { Socket } from 'socket.io'; import { Socket } from 'socket.io';
import { BoundingBox } from '../physics/bounding-box'; import { BoundingBox } from '../physics/bounding-box';
@ -36,6 +37,7 @@ import { PhysicalContainer } from '../physics/physical-container';
import { PlayerContainer } from './player-container'; import { PlayerContainer } from './player-container';
import { PlayerBase, Score } from './player-base'; import { PlayerBase, Score } from './player-base';
import { CharacterPhysical } from '../objects/character-physical'; import { CharacterPhysical } from '../objects/character-physical';
import { planetsNear } from '../objects/planet-physical';
const pingIntervalSeconds = 1; const pingIntervalSeconds = 1;
const minimumAspectRatio = 0.2; const minimumAspectRatio = 0.2;
@ -59,7 +61,7 @@ export class Player extends PlayerBase {
private lastInputClientTimeMs = 0; private lastInputClientTimeMs = 0;
private lastInputReceiptMs = 0; private lastInputReceiptMs = 0;
private lastLeapClientTimeMs = 0; private lastLeapClientTimeMs = -1;
private timeSinceLastPing = 0; private timeSinceLastPing = 0;
private lastPingSentMs = 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. // 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 = Math.min(
this.timeSinceLastMessage - settings.updateMessageInterval, this.timeSinceLastMessage - settings.updateMessageInterval,
settings.updateMessageInterval, settings.updateMessageInterval,
@ -206,6 +208,14 @@ export class Player extends PlayerBase {
); );
if (this.character) { if (this.character) {
inViewArea.add(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)); const created = [...inViewArea].filter((o) => !this.objectsInViewArea.has(o));
@ -258,19 +268,45 @@ export class Player extends PlayerBase {
} }
private get bufferedBytes(): number { private get bufferedBytes(): number {
const conn = this.socket.conn as const conn = this.socket.conn as unknown as
| { transport?: { socket?: { bufferedAmount?: unknown } } } | {
transport?: { socket?: { bufferedAmount?: unknown } };
writeBuffer?: Array<{ data?: unknown }>;
}
| undefined; | undefined;
const buffered = conn?.transport?.socket?.bufferedAmount; 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() { public sendQueuedCommandsToClient() {
// Only state the next snapshot regenerates may be dropped; create/delete are one-shot. // Only state the next snapshot regenerates may be dropped; create/delete are one-shot.
if (this.bufferedBytes > settings.maxBufferedBytesPerClient) { if (this.bufferedBytes > settings.maxBufferedBytesPerClient) {
this.commandsToBeSent = this.commandsToBeSent.filter( const hasReliableCommands = this.commandsToBeSent.some(
(c) => !sheddableWhenBackedUp.includes(c.constructor.name), (c) => !sheddableWhenBackedUp.includes(c.constructor.name),
); );
this.commandsToBeSent = hasReliableCommands
? this.commandsToBeSent.flatMap((c): Array<Command> => {
// 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) { if (this.commandsToBeSent.length === 0) {
return; return;

View file

@ -15,7 +15,7 @@ import '../static/favicons/favicon-32x32.png';
import '../static/favicons/favicon.ico'; import '../static/favicons/favicon.ico';
import ResizeObserver from 'resize-observer-polyfill'; import ResizeObserver from 'resize-observer-polyfill';
import { LandingPageBackground } from './scripts/landing-page-background'; 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 { handleFullScreen } from './scripts/helper/handle-full-screen';
import { Game } from './scripts/game'; import { Game } from './scripts/game';
import { initializeOptions } from './scripts/options-handler'; 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 { LampView } from './scripts/objects/types/lamp-view';
import { PlanetView } from './scripts/objects/types/planet-view'; import { PlanetView } from './scripts/objects/types/planet-view';
import { ProjectileView } from './scripts/objects/types/projectile-view'; import { ProjectileView } from './scripts/objects/types/projectile-view';
import { loadStoredValue, saveStoredValue } from './scripts/helper/storage';
glMatrix.setMatrixArrayType(Array); glMatrix.setMatrixArrayType(Array);
@ -88,9 +89,9 @@ const applyServerContainerShadows = () => {
const main = async () => { const main = async () => {
let game: Game | undefined; let game: Game | undefined;
const storedUserName = localStorage.getItem('userName'); const storedUserName = loadStoredValue('userName');
if (storedUserName) { if (typeof storedUserName === 'string') {
nameInput.value = JSON.parse(storedUserName); nameInput.value = storedUserName;
} }
const firstClickListener = () => { const firstClickListener = () => {
@ -138,12 +139,17 @@ const main = async () => {
const background = new LandingPageBackground(canvas); const background = new LandingPageBackground(canvas);
const joinHandler = new JoinFormHandler(joinGameForm, serverContainer); const joinHandler = new JoinFormHandler(joinGameForm, serverContainer);
await background.renderer; let playerDecision: PlayerDecision;
setVisible(spinner, false); try {
await background.renderer;
setVisible(spinner, false);
playerDecision = await joinHandler.getPlayerDecision();
} finally {
background.destroy();
joinHandler.destroy();
}
const playerDecision = await joinHandler.getPlayerDecision(); saveStoredValue('userName', playerDecision.name);
localStorage.setItem('userName', JSON.stringify(playerDecision.name));
if (!history.state) { if (!history.state) {
history.pushState(true, ''); history.pushState(true, '');
@ -151,7 +157,6 @@ const main = async () => {
setDisplayed(landingUI, null); setDisplayed(landingUI, null);
setVisible(spinner, true); setVisible(spinner, true);
background.destroy();
game = new Game(playerDecision, canvas, overlay); game = new Game(playerDecision, canvas, overlay);
const gameOver = game.start(); const gameOver = game.start();
await game.started; await game.started;
@ -168,6 +173,7 @@ const main = async () => {
main().catch((error) => { main().catch((error) => {
console.error(error); console.error(error);
setVisible(spinner, false); setVisible(spinner, false);
setDisplayed(landingUI, 'flex');
joinNotice.innerText = 'Something went wrong. Please reload the page.'; joinNotice.innerText = 'Something went wrong. Please reload the page.';
setDisplayed(joinNotice, 'block'); setDisplayed(joinNotice, 'block');
}); });

View file

@ -22,6 +22,11 @@ export class CommandSocket {
this.commandQueue.push(command); this.commandQueue.push(command);
} }
public reset() {
this.commandQueue = [];
this.lastSendMs = -Infinity;
}
public sendQueuedCommands() { public sendQueuedCommands() {
// socket.io-client would buffer emits made while disconnected and flush them before the re-join. // socket.io-client would buffer emits made while disconnected and flush them before the re-join.
if (!this.socket.connected) { if (!this.socket.connected) {

View file

@ -2,6 +2,18 @@ import { vec2 } from 'gl-matrix';
import type { Command } from 'shared'; import type { Command } from 'shared';
import { InputGenerator } from './input-generator'; 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 { export class KeyboardListener extends InputGenerator {
private keysDown: Set<string> = new Set(); private keysDown: Set<string> = new Set();
@ -14,18 +26,23 @@ export class KeyboardListener extends InputGenerator {
private keyDownListener = (event: KeyboardEvent) => { private keyDownListener = (event: KeyboardEvent) => {
const key = event.code; const key = event.code;
if (!movementKeys.has(key) && !leapKeys.has(key)) {
return;
}
event.preventDefault();
if (this.keysDown.has(key)) { if (this.keysDown.has(key)) {
return; return;
} }
this.keysDown.add(key); this.keysDown.add(key);
if (key === 'Space' || key === 'ShiftLeft' || key === 'ShiftRight') { if (leapKeys.has(key)) {
this.sendLeap(); this.sendLeap();
} else {
this.sendMovement();
} }
this.sendMovement();
}; };
private keyUpListener = (event: KeyboardEvent) => { private keyUpListener = (event: KeyboardEvent) => {
if (this.keysDown.delete(event.code)) { if (this.keysDown.delete(event.code) && movementKeys.has(event.code)) {
this.sendMovement(); this.sendMovement();
} }
}; };
@ -35,6 +52,12 @@ export class KeyboardListener extends InputGenerator {
this.sendMovement(); this.sendMovement();
}; };
public resendMovement() {
if ([...this.keysDown].some((key) => movementKeys.has(key))) {
this.sendMovement();
}
}
private sendMovement() { private sendMovement() {
const down = (...codes: Array<string>) => const down = (...codes: Array<string>) =>
codes.some((c) => this.keysDown.has(c)) ? 1 : 0; codes.some((c) => this.keysDown.has(c)) ? 1 : 0;

View file

@ -28,6 +28,7 @@ export class MouseListener extends InputGenerator {
private mouseDownListener = (event: MouseEvent) => { private mouseDownListener = (event: MouseEvent) => {
if (event.button === 0) { if (event.button === 0) {
this.mouseMoveListener(event);
this.primaryDownAt = performance.now(); this.primaryDownAt = performance.now();
ChargeIndicator.begin(event.clientX, event.clientY, true); ChargeIndicator.begin(event.clientX, event.clientY, true);
} }

View file

@ -21,12 +21,13 @@ export class TouchListener extends InputGenerator {
private isJoystickActive = false; private isJoystickActive = false;
private touchStartPosition = vec2.create(); private touchStartPosition = vec2.create();
private movement = vec2.create();
private primaryDownAt: number | null = null; private primaryDownAt: number | null = null;
private gestureTouchId: number | null = null; private gestureTouchId: number | null = null;
private fireDownAt: number | null = null; private fireDownAt: number | null = null;
private fireTouchId: number | null = null;
private fireButtonCenter = vec2.create(); private fireButtonCenter = vec2.create();
private fireAim: vec2 | null = null;
constructor( constructor(
private readonly target: HTMLElement, private readonly target: HTMLElement,
@ -46,6 +47,7 @@ export class TouchListener extends InputGenerator {
this.fireButton.addEventListener('touchstart', this.fireButtonDownListener); this.fireButton.addEventListener('touchstart', this.fireButtonDownListener);
this.fireButton.addEventListener('touchmove', this.fireButtonMoveListener); this.fireButton.addEventListener('touchmove', this.fireButtonMoveListener);
this.fireButton.addEventListener('touchend', this.fireButtonUpListener); this.fireButton.addEventListener('touchend', this.fireButtonUpListener);
this.fireButton.addEventListener('touchcancel', this.fireButtonCancelListener);
this.leapButton.className = 'touch-button leap'; this.leapButton.className = 'touch-button leap';
this.leapButton.addEventListener('touchstart', this.leapButtonListener); this.leapButton.addEventListener('touchstart', this.leapButtonListener);
@ -54,11 +56,12 @@ export class TouchListener extends InputGenerator {
target.addEventListener('touchmove', this.touchMoveListener); target.addEventListener('touchmove', this.touchMoveListener);
target.addEventListener('touchend', this.touchEndListener); target.addEventListener('touchend', this.touchEndListener);
target.addEventListener('touchcancel', this.touchCancelListener); 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++) { for (let i = 0; i < list.length; i++) {
if (list[i].identifier === this.gestureTouchId) { if (list[i].identifier === id) {
return list[i]; return list[i];
} }
} }
@ -78,7 +81,7 @@ export class TouchListener extends InputGenerator {
), ),
0, 0,
); );
} else { } else if (this.gestureTouchId === null) {
this.gestureTouchId = touch.identifier; this.gestureTouchId = touch.identifier;
vec2.set(this.touchStartPosition, touch.clientX, touch.clientY); vec2.set(this.touchStartPosition, touch.clientX, touch.clientY);
this.primaryDownAt = performance.now(); this.primaryDownAt = performance.now();
@ -89,7 +92,7 @@ export class TouchListener extends InputGenerator {
private touchMoveListener = (event: TouchEvent) => { private touchMoveListener = (event: TouchEvent) => {
event.preventDefault(); event.preventDefault();
const touch = this.findGestureTouch(event.touches); const touch = this.findTouch(event.touches, this.gestureTouchId);
if (!touch) { if (!touch) {
return; return;
} }
@ -117,16 +120,17 @@ export class TouchListener extends InputGenerator {
if (deltaLength > deadZone) { if (deltaLength > deadZone) {
vec2.set(delta, delta[0], -delta[1]); vec2.set(delta, delta[0], -delta[1]);
this.sendMove(vec2.normalize(delta, delta)); vec2.normalize(this.movement, delta);
} else { } else {
this.sendMove(vec2.create()); vec2.zero(this.movement);
} }
this.sendMove(vec2.clone(this.movement));
}; };
private touchEndListener = (event: TouchEvent) => { private touchEndListener = (event: TouchEvent) => {
event.preventDefault(); event.preventDefault();
const touch = this.findGestureTouch(event.changedTouches); const touch = this.findTouch(event.changedTouches, this.gestureTouchId);
if (!touch) { if (!touch) {
return; return;
} }
@ -147,7 +151,7 @@ export class TouchListener extends InputGenerator {
}; };
private touchCancelListener = (event: TouchEvent) => { private touchCancelListener = (event: TouchEvent) => {
if (!this.findGestureTouch(event.changedTouches)) { if (!this.findTouch(event.changedTouches, this.gestureTouchId)) {
return; return;
} }
this.gestureTouchId = null; this.gestureTouchId = null;
@ -161,7 +165,30 @@ export class TouchListener extends InputGenerator {
private releaseJoystick() { private releaseJoystick() {
this.isJoystickActive = false; this.isJoystickActive = false;
this.joystick.remove(); 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) { private swallowTouch(event: TouchEvent) {
@ -176,6 +203,11 @@ export class TouchListener extends InputGenerator {
private fireButtonDownListener = (event: TouchEvent) => { private fireButtonDownListener = (event: TouchEvent) => {
this.swallowTouch(event); this.swallowTouch(event);
const touch = event.changedTouches[0];
if (this.fireTouchId !== null || !touch) {
return;
}
this.fireTouchId = touch.identifier;
this.fireDownAt = performance.now(); this.fireDownAt = performance.now();
const rect = this.fireButton.getBoundingClientRect(); const rect = this.fireButton.getBoundingClientRect();
vec2.set( vec2.set(
@ -183,18 +215,16 @@ export class TouchListener extends InputGenerator {
rect.left + rect.width / 2, rect.left + rect.width / 2,
rect.top + rect.height / 2, rect.top + rect.height / 2,
); );
this.fireAim = null;
ChargeIndicator.begin(this.fireButtonCenter[0], this.fireButtonCenter[1]); ChargeIndicator.begin(this.fireButtonCenter[0], this.fireButtonCenter[1]);
}; };
private fireButtonMoveListener = (event: TouchEvent) => { private fireButtonMoveListener = (event: TouchEvent) => {
this.swallowTouch(event); this.swallowTouch(event);
const touch = event.targetTouches[0] ?? event.changedTouches[0]; const touch = this.findTouch(event.touches, this.fireTouchId);
if (this.fireDownAt === null || !touch) { if (this.fireDownAt === null || !touch) {
return; return;
} }
const aim = this.aimFromTouch(touch); const aim = this.aimFromTouch(touch);
this.fireAim = aim;
if (aim) { if (aim) {
this.fireAimLine.style.opacity = '1'; this.fireAimLine.style.opacity = '1';
this.fireAimLine.style.transform = `translateY(-50%) rotate(${Math.atan2(-aim[1], aim[0])}rad)`; 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) => { private fireButtonUpListener = (event: TouchEvent) => {
this.swallowTouch(event); this.swallowTouch(event);
ChargeIndicator.end(); const touch = this.findTouch(event.changedTouches, this.fireTouchId);
this.fireAimLine.style.opacity = '0'; if (this.fireDownAt === null || !touch) {
if (this.fireDownAt === null) {
return; return;
} }
const charge = chargeHeldSince(this.fireDownAt); const charge = chargeHeldSince(this.fireDownAt);
this.fireDownAt = null; const aim = this.aimFromTouch(touch);
this.cancelFire();
const character = this.game.gameObjects.localPlayer; const character = this.game.gameObjects.localPlayer;
if (!character) { if (!character) {
return; return;
} }
const direction = this.fireAim ?? character.facingDirection; const direction = aim ?? character.facingDirection;
this.fireAim = null;
this.sendPrimary( this.sendPrimary(
vec2.scaleAndAdd( vec2.scaleAndAdd(
vec2.create(), 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() { public update() {
if (!this.fireButton.parentElement) { if (!this.fireButton.parentElement) {
this.overlay.append(this.fireButton, this.leapButton); this.overlay.append(this.fireButton, this.leapButton);
} }
if (this.isJoystickActive && !this.joystick.parentElement) {
this.overlay.appendChild(this.joystick);
}
const character = this.game.gameObjects.localPlayer; const character = this.game.gameObjects.localPlayer;
if (character) { if (character) {
@ -261,13 +300,16 @@ export class TouchListener extends InputGenerator {
this.target.removeEventListener('touchmove', this.touchMoveListener); this.target.removeEventListener('touchmove', this.touchMoveListener);
this.target.removeEventListener('touchend', this.touchEndListener); this.target.removeEventListener('touchend', this.touchEndListener);
this.target.removeEventListener('touchcancel', this.touchCancelListener); this.target.removeEventListener('touchcancel', this.touchCancelListener);
window.removeEventListener('blur', this.cancelTouches);
this.fireButton.removeEventListener('touchstart', this.fireButtonDownListener); this.fireButton.removeEventListener('touchstart', this.fireButtonDownListener);
this.fireButton.removeEventListener('touchmove', this.fireButtonMoveListener); this.fireButton.removeEventListener('touchmove', this.fireButtonMoveListener);
this.fireButton.removeEventListener('touchend', this.fireButtonUpListener); this.fireButton.removeEventListener('touchend', this.fireButtonUpListener);
this.fireButton.removeEventListener('touchcancel', this.fireButtonCancelListener);
this.leapButton.removeEventListener('touchstart', this.leapButtonListener); this.leapButton.removeEventListener('touchstart', this.leapButtonListener);
this.fireButton.remove(); this.fireButton.remove();
this.leapButton.remove(); this.leapButton.remove();
this.joystick.remove();
} }
} }

View file

@ -59,8 +59,6 @@ export class Game extends CommandReceiver {
private resolveStarted!: () => void; private resolveStarted!: () => void;
private isBetweenGames = false; private isBetweenGames = false;
private isActive = true; private isActive = true;
private isEnding = false;
private timeScaling = 1;
private readonly keyboardListener: KeyboardListener; private readonly keyboardListener: KeyboardListener;
private readonly mouseListener: MouseListener; private readonly mouseListener: MouseListener;
@ -92,7 +90,7 @@ export class Game extends CommandReceiver {
c.lastLeapClientTimeMs, c.lastLeapClientTimeMs,
c.ackAgeMs, c.ackAgeMs,
), ),
[GameEndCommand.name]: () => (this.isEnding = true), [GameEndCommand.name]: () => (localCharacterPredictor.enabled = false),
[UpdateMinimap.name]: (c: UpdateMinimap) => (this.lastMinimap = c), [UpdateMinimap.name]: (c: UpdateMinimap) => (this.lastMinimap = c),
[GameStartCommand.name]: () => this.initialize(), [GameStartCommand.name]: () => this.initialize(),
}; };
@ -125,6 +123,7 @@ export class Game extends CommandReceiver {
this.socket?.close(); this.socket?.close();
serverTimeline.reset(); serverTimeline.reset();
localCharacterPredictor.reset(); localCharacterPredictor.reset();
localCharacterPredictor.enabled = true;
ScreenShake.reset(); ScreenShake.reset();
this.gameObjects.reset(); this.gameObjects.reset();
this.gameObjects = new GameObjectContainer(this); this.gameObjects = new GameObjectContainer(this);
@ -132,10 +131,8 @@ export class Game extends CommandReceiver {
this.keystoneArrow = undefined; this.keystoneArrow = undefined;
this.connectionBanner = undefined; this.connectionBanner = undefined;
this.lastMinimap = undefined; this.lastMinimap = undefined;
this.isEnding = false;
this.lastAnnouncementText = ''; this.lastAnnouncementText = '';
this.announcementText.innerText = ''; this.announcementText.innerText = '';
this.timeScaling = 1;
this.overlay.append( this.overlay.append(
this.scoreboard.element, this.scoreboard.element,
this.minimap.element, this.minimap.element,
@ -172,6 +169,7 @@ export class Game extends CommandReceiver {
serverTimeline.reset(); serverTimeline.reset();
localCharacterPredictor.reset(); localCharacterPredictor.reset();
this.gameObjects.reset(); this.gameObjects.reset();
this.socketReceiver.reset();
this.socket.emit(TransportEvents.PlayerJoining, { this.socket.emit(TransportEvents.PlayerJoining, {
...this.playerDecision, ...this.playerDecision,
reconnectToken: this.reconnectToken, reconnectToken: this.reconnectToken,
@ -220,49 +218,64 @@ export class Game extends CommandReceiver {
} }
public async start(): Promise<void> { public async start(): Promise<void> {
this.initialize(); setFrameTimeMs(0);
this.resolveStarted(); try {
const noiseTexture = await renderNoise([256, 256], 2, 1); this.initialize();
this.resolveStarted();
let renderError: unknown;
const noiseTexture = await renderNoise([256, 256], 2, 1);
await runAnimation( await runAnimation(
this.canvas, this.canvas,
[ [
PlanetShape.descriptor, PlanetShape.descriptor,
CharacterShape.descriptor, CharacterShape.descriptor,
{ {
...CircleLight.descriptor, ...CircleLight.descriptor,
shaderCombinationSteps: [0, 1, 2, 4, 8, 16], 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,
shadowTraceCount: 16, colorPalette: settings.paletteDim,
paletteSize: settings.paletteDim.length, enableHighDpiRendering: true,
colorPalette: settings.paletteDim, lightCutoffDistance: settings.lightCutoffDistance,
enableHighDpiRendering: true, lightOverlapReduction: settings.lightOverlapReduction,
lightCutoffDistance: settings.lightCutoffDistance, textures: {
lightOverlapReduction: settings.lightOverlapReduction, noiseTexture: {
textures: { source: noiseTexture,
noiseTexture: { overrides: {
source: noiseTexture, maxFilter: FilteringOptions.LINEAR,
overrides: { wrapS: WrapOptions.MIRRORED_REPEAT,
maxFilter: FilteringOptions.LINEAR, wrapT: WrapOptions.MIRRORED_REPEAT,
wrapS: WrapOptions.MIRRORED_REPEAT, },
wrapT: WrapOptions.MIRRORED_REPEAT,
}, },
}, },
}, },
}, );
); if (renderError) {
throw renderError;
this.socket.close(); }
this.gameObjects.reset(); } finally {
this.overlay.innerHTML = ''; this.resolveStarted();
this.hideConnectionBanner(); this.socket?.close();
FeedbackHud.reset(); this.gameObjects.reset();
this.keyboardListener.destroy(); this.overlay.innerHTML = '';
this.mouseListener.destroy(); this.hideConnectionBanner();
this.touchListener.destroy(); FeedbackHud.reset();
this.keyboardListener.destroy();
this.mouseListener.destroy();
this.touchListener.destroy();
}
} }
public static rejectionText(reason: JoinRejectionReason): string { public static rejectionText(reason: JoinRejectionReason): string {
@ -307,6 +320,11 @@ export class Game extends CommandReceiver {
this.isActive = false; this.isActive = false;
} }
public resendMovement() {
this.keyboardListener.resendMovement();
this.touchListener.resendMovement();
}
private gameLoop( private gameLoop(
renderer: Renderer, renderer: Renderer,
currentTime: DOMHighResTimeStamp, currentTime: DOMHighResTimeStamp,
@ -333,11 +351,6 @@ export class Game extends CommandReceiver {
this.lastAnnouncementText = ''; this.lastAnnouncementText = '';
} }
if (this.isEnding) {
this.timeScaling *= Math.pow(settings.endGameDeltaScaling, deltaTime);
deltaTime /= this.timeScaling;
}
this.gameObjects.step(deltaTime); this.gameObjects.step(deltaTime);
this.gameObjects.render(renderer, this.overlay, shouldChangeLayout); this.gameObjects.render(renderer, this.overlay, shouldChangeLayout);
this.touchListener.update(); this.touchListener.update();

View file

@ -18,32 +18,25 @@ export const handleFullScreen = (
}; };
showButtons(); showButtons();
document.addEventListener('fullscreenchange', showButtons);
let currentWindowHeight = innerHeight;
const followToggle = () => {
SoundHandler.play(Sounds.click);
showButtons();
currentWindowHeight = innerHeight;
};
const triggerToggle = async () => { const triggerToggle = async () => {
await (isInFullScreen() try {
? document.exitFullscreen() await (isInFullScreen()
: document.body.requestFullscreen()); ? document.exitFullscreen()
followToggle(); : document.body.requestFullscreen());
SoundHandler.play(Sounds.click);
} catch {
// The browser may deny fullscreen (for example, when embedded).
}
}; };
addEventListener('keydown', (e) => { addEventListener('keydown', (e) => {
if (e.key === 'F11') { if (e.key === 'F11') {
triggerToggle();
e.preventDefault(); e.preventDefault();
} if (!e.repeat) {
}); void triggerToggle();
}
addEventListener('resize', () => {
if (isInFullScreen() && currentWindowHeight > innerHeight) {
followToggle();
} }
}); });

View file

@ -28,7 +28,10 @@ export class LinearInterpolator {
const newest = this.frames.length > 0 ? this.frames[this.frames.length - 1] : null; const newest = this.frames.length > 0 ? this.frames[this.frames.length - 1] : null;
const wasCoasting = this.isCoasting; 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] = { this.frames[this.frames.length - 1] = {
time: newest.time, time: newest.time,
value, value,

View file

@ -9,11 +9,16 @@ import {
} from 'shared'; } from 'shared';
import { PlanetView } from '../../objects/types/planet-view'; 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. // Planets only, re-seeded from the newest snapshot every frame; never dispatches collisions.
export class ClientCharacterWorld implements CharacterWorld { export class ClientCharacterWorld implements CharacterWorld {
private surfaces: Array<PlanetBase> = []; private surfaces: Array<PlanetBase> = [];
public sync(planets: Array<PlanetView>) { public sync(planets: Array<PlanetSnapshot>) {
this.surfaces = planets this.surfaces = planets
.map((planet) => { .map((planet) => {
const surface = new PlanetBase(planet.id, planet.vertices); const surface = new PlanetBase(planet.id, planet.vertices);

View file

@ -11,7 +11,7 @@ import {
feetRadius, feetRadius,
headRadius, headRadius,
} from 'shared'; } from 'shared';
import { ClientCharacterWorld } from './client-character-world'; import { ClientCharacterWorld, PlanetSnapshot } from './client-character-world';
import type { PlanetView } from '../../objects/types/planet-view'; import type { PlanetView } from '../../objects/types/planet-view';
import { InputHistory } from './input-history'; import { InputHistory } from './input-history';
@ -25,6 +25,18 @@ export const setFrameTimeMs = (timeMs: number): void => {
export const clientTimeMs = (): number => frameTimeMs; export const clientTimeMs = (): number => frameTimeMs;
const maxReplayMs = 400; 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<PlanetSnapshot>;
horizonState?: CharacterMovementState;
}
const upwards = vec2.fromValues(0, 1); const upwards = vec2.fromValues(0, 1);
@ -37,6 +49,7 @@ const makeBody = (center: vec2, radius: number, normal: vec2): PhysicsBody => ({
}); });
export class LocalCharacterPredictor { export class LocalCharacterPredictor {
public enabled = true;
private readonly inputHistory = new InputHistory(); private readonly inputHistory = new InputHistory();
private readonly world = new ClientCharacterWorld(); private readonly world = new ClientCharacterWorld();
@ -48,6 +61,9 @@ export class LocalCharacterPredictor {
private currentStrength = settings.playerMaxStrength; private currentStrength = settings.playerMaxStrength;
private alive = true; private alive = true;
private previousSnapshot?: ReplaySnapshot;
private lastUpdateMs?: number;
private readonly correction = vec2.create();
private renderHead = new Circle(vec2.create(), headRadius); private renderHead = new Circle(vec2.create(), headRadius);
private renderLeftFoot = new Circle(vec2.create(), feetRadius); private renderLeftFoot = new Circle(vec2.create(), feetRadius);
@ -75,10 +91,7 @@ export class LocalCharacterPredictor {
lastLeapClientTimeMs: number, lastLeapClientTimeMs: number,
ackAgeMs = 0, ackAgeMs = 0,
): void { ): void {
const anchor = clientTimeMs + Math.max(0, ackAgeMs); this.replayAnchorMs = clientTimeMs + Math.max(0, ackAgeMs);
if (this.replayAnchorMs === undefined || anchor > this.replayAnchorMs) {
this.replayAnchorMs = anchor;
}
this.movement = movement; this.movement = movement;
this.lastLeapAckMs = lastLeapClientTimeMs; this.lastLeapAckMs = lastLeapClientTimeMs;
} }
@ -114,6 +127,9 @@ export class LocalCharacterPredictor {
this.movement = undefined; this.movement = undefined;
this.currentStrength = settings.playerMaxStrength; this.currentStrength = settings.playerMaxStrength;
this.alive = true; this.alive = true;
this.previousSnapshot = undefined;
this.lastUpdateMs = undefined;
vec2.zero(this.correction);
} }
public get canPredict(): boolean { public get canPredict(): boolean {
@ -132,29 +148,77 @@ export class LocalCharacterPredictor {
} }
public update(planets: Array<PlanetView>): boolean { public update(planets: Array<PlanetView>): 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; return false;
} }
this.world.sync(planets); const now = clientTimeMs();
const predicted = this.simulate(); 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); if (this.previousSnapshot && this.previousSnapshot.pose !== snapshot.pose) {
this.renderHead.radius = predicted.head.radius; // Compare both snapshots at the same instant: smooth only the correction,
vec2.copy(this.renderLeftFoot.center, predicted.leftFoot.center); // leaving fresh input and ordinary movement immediate.
this.renderLeftFoot.radius = predicted.leftFoot.radius; const previous = this.simulate(this.previousSnapshot);
vec2.copy(this.renderRightFoot.center, predicted.rightFoot.center); vec2.add(
this.renderRightFoot.radius = predicted.rightFoot.radius; 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; return true;
} }
private simulate(): CharacterMovementState { private simulate(snapshot: ReplaySnapshot): CharacterMovementState {
const auth = this.authoritative!; if (snapshot.horizonState) {
const movement = this.movement!; return snapshot.horizonState;
}
this.world.sync(snapshot.planets);
const { pose: auth, movement } = snapshot;
const now = clientTimeMs(); const now = clientTimeMs();
const startMs = Math.max(this.replayAnchorMs!, now - maxReplayMs); const startMs = snapshot.anchorMs;
const windowMs = Math.max(0, now - startMs); const windowMs = Math.min(maxReplayMs, Math.max(0, now - startMs));
const steps = Math.floor(windowMs / stepMs); const steps = Math.floor(windowMs / stepMs);
const partialStep = (windowMs - steps * stepMs) / stepMs; const partialStep = (windowMs - steps * stepMs) / stepMs;
@ -176,39 +240,32 @@ export class LocalCharacterPredictor {
bodyVelocity: vec2.fromValues(movement.bodyVelocity[0], movement.bodyVelocity[1]), bodyVelocity: vec2.fromValues(movement.bodyVelocity[0], movement.bodyVelocity[1]),
}; };
const cooldownMs = settings.leapCooldownSeconds * 1000; let availableStrength = snapshot.strength;
let availableStrength = this.currentStrength; let cooldown = movement.leapCooldownRemaining ?? 0;
let lastLeapMs = -Infinity;
let t = startMs; let t = startMs;
for (let i = 0; i < steps; i++) { for (let i = 0; i < steps + (partialStep > 0 ? 1 : 0); i++) {
const input = this.inputHistory.directionAt(t); const fraction = i < steps ? 1 : partialStep;
this.world.advance(stepSeconds); const from =
tickPlanetDetachment(state, stepSeconds); fraction < 1
stepCharacterMovement(state, this.world, input, stepSeconds); ? [state.head, state.leftFoot, state.rightFoot].map((body) =>
vec2.clone(body.center),
)
: undefined;
for (const leapMs of this.leapHistory) { for (const leapMs of this.leapHistory) {
if ( if (
leapMs >= t && leapMs >= t &&
leapMs < t + stepMs && leapMs < t + stepMs * fraction &&
leapMs > this.lastLeapAckMs && leapMs > snapshot.leapAckMs &&
state.currentPlanet && state.currentPlanet &&
leapMs - lastLeapMs >= cooldownMs && cooldown <= 0 &&
availableStrength >= settings.leapStrengthCost availableStrength >= settings.leapStrengthCost
) { ) {
applyLeapImpulse(state, this.inputHistory.directionAt(leapMs)); applyLeapImpulse(state, this.inputHistory.directionAt(leapMs));
availableStrength -= settings.leapStrengthCost; 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); this.world.advance(stepSeconds);
tickPlanetDetachment(state, stepSeconds); tickPlanetDetachment(state, stepSeconds);
stepCharacterMovement( stepCharacterMovement(
@ -217,11 +274,22 @@ export class LocalCharacterPredictor {
this.inputHistory.directionAt(t), this.inputHistory.directionAt(t),
stepSeconds, stepSeconds,
); );
[state.head, state.leftFoot, state.rightFoot].forEach((body, i) => cooldown = Math.max(0, cooldown - stepSeconds);
vec2.lerp(body.center, from[i], body.center, partialStep), 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; return state;
} }
} }

View file

@ -9,7 +9,7 @@ const resyncSeconds = 0.3;
class ServerTimeline { class ServerTimeline {
private cursor?: number; private cursor?: number;
private newestSnapshotTime?: number; private newestSnapshotTime?: number;
private sinceNewestSnapshot = 0; private newestReceivedAtMs = 0;
public snapshotTime = 0; public snapshotTime = 0;
public get renderTime(): number { public get renderTime(): number {
@ -20,7 +20,7 @@ class ServerTimeline {
this.snapshotTime = timestamp; this.snapshotTime = timestamp;
if (this.newestSnapshotTime === undefined || timestamp > this.newestSnapshotTime) { if (this.newestSnapshotTime === undefined || timestamp > this.newestSnapshotTime) {
this.newestSnapshotTime = timestamp; this.newestSnapshotTime = timestamp;
this.sinceNewestSnapshot = 0; this.newestReceivedAtMs = performance.now();
} }
} }
@ -29,10 +29,9 @@ class ServerTimeline {
return; return;
} }
this.sinceNewestSnapshot += deltaTimeInSeconds;
const target = const target =
this.newestSnapshotTime + this.newestSnapshotTime +
this.sinceNewestSnapshot - (performance.now() - this.newestReceivedAtMs) / 1000 -
settings.interpolationDelaySeconds; settings.interpolationDelaySeconds;
if (this.cursor === undefined || Math.abs(target - this.cursor) > resyncSeconds) { if (this.cursor === undefined || Math.abs(target - this.cursor) > resyncSeconds) {
@ -40,18 +39,16 @@ class ServerTimeline {
return; return;
} }
const rate = clamp( this.cursor += deltaTimeInSeconds;
1 + (target - this.cursor) * rateGain, this.cursor +=
1 - maxRateAdjustment, deltaTimeInSeconds *
1 + maxRateAdjustment, clamp((target - this.cursor) * rateGain, -maxRateAdjustment, maxRateAdjustment);
);
this.cursor += deltaTimeInSeconds * rate;
} }
public reset() { public reset() {
this.cursor = undefined; this.cursor = undefined;
this.newestSnapshotTime = undefined; this.newestSnapshotTime = undefined;
this.sinceNewestSnapshot = 0; this.newestReceivedAtMs = 0;
this.snapshotTime = 0; this.snapshotTime = 0;
} }
} }

View file

@ -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.
}
}

Some files were not shown because too many files have changed in this diff Show more