Claude fixes
This commit is contained in:
parent
5d854440ec
commit
1295828ac8
9 changed files with 2086 additions and 205 deletions
|
|
@ -1,5 +1,21 @@
|
|||
import { Command, CommandReceiver, serialize, TransportEvents } from 'shared';
|
||||
import {
|
||||
ClientHeartbeatCommand,
|
||||
Command,
|
||||
CommandReceiver,
|
||||
serialize,
|
||||
settings,
|
||||
TransportEvents,
|
||||
} from 'shared';
|
||||
import { Socket } from 'socket.io-client';
|
||||
import { predictorNowMs } from '../helper/prediction/local-character-predictor';
|
||||
|
||||
const heartbeatIntervalMs = settings.clientSendInterval * 1000;
|
||||
|
||||
// A frame that lands a hair short of the deadline still counts as due. At an
|
||||
// exact ratio of frame rate to interval — 60 Hz against 30 Hz — whether the gap
|
||||
// compares as reached is decided by floating-point noise, and the heartbeat
|
||||
// slips a whole frame every third beat, drifting to ~21 Hz instead of 30.
|
||||
const dueToleranceMs = 1;
|
||||
|
||||
export class CommandSocket extends CommandReceiver {
|
||||
constructor(private readonly socket: Socket) {
|
||||
|
|
@ -7,14 +23,35 @@ export class CommandSocket extends CommandReceiver {
|
|||
}
|
||||
|
||||
private commandQueue: Array<Command> = [];
|
||||
private lastSendMs = -Infinity;
|
||||
|
||||
protected defaultCommandExecutor(command: Command) {
|
||||
this.commandQueue.push(command);
|
||||
}
|
||||
|
||||
public sendQueuedCommands() {
|
||||
if (this.commandQueue.length > 0) {
|
||||
this.socket.emit(TransportEvents.PlayerToServer, serialize(this.commandQueue));
|
||||
this.commandQueue = [];
|
||||
const nowMs = predictorNowMs();
|
||||
|
||||
// Real input goes out on the frame it happens — that is the whole latency
|
||||
// budget the player feels. An idle client still has to keep the server's
|
||||
// input acknowledgement moving (a held key generates no fresh command), but
|
||||
// that heartbeat is paced by the clock rather than by the frame rate.
|
||||
//
|
||||
// Sending once per rendered frame instead put every display above
|
||||
// settings.maxInboundMessagesPerSecond permanently over the server's inbound
|
||||
// allowance; once the burst was spent it silently discarded whole batches,
|
||||
// and since movement commands are edge-triggered and never re-sent, a lost
|
||||
// batch meant the direction change simply never happened.
|
||||
if (
|
||||
this.commandQueue.length === 0 &&
|
||||
nowMs - this.lastSendMs < heartbeatIntervalMs - dueToleranceMs
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.lastSendMs = nowMs;
|
||||
|
||||
this.commandQueue.push(new ClientHeartbeatCommand(Math.round(nowMs)));
|
||||
this.socket.emit(TransportEvents.PlayerToServer, serialize(this.commandQueue));
|
||||
this.commandQueue = [];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,20 +14,26 @@ export class KeyboardListener extends CommandGenerator {
|
|||
}
|
||||
|
||||
private keyDownListener = (event: KeyboardEvent) => {
|
||||
const key = event.key.toLowerCase();
|
||||
// Space leaps (W / ArrowUp already cover walking up). Edge-triggered so a
|
||||
// held key's auto-repeat doesn't spam leaps.
|
||||
if ((key === ' ' || key === 'shift') && !this.keysDown.has(key)) {
|
||||
const key = event.code;
|
||||
if (
|
||||
(key === 'Space' || key === 'ShiftLeft' || key === 'ShiftRight') &&
|
||||
!this.keysDown.has(key)
|
||||
) {
|
||||
const clientTimeMs = localCharacterPredictor.recordLeap();
|
||||
this.sendCommandToSubscribers(new LeapActionCommand(clientTimeMs));
|
||||
}
|
||||
|
||||
if (this.keysDown.has(key)) {
|
||||
return;
|
||||
}
|
||||
this.keysDown.add(key);
|
||||
this.generateCommands();
|
||||
};
|
||||
|
||||
private keyUpListener = (event: KeyboardEvent) => {
|
||||
this.keysDown.delete(event.key.toLowerCase());
|
||||
this.generateCommands();
|
||||
if (this.keysDown.delete(event.code)) {
|
||||
this.generateCommands();
|
||||
}
|
||||
};
|
||||
|
||||
private blurListener = () => {
|
||||
|
|
@ -36,10 +42,10 @@ export class KeyboardListener extends CommandGenerator {
|
|||
};
|
||||
|
||||
private generateCommands() {
|
||||
const up = ~~(this.keysDown.has('w') || this.keysDown.has('arrowup'));
|
||||
const down = ~~(this.keysDown.has('s') || this.keysDown.has('arrowdown'));
|
||||
const left = ~~(this.keysDown.has('a') || this.keysDown.has('arrowleft'));
|
||||
const right = ~~(this.keysDown.has('d') || this.keysDown.has('arrowright'));
|
||||
const up = ~~(this.keysDown.has('KeyW') || this.keysDown.has('ArrowUp'));
|
||||
const down = ~~(this.keysDown.has('KeyS') || this.keysDown.has('ArrowDown'));
|
||||
const left = ~~(this.keysDown.has('KeyA') || this.keysDown.has('ArrowLeft'));
|
||||
const right = ~~(this.keysDown.has('KeyD') || this.keysDown.has('ArrowRight'));
|
||||
|
||||
const movement = vec2.fromValues(right - left, up - down);
|
||||
if (vec2.squaredLength(movement) > 0) {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { vec2 } from 'gl-matrix';
|
|||
import { CommandGenerator, PrimaryActionCommand, holdDurationToCharge } from 'shared';
|
||||
import { Game } from '../game';
|
||||
import { ChargeIndicator } from '../charge-indicator';
|
||||
import { predictorNowMs } from '../helper/prediction/local-character-predictor';
|
||||
import { Pointer } from '../helper/pointer';
|
||||
|
||||
export class MouseListener extends CommandGenerator {
|
||||
|
|
@ -16,9 +17,13 @@ export class MouseListener extends CommandGenerator {
|
|||
super();
|
||||
|
||||
target.addEventListener('mousedown', this.mouseDownListener);
|
||||
target.addEventListener('mouseup', this.mouseUpListener);
|
||||
target.addEventListener('mousemove', this.mouseMoveListener);
|
||||
target.addEventListener('contextmenu', this.contextMenuListener);
|
||||
// Release is watched on the window, not the canvas: a charge released over
|
||||
// the settings gear, the fullscreen icon, or outside the window otherwise
|
||||
// never fired the shot and left the charge ring up until the next press.
|
||||
window.addEventListener('mouseup', this.mouseUpListener);
|
||||
window.addEventListener('blur', this.cancelPrimary);
|
||||
}
|
||||
|
||||
// Only the screen position is stored; it is reprojected to world space each
|
||||
|
|
@ -46,10 +51,23 @@ export class MouseListener extends CommandGenerator {
|
|||
const charge = holdDurationToCharge((performance.now() - this.primaryDownAt) / 1000);
|
||||
this.primaryDownAt = null;
|
||||
this.sendCommandToSubscribers(
|
||||
new PrimaryActionCommand(this.positionFromEvent(event), charge),
|
||||
new PrimaryActionCommand(
|
||||
this.positionFromEvent(event),
|
||||
charge,
|
||||
Math.round(predictorNowMs()),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
// Losing the window mid-charge cannot produce a meaningful aim point, so drop
|
||||
// the charge rather than leaving the indicator running.
|
||||
private cancelPrimary = () => {
|
||||
if (this.primaryDownAt !== null) {
|
||||
this.primaryDownAt = null;
|
||||
ChargeIndicator.end();
|
||||
}
|
||||
};
|
||||
|
||||
// Suppress the browser context menu on the canvas; right-click has no action.
|
||||
private contextMenuListener = (event: MouseEvent) => {
|
||||
event.preventDefault();
|
||||
|
|
@ -64,8 +82,9 @@ export class MouseListener extends CommandGenerator {
|
|||
public destroy() {
|
||||
ChargeIndicator.end();
|
||||
this.target.removeEventListener('mousedown', this.mouseDownListener);
|
||||
this.target.removeEventListener('mouseup', this.mouseUpListener);
|
||||
this.target.removeEventListener('mousemove', this.mouseMoveListener);
|
||||
this.target.removeEventListener('contextmenu', this.contextMenuListener);
|
||||
window.removeEventListener('mouseup', this.mouseUpListener);
|
||||
window.removeEventListener('blur', this.cancelPrimary);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,10 @@ import {
|
|||
} from 'shared';
|
||||
import { Game } from '../game';
|
||||
import { ChargeIndicator } from '../charge-indicator';
|
||||
import { localCharacterPredictor } from '../helper/prediction/local-character-predictor';
|
||||
import {
|
||||
localCharacterPredictor,
|
||||
predictorNowMs,
|
||||
} from '../helper/prediction/local-character-predictor';
|
||||
|
||||
export class TouchListener extends CommandGenerator {
|
||||
private static readonly deadZone = 8;
|
||||
|
|
@ -24,6 +27,7 @@ export class TouchListener extends CommandGenerator {
|
|||
private isJoystickActive = false;
|
||||
private touchStartPosition!: vec2;
|
||||
private primaryDownAt: number | null = null;
|
||||
private gestureTouchId: number | null = null;
|
||||
|
||||
private fireButton: HTMLElement;
|
||||
private fireStrengthRing: HTMLElement;
|
||||
|
|
@ -69,23 +73,43 @@ export class TouchListener extends CommandGenerator {
|
|||
target.addEventListener('touchstart', this.touchStartListener);
|
||||
target.addEventListener('touchmove', this.touchMoveListener);
|
||||
target.addEventListener('touchend', this.touchEndListener);
|
||||
target.addEventListener('touchcancel', this.touchCancelListener);
|
||||
}
|
||||
|
||||
// The contact that started this gesture, or undefined if it is not in `list`.
|
||||
private findGestureTouch(list: TouchList): Touch | undefined {
|
||||
if (this.gestureTouchId === null) {
|
||||
return undefined;
|
||||
}
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
if (list[i].identifier === this.gestureTouchId) {
|
||||
return list[i];
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private touchStartListener = (event: TouchEvent) => {
|
||||
event.preventDefault();
|
||||
if (this.isJoystickActive) {
|
||||
const center = vec2.fromValues(
|
||||
last(event.touches)!.clientX,
|
||||
last(event.touches)!.clientY,
|
||||
last(event.changedTouches)!.clientX,
|
||||
last(event.changedTouches)!.clientY,
|
||||
);
|
||||
this.sendCommandToSubscribers(
|
||||
new PrimaryActionCommand(this.game.displayToWorldCoordinates(center)),
|
||||
new PrimaryActionCommand(
|
||||
this.game.displayToWorldCoordinates(center),
|
||||
0,
|
||||
Math.round(predictorNowMs()),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
this.touchStartPosition = vec2.fromValues(
|
||||
event.touches[0].clientX,
|
||||
event.touches[0].clientY,
|
||||
);
|
||||
const touch = event.changedTouches[0];
|
||||
if (!touch) {
|
||||
return;
|
||||
}
|
||||
this.gestureTouchId = touch.identifier;
|
||||
this.touchStartPosition = vec2.fromValues(touch.clientX, touch.clientY);
|
||||
this.primaryDownAt = performance.now();
|
||||
ChargeIndicator.begin(this.touchStartPosition.x, this.touchStartPosition.y);
|
||||
}
|
||||
|
|
@ -94,10 +118,12 @@ export class TouchListener extends CommandGenerator {
|
|||
private touchMoveListener = (event: TouchEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
const touchPosition = vec2.fromValues(
|
||||
event.touches[0].clientX,
|
||||
event.touches[0].clientY,
|
||||
);
|
||||
const touch = this.findGestureTouch(event.touches);
|
||||
if (!touch || !this.touchStartPosition) {
|
||||
return;
|
||||
}
|
||||
|
||||
const touchPosition = vec2.fromValues(touch.clientX, touch.clientY);
|
||||
|
||||
const delta = vec2.subtract(vec2.create(), touchPosition, this.touchStartPosition);
|
||||
vec2.scale(delta, delta, TouchListener.deltaScaling);
|
||||
|
|
@ -133,6 +159,14 @@ export class TouchListener extends CommandGenerator {
|
|||
private touchEndListener = (event: TouchEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
// Only the contact that owns the gesture ends it — not whichever finger
|
||||
// happens to lift, and not "the screen is now empty".
|
||||
const touch = this.findGestureTouch(event.changedTouches);
|
||||
if (!touch) {
|
||||
return;
|
||||
}
|
||||
this.gestureTouchId = null;
|
||||
|
||||
if (!this.isJoystickActive) {
|
||||
ChargeIndicator.end();
|
||||
const charge =
|
||||
|
|
@ -140,20 +174,39 @@ export class TouchListener extends CommandGenerator {
|
|||
? 0
|
||||
: holdDurationToCharge((performance.now() - this.primaryDownAt) / 1000);
|
||||
this.primaryDownAt = null;
|
||||
const center = vec2.fromValues(
|
||||
event.changedTouches[0].clientX,
|
||||
event.changedTouches[0].clientY,
|
||||
);
|
||||
const center = vec2.fromValues(touch.clientX, touch.clientY);
|
||||
this.sendCommandToSubscribers(
|
||||
new PrimaryActionCommand(this.game.displayToWorldCoordinates(center), charge),
|
||||
new PrimaryActionCommand(
|
||||
this.game.displayToWorldCoordinates(center),
|
||||
charge,
|
||||
Math.round(predictorNowMs()),
|
||||
),
|
||||
);
|
||||
} else if (event.touches.length === 0) {
|
||||
this.isJoystickActive = false;
|
||||
this.joystick.parentElement?.removeChild(this.joystick);
|
||||
this.sendMove(vec2.create());
|
||||
} else {
|
||||
this.releaseJoystick();
|
||||
}
|
||||
};
|
||||
|
||||
// Also reached from touchcancel: a system gesture, an incoming call or a
|
||||
// palm rejection would otherwise leave the stick latched at full tilt.
|
||||
private touchCancelListener = (event: TouchEvent) => {
|
||||
if (!this.findGestureTouch(event.changedTouches)) {
|
||||
return;
|
||||
}
|
||||
this.gestureTouchId = null;
|
||||
this.primaryDownAt = null;
|
||||
ChargeIndicator.end();
|
||||
if (this.isJoystickActive) {
|
||||
this.releaseJoystick();
|
||||
}
|
||||
};
|
||||
|
||||
private releaseJoystick() {
|
||||
this.isJoystickActive = false;
|
||||
this.joystick.parentElement?.removeChild(this.joystick);
|
||||
this.sendMove(vec2.create());
|
||||
}
|
||||
|
||||
private swallowTouch = (event: TouchEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
|
@ -237,7 +290,9 @@ export class TouchListener extends CommandGenerator {
|
|||
direction,
|
||||
settings.touchAimRange,
|
||||
);
|
||||
this.sendCommandToSubscribers(new PrimaryActionCommand(aim, charge));
|
||||
this.sendCommandToSubscribers(
|
||||
new PrimaryActionCommand(aim, charge, Math.round(predictorNowMs())),
|
||||
);
|
||||
};
|
||||
|
||||
public update(_deltaTimeInSeconds: number) {
|
||||
|
|
@ -250,9 +305,8 @@ export class TouchListener extends CommandGenerator {
|
|||
|
||||
const character = this.game.gameObjects.player;
|
||||
if (character) {
|
||||
this.fireStrengthRing.style.background = `conic-gradient(rgba(255, 255, 255, 0.75) ${
|
||||
character.strengthFraction * 360
|
||||
}deg, transparent 0deg)`;
|
||||
this.fireStrengthRing.style.background = `conic-gradient(rgba(255, 255, 255, 0.75) ${character.strengthFraction * 360
|
||||
}deg, transparent 0deg)`;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -261,6 +315,7 @@ export class TouchListener extends CommandGenerator {
|
|||
this.target.removeEventListener('touchstart', this.touchStartListener);
|
||||
this.target.removeEventListener('touchmove', this.touchMoveListener);
|
||||
this.target.removeEventListener('touchend', this.touchEndListener);
|
||||
this.target.removeEventListener('touchcancel', this.touchCancelListener);
|
||||
|
||||
this.fireButton.removeEventListener('touchstart', this.fireButtonDownListener);
|
||||
this.fireButton.removeEventListener('touchmove', this.fireButtonMoveListener);
|
||||
|
|
|
|||
|
|
@ -99,6 +99,18 @@ export abstract class FeedbackHud {
|
|||
this.elimination = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tear the HUD down entirely. Its root lives on document.body rather than the
|
||||
* game overlay, so leaving a match while dead used to leave "Eliminated /
|
||||
* Respawning…" painted over the landing page.
|
||||
*/
|
||||
public static reset(): void {
|
||||
this.hideElimination();
|
||||
this.root?.parentElement?.removeChild(this.root);
|
||||
this.root = undefined;
|
||||
this.killfeed = undefined;
|
||||
}
|
||||
|
||||
private static streakName(streak: number): string | undefined {
|
||||
switch (streak) {
|
||||
case 2:
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import {
|
|||
Command,
|
||||
settings,
|
||||
InputAcknowledgement,
|
||||
JoinRejectionReason,
|
||||
} from 'shared';
|
||||
import { io, Socket } from 'socket.io-client';
|
||||
import { KeyboardListener } from './commands/keyboard-listener';
|
||||
|
|
@ -40,6 +41,7 @@ import { Tutorial } from './tutorial';
|
|||
import { Scoreboard } from './scoreboard';
|
||||
import { Minimap } from './minimap';
|
||||
import { ScreenShake } from './screen-shake';
|
||||
import { FeedbackHud } from './feedback-hud';
|
||||
|
||||
export class Game extends CommandReceiver {
|
||||
public gameObjects = new GameObjectContainer(this);
|
||||
|
|
@ -60,6 +62,11 @@ export class Game extends CommandReceiver {
|
|||
private keystoneArrow?: HTMLElement;
|
||||
private socketReceiver!: CommandSocket;
|
||||
private tutorial!: Tutorial;
|
||||
// Issued by the server on join; presented on reconnect to reclaim this
|
||||
// player's team and score instead of coming back as a blank slate.
|
||||
private reconnectToken?: string;
|
||||
private connectionBanner?: HTMLElement;
|
||||
private rejectionReason?: JoinRejectionReason;
|
||||
|
||||
constructor(
|
||||
private readonly playerDecision: PlayerDecision,
|
||||
|
|
@ -110,19 +117,76 @@ export class Game extends CommandReceiver {
|
|||
this.socket.io.opts.transports = ['polling', 'websocket'];
|
||||
});
|
||||
|
||||
// A transport drop is not the end of the match. Previously this tore the
|
||||
// game down on the first `disconnect`, which stopped the render loop, which
|
||||
// closed the socket — cancelling the reconnection the client is configured
|
||||
// for before it could ever run, and dumping the player on the server list.
|
||||
this.socket.on('disconnect', () => {
|
||||
if (!this.isBetweenGames) {
|
||||
this.destroy();
|
||||
if (this.isBetweenGames) {
|
||||
return;
|
||||
}
|
||||
this.showConnectionBanner('Reconnecting…');
|
||||
});
|
||||
|
||||
this.socket.on(TransportEvents.Ping, () => {
|
||||
this.socket.emit(TransportEvents.Pong);
|
||||
this.socket.on('connect', () => {
|
||||
if (this.isBetweenGames) {
|
||||
return;
|
||||
}
|
||||
// A reconnect is a brand-new server-side connection that has never seen a
|
||||
// join, so the join has to be re-sent or the client sits connected and
|
||||
// invisible forever.
|
||||
this.hideConnectionBanner();
|
||||
serverTimeline.reset();
|
||||
localCharacterPredictor.reset();
|
||||
// ...and it is a brand-new server-side *player*, whose view-area
|
||||
// bookkeeping starts empty. Anything the dropped session was told about
|
||||
// will never be retracted, so the old world has to go here.
|
||||
this.gameObjects.reset();
|
||||
this.socket.emit(TransportEvents.PlayerJoining, {
|
||||
...this.playerDecision,
|
||||
reconnectToken: this.reconnectToken,
|
||||
});
|
||||
});
|
||||
|
||||
this.socket.io.on('reconnect_failed', () => {
|
||||
this.showConnectionBanner('Connection lost');
|
||||
this.destroy();
|
||||
});
|
||||
|
||||
this.socket.on(TransportEvents.PlayerJoined, (token: string) => {
|
||||
this.reconnectToken = typeof token === 'string' ? token : undefined;
|
||||
this.hideConnectionBanner();
|
||||
});
|
||||
|
||||
this.socket.on(TransportEvents.JoinRejected, (reason: JoinRejectionReason) => {
|
||||
this.rejectionReason = reason;
|
||||
this.showConnectionBanner(Game.rejectionText(reason));
|
||||
this.destroy();
|
||||
});
|
||||
|
||||
// Echo the nonce: the server only accepts a reply that matches the ping
|
||||
// still outstanding, so a stale or duplicated Pong cannot move its RTT.
|
||||
this.socket.on(TransportEvents.Ping, (nonce: unknown) => {
|
||||
this.socket.emit(TransportEvents.Pong, nonce);
|
||||
});
|
||||
|
||||
this.socket.on(TransportEvents.ServerToPlayer, (serializedCommands: string) => {
|
||||
const commands: Array<Command> = deserialize(serializedCommands);
|
||||
commands.forEach((c) => this.handleCommand(c));
|
||||
// One malformed object must not take down the message pump. deserialize
|
||||
// revives classes by name from the payload, so a hostile or corrupt field
|
||||
// can throw inside JSON.parse's reviver — and every later batch would be
|
||||
// lost with it.
|
||||
try {
|
||||
const commands: Array<Command> = deserialize(serializedCommands);
|
||||
commands.forEach((c) => {
|
||||
try {
|
||||
this.handleCommand(c);
|
||||
} catch (e) {
|
||||
console.error('Failed to apply a server command', e);
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Dropped an undecodable server message', e);
|
||||
}
|
||||
});
|
||||
|
||||
this.socketReceiver = new CommandSocket(this.socket);
|
||||
|
|
@ -138,9 +202,10 @@ export class Game extends CommandReceiver {
|
|||
this.touchListener.subscribe(this.socketReceiver);
|
||||
this.touchListener.subscribe(this.tutorial);
|
||||
|
||||
// The join is emitted from the socket's `connect` handler above, which fires
|
||||
// for the first connection and for every reconnection alike — so one code
|
||||
// path covers both, and a reconnect can never be left unjoined.
|
||||
this.isBetweenGames = false;
|
||||
|
||||
this.socket.emit(TransportEvents.PlayerJoining, this.playerDecision);
|
||||
}
|
||||
|
||||
protected defaultCommandExecutor(c: Command) {
|
||||
|
|
@ -163,6 +228,7 @@ export class Game extends CommandReceiver {
|
|||
c.clientTimeMs,
|
||||
c.bodyVelocity,
|
||||
c.lastLeapClientTimeMs,
|
||||
c.ackAgeMs,
|
||||
),
|
||||
[GameEndCommand.type]: () => (this.isEnding = true),
|
||||
[UpdateMinimap.type]: (c: UpdateMinimap) => (this.lastMinimap = c),
|
||||
|
|
@ -208,11 +274,49 @@ export class Game extends CommandReceiver {
|
|||
);
|
||||
this.socket.close();
|
||||
this.overlay.innerHTML = '';
|
||||
this.hideConnectionBanner();
|
||||
// The HUD root lives on document.body, not the overlay, so it has to be torn
|
||||
// down explicitly or it stays painted over the landing page.
|
||||
FeedbackHud.reset();
|
||||
this.keyboardListener.destroy();
|
||||
this.mouseListener.destroy();
|
||||
this.touchListener.destroy();
|
||||
}
|
||||
|
||||
/** Why the player was sent back to the server list, if they were. */
|
||||
public get lastRejectionReason(): JoinRejectionReason | undefined {
|
||||
return this.rejectionReason;
|
||||
}
|
||||
|
||||
public static rejectionText(reason: JoinRejectionReason): string {
|
||||
switch (reason) {
|
||||
case JoinRejectionReason.ServerFull:
|
||||
return 'That server is full — pick another';
|
||||
case JoinRejectionReason.RoundEnding:
|
||||
return 'That round is just finishing — try again in a moment';
|
||||
case JoinRejectionReason.AlreadyJoined:
|
||||
return 'Already joined on this connection';
|
||||
default:
|
||||
return 'The server refused the connection';
|
||||
}
|
||||
}
|
||||
|
||||
private showConnectionBanner(text: string) {
|
||||
if (!this.connectionBanner) {
|
||||
this.connectionBanner = document.createElement('div');
|
||||
this.connectionBanner.className = 'connection-banner';
|
||||
this.overlay.appendChild(this.connectionBanner);
|
||||
}
|
||||
this.connectionBanner.innerText = text;
|
||||
this.connectionBanner.style.display = 'block';
|
||||
}
|
||||
|
||||
private hideConnectionBanner() {
|
||||
if (this.connectionBanner) {
|
||||
this.connectionBanner.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
public displayToWorldCoordinates(p: vec2): vec2 {
|
||||
return this.renderer?.displayToWorldCoordinates(p) ?? vec2.create();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,10 +25,16 @@ export const setPredictorClockForTesting = (clock: () => number): void => {
|
|||
nowMs = clock;
|
||||
};
|
||||
|
||||
// The same clock the predictor stamps input with. Anything that tells the server
|
||||
// "this is how far my input timeline has got" must read it from here, or the
|
||||
// acknowledgement comes back in a different time base than the replay window.
|
||||
export const predictorNowMs = (): number => nowMs();
|
||||
|
||||
// Don't replay more than this far back: if the last acknowledged input is older
|
||||
// (a stall, or a backgrounded tab catching up) snap to the authoritative pose
|
||||
// instead of grinding through hundreds of steps.
|
||||
const maxReplayMs = 300;
|
||||
// (a stall, or a backgrounded tab catching up) fall back to a shorter window
|
||||
// instead of grinding through hundreds of steps. Comfortably covers the replay
|
||||
// a 300 ms round trip needs; beyond that the body simply trails a little.
|
||||
const maxReplayMs = 400;
|
||||
|
||||
// Render-side easing of the correction the reconciliation produces each frame,
|
||||
// so a snapshot that disagrees with the prediction is smoothed out instead of
|
||||
|
|
@ -60,15 +66,33 @@ export class LocalCharacterPredictor {
|
|||
private readonly world = new ClientCharacterWorld();
|
||||
|
||||
private authoritative?: { head: Circle; leftFoot: Circle; rightFoot: Circle };
|
||||
private lastAckClientTimeMs?: number;
|
||||
// Wall-clock (client) time the latest authoritative snapshot was received. The
|
||||
// replay predicts forward from HERE by the snapshot's age, so the local pose
|
||||
// advances smoothly with real time between the 25 Hz snapshots. Anchoring to
|
||||
// the last *acked input* time instead breaks when input is sent only on change
|
||||
// (a held key sends nothing): that time freezes, the window pins to the
|
||||
// maxReplayMs clamp, the replay displacement goes constant, and the pose
|
||||
// stair-steps at the snapshot rate.
|
||||
private authReceiptMs = 0;
|
||||
// Client-clock instant the authoritative pose below belongs to, less one
|
||||
// one-way trip — which is exactly the lead the local body should have, since
|
||||
// the input the player is giving right now will not reach the server for that
|
||||
// long. Replaying from here reproduces every input the server has not applied
|
||||
// yet, so the body responds immediately at any latency.
|
||||
//
|
||||
// It is built from BOTH halves of the acknowledgement: the client-clock time
|
||||
// of the newest input the server had applied, plus how long after that the
|
||||
// snapshot was actually taken. Both halves are needed.
|
||||
//
|
||||
// Anchoring on the acknowledged input time ALONE beats against the send
|
||||
// cadence: input is sent once per frame, so at snapshot time the newest input
|
||||
// the server holds is 0..1 frame old depending on where the client's frames
|
||||
// fell, and that age lands directly in the replay window. Measured on
|
||||
// localhost it walked a sawtooth — 17, 10, 6, 1, 11, 6, 2, 12 ms — so the
|
||||
// window jumped by up to a frame of travel every snapshot and the body
|
||||
// stuttered at 25 Hz. ackAgeMs cancels it exactly.
|
||||
//
|
||||
// Anchoring on the snapshot's ARRIVAL is free of that beat, but it under-
|
||||
// advances by a one-way trip, so the local body trails its own input at any
|
||||
// real latency. This anchor degenerates to arrival on a zero-latency link and
|
||||
// grows into full compensation as latency rises.
|
||||
//
|
||||
// A held key sends no fresh movement, which would freeze the acknowledgement
|
||||
// altogether; that is fixed at the source, by closing every outgoing batch
|
||||
// with a ClientHeartbeatCommand.
|
||||
private replayAnchorMs?: number;
|
||||
// Authoritative launch momentum at the last snapshot — seeds each replay so a
|
||||
// leap/slingshot/recoil flight is reproduced and continuously corrected.
|
||||
private authoritativeBodyVelocity = vec2.create();
|
||||
|
|
@ -123,14 +147,13 @@ export class LocalCharacterPredictor {
|
|||
clientTimeMs: number,
|
||||
bodyVelocity: vec2,
|
||||
lastLeapClientTimeMs: number,
|
||||
ackAgeMs = 0,
|
||||
): void {
|
||||
// Inputs only advance the acknowledgement forward; the launch momentum and
|
||||
// leap boundary always adopt the latest authoritative values.
|
||||
if (
|
||||
this.lastAckClientTimeMs === undefined ||
|
||||
clientTimeMs > this.lastAckClientTimeMs
|
||||
) {
|
||||
this.lastAckClientTimeMs = clientTimeMs;
|
||||
const anchor = clientTimeMs + Math.max(0, ackAgeMs);
|
||||
if (this.replayAnchorMs === undefined || anchor > this.replayAnchorMs) {
|
||||
this.replayAnchorMs = anchor;
|
||||
}
|
||||
vec2.set(this.authoritativeBodyVelocity, bodyVelocity[0], bodyVelocity[1]);
|
||||
this.lastLeapAckMs = lastLeapClientTimeMs;
|
||||
|
|
@ -138,7 +161,6 @@ export class LocalCharacterPredictor {
|
|||
|
||||
public setAuthoritative(head: Circle, leftFoot: Circle, rightFoot: Circle): void {
|
||||
this.authoritative = { head, leftFoot, rightFoot };
|
||||
this.authReceiptMs = Math.round(nowMs());
|
||||
}
|
||||
|
||||
// The player pressed leap; remember when, so the replay applies the same
|
||||
|
|
@ -168,8 +190,7 @@ export class LocalCharacterPredictor {
|
|||
this.leapHistory = [];
|
||||
this.lastLeapAckMs = -Infinity;
|
||||
this.authoritative = undefined;
|
||||
this.lastAckClientTimeMs = undefined;
|
||||
this.authReceiptMs = 0;
|
||||
this.replayAnchorMs = undefined;
|
||||
vec2.zero(this.authoritativeBodyVelocity);
|
||||
this.currentStrength = settings.playerMaxStrength;
|
||||
this.carriedPlanetId = undefined;
|
||||
|
|
@ -179,7 +200,7 @@ export class LocalCharacterPredictor {
|
|||
}
|
||||
|
||||
public get canPredict(): boolean {
|
||||
return this.authoritative !== undefined && this.lastAckClientTimeMs !== undefined;
|
||||
return this.authoritative !== undefined && this.replayAnchorMs !== undefined;
|
||||
}
|
||||
|
||||
// During spawn-in and death the server freezes walking and only scales the
|
||||
|
|
@ -232,11 +253,10 @@ export class LocalCharacterPredictor {
|
|||
private simulate(): CharacterMovementState {
|
||||
const auth = this.authoritative!;
|
||||
const now = Math.round(nowMs());
|
||||
// Predict forward from the latest snapshot by its age, clamped so a stall
|
||||
// (or a backgrounded tab catching up) snaps instead of grinding hundreds of
|
||||
// steps. This advances with wall-clock time even while a held key sends no
|
||||
// fresh input, so the pose no longer pins to the 25 Hz snapshot cadence.
|
||||
const startMs = Math.max(this.authReceiptMs, now - maxReplayMs);
|
||||
// Replay every input the server has not confirmed yet: from the anchor up to
|
||||
// now, clamped so a stall (or a backgrounded tab catching up) cannot grind
|
||||
// through hundreds of steps.
|
||||
const startMs = Math.min(now, Math.max(this.replayAnchorMs!, now - maxReplayMs));
|
||||
const windowMs = Math.max(0, now - startMs);
|
||||
const steps = Math.floor(windowMs / stepMs);
|
||||
const remainderSeconds = (windowMs - steps * stepMs) / 1000;
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
UpdatePropertyCommand,
|
||||
} from 'shared';
|
||||
import { BeforeDestroyCommand } from '../commands/types/before-destroy';
|
||||
import { RenderCommand } from '../commands/types/render';
|
||||
import { StepCommand } from '../commands/types/step';
|
||||
import { FeedbackHud } from '../feedback-hud';
|
||||
import { Game } from '../game';
|
||||
|
|
@ -31,6 +32,19 @@ export class GameObjectContainer extends CommandReceiver {
|
|||
public camera: Camera = new Camera(this.game);
|
||||
private wasLocalPlayerAlive = false;
|
||||
|
||||
// Create and delete arrive on the wire, but positions are drawn
|
||||
// interpolationDelaySeconds in the past — so applying them the instant a
|
||||
// packet lands made every projectile pop into existence and hang at its muzzle
|
||||
// for 100 ms, then disappear a few hundred units before its rendered impact.
|
||||
// Objects are therefore admitted and retired on the render timeline instead:
|
||||
// they exist (and accumulate interpolation frames) from the moment they
|
||||
// arrive, but only draw once the cursor reaches the snapshot that introduced
|
||||
// them, and keep drawing until it reaches the one that removed them.
|
||||
private visibleFrom = new Map<Id, number>();
|
||||
private deleteAt = new Map<Id, number>();
|
||||
private awaitingCreateStamp: Array<Id> = [];
|
||||
private awaitingDeleteStamp: Array<Id> = [];
|
||||
|
||||
protected commandExecutors: CommandExecutors = {
|
||||
[CreatePlayerCommand.type]: (c: CreatePlayerCommand) => {
|
||||
this.player = c.character as CharacterView;
|
||||
|
|
@ -45,9 +59,17 @@ export class GameObjectContainer extends CommandReceiver {
|
|||
},
|
||||
|
||||
[CreateObjectsCommand.type]: (c: CreateObjectsCommand) =>
|
||||
c.objects.forEach((o) => this.addObject(o as GameObject)),
|
||||
c.objects.forEach((o) => {
|
||||
this.addObject(o as GameObject);
|
||||
this.awaitingCreateStamp.push((o as GameObject).id);
|
||||
}),
|
||||
|
||||
[StepCommand.type]: (c: StepCommand) => {
|
||||
// A batch carrying no property updates (a bare announcement, say) leaves
|
||||
// entries unstamped; fall back to the newest known snapshot so nothing is
|
||||
// stranded invisible.
|
||||
this.stampPending(serverTimeline.snapshotTime);
|
||||
this.retireDueObjects();
|
||||
this.defaultCommandExecutor(c);
|
||||
|
||||
// The local body is alive only while its object still exists (the server
|
||||
|
|
@ -91,6 +113,9 @@ export class GameObjectContainer extends CommandReceiver {
|
|||
|
||||
[PropertyUpdatesForObjects.type]: (c: PropertyUpdatesForObjects) => {
|
||||
serverTimeline.onSnapshot(c.timestamp);
|
||||
// Create/delete precede the property updates inside a batch, so this is
|
||||
// where the batch's server timestamp becomes known to them.
|
||||
this.stampPending(c.timestamp);
|
||||
c.updates.forEach((u) => {
|
||||
u.updates.forEach((au) => this.objects.get(u.id)?.handleCommand(au));
|
||||
if (this.player && u.id === this.player.id) {
|
||||
|
|
@ -100,17 +125,36 @@ export class GameObjectContainer extends CommandReceiver {
|
|||
},
|
||||
|
||||
[DeleteObjectsCommand.type]: (c: DeleteObjectsCommand) =>
|
||||
c.ids.forEach((id: Id) => this.deleteObject(id)),
|
||||
c.ids.forEach((id: Id) => this.awaitingDeleteStamp.push(id)),
|
||||
};
|
||||
|
||||
constructor(private game: Game) {
|
||||
super();
|
||||
}
|
||||
|
||||
// The local player's world position, but only while the body is alive. On
|
||||
// death the server deletes the character object (yet `player` keeps pointing
|
||||
// at the now-stale view), so gate on the object still being present — otherwise
|
||||
// the minimap would pin the "you" dot at the death spot for the whole respawn.
|
||||
/**
|
||||
* Forget everything the previous connection was told about.
|
||||
*
|
||||
* A reconnect is a brand-new server-side player whose view-area bookkeeping
|
||||
* starts empty, so it re-announces what is in view NOW and never sends a
|
||||
* delete for anything the dropped session held. Without this, every object
|
||||
* that has since moved out of view — the old character included — stays in the
|
||||
* map and on screen forever, as a frozen ghost.
|
||||
*
|
||||
* `player` is deliberately left pointing at the stale view: every read of it
|
||||
* is gated on the object still being in `objects`, so clearing the map is what
|
||||
* makes it inert, and the next CreatePlayerCommand replaces it.
|
||||
*/
|
||||
public reset() {
|
||||
this.objects.forEach((o) => o.handleCommand(new BeforeDestroyCommand()));
|
||||
this.objects.clear();
|
||||
this.visibleFrom.clear();
|
||||
this.deleteAt.clear();
|
||||
this.awaitingCreateStamp = [];
|
||||
this.awaitingDeleteStamp = [];
|
||||
this.wasLocalPlayerAlive = false;
|
||||
}
|
||||
|
||||
public get localPlayerPosition(): vec2 | undefined {
|
||||
return this.player && this.objects.has(this.player.id)
|
||||
? this.player.position
|
||||
|
|
@ -128,10 +172,48 @@ export class GameObjectContainer extends CommandReceiver {
|
|||
}
|
||||
|
||||
protected defaultCommandExecutor(c: Command) {
|
||||
this.objects.forEach((o) => o.handleCommand(c));
|
||||
const isRender = c.type === RenderCommand.type;
|
||||
this.objects.forEach((o) => {
|
||||
// Stepping always runs, so an object that has not appeared yet is still
|
||||
// accumulating interpolation frames and enters the scene already moving.
|
||||
if (isRender && !this.isVisible(o.id)) {
|
||||
return;
|
||||
}
|
||||
o.handleCommand(c);
|
||||
});
|
||||
this.camera.handleCommand(c);
|
||||
}
|
||||
|
||||
private isVisible(id: Id): boolean {
|
||||
const from = this.visibleFrom.get(id);
|
||||
return from === undefined || serverTimeline.renderTime >= from;
|
||||
}
|
||||
|
||||
private stampPending(timestamp: number) {
|
||||
for (const id of this.awaitingCreateStamp) {
|
||||
this.visibleFrom.set(id, timestamp);
|
||||
}
|
||||
this.awaitingCreateStamp = [];
|
||||
|
||||
for (const id of this.awaitingDeleteStamp) {
|
||||
this.deleteAt.set(id, timestamp);
|
||||
}
|
||||
this.awaitingDeleteStamp = [];
|
||||
}
|
||||
|
||||
private retireDueObjects() {
|
||||
if (this.deleteAt.size === 0) {
|
||||
return;
|
||||
}
|
||||
const renderTime = serverTimeline.renderTime;
|
||||
for (const [id, at] of [...this.deleteAt]) {
|
||||
if (renderTime >= at) {
|
||||
this.deleteAt.delete(id);
|
||||
this.deleteObject(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Hand the local player's raw authoritative pose to the predictor (the
|
||||
// interpolated pose would already be ~100 ms stale). The three body parts
|
||||
// arrive together in one snapshot.
|
||||
|
|
@ -168,5 +250,7 @@ export class GameObjectContainer extends CommandReceiver {
|
|||
const object = this.objects.get(id);
|
||||
object?.handleCommand(new BeforeDestroyCommand());
|
||||
this.objects.delete(id);
|
||||
this.visibleFrom.delete(id);
|
||||
this.deleteAt.delete(id);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -57,13 +57,20 @@ export abstract class SoundHandler {
|
|||
return;
|
||||
}
|
||||
|
||||
const audio =
|
||||
this.sounds[sound].currentTime > 0
|
||||
? (this.sounds[sound].cloneNode(true) as HTMLAudioElement)
|
||||
: this.sounds[sound];
|
||||
// Reuse the pooled element unless it is still mid-playback; only overlapping
|
||||
// sounds need a clone. Testing `currentTime > 0` instead stayed true forever
|
||||
// after the first play, so every later effect allocated a fresh element.
|
||||
const pooled = this.sounds[sound];
|
||||
const isBusy = !pooled.paused && !pooled.ended;
|
||||
const audio = isBusy ? (pooled.cloneNode(true) as HTMLAudioElement) : pooled;
|
||||
if (!isBusy) {
|
||||
audio.currentTime = 0;
|
||||
}
|
||||
audio.volume = Math.max(0, Math.min(1, volume));
|
||||
audio.playbackRate = playbackRate;
|
||||
audio.play();
|
||||
// A rejected play() (autoplay policy, or a clone GC'd mid-play) is expected
|
||||
// and must not surface as an unhandled rejection.
|
||||
void audio.play().catch(() => undefined);
|
||||
}
|
||||
|
||||
public static playAmbient() {
|
||||
|
|
|
|||
Loading…
Reference in a new issue