even more
This commit is contained in:
parent
82e092ba55
commit
8f50ebdb97
32 changed files with 1842 additions and 3095 deletions
|
|
@ -1,4 +1,4 @@
|
|||
import { holdDurationToCharge } from 'shared';
|
||||
import { chargeHeldSince } from 'shared';
|
||||
import { pointer } from './helper/pointer';
|
||||
|
||||
let element: HTMLElement | undefined;
|
||||
|
|
@ -24,7 +24,7 @@ function beginChargeIndicator(x: number, y: number, followPointer = false) {
|
|||
function endChargeIndicator() {
|
||||
if (element) {
|
||||
cancelAnimationFrame(raf);
|
||||
element.parentElement?.removeChild(element);
|
||||
element.remove();
|
||||
element = undefined;
|
||||
}
|
||||
}
|
||||
|
|
@ -34,7 +34,7 @@ function updateChargeIndicator() {
|
|||
return;
|
||||
}
|
||||
|
||||
const charge = holdDurationToCharge((performance.now() - heldSince) / 1000);
|
||||
const charge = chargeHeldSince(heldSince);
|
||||
element.style.opacity = charge < 0.12 ? '0' : '1';
|
||||
element.style.background = `conic-gradient(rgba(255, 255, 255, 0.85) ${
|
||||
charge * 360
|
||||
|
|
@ -44,15 +44,14 @@ function updateChargeIndicator() {
|
|||
if (isFollowingPointer) {
|
||||
const cursor = pointer.displayPosition;
|
||||
if (cursor) {
|
||||
element.style.left = `${cursor.x}px`;
|
||||
element.style.top = `${cursor.y}px`;
|
||||
element.style.left = `${cursor[0]}px`;
|
||||
element.style.top = `${cursor[1]}px`;
|
||||
}
|
||||
}
|
||||
|
||||
raf = requestAnimationFrame(updateChargeIndicator);
|
||||
}
|
||||
|
||||
// The one public surface; the functions above stay module-private.
|
||||
export const ChargeIndicator = {
|
||||
begin: beginChargeIndicator,
|
||||
end: endChargeIndicator,
|
||||
|
|
|
|||
|
|
@ -1,49 +1,35 @@
|
|||
import {
|
||||
ClientHeartbeatCommand,
|
||||
Command,
|
||||
CommandReceiver,
|
||||
serialize,
|
||||
settings,
|
||||
TransportEvents,
|
||||
} from 'shared';
|
||||
import { Socket } from 'socket.io-client';
|
||||
import { predictorNowMs } from '../helper/prediction/local-character-predictor';
|
||||
import { clientTimeMs } 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.
|
||||
// At an exact frame-rate/interval ratio (60 Hz vs 30 Hz) float noise would otherwise drop every third beat.
|
||||
const dueToleranceMs = 1;
|
||||
|
||||
export class CommandSocket extends CommandReceiver {
|
||||
constructor(private readonly socket: Socket) {
|
||||
super();
|
||||
}
|
||||
|
||||
export class CommandSocket {
|
||||
private commandQueue: Array<Command> = [];
|
||||
private lastSendMs = -Infinity;
|
||||
|
||||
protected defaultCommandExecutor(command: Command) {
|
||||
constructor(private readonly socket: Socket) {}
|
||||
|
||||
public queue(command: Command) {
|
||||
this.commandQueue.push(command);
|
||||
}
|
||||
|
||||
public sendQueuedCommands() {
|
||||
// Nothing goes out while the transport is down. socket.io-client would
|
||||
// otherwise buffer every emit in an uncapped sendBuffer and flush it on
|
||||
// reconnect BEFORE the `connect` handler re-sends PlayerJoining — the
|
||||
// server has no PlayerToServer listener attached yet and drops the lot.
|
||||
// The local queue is dropped with it: these are timestamped inputs, and
|
||||
// replaying a minute-old shot after the gap would be worse than losing it.
|
||||
// socket.io-client would buffer emits made while disconnected and flush them before the re-join.
|
||||
if (!this.socket.connected) {
|
||||
this.commandQueue = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const nowMs = predictorNowMs();
|
||||
|
||||
// Real input goes out on the frame it happens — that is the latency budget the player feels. An idle client still keeps the server's input acknowledgement moving (a held key generates no fresh command), paced by the clock not the frame rate; sending every rendered frame would exceed settings.maxInboundMessagesPerSecond, and since movement commands are edge-triggered and never re-sent, a discarded batch would drop the direction change for good.
|
||||
const nowMs = clientTimeMs();
|
||||
if (
|
||||
this.commandQueue.length === 0 &&
|
||||
nowMs - this.lastSendMs < heartbeatIntervalMs - dueToleranceMs
|
||||
|
|
@ -52,7 +38,7 @@ export class CommandSocket extends CommandReceiver {
|
|||
}
|
||||
this.lastSendMs = nowMs;
|
||||
|
||||
this.commandQueue.push(new ClientHeartbeatCommand(Math.round(nowMs)));
|
||||
this.commandQueue.push(new ClientHeartbeatCommand(nowMs));
|
||||
this.socket.emit(TransportEvents.PlayerToServer, serialize(this.commandQueue));
|
||||
this.commandQueue = [];
|
||||
}
|
||||
|
|
|
|||
29
frontend/src/scripts/commands/input-generator.ts
Normal file
29
frontend/src/scripts/commands/input-generator.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
import {
|
||||
Command,
|
||||
LeapActionCommand,
|
||||
MoveActionCommand,
|
||||
PrimaryActionCommand,
|
||||
} from 'shared';
|
||||
import {
|
||||
clientTimeMs,
|
||||
localCharacterPredictor,
|
||||
} from '../helper/prediction/local-character-predictor';
|
||||
|
||||
export abstract class InputGenerator {
|
||||
constructor(protected readonly onCommand: (command: Command) => void) {}
|
||||
|
||||
protected sendMove(direction: vec2) {
|
||||
this.onCommand(
|
||||
new MoveActionCommand(direction, localCharacterPredictor.recordInput(direction)),
|
||||
);
|
||||
}
|
||||
|
||||
protected sendLeap() {
|
||||
this.onCommand(new LeapActionCommand(localCharacterPredictor.recordLeap()));
|
||||
}
|
||||
|
||||
protected sendPrimary(position: vec2, charge: number) {
|
||||
this.onCommand(new PrimaryActionCommand(position, charge, clientTimeMs()));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +1,11 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
import { CommandGenerator, LeapActionCommand, MoveActionCommand } from 'shared';
|
||||
import { localCharacterPredictor } from '../helper/prediction/local-character-predictor';
|
||||
import { InputGenerator } from './input-generator';
|
||||
|
||||
export class KeyboardListener extends CommandGenerator {
|
||||
export class KeyboardListener extends InputGenerator {
|
||||
private keysDown: Set<string> = new Set();
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
constructor(onCommand: ConstructorParameters<typeof InputGenerator>[0]) {
|
||||
super(onCommand);
|
||||
addEventListener('keydown', this.keyDownListener);
|
||||
addEventListener('keyup', this.keyUpListener);
|
||||
addEventListener('blur', this.blurListener);
|
||||
|
|
@ -15,45 +13,35 @@ export class KeyboardListener extends CommandGenerator {
|
|||
|
||||
private keyDownListener = (event: KeyboardEvent) => {
|
||||
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();
|
||||
if (key === 'Space' || key === 'ShiftLeft' || key === 'ShiftRight') {
|
||||
this.sendLeap();
|
||||
}
|
||||
this.sendMovement();
|
||||
};
|
||||
|
||||
private keyUpListener = (event: KeyboardEvent) => {
|
||||
if (this.keysDown.delete(event.code)) {
|
||||
this.generateCommands();
|
||||
this.sendMovement();
|
||||
}
|
||||
};
|
||||
|
||||
private blurListener = () => {
|
||||
this.keysDown.clear();
|
||||
this.generateCommands();
|
||||
this.sendMovement();
|
||||
};
|
||||
|
||||
private generateCommands() {
|
||||
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) {
|
||||
vec2.normalize(movement, movement);
|
||||
}
|
||||
|
||||
const clientTimeMs = localCharacterPredictor.recordInput(movement);
|
||||
this.sendCommandToSubscribers(new MoveActionCommand(movement, clientTimeMs));
|
||||
private sendMovement() {
|
||||
const down = (...codes: Array<string>) =>
|
||||
codes.some((c) => this.keysDown.has(c)) ? 1 : 0;
|
||||
const movement = vec2.fromValues(
|
||||
down('KeyD', 'ArrowRight') - down('KeyA', 'ArrowLeft'),
|
||||
down('KeyW', 'ArrowUp') - down('KeyS', 'ArrowDown'),
|
||||
);
|
||||
this.sendMove(vec2.normalize(movement, movement));
|
||||
}
|
||||
|
||||
public destroy() {
|
||||
|
|
|
|||
|
|
@ -1,28 +1,26 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
import { CommandGenerator, PrimaryActionCommand, holdDurationToCharge } from 'shared';
|
||||
import { chargeHeldSince, Command } from 'shared';
|
||||
import { Game } from '../game';
|
||||
import { ChargeIndicator } from '../charge-indicator';
|
||||
import { predictorNowMs } from '../helper/prediction/local-character-predictor';
|
||||
import { pointer } from '../helper/pointer';
|
||||
import { InputGenerator } from './input-generator';
|
||||
|
||||
export class MouseListener extends CommandGenerator {
|
||||
export class MouseListener extends InputGenerator {
|
||||
private primaryDownAt: number | null = null;
|
||||
|
||||
constructor(
|
||||
private target: HTMLElement,
|
||||
private readonly target: HTMLElement,
|
||||
private readonly game: Game,
|
||||
onCommand: (command: Command) => void,
|
||||
) {
|
||||
super();
|
||||
|
||||
super(onCommand);
|
||||
target.addEventListener('mousedown', this.mouseDownListener);
|
||||
target.addEventListener('mousemove', this.mouseMoveListener);
|
||||
target.addEventListener('contextmenu', this.contextMenuListener);
|
||||
// Release watched on the window, not the canvas — a release over UI or outside the window must still fire.
|
||||
window.addEventListener('mouseup', this.mouseUpListener);
|
||||
window.addEventListener('blur', this.cancelPrimary);
|
||||
}
|
||||
|
||||
// Store screen position; reproject to world each frame so gaze stays correct while the camera pans.
|
||||
private mouseMoveListener = (event: MouseEvent) => {
|
||||
pointer.displayPosition = vec2.fromValues(event.clientX, event.clientY);
|
||||
};
|
||||
|
|
@ -38,36 +36,24 @@ export class MouseListener extends CommandGenerator {
|
|||
if (event.button !== 0 || this.primaryDownAt === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
ChargeIndicator.end();
|
||||
const charge = holdDurationToCharge((performance.now() - this.primaryDownAt) / 1000);
|
||||
const charge = chargeHeldSince(this.primaryDownAt);
|
||||
this.primaryDownAt = null;
|
||||
this.sendCommandToSubscribers(
|
||||
new PrimaryActionCommand(
|
||||
this.positionFromEvent(event),
|
||||
charge,
|
||||
Math.round(predictorNowMs()),
|
||||
),
|
||||
this.sendPrimary(
|
||||
this.game.displayToWorldCoordinates(vec2.fromValues(event.clientX, event.clientY)),
|
||||
charge,
|
||||
);
|
||||
};
|
||||
|
||||
private cancelPrimary = () => {
|
||||
if (this.primaryDownAt !== null) {
|
||||
this.primaryDownAt = null;
|
||||
ChargeIndicator.end();
|
||||
}
|
||||
this.primaryDownAt = null;
|
||||
ChargeIndicator.end();
|
||||
};
|
||||
|
||||
private contextMenuListener = (event: MouseEvent) => {
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
private positionFromEvent(event: MouseEvent): vec2 {
|
||||
return this.game.displayToWorldCoordinates(
|
||||
vec2.fromValues(event.clientX, event.clientY),
|
||||
);
|
||||
}
|
||||
|
||||
public destroy() {
|
||||
ChargeIndicator.end();
|
||||
this.target.removeEventListener('mousedown', this.mouseDownListener);
|
||||
|
|
|
|||
|
|
@ -1,72 +1,53 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
import {
|
||||
CommandGenerator,
|
||||
MoveActionCommand,
|
||||
PrimaryActionCommand,
|
||||
LeapActionCommand,
|
||||
holdDurationToCharge,
|
||||
settings,
|
||||
} from 'shared';
|
||||
import { chargeHeldSince, Command, settings } from 'shared';
|
||||
import { Game } from '../game';
|
||||
import { ChargeIndicator } from '../charge-indicator';
|
||||
import {
|
||||
localCharacterPredictor,
|
||||
predictorNowMs,
|
||||
} from '../helper/prediction/local-character-predictor';
|
||||
import { InputGenerator } from './input-generator';
|
||||
|
||||
export class TouchListener extends CommandGenerator {
|
||||
private static readonly deadZone = 8;
|
||||
private static readonly deltaScaling = 0.4;
|
||||
private static readonly aimDeadZone = 18;
|
||||
const deadZone = 8;
|
||||
const deltaScaling = 0.4;
|
||||
const aimDeadZone = 18;
|
||||
const joystickMaxLength = 20;
|
||||
|
||||
export class TouchListener extends InputGenerator {
|
||||
private readonly joystick = document.createElement('div');
|
||||
private readonly joystickButton = document.createElement('div');
|
||||
private readonly fireButton = document.createElement('div');
|
||||
private readonly fireStrengthRing = document.createElement('div');
|
||||
private readonly fireAimLine = document.createElement('div');
|
||||
private readonly leapButton = document.createElement('div');
|
||||
|
||||
private joystick: HTMLElement;
|
||||
private joystickButton: HTMLElement;
|
||||
private isJoystickActive = false;
|
||||
private touchStartPosition!: vec2;
|
||||
private touchStartPosition = vec2.create();
|
||||
private primaryDownAt: number | null = null;
|
||||
private gestureTouchId: number | null = null;
|
||||
|
||||
private fireButton: HTMLElement;
|
||||
private fireStrengthRing: HTMLElement;
|
||||
private fireAimLine!: HTMLElement;
|
||||
private leapButton: HTMLElement;
|
||||
|
||||
private fireDownAt: number | null = null;
|
||||
private fireButtonCenter: vec2 | null = null;
|
||||
private fireAimScreen: vec2 | null = null;
|
||||
private fireButtonCenter = vec2.create();
|
||||
private fireAim: vec2 | null = null;
|
||||
|
||||
constructor(
|
||||
private target: HTMLElement,
|
||||
private overlay: HTMLElement,
|
||||
private readonly target: HTMLElement,
|
||||
private readonly overlay: HTMLElement,
|
||||
private readonly game: Game,
|
||||
onCommand: (command: Command) => void,
|
||||
) {
|
||||
super();
|
||||
super(onCommand);
|
||||
|
||||
this.joystick = document.createElement('div');
|
||||
this.joystick.className = 'joystick';
|
||||
this.joystickButton = document.createElement('div');
|
||||
this.joystick.appendChild(this.joystickButton);
|
||||
|
||||
this.fireButton = document.createElement('div');
|
||||
this.fireButton.className = 'touch-button fire';
|
||||
this.fireStrengthRing = document.createElement('div');
|
||||
this.fireStrengthRing.className = 'strength-ring';
|
||||
this.fireButton.appendChild(this.fireStrengthRing);
|
||||
this.fireAimLine = document.createElement('div');
|
||||
this.fireAimLine.className = 'aim-line';
|
||||
this.fireButton.appendChild(this.fireAimLine);
|
||||
|
||||
this.fireButton.append(this.fireStrengthRing, this.fireAimLine);
|
||||
this.fireButton.addEventListener('touchstart', this.fireButtonDownListener);
|
||||
this.fireButton.addEventListener('touchmove', this.fireButtonMoveListener);
|
||||
this.fireButton.addEventListener('touchend', this.fireButtonUpListener);
|
||||
|
||||
this.leapButton = document.createElement('div');
|
||||
this.leapButton.className = 'touch-button leap';
|
||||
this.leapButton.addEventListener('touchstart', this.leapButtonListener);
|
||||
|
||||
this.overlay.appendChild(this.fireButton);
|
||||
this.overlay.appendChild(this.leapButton);
|
||||
|
||||
target.addEventListener('touchstart', this.touchStartListener);
|
||||
target.addEventListener('touchmove', this.touchMoveListener);
|
||||
target.addEventListener('touchend', this.touchEndListener);
|
||||
|
|
@ -74,9 +55,6 @@ export class TouchListener extends CommandGenerator {
|
|||
}
|
||||
|
||||
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];
|
||||
|
|
@ -87,27 +65,22 @@ export class TouchListener extends CommandGenerator {
|
|||
|
||||
private touchStartListener = (event: TouchEvent) => {
|
||||
event.preventDefault();
|
||||
const touch = event.changedTouches[event.changedTouches.length - 1];
|
||||
if (!touch) {
|
||||
return;
|
||||
}
|
||||
if (this.isJoystickActive) {
|
||||
const center = vec2.fromValues(
|
||||
event.changedTouches[event.changedTouches.length - 1].clientX,
|
||||
event.changedTouches[event.changedTouches.length - 1].clientY,
|
||||
);
|
||||
this.sendCommandToSubscribers(
|
||||
new PrimaryActionCommand(
|
||||
this.game.displayToWorldCoordinates(center),
|
||||
0,
|
||||
Math.round(predictorNowMs()),
|
||||
this.sendPrimary(
|
||||
this.game.displayToWorldCoordinates(
|
||||
vec2.fromValues(touch.clientX, touch.clientY),
|
||||
),
|
||||
0,
|
||||
);
|
||||
} else {
|
||||
const touch = event.changedTouches[0];
|
||||
if (!touch) {
|
||||
return;
|
||||
}
|
||||
this.gestureTouchId = touch.identifier;
|
||||
this.touchStartPosition = vec2.fromValues(touch.clientX, touch.clientY);
|
||||
vec2.set(this.touchStartPosition, touch.clientX, touch.clientY);
|
||||
this.primaryDownAt = performance.now();
|
||||
ChargeIndicator.begin(this.touchStartPosition.x, this.touchStartPosition.y);
|
||||
ChargeIndicator.begin(touch.clientX, touch.clientY);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -115,43 +88,36 @@ export class TouchListener extends CommandGenerator {
|
|||
event.preventDefault();
|
||||
|
||||
const touch = this.findGestureTouch(event.touches);
|
||||
if (!touch || !this.touchStartPosition) {
|
||||
if (!touch) {
|
||||
return;
|
||||
}
|
||||
|
||||
const touchPosition = vec2.fromValues(touch.clientX, touch.clientY);
|
||||
|
||||
const delta = vec2.subtract(vec2.create(), touchPosition, this.touchStartPosition);
|
||||
vec2.scale(delta, delta, TouchListener.deltaScaling);
|
||||
const delta = vec2.fromValues(
|
||||
touch.clientX - this.touchStartPosition[0],
|
||||
touch.clientY - this.touchStartPosition[1],
|
||||
);
|
||||
vec2.scale(delta, delta, deltaScaling);
|
||||
const deltaLength = vec2.length(delta);
|
||||
|
||||
if (!this.isJoystickActive && deltaLength > TouchListener.deadZone) {
|
||||
if (!this.isJoystickActive && deltaLength > deadZone) {
|
||||
this.isJoystickActive = true;
|
||||
this.primaryDownAt = null;
|
||||
ChargeIndicator.end();
|
||||
this.overlay.appendChild(this.joystick);
|
||||
this.joystickButton.style.transform = `translateX(-50%) translateY(-50%)`;
|
||||
this.joystick.style.transform = `translateX(${this.touchStartPosition.x}px) translateY(${this.touchStartPosition.y}px) translateX(-50%) translateY(-50%)`;
|
||||
this.joystick.style.transform = `translateX(${this.touchStartPosition[0]}px) translateY(${this.touchStartPosition[1]}px) translateX(-50%) translateY(-50%)`;
|
||||
}
|
||||
|
||||
const maxLength = 20;
|
||||
vec2.scale(delta, delta, Math.min(1, maxLength / deltaLength));
|
||||
this.joystickButton.style.transform = `translateX(${delta.x}px) translateY(${delta.y}px) translateX(-50%) translateY(-50%)`;
|
||||
vec2.scale(delta, delta, Math.min(1, joystickMaxLength / deltaLength));
|
||||
this.joystickButton.style.transform = `translateX(${delta[0]}px) translateY(${delta[1]}px) translateX(-50%) translateY(-50%)`;
|
||||
|
||||
vec2.set(delta, delta.x, -delta.y);
|
||||
if (deltaLength > TouchListener.deadZone) {
|
||||
const direction = vec2.normalize(delta, delta);
|
||||
this.sendMove(direction);
|
||||
if (deltaLength > deadZone) {
|
||||
vec2.set(delta, delta[0], -delta[1]);
|
||||
this.sendMove(vec2.normalize(delta, delta));
|
||||
} else {
|
||||
this.sendMove(vec2.create());
|
||||
}
|
||||
};
|
||||
|
||||
private sendMove(direction: vec2) {
|
||||
const clientTimeMs = localCharacterPredictor.recordInput(direction);
|
||||
this.sendCommandToSubscribers(new MoveActionCommand(direction, clientTimeMs));
|
||||
}
|
||||
|
||||
private touchEndListener = (event: TouchEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
|
|
@ -161,27 +127,20 @@ export class TouchListener extends CommandGenerator {
|
|||
}
|
||||
this.gestureTouchId = null;
|
||||
|
||||
if (!this.isJoystickActive) {
|
||||
ChargeIndicator.end();
|
||||
const charge =
|
||||
this.primaryDownAt === null
|
||||
? 0
|
||||
: holdDurationToCharge((performance.now() - this.primaryDownAt) / 1000);
|
||||
this.primaryDownAt = null;
|
||||
const center = vec2.fromValues(touch.clientX, touch.clientY);
|
||||
this.sendCommandToSubscribers(
|
||||
new PrimaryActionCommand(
|
||||
this.game.displayToWorldCoordinates(center),
|
||||
charge,
|
||||
Math.round(predictorNowMs()),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
if (this.isJoystickActive) {
|
||||
this.releaseJoystick();
|
||||
return;
|
||||
}
|
||||
|
||||
ChargeIndicator.end();
|
||||
const charge = this.primaryDownAt === null ? 0 : chargeHeldSince(this.primaryDownAt);
|
||||
this.primaryDownAt = null;
|
||||
this.sendPrimary(
|
||||
this.game.displayToWorldCoordinates(vec2.fromValues(touch.clientX, touch.clientY)),
|
||||
charge,
|
||||
);
|
||||
};
|
||||
|
||||
// Also reached from touchcancel — otherwise a system gesture leaves the stick latched.
|
||||
private touchCancelListener = (event: TouchEvent) => {
|
||||
if (!this.findGestureTouch(event.changedTouches)) {
|
||||
return;
|
||||
|
|
@ -196,53 +155,59 @@ export class TouchListener extends CommandGenerator {
|
|||
|
||||
private releaseJoystick() {
|
||||
this.isJoystickActive = false;
|
||||
this.joystick.parentElement?.removeChild(this.joystick);
|
||||
this.joystick.remove();
|
||||
this.sendMove(vec2.create());
|
||||
}
|
||||
|
||||
private swallowTouch = (event: TouchEvent) => {
|
||||
private swallowTouch(event: TouchEvent) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
};
|
||||
}
|
||||
|
||||
private leapButtonListener = (event: TouchEvent) => {
|
||||
this.swallowTouch(event);
|
||||
const clientTimeMs = localCharacterPredictor.recordLeap();
|
||||
this.sendCommandToSubscribers(new LeapActionCommand(clientTimeMs));
|
||||
this.sendLeap();
|
||||
};
|
||||
|
||||
private fireButtonDownListener = (event: TouchEvent) => {
|
||||
this.swallowTouch(event);
|
||||
this.fireDownAt = performance.now();
|
||||
const rect = this.fireButton.getBoundingClientRect();
|
||||
this.fireButtonCenter = vec2.fromValues(
|
||||
vec2.set(
|
||||
this.fireButtonCenter,
|
||||
rect.left + rect.width / 2,
|
||||
rect.top + rect.height / 2,
|
||||
);
|
||||
this.fireAimScreen = null;
|
||||
this.fireAim = null;
|
||||
ChargeIndicator.begin(this.fireButtonCenter[0], this.fireButtonCenter[1]);
|
||||
};
|
||||
|
||||
private fireButtonMoveListener = (event: TouchEvent) => {
|
||||
this.swallowTouch(event);
|
||||
if (this.fireDownAt === null || !this.fireButtonCenter) {
|
||||
return;
|
||||
}
|
||||
const touch = event.targetTouches[0] ?? event.changedTouches[0];
|
||||
if (!touch) {
|
||||
if (this.fireDownAt === null || !touch) {
|
||||
return;
|
||||
}
|
||||
this.fireAimScreen = vec2.fromValues(touch.clientX, touch.clientY);
|
||||
const dx = this.fireAimScreen[0] - this.fireButtonCenter[0];
|
||||
const dy = this.fireAimScreen[1] - this.fireButtonCenter[1];
|
||||
if (dx * dx + dy * dy > TouchListener.aimDeadZone * TouchListener.aimDeadZone) {
|
||||
const aim = this.aimFromTouch(touch);
|
||||
this.fireAim = aim;
|
||||
if (aim) {
|
||||
this.fireAimLine.style.opacity = '1';
|
||||
this.fireAimLine.style.transform = `translateY(-50%) rotate(${Math.atan2(dy, dx)}rad)`;
|
||||
this.fireAimLine.style.transform = `translateY(-50%) rotate(${Math.atan2(-aim[1], aim[0])}rad)`;
|
||||
} else {
|
||||
this.fireAimLine.style.opacity = '0';
|
||||
}
|
||||
};
|
||||
|
||||
private aimFromTouch(touch: Touch): vec2 | null {
|
||||
const dx = touch.clientX - this.fireButtonCenter[0];
|
||||
const dy = touch.clientY - this.fireButtonCenter[1];
|
||||
if (dx * dx + dy * dy <= aimDeadZone * aimDeadZone) {
|
||||
return null;
|
||||
}
|
||||
const aim = vec2.fromValues(dx, -dy);
|
||||
return vec2.normalize(aim, aim);
|
||||
}
|
||||
|
||||
private fireButtonUpListener = (event: TouchEvent) => {
|
||||
this.swallowTouch(event);
|
||||
ChargeIndicator.end();
|
||||
|
|
@ -251,48 +216,33 @@ export class TouchListener extends CommandGenerator {
|
|||
return;
|
||||
}
|
||||
|
||||
const charge = holdDurationToCharge((performance.now() - this.fireDownAt) / 1000);
|
||||
const charge = chargeHeldSince(this.fireDownAt);
|
||||
this.fireDownAt = null;
|
||||
|
||||
const character = this.game.gameObjects.player;
|
||||
const character = this.game.gameObjects.localPlayer;
|
||||
if (!character) {
|
||||
this.fireButtonCenter = null;
|
||||
this.fireAimScreen = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// Screen drag → world aim direction (flip Y: screen +y is down).
|
||||
let direction = character.facingDirection;
|
||||
if (this.fireButtonCenter && this.fireAimScreen) {
|
||||
const dx = this.fireAimScreen[0] - this.fireButtonCenter[0];
|
||||
const dy = this.fireAimScreen[1] - this.fireButtonCenter[1];
|
||||
if (dx * dx + dy * dy > TouchListener.aimDeadZone * TouchListener.aimDeadZone) {
|
||||
direction = vec2.normalize(vec2.create(), vec2.fromValues(dx, -dy));
|
||||
}
|
||||
}
|
||||
this.fireButtonCenter = null;
|
||||
this.fireAimScreen = null;
|
||||
|
||||
const aim = vec2.scaleAndAdd(
|
||||
vec2.create(),
|
||||
character.bodyCenter,
|
||||
direction,
|
||||
settings.touchAimRange,
|
||||
);
|
||||
this.sendCommandToSubscribers(
|
||||
new PrimaryActionCommand(aim, charge, Math.round(predictorNowMs())),
|
||||
const direction = this.fireAim ?? character.facingDirection;
|
||||
this.fireAim = null;
|
||||
this.sendPrimary(
|
||||
vec2.scaleAndAdd(
|
||||
vec2.create(),
|
||||
character.bodyCenter,
|
||||
direction,
|
||||
settings.touchAimRange,
|
||||
),
|
||||
charge,
|
||||
);
|
||||
};
|
||||
|
||||
public update() {
|
||||
if (!this.fireButton.parentElement) {
|
||||
this.overlay.appendChild(this.fireButton);
|
||||
}
|
||||
if (!this.leapButton.parentElement) {
|
||||
this.overlay.appendChild(this.leapButton);
|
||||
this.overlay.append(this.fireButton, this.leapButton);
|
||||
}
|
||||
|
||||
const character = this.game.gameObjects.player;
|
||||
const character = this.game.gameObjects.localPlayer;
|
||||
if (character) {
|
||||
this.fireStrengthRing.style.background = `conic-gradient(rgba(255, 255, 255, 0.75) ${
|
||||
character.strengthFraction * 360
|
||||
|
|
@ -312,7 +262,7 @@ export class TouchListener extends CommandGenerator {
|
|||
this.fireButton.removeEventListener('touchend', this.fireButtonUpListener);
|
||||
this.leapButton.removeEventListener('touchstart', this.leapButtonListener);
|
||||
|
||||
this.fireButton.parentElement?.removeChild(this.fireButton);
|
||||
this.leapButton.parentElement?.removeChild(this.leapButton);
|
||||
this.fireButton.remove();
|
||||
this.leapButton.remove();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,5 @@
|
|||
/**
|
||||
* Game server origins. The join screen polls each and only shows those that
|
||||
* respond, so listing an offline origin here is harmless.
|
||||
*/
|
||||
const productionServers: Array<string> = ['https://declared.schmelczer.dev'];
|
||||
|
||||
/**
|
||||
* On localhost (webpack-dev-server) also offer the local backend on its default
|
||||
* port; in production the page is served from its own hostname, so this never leaks.
|
||||
*/
|
||||
const isDevelopment =
|
||||
typeof location !== 'undefined' &&
|
||||
(location.hostname === 'localhost' || location.hostname === '127.0.0.1');
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ function ensureRoot(): { root: HTMLElement; killfeed: HTMLElement } {
|
|||
function focusPoint(): { x: number; y: number } {
|
||||
const cursor = pointer.displayPosition;
|
||||
if (cursor) {
|
||||
return { x: cursor.x, y: cursor.y };
|
||||
return { x: cursor[0], y: cursor[1] };
|
||||
}
|
||||
return { x: window.innerWidth / 2, y: window.innerHeight / 2 };
|
||||
}
|
||||
|
|
@ -30,7 +30,7 @@ function focusPoint(): { x: number; y: number } {
|
|||
function addTransient(el: HTMLElement, lifetimeMs: number) {
|
||||
const { root: r } = ensureRoot();
|
||||
r.appendChild(el);
|
||||
setTimeout(() => el.parentElement?.removeChild(el), lifetimeMs);
|
||||
setTimeout(() => el.remove(), lifetimeMs);
|
||||
}
|
||||
|
||||
function hitMarker(charge = 0) {
|
||||
|
|
@ -55,7 +55,7 @@ function killConfirmed(victimName?: string, streak = 1, charge = 0) {
|
|||
entry.className = 'kill-entry';
|
||||
entry.innerHTML = `Eliminated <b>${escapeHtml(victimName ?? 'enemy')}</b>`;
|
||||
kf.insertBefore(entry, kf.firstChild);
|
||||
setTimeout(() => entry.parentElement?.removeChild(entry), 4500);
|
||||
setTimeout(() => entry.remove(), 4500);
|
||||
|
||||
const { x, y } = focusPoint();
|
||||
const popup = document.createElement('div');
|
||||
|
|
@ -89,13 +89,13 @@ function showElimination(): void {
|
|||
}
|
||||
|
||||
function hideElimination(): void {
|
||||
elimination?.parentElement?.removeChild(elimination);
|
||||
elimination?.remove();
|
||||
elimination = undefined;
|
||||
}
|
||||
|
||||
function resetFeedbackHud(): void {
|
||||
hideElimination();
|
||||
root?.parentElement?.removeChild(root);
|
||||
root?.remove();
|
||||
root = undefined;
|
||||
killfeed = undefined;
|
||||
}
|
||||
|
|
@ -119,7 +119,6 @@ function escapeHtml(text: string): string {
|
|||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// The one public surface; the functions above stay module-private.
|
||||
export const FeedbackHud = {
|
||||
hitMarker,
|
||||
killConfirmed,
|
||||
|
|
|
|||
|
|
@ -33,8 +33,6 @@ import { GameObjectContainer } from './objects/game-object-container';
|
|||
import parser from 'socket.io-msgpack-parser';
|
||||
import { CharacterShape } from './shapes/character-shape';
|
||||
import { PlanetShape } from './shapes/planet-shape';
|
||||
import { RenderCommand } from './commands/types/render';
|
||||
import { StepCommand } from './commands/types/step';
|
||||
import { serverTimeline } from './helper/server-timeline';
|
||||
import {
|
||||
localCharacterPredictor,
|
||||
|
|
@ -46,32 +44,57 @@ import { Minimap } from './minimap';
|
|||
import { ScreenShake } from './screen-shake';
|
||||
import { FeedbackHud } from './feedback-hud';
|
||||
|
||||
// Ten attempts at the configured backoff is on the order of a minute of
|
||||
// retrying before the client gives up and returns to the landing page.
|
||||
const maximumReconnectionAttempts = 10;
|
||||
|
||||
export class Game extends CommandReceiver {
|
||||
public gameObjects = new GameObjectContainer(this);
|
||||
public renderer?: Renderer;
|
||||
public rejectionReason?: JoinRejectionReason;
|
||||
public readonly started: Promise<void>;
|
||||
|
||||
private socket!: Socket;
|
||||
private isBetweenGames = false;
|
||||
|
||||
public started: Promise<void>;
|
||||
private resolveStarted!: () => unknown;
|
||||
|
||||
private keyboardListener: KeyboardListener;
|
||||
private mouseListener: MouseListener;
|
||||
private touchListener: TouchListener;
|
||||
|
||||
private scoreboard = new Scoreboard();
|
||||
private minimap = new Minimap();
|
||||
private announcementText = document.createElement('h2');
|
||||
private keystoneArrow?: HTMLElement;
|
||||
private socketReceiver!: CommandSocket;
|
||||
private tutorial!: Tutorial;
|
||||
private reconnectToken?: string;
|
||||
private resolveStarted!: () => void;
|
||||
private isBetweenGames = false;
|
||||
private isActive = true;
|
||||
private isEnding = false;
|
||||
private timeScaling = 1;
|
||||
|
||||
private readonly keyboardListener: KeyboardListener;
|
||||
private readonly mouseListener: MouseListener;
|
||||
private readonly touchListener: TouchListener;
|
||||
|
||||
private readonly scoreboard = new Scoreboard();
|
||||
private readonly minimap = new Minimap();
|
||||
private readonly announcementText = document.createElement('h2');
|
||||
private keystoneArrow?: HTMLElement;
|
||||
private connectionBanner?: HTMLElement;
|
||||
private rejectionReason?: JoinRejectionReason;
|
||||
private reconnectToken?: string;
|
||||
private lastAspectRatio?: number;
|
||||
private lastGameState?: UpdateGameState;
|
||||
private lastMinimap?: UpdateMinimap;
|
||||
private lastAnnouncementText = '';
|
||||
private timeSinceLastAnnouncement = 0;
|
||||
private framesSinceLastLayoutUpdate = 0;
|
||||
|
||||
protected commandExecutors: CommandExecutors = {
|
||||
[ServerAnnouncement.type]: (c: ServerAnnouncement) => {
|
||||
this.lastAnnouncementText = c.text;
|
||||
this.timeSinceLastAnnouncement = 0;
|
||||
},
|
||||
[UpdateGameState.type]: (c: UpdateGameState) => (this.lastGameState = c),
|
||||
[InputAcknowledgement.type]: (c: InputAcknowledgement) =>
|
||||
localCharacterPredictor.acknowledge(
|
||||
c.clientTimeMs,
|
||||
c.movement,
|
||||
c.lastLeapClientTimeMs,
|
||||
c.ackAgeMs,
|
||||
),
|
||||
[GameEndCommand.type]: () => (this.isEnding = true),
|
||||
[UpdateMinimap.type]: (c: UpdateMinimap) => (this.lastMinimap = c),
|
||||
[GameStartCommand.type]: () => this.initialize(),
|
||||
};
|
||||
|
||||
constructor(
|
||||
private readonly playerDecision: PlayerDecision,
|
||||
|
|
@ -82,9 +105,17 @@ export class Game extends CommandReceiver {
|
|||
this.started = new Promise((r) => (this.resolveStarted = r));
|
||||
this.announcementText.className = 'announcement';
|
||||
|
||||
this.keyboardListener = new KeyboardListener();
|
||||
this.mouseListener = new MouseListener(this.canvas, this);
|
||||
this.touchListener = new TouchListener(this.canvas, this.overlay, this);
|
||||
const onInput = (c: Command) => {
|
||||
this.socketReceiver.queue(c);
|
||||
this.tutorial.handleCommand(c);
|
||||
};
|
||||
this.keyboardListener = new KeyboardListener(onInput);
|
||||
this.mouseListener = new MouseListener(this.canvas, this, onInput);
|
||||
this.touchListener = new TouchListener(this.canvas, this.overlay, this, onInput);
|
||||
}
|
||||
|
||||
protected defaultCommandExecutor(c: Command) {
|
||||
this.gameObjects.handleCommand(c);
|
||||
}
|
||||
|
||||
private initialize() {
|
||||
|
|
@ -94,6 +125,7 @@ export class Game extends CommandReceiver {
|
|||
serverTimeline.reset();
|
||||
localCharacterPredictor.reset();
|
||||
ScreenShake.reset();
|
||||
this.gameObjects.reset();
|
||||
this.gameObjects = new GameObjectContainer(this);
|
||||
this.overlay.innerHTML = '';
|
||||
this.keystoneArrow = undefined;
|
||||
|
|
@ -101,58 +133,50 @@ export class Game extends CommandReceiver {
|
|||
this.lastMinimap = undefined;
|
||||
this.isEnding = false;
|
||||
this.lastAnnouncementText = '';
|
||||
this.overlay.appendChild(this.scoreboard.element);
|
||||
this.overlay.appendChild(this.minimap.element);
|
||||
this.announcementText.innerText = '';
|
||||
this.timeScaling = 1;
|
||||
this.overlay.appendChild(this.announcementText);
|
||||
this.overlay.append(
|
||||
this.scoreboard.element,
|
||||
this.minimap.element,
|
||||
this.announcementText,
|
||||
);
|
||||
this.tutorial = new Tutorial(this.overlay);
|
||||
|
||||
this.socket = io(this.playerDecision.server, {
|
||||
reconnectionDelayMax: 10000,
|
||||
// Must be finite. The default is Infinity, and with it the manager never
|
||||
// gives up, so `reconnect_failed` below never fires and a client whose
|
||||
// server is gone sits on a frozen world behind the banner forever.
|
||||
// Must be finite, otherwise `reconnect_failed` never fires.
|
||||
reconnectionAttempts: maximumReconnectionAttempts,
|
||||
transports: ['websocket'],
|
||||
forceNew: true,
|
||||
parser,
|
||||
} as any);
|
||||
this.socketReceiver = new CommandSocket(this.socket);
|
||||
|
||||
// In socket.io-client v4 reconnection events are emitted by the Manager (`socket.io`), not the Socket itself.
|
||||
this.socket.io.on('reconnect_attempt', () => {
|
||||
this.socket.io.opts.transports = ['polling', 'websocket'];
|
||||
});
|
||||
|
||||
// A transport drop is not the end of the match — tearing the game down would cancel the reconnection the client is configured for.
|
||||
this.socket.on('disconnect', () => {
|
||||
if (this.isBetweenGames) {
|
||||
return;
|
||||
if (!this.isBetweenGames) {
|
||||
this.showConnectionBanner('Reconnecting…');
|
||||
}
|
||||
this.showConnectionBanner('Reconnecting…');
|
||||
});
|
||||
|
||||
// Fires for every (re)connection: a reconnect is a brand-new server-side player.
|
||||
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 must be re-sent or the client sits connected and invisible forever.
|
||||
this.hideConnectionBanner();
|
||||
serverTimeline.reset();
|
||||
localCharacterPredictor.reset();
|
||||
// A brand-new server-side player whose view-area bookkeeping starts empty: the dropped session's objects will never be retracted, so reset here.
|
||||
this.gameObjects.reset();
|
||||
this.socket.emit(TransportEvents.PlayerJoining, {
|
||||
...this.playerDecision,
|
||||
reconnectToken: this.reconnectToken,
|
||||
});
|
||||
// A reconnect gets a brand-new server-side Player, back on the default
|
||||
// aspect ratio; the camera only reports a ratio when it changes, so
|
||||
// without this re-send it would stay wrong for the rest of the match.
|
||||
if (this.lastAspectRatio !== undefined) {
|
||||
this.socketReceiver.handleCommand(
|
||||
new SetAspectRatioActionCommand(this.lastAspectRatio),
|
||||
);
|
||||
this.socketReceiver.queue(new SetAspectRatioActionCommand(this.lastAspectRatio));
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -172,13 +196,11 @@ export class Game extends CommandReceiver {
|
|||
this.destroy();
|
||||
});
|
||||
|
||||
// Echo the nonce: the server only accepts a reply matching 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) => {
|
||||
// deserialize revives classes by name from the payload, so a hostile or corrupt field can throw inside JSON.parse's reviver — one malformed object must not take down the message pump or every later batch is lost with it.
|
||||
try {
|
||||
const commands: Array<Command> = deserialize(serializedCommands);
|
||||
commands.forEach((c) => {
|
||||
|
|
@ -193,54 +215,12 @@ export class Game extends CommandReceiver {
|
|||
}
|
||||
});
|
||||
|
||||
this.socketReceiver = new CommandSocket(this.socket);
|
||||
this.keyboardListener.clearSubscribers();
|
||||
this.keyboardListener.subscribe(this.socketReceiver);
|
||||
this.keyboardListener.subscribe(this.tutorial);
|
||||
this.mouseListener.clearSubscribers();
|
||||
this.mouseListener.subscribe(this.socketReceiver);
|
||||
this.mouseListener.subscribe(this.tutorial);
|
||||
this.touchListener.clearSubscribers();
|
||||
this.touchListener.subscribe(this.socketReceiver);
|
||||
this.touchListener.subscribe(this.tutorial);
|
||||
|
||||
// Join is emitted from the `connect` handler above, which fires for the first connection and every reconnection alike — so one code path covers both and a reconnect can never be left unjoined.
|
||||
this.isBetweenGames = false;
|
||||
}
|
||||
|
||||
protected defaultCommandExecutor(c: Command) {
|
||||
this.gameObjects.handleCommand(c);
|
||||
}
|
||||
|
||||
private lastGameState?: UpdateGameState;
|
||||
private isEnding = false;
|
||||
private timeScaling = 1;
|
||||
|
||||
private lastAnnouncementText = '';
|
||||
protected commandExecutors: CommandExecutors = {
|
||||
[ServerAnnouncement.type]: (c: ServerAnnouncement) => {
|
||||
this.lastAnnouncementText = c.text;
|
||||
this.timeSinceLastAnnouncement = 0;
|
||||
},
|
||||
[UpdateGameState.type]: (c: UpdateGameState) => (this.lastGameState = c),
|
||||
[InputAcknowledgement.type]: (c: InputAcknowledgement) =>
|
||||
localCharacterPredictor.acknowledge(
|
||||
c.clientTimeMs,
|
||||
c.movement,
|
||||
c.lastLeapClientTimeMs,
|
||||
c.ackAgeMs,
|
||||
),
|
||||
[GameEndCommand.type]: () => (this.isEnding = true),
|
||||
[UpdateMinimap.type]: (c: UpdateMinimap) => (this.lastMinimap = c),
|
||||
[GameStartCommand.type]: this.initialize.bind(this),
|
||||
};
|
||||
|
||||
private lastMinimap?: UpdateMinimap;
|
||||
|
||||
public async start(): Promise<void> {
|
||||
const noiseTexture = await renderNoise([256, 256], 2, 1);
|
||||
|
||||
this.initialize();
|
||||
const noiseTexture = await renderNoise([256, 256], 2, 1);
|
||||
|
||||
await runAnimation(
|
||||
this.canvas,
|
||||
|
|
@ -272,20 +252,17 @@ export class Game extends CommandReceiver {
|
|||
},
|
||||
},
|
||||
);
|
||||
|
||||
this.socket.close();
|
||||
this.gameObjects.reset();
|
||||
this.overlay.innerHTML = '';
|
||||
this.hideConnectionBanner();
|
||||
// The HUD root lives on document.body, not the overlay, so it must be torn down explicitly or it stays painted over the landing page.
|
||||
FeedbackHud.reset();
|
||||
this.keyboardListener.destroy();
|
||||
this.mouseListener.destroy();
|
||||
this.touchListener.destroy();
|
||||
}
|
||||
|
||||
public get lastRejectionReason(): JoinRejectionReason | undefined {
|
||||
return this.rejectionReason;
|
||||
}
|
||||
|
||||
public static rejectionText(reason: JoinRejectionReason): string {
|
||||
switch (reason) {
|
||||
case JoinRejectionReason.ServerFull:
|
||||
|
|
@ -319,38 +296,32 @@ export class Game extends CommandReceiver {
|
|||
return this.renderer?.displayToWorldCoordinates(p) ?? vec2.create();
|
||||
}
|
||||
|
||||
private lastAspectRatio?: number;
|
||||
public aspectRatioChanged(aspectRatio: number) {
|
||||
this.lastAspectRatio = aspectRatio;
|
||||
this.socketReceiver.handleCommand(new SetAspectRatioActionCommand(aspectRatio));
|
||||
this.socketReceiver.queue(new SetAspectRatioActionCommand(aspectRatio));
|
||||
}
|
||||
|
||||
private isActive = true;
|
||||
public destroy() {
|
||||
this.isActive = false;
|
||||
}
|
||||
|
||||
private timeSinceLastAnnouncement = 0;
|
||||
private framesSinceLastLayoutUpdate = 0;
|
||||
private gameLoop(
|
||||
renderer: Renderer,
|
||||
currentTime: DOMHighResTimeStamp,
|
||||
deltaTime: DOMHighResTimeStamp,
|
||||
): boolean {
|
||||
this.renderer = renderer;
|
||||
this.resolveStarted();
|
||||
// Must use the frame's timestamp, not performance.now(): the dispatch delay would land in the prediction replay window and jitter the predicted body.
|
||||
// The frame timestamp, not performance.now(): dispatch jitter would land in the replay window.
|
||||
setFrameTimeMs(currentTime);
|
||||
deltaTime /= 1000;
|
||||
|
||||
// Camera impact decays on raw wall-clock time, before the end-game slow-motion scaling below — view-only, never the simulation, so it stays decoupled from prediction and netcode.
|
||||
// Both run on wall-clock time: the end-game slow motion is already baked into the snapshots.
|
||||
ScreenShake.step(deltaTime);
|
||||
|
||||
// Stepped before the end-game time scaling on purpose: the slow motion is already baked into the server's snapshots, so the playback cursor must keep running on wall-clock time.
|
||||
serverTimeline.step(deltaTime);
|
||||
|
||||
let shouldChangeLayout = false;
|
||||
if (++this.framesSinceLastLayoutUpdate > 1) {
|
||||
shouldChangeLayout = true;
|
||||
const shouldChangeLayout = ++this.framesSinceLastLayoutUpdate > 1;
|
||||
if (shouldChangeLayout) {
|
||||
this.framesSinceLastLayoutUpdate = 0;
|
||||
this.draw();
|
||||
}
|
||||
|
|
@ -366,17 +337,10 @@ export class Game extends CommandReceiver {
|
|||
deltaTime /= this.timeScaling;
|
||||
}
|
||||
|
||||
this.renderer = renderer;
|
||||
|
||||
this.gameObjects.handleCommand(new StepCommand(deltaTime));
|
||||
this.gameObjects.handleCommand(
|
||||
new RenderCommand(this.renderer, this.overlay, shouldChangeLayout),
|
||||
);
|
||||
|
||||
this.gameObjects.step(deltaTime);
|
||||
this.gameObjects.render(renderer, this.overlay, shouldChangeLayout);
|
||||
this.touchListener.update();
|
||||
|
||||
this.tutorial.step(this.gameObjects);
|
||||
|
||||
this.socketReceiver.sendQueuedCommands();
|
||||
|
||||
return this.isActive;
|
||||
|
|
@ -384,11 +348,11 @@ export class Game extends CommandReceiver {
|
|||
|
||||
private draw() {
|
||||
if (this.lastGameState) {
|
||||
this.scoreboard.update(this.lastGameState, this.gameObjects.player?.team);
|
||||
this.scoreboard.update(this.lastGameState, this.gameObjects.localPlayer?.team);
|
||||
}
|
||||
|
||||
this.minimap.update(
|
||||
this.gameObjects.localPlayerPosition,
|
||||
this.gameObjects.localPlayer?.position,
|
||||
this.lastMinimap?.players ?? [],
|
||||
);
|
||||
|
||||
|
|
@ -400,11 +364,8 @@ export class Game extends CommandReceiver {
|
|||
}
|
||||
|
||||
private handleKeystoneArrow() {
|
||||
if (!this.renderer) {
|
||||
return;
|
||||
}
|
||||
const keystone = this.gameObjects.planets.find((p) => p.isKeystone);
|
||||
if (!keystone) {
|
||||
if (!this.renderer || !keystone) {
|
||||
if (this.keystoneArrow) {
|
||||
this.keystoneArrow.style.display = 'none';
|
||||
}
|
||||
|
|
@ -416,40 +377,34 @@ export class Game extends CommandReceiver {
|
|||
this.overlay.appendChild(this.keystoneArrow);
|
||||
}
|
||||
|
||||
const width = this.renderer.canvasSize.x;
|
||||
const height = this.renderer.canvasSize.y;
|
||||
const [width, height] = this.renderer.canvasSize;
|
||||
const display = this.renderer.worldToDisplayCoordinates(keystone.center);
|
||||
const margin = 48;
|
||||
const onScreen =
|
||||
display.x >= margin &&
|
||||
display.x <= width - margin &&
|
||||
display.y >= margin &&
|
||||
display.y <= height - margin;
|
||||
display[0] >= margin &&
|
||||
display[0] <= width - margin &&
|
||||
display[1] >= margin &&
|
||||
display[1] <= height - margin;
|
||||
|
||||
this.keystoneArrow.className = 'keystone-arrow ' + keystone.team;
|
||||
|
||||
this.keystoneArrow.style.display = onScreen ? 'none' : 'block';
|
||||
if (onScreen) {
|
||||
this.keystoneArrow.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
this.keystoneArrow.style.display = 'block';
|
||||
|
||||
const center = vec2.fromValues(width / 2, height / 2);
|
||||
const dir = vec2.fromValues(display.x - center.x, display.y - center.y);
|
||||
const angle = Math.atan2(dir.y, dir.x);
|
||||
const dx = display[0] - width / 2;
|
||||
const dy = display[1] - height / 2;
|
||||
const angle = Math.atan2(dy, dx);
|
||||
|
||||
const aspectRatio = width / height;
|
||||
const directionRatio = dir.x / dir.y;
|
||||
let deltaX: number, deltaY: number;
|
||||
if (aspectRatio < Math.abs(directionRatio)) {
|
||||
deltaX = (width / 2 - margin) * Math.sign(dir.x);
|
||||
deltaY = deltaX / directionRatio;
|
||||
if (width / height < Math.abs(dx / dy)) {
|
||||
deltaX = (width / 2 - margin) * Math.sign(dx);
|
||||
deltaY = (deltaX * dy) / dx;
|
||||
} else {
|
||||
deltaY = (height / 2 - margin) * Math.sign(dir.y);
|
||||
deltaX = deltaY * directionRatio;
|
||||
deltaY = (height / 2 - margin) * Math.sign(dy);
|
||||
deltaX = (deltaY * dx) / dy;
|
||||
}
|
||||
|
||||
const p = vec2.add(center, center, vec2.fromValues(deltaX, deltaY));
|
||||
this.keystoneArrow.style.transform = `translateX(${p.x}px) translateY(${p.y}px) translateX(-50%) translateY(-50%) rotate(${angle + Math.PI / 2}rad)`;
|
||||
this.keystoneArrow.style.transform = `translateX(${width / 2 + deltaX}px) translateY(${height / 2 + deltaY}px) translateX(-50%) translateY(-50%) rotate(${angle + Math.PI / 2}rad)`;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,11 +13,6 @@ const blendSeconds = 0.12;
|
|||
|
||||
const maxFrames = 32;
|
||||
|
||||
/**
|
||||
* Replays a server-streamed scalar by interpolating between timestamped
|
||||
* snapshots at the render time; coasts briefly on the streamed rate of change
|
||||
* when the buffer runs dry.
|
||||
*/
|
||||
export class LinearInterpolator {
|
||||
private frames: Array<Frame> = [];
|
||||
private blendOffset = 0;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
import { LinearInterpolator } from './linear-interpolator';
|
||||
|
||||
// Indexed access, not .x/.y, so it never depends on the vec2 prototype plugin.
|
||||
export class Vec2Interpolator {
|
||||
private x: LinearInterpolator;
|
||||
private y: LinearInterpolator;
|
||||
|
|
|
|||
|
|
@ -4,119 +4,43 @@ import {
|
|||
GroundSurface,
|
||||
Id,
|
||||
PhysicsBody,
|
||||
planetDistance,
|
||||
planetGravity,
|
||||
PlanetBase,
|
||||
resolveCircleMovement,
|
||||
} from 'shared';
|
||||
import { PlanetView } from '../../objects/types/planet-view';
|
||||
|
||||
// What the predictor needs to know about a planet to collide and be pulled by it.
|
||||
export interface PredictablePlanet {
|
||||
id: Id;
|
||||
vertices: Array<vec2>;
|
||||
center: vec2;
|
||||
radius: number;
|
||||
rotation: number;
|
||||
rotationSpeed: number;
|
||||
}
|
||||
|
||||
// A planet surface whose rotation advances during replay, in lockstep with the body — exactly as the server steps the planet before the character each tick.
|
||||
class PlanetSurface implements GroundSurface {
|
||||
public readonly canCollide = true;
|
||||
public readonly isGround = true;
|
||||
public readonly id: Id;
|
||||
public center: vec2;
|
||||
public angularVelocity: number;
|
||||
private vertices: Array<vec2>;
|
||||
private radius: number;
|
||||
private rotation = 0;
|
||||
private cos = 1;
|
||||
private sin = 0;
|
||||
|
||||
constructor(planet: PredictablePlanet) {
|
||||
this.id = planet.id;
|
||||
this.center = planet.center;
|
||||
this.vertices = planet.vertices;
|
||||
this.radius = planet.radius;
|
||||
this.angularVelocity = planet.rotationSpeed;
|
||||
this.setRotation(planet.rotation);
|
||||
}
|
||||
|
||||
public sync(planet: PredictablePlanet) {
|
||||
this.center = planet.center;
|
||||
this.vertices = planet.vertices;
|
||||
this.radius = planet.radius;
|
||||
this.angularVelocity = planet.rotationSpeed;
|
||||
this.setRotation(planet.rotation);
|
||||
}
|
||||
|
||||
private setRotation(rotation: number) {
|
||||
this.rotation = rotation;
|
||||
this.cos = Math.cos(rotation);
|
||||
this.sin = Math.sin(rotation);
|
||||
}
|
||||
|
||||
public advance(deltaTimeInSeconds: number) {
|
||||
this.setRotation(this.rotation + this.angularVelocity * deltaTimeInSeconds);
|
||||
}
|
||||
|
||||
public distance(target: vec2): number {
|
||||
return planetDistance(target, this.vertices, this.center, this.cos, this.sin);
|
||||
}
|
||||
|
||||
public gravityAt(target: vec2): vec2 {
|
||||
return planetGravity(this.center, this.radius, target);
|
||||
}
|
||||
}
|
||||
|
||||
// Planets-only collision world for the predictor. Persistent surfaces keyed by id (currentPlanet stays valid across frames); never dispatches collision reactions — those are server-authoritative.
|
||||
// Planets only, re-seeded from the newest snapshot every frame; never dispatches collisions.
|
||||
export class ClientCharacterWorld implements CharacterWorld {
|
||||
private surfaces = new Map<Id, PlanetSurface>();
|
||||
private ordered: Array<PlanetSurface> = [];
|
||||
private surfaces: Array<PlanetBase> = [];
|
||||
|
||||
// Far planets contribute zero gravity (the falloff clamps to 0 past maxGravityDistance) and never collide, so the whole set can be handed to every query without a range filter. Ordered by id so the (rare) two-surface contact picks a stable surface.
|
||||
public sync(planets: Array<PredictablePlanet>) {
|
||||
const seen = new Set<Id>();
|
||||
for (const planet of planets) {
|
||||
seen.add(planet.id);
|
||||
const existing = this.surfaces.get(planet.id);
|
||||
if (existing) {
|
||||
existing.sync(planet);
|
||||
} else {
|
||||
this.surfaces.set(planet.id, new PlanetSurface(planet));
|
||||
}
|
||||
}
|
||||
for (const id of [...this.surfaces.keys()]) {
|
||||
if (!seen.has(id)) {
|
||||
this.surfaces.delete(id);
|
||||
}
|
||||
}
|
||||
this.ordered = [...this.surfaces.entries()]
|
||||
.sort((a, b) => Number(a[0]) - Number(b[0]))
|
||||
.map((e) => e[1]);
|
||||
public sync(planets: Array<PlanetView>) {
|
||||
this.surfaces = planets
|
||||
.map((planet) => {
|
||||
const surface = new PlanetBase(planet.id, planet.vertices);
|
||||
surface.rotation = planet.snapshotRotation;
|
||||
surface.angularVelocity = planet.snapshotRotationSpeed;
|
||||
return surface;
|
||||
})
|
||||
.sort((a, b) => a.id - b.id);
|
||||
}
|
||||
|
||||
// Advance every planet's collision frame by one replay substep, so surfaces and the carried body rotate together. Surfaces are re-synced to the newest snapshot rotation each frame (see sync), so the replay only ever steps forward from there.
|
||||
public advance(deltaTimeInSeconds: number) {
|
||||
for (const surface of this.ordered) {
|
||||
surface.advance(deltaTimeInSeconds);
|
||||
}
|
||||
this.surfaces.forEach((s) => s.advanceRotation(deltaTimeInSeconds));
|
||||
}
|
||||
|
||||
public surfaceById(id: Id | undefined): GroundSurface | undefined {
|
||||
return id == null ? undefined : this.surfaces.get(id);
|
||||
public surfaceById(id: Id | null): GroundSurface | undefined {
|
||||
return this.surfaces.find((s) => s.id === id);
|
||||
}
|
||||
|
||||
public groundsNear(): Array<GroundSurface> {
|
||||
return this.ordered;
|
||||
public groundsNear(_center: vec2, _radius: number): Array<GroundSurface> {
|
||||
return this.surfaces;
|
||||
}
|
||||
|
||||
public stepBody(
|
||||
body: PhysicsBody,
|
||||
deltaTimeInSeconds: number,
|
||||
): GroundSurface | undefined {
|
||||
const { hitObject } = resolveCircleMovement(body, deltaTimeInSeconds, this.ordered);
|
||||
return hitObject && (hitObject as GroundSurface).isGround
|
||||
? (hitObject as GroundSurface)
|
||||
: undefined;
|
||||
const hit = resolveCircleMovement(body, deltaTimeInSeconds, this.surfaces);
|
||||
return hit instanceof PlanetBase ? hit : undefined;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ interface InputSample {
|
|||
|
||||
const retainMs = 1500;
|
||||
|
||||
// Timeline of local movement directions; the predictor replays exactly the input the server receives.
|
||||
export class InputHistory {
|
||||
private samples: Array<InputSample> = [];
|
||||
|
||||
|
|
|
|||
|
|
@ -5,25 +5,26 @@ import {
|
|||
CharacterMovementState,
|
||||
PhysicsBody,
|
||||
settings,
|
||||
clamp,
|
||||
easeVec2,
|
||||
stepCharacterMovement,
|
||||
applyLeapImpulse,
|
||||
tickPlanetDetachment,
|
||||
feetRadius,
|
||||
headRadius,
|
||||
} from 'shared';
|
||||
import { ClientCharacterWorld, PredictablePlanet } from './client-character-world';
|
||||
import { ClientCharacterWorld } from './client-character-world';
|
||||
import { PlanetView } from '../../objects/types/planet-view';
|
||||
import { InputHistory } from './input-history';
|
||||
|
||||
// Must match the server's tick source — the replay integrates over this dt.
|
||||
const stepSeconds = settings.targetPhysicsDeltaTimeInSeconds;
|
||||
const stepMs = stepSeconds * 1000;
|
||||
|
||||
// Frame timestamp, not performance.now() — dispatch jitter would land in the replay window.
|
||||
let frameTimeMs = 0;
|
||||
export const setFrameTimeMs = (timeMs: number): void => {
|
||||
frameTimeMs = timeMs;
|
||||
};
|
||||
export const predictorNowMs = (): number => frameTimeMs;
|
||||
export const clientTimeMs = (): number => Math.round(frameTimeMs);
|
||||
|
||||
const maxReplayMs = 400;
|
||||
|
||||
|
|
@ -33,7 +34,6 @@ const snapDistance = 250;
|
|||
|
||||
const upwards = vec2.fromValues(0, 1);
|
||||
|
||||
// Velocity starts at zero — the sim rebuilds it each tick, so it is not carried state.
|
||||
const makeBody = (center: vec2, radius: number, normal: vec2): PhysicsBody => ({
|
||||
center: vec2.clone(center),
|
||||
radius,
|
||||
|
|
@ -47,15 +47,12 @@ export class LocalCharacterPredictor {
|
|||
private readonly world = new ClientCharacterWorld();
|
||||
|
||||
private authoritative?: { head: Circle; leftFoot: Circle; rightFoot: Circle };
|
||||
// Replay anchor: built from both halves of the ack (input time + ackAge), advances forward only.
|
||||
private replayAnchorMs?: number;
|
||||
private movement?: CharacterMovementSnapshot;
|
||||
private leapHistory: Array<number> = [];
|
||||
// Last leap the server folded into streamed momentum; newer ones only, so a leap is never applied twice.
|
||||
private lastLeapAckMs = -Infinity;
|
||||
private currentStrength = settings.playerMaxStrength;
|
||||
|
||||
// Dead players don't predict — the server ignores their input, so moving would desync.
|
||||
private alive = true;
|
||||
|
||||
private renderHead = new Circle(vec2.create(), headRadius);
|
||||
|
|
@ -74,7 +71,7 @@ export class LocalCharacterPredictor {
|
|||
}
|
||||
|
||||
public recordInput(direction: vec2): number {
|
||||
const timeMs = Math.round(frameTimeMs);
|
||||
const timeMs = clientTimeMs();
|
||||
this.inputHistory.record(direction, timeMs);
|
||||
return timeMs;
|
||||
}
|
||||
|
|
@ -85,7 +82,6 @@ export class LocalCharacterPredictor {
|
|||
lastLeapClientTimeMs: number,
|
||||
ackAgeMs = 0,
|
||||
): void {
|
||||
// The anchor only advances forward; movement state and leap boundary always adopt the latest authoritative values.
|
||||
const anchor = clientTimeMs + Math.max(0, ackAgeMs);
|
||||
if (this.replayAnchorMs === undefined || anchor > this.replayAnchorMs) {
|
||||
this.replayAnchorMs = anchor;
|
||||
|
|
@ -98,9 +94,8 @@ export class LocalCharacterPredictor {
|
|||
this.authoritative = { head, leftFoot, rightFoot };
|
||||
}
|
||||
|
||||
// Recorded regardless of acceptance — a rejected leap self-corrects via streamed momentum.
|
||||
public recordLeap(): number {
|
||||
const timeMs = Math.round(frameTimeMs);
|
||||
const timeMs = clientTimeMs();
|
||||
this.leapHistory.push(timeMs);
|
||||
const cutoff = timeMs - 1500;
|
||||
while (this.leapHistory.length > 0 && this.leapHistory[0] <= cutoff) {
|
||||
|
|
@ -137,7 +132,6 @@ export class LocalCharacterPredictor {
|
|||
);
|
||||
}
|
||||
|
||||
// While spawn-in/death the server freezes walking (head radius < nominal) — don't predict, let interpolation show it.
|
||||
private get isAnimatingInOrOut(): boolean {
|
||||
return (
|
||||
this.authoritative !== undefined &&
|
||||
|
|
@ -145,7 +139,7 @@ export class LocalCharacterPredictor {
|
|||
);
|
||||
}
|
||||
|
||||
public update(planets: Array<PredictablePlanet>, frameSeconds: number): boolean {
|
||||
public update(planets: Array<PlanetView>, frameSeconds: number): boolean {
|
||||
if (!this.alive || !this.canPredict || this.isAnimatingInOrOut) {
|
||||
this.hasRender = false;
|
||||
return false;
|
||||
|
|
@ -166,14 +160,12 @@ export class LocalCharacterPredictor {
|
|||
private simulate(): CharacterMovementState {
|
||||
const auth = this.authoritative!;
|
||||
const movement = this.movement!;
|
||||
const now = Math.round(frameTimeMs);
|
||||
// Replay unconfirmed input from anchor to now, clamped so a stalled tab can't grind hundreds of steps.
|
||||
const startMs = Math.min(now, Math.max(this.replayAnchorMs!, now - maxReplayMs));
|
||||
const now = clientTimeMs();
|
||||
const startMs = clamp(this.replayAnchorMs!, now - maxReplayMs, now);
|
||||
const windowMs = Math.max(0, now - startMs);
|
||||
const steps = Math.floor(windowMs / stepMs);
|
||||
const remainderSeconds = (windowMs - steps * stepMs) / 1000;
|
||||
|
||||
// Full authoritative state — replay is a pure function of (snapshot, input, window) and cannot drift.
|
||||
const state: CharacterMovementState = {
|
||||
head: makeBody(auth.head.center, auth.head.radius, upwards),
|
||||
leftFoot: makeBody(
|
||||
|
|
@ -189,14 +181,10 @@ export class LocalCharacterPredictor {
|
|||
direction: movement.direction,
|
||||
currentPlanet: this.world.surfaceById(movement.groundPlanetId),
|
||||
secondsSinceOnSurface: movement.secondsSinceOnSurface,
|
||||
// Leaps before the window are baked in; inside it they're re-applied — neither double-counted.
|
||||
bodyVelocity: vec2.fromValues(movement.bodyVelocity[0], movement.bodyVelocity[1]),
|
||||
};
|
||||
|
||||
// Planet frames are synced to the newest snapshot's rotation, then advanced forward in lockstep with the body — no rewind needed.
|
||||
|
||||
const cooldownMs = settings.leapCooldownSeconds * 1000;
|
||||
// Mirror the server: each accepted leap spends strength, so a burst is gated by running strength.
|
||||
let availableStrength = this.currentStrength;
|
||||
let lastLeapMs = -Infinity;
|
||||
|
||||
|
|
@ -211,7 +199,6 @@ export class LocalCharacterPredictor {
|
|||
if (
|
||||
leapMs >= t &&
|
||||
leapMs < t + stepMs &&
|
||||
// Only leaps the server hasn't seeded — never both seeded and replayed.
|
||||
leapMs > this.lastLeapAckMs &&
|
||||
state.currentPlanet &&
|
||||
leapMs - lastLeapMs >= cooldownMs &&
|
||||
|
|
@ -226,7 +213,6 @@ export class LocalCharacterPredictor {
|
|||
t += stepMs;
|
||||
}
|
||||
|
||||
// Sub-tick the remainder so the pose is continuous in the window length, not quantized to stepMs.
|
||||
if (remainderSeconds > 0) {
|
||||
tickPlanetDetachment(state, remainderSeconds);
|
||||
stepCharacterMovement(
|
||||
|
|
@ -254,18 +240,13 @@ export class LocalCharacterPredictor {
|
|||
}
|
||||
|
||||
private easeRenderTo(state: CharacterMovementState, frameSeconds: number): void {
|
||||
const q = 1 - Math.exp(-frameSeconds / smoothSeconds);
|
||||
this.easePart(this.renderHead, state.head, q);
|
||||
this.easePart(this.renderLeftFoot, state.leftFoot, q);
|
||||
this.easePart(this.renderRightFoot, state.rightFoot, q);
|
||||
this.easePart(this.renderHead, state.head, frameSeconds);
|
||||
this.easePart(this.renderLeftFoot, state.leftFoot, frameSeconds);
|
||||
this.easePart(this.renderRightFoot, state.rightFoot, frameSeconds);
|
||||
}
|
||||
|
||||
private easePart(render: Circle, target: PhysicsBody, q: number): void {
|
||||
if (vec2.distance(render.center, target.center) > snapDistance) {
|
||||
vec2.copy(render.center, target.center);
|
||||
} else {
|
||||
vec2.lerp(render.center, render.center, target.center, q);
|
||||
}
|
||||
private easePart(render: Circle, target: PhysicsBody, frameSeconds: number): void {
|
||||
easeVec2(render.center, target.center, frameSeconds, smoothSeconds, snapDistance);
|
||||
render.radius = target.radius;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,35 +6,24 @@ const maxRateAdjustment = 0.25;
|
|||
|
||||
const resyncSeconds = 0.3;
|
||||
|
||||
/**
|
||||
* Playback clock for streamed server state — renders interpolationDelaySeconds
|
||||
* behind the server clock so a newer snapshot is usually available; the cursor
|
||||
* adjusts its rate gently and only snaps after a long divergence.
|
||||
*/
|
||||
class ServerTimeline {
|
||||
private cursor?: number;
|
||||
private newestSnapshotTime?: number;
|
||||
private sinceNewestSnapshot = 0;
|
||||
private _snapshotTime = 0;
|
||||
|
||||
public get snapshotTime(): number {
|
||||
return this._snapshotTime;
|
||||
}
|
||||
public snapshotTime = 0;
|
||||
|
||||
public get renderTime(): number {
|
||||
return this.cursor ?? 0;
|
||||
}
|
||||
|
||||
public onSnapshot(timestamp: number) {
|
||||
this._snapshotTime = timestamp;
|
||||
this.snapshotTime = timestamp;
|
||||
if (this.newestSnapshotTime === undefined || timestamp > this.newestSnapshotTime) {
|
||||
this.newestSnapshotTime = timestamp;
|
||||
this.sinceNewestSnapshot = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Must be called with unscaled wall-clock time, even during the end-game
|
||||
// slow motion: the slowdown is already baked into the snapshots.
|
||||
public step(deltaTimeInSeconds: number) {
|
||||
if (this.newestSnapshotTime === undefined) {
|
||||
return;
|
||||
|
|
@ -63,7 +52,7 @@ class ServerTimeline {
|
|||
this.cursor = undefined;
|
||||
this.newestSnapshotTime = undefined;
|
||||
this.sinceNewestSnapshot = 0;
|
||||
this._snapshotTime = 0;
|
||||
this.snapshotTime = 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,52 +10,43 @@ export type PlayerDecision = {
|
|||
};
|
||||
|
||||
const pollInterval = 8000;
|
||||
|
||||
export class JoinFormHandler {
|
||||
private joinButton: HTMLButtonElement;
|
||||
private waitingForDecision: Promise<PlayerDecision>;
|
||||
private resolvePlayerDecision!: (d: PlayerDecision) => void;
|
||||
private pollServersTimer: any;
|
||||
private readonly joinButton: HTMLButtonElement;
|
||||
private readonly decision: Promise<PlayerDecision>;
|
||||
private readonly pollServersTimer: ReturnType<typeof setInterval>;
|
||||
private servers: Array<ServerChooserOption> = [];
|
||||
|
||||
private keyUpListener = (e: KeyboardEvent) => {
|
||||
// requestSubmit() (not submit()) fires onsubmit and runs HTML5 validation, so Enter matches clicking Join.
|
||||
if (e.key === 'Enter' && !this.joinButton.disabled) {
|
||||
this.form.requestSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
constructor(
|
||||
private form: HTMLFormElement,
|
||||
private readonly form: HTMLFormElement,
|
||||
private readonly container: HTMLElement,
|
||||
) {
|
||||
this.joinButton = form.querySelector('button[type="submit"]') as HTMLButtonElement;
|
||||
this.joinButton.disabled = true;
|
||||
this.waitingForDecision = new Promise((r) => (this.resolvePlayerDecision = r));
|
||||
|
||||
new FormData(form);
|
||||
this.decision = new Promise((resolve) => {
|
||||
form.onsubmit = (e) => {
|
||||
e.preventDefault();
|
||||
SoundHandler.play(Sounds.click);
|
||||
const data = new FormData(form);
|
||||
resolve({ name: String(data.get('name')), server: String(data.get('server')) });
|
||||
};
|
||||
});
|
||||
this.decision.then(() => this.destroy());
|
||||
|
||||
addEventListener('keyup', this.keyUpListener);
|
||||
|
||||
form.onsubmit = (e) => {
|
||||
SoundHandler.play(Sounds.click);
|
||||
const result: PlayerDecision = (
|
||||
Array.from((new FormData(form) as any).entries()) as Array<[string, any]>
|
||||
).reduce((result, [name, value]) => {
|
||||
(result as any)[name] = value;
|
||||
return result;
|
||||
}, {}) as any;
|
||||
|
||||
this.resolvePlayerDecision(result);
|
||||
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
this.pollServersTimer = setInterval(this.loadServers.bind(this), pollInterval);
|
||||
this.pollServersTimer = setInterval(() => this.loadServers(), pollInterval);
|
||||
this.loadServers();
|
||||
this.waitForFinish();
|
||||
}
|
||||
|
||||
private async waitForFinish() {
|
||||
await this.waitingForDecision;
|
||||
this.destroy();
|
||||
public getPlayerDecision(): Promise<PlayerDecision> {
|
||||
return this.decision;
|
||||
}
|
||||
|
||||
private destroy() {
|
||||
|
|
@ -64,77 +55,86 @@ export class JoinFormHandler {
|
|||
this.servers.forEach((s) => s.destroy());
|
||||
}
|
||||
|
||||
private servers: Array<ServerChooserOption> = [];
|
||||
private async loadServers() {
|
||||
const serverList = servers.filter((u) => !this.servers.find((s) => s.url === u));
|
||||
private loadServers() {
|
||||
servers
|
||||
.filter((url) => !this.servers.some((s) => s.url === url))
|
||||
.forEach(async (url) => {
|
||||
const controller = new AbortController();
|
||||
setTimeout(() => controller.abort(), pollInterval * 0.8);
|
||||
|
||||
serverList.map(async (url) => {
|
||||
const controller = new AbortController();
|
||||
const signal = controller.signal;
|
||||
setTimeout(() => controller.abort(), pollInterval * 0.8);
|
||||
let content: ServerInformation;
|
||||
try {
|
||||
const response = await fetch(url + serverInformationEndpoint, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
return;
|
||||
}
|
||||
content = await response.json();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
let response: Response | undefined;
|
||||
try {
|
||||
response = await fetch(url + serverInformationEndpoint, { signal });
|
||||
} catch {}
|
||||
|
||||
if (response?.ok) {
|
||||
const content: ServerInformation = await response.json();
|
||||
if (!this.servers.find((s) => s.url === url)) {
|
||||
if (!this.servers.some((s) => s.url === url)) {
|
||||
const server = new ServerChooserOption(
|
||||
content,
|
||||
url,
|
||||
this.removeServer.bind(this),
|
||||
(s) => this.removeServer(s),
|
||||
this.servers.length === 0,
|
||||
);
|
||||
this.servers.push(server);
|
||||
this.joinButton.disabled = false;
|
||||
this.container.appendChild(server.element);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private removeServer(server: ServerChooserOption) {
|
||||
this.servers = this.servers.filter((s) => s !== server);
|
||||
if (!this.servers.length) {
|
||||
this.joinButton.disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
public async getPlayerDecision(): Promise<PlayerDecision> {
|
||||
return this.waitingForDecision;
|
||||
this.joinButton.disabled = this.servers.length === 0;
|
||||
}
|
||||
}
|
||||
|
||||
class ServerChooserOption {
|
||||
private divElement = document.createElement('div');
|
||||
private inputElement = document.createElement('input');
|
||||
private labelElement = document.createElement('label');
|
||||
private serverNameElement = document.createElement('span');
|
||||
private completionElement = document.createElement('span');
|
||||
const roundCompletionTexts = [
|
||||
'Just started',
|
||||
'Just started',
|
||||
'Ongoing',
|
||||
'Halfway through',
|
||||
'Nearly over',
|
||||
'About to finish',
|
||||
'Game is over',
|
||||
];
|
||||
|
||||
private socket: Socket;
|
||||
class ServerChooserOption {
|
||||
public readonly element = document.createElement('div');
|
||||
private readonly serverNameElement = document.createElement('span');
|
||||
private readonly completionElement = document.createElement('span');
|
||||
private readonly socket: Socket;
|
||||
|
||||
constructor(
|
||||
private content: ServerInformation,
|
||||
private readonly content: ServerInformation,
|
||||
public readonly url: string,
|
||||
private onDestroy: (v: ServerChooserOption) => unknown,
|
||||
private readonly onDestroy: (v: ServerChooserOption) => unknown,
|
||||
isFirst: boolean,
|
||||
) {
|
||||
this.inputElement.required = true;
|
||||
this.inputElement.type = 'radio';
|
||||
this.inputElement.id = this.inputElement.value = url;
|
||||
this.inputElement.name = 'server';
|
||||
this.inputElement.checked = isFirst;
|
||||
this.labelElement.htmlFor = url;
|
||||
this.labelElement.onclick = () => SoundHandler.play(Sounds.click);
|
||||
this.divElement.appendChild(this.inputElement);
|
||||
this.divElement.appendChild(this.labelElement);
|
||||
this.labelElement.appendChild(this.serverNameElement);
|
||||
this.labelElement.appendChild(document.createElement('br'));
|
||||
this.labelElement.appendChild(this.completionElement);
|
||||
const input = document.createElement('input');
|
||||
input.required = true;
|
||||
input.type = 'radio';
|
||||
input.id = input.value = url;
|
||||
input.name = 'server';
|
||||
input.checked = isFirst;
|
||||
|
||||
const label = document.createElement('label');
|
||||
label.htmlFor = url;
|
||||
label.onclick = () => SoundHandler.play(Sounds.click);
|
||||
label.append(
|
||||
this.serverNameElement,
|
||||
document.createElement('br'),
|
||||
this.completionElement,
|
||||
);
|
||||
this.completionElement.className = 'completion';
|
||||
|
||||
this.element.append(input, label);
|
||||
this.setServerInfoLabelText();
|
||||
|
||||
this.socket = io(url, {
|
||||
|
|
@ -144,8 +144,7 @@ class ServerChooserOption {
|
|||
parser,
|
||||
} as any);
|
||||
|
||||
this.socket.io.on('reconnect_failed', this.destroy.bind(this));
|
||||
|
||||
this.socket.io.on('reconnect_failed', () => this.destroy());
|
||||
this.socket.on('connect', () =>
|
||||
this.socket.emit(TransportEvents.SubscribeForServerInfoUpdates),
|
||||
);
|
||||
|
|
@ -161,32 +160,16 @@ class ServerChooserOption {
|
|||
|
||||
public destroy() {
|
||||
this.socket.close();
|
||||
this.divElement.parentElement?.removeChild(this.divElement);
|
||||
this.element.remove();
|
||||
this.onDestroy(this);
|
||||
}
|
||||
|
||||
private getRoundCompletionText(percent: number): string {
|
||||
const texts = [
|
||||
'Just started',
|
||||
'Just started',
|
||||
'Ongoing',
|
||||
'Halfway through',
|
||||
'Nearly over',
|
||||
'About to finish',
|
||||
'Game is over',
|
||||
];
|
||||
|
||||
return texts[Math.floor((percent / 100) * (texts.length - 1))];
|
||||
}
|
||||
|
||||
private setServerInfoLabelText() {
|
||||
this.serverNameElement.innerText = `${this.content.serverName} - ${this.content.playerCount}/${this.content.playerLimit} players`;
|
||||
this.completionElement.innerText = this.getRoundCompletionText(
|
||||
this.content.gameStatePercent,
|
||||
);
|
||||
}
|
||||
|
||||
public get element(): HTMLElement {
|
||||
return this.divElement;
|
||||
const { serverName, playerCount, playerLimit, gameStatePercent } = this.content;
|
||||
this.serverNameElement.innerText = `${serverName} - ${playerCount}/${playerLimit} players`;
|
||||
this.completionElement.innerText =
|
||||
roundCompletionTexts[
|
||||
Math.floor((gameStatePercent / 100) * (roundCompletionTexts.length - 1))
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,27 +1,34 @@
|
|||
import { vec2, vec3 } from 'gl-matrix';
|
||||
import { ReadonlyVec2, vec2, vec3 } from 'gl-matrix';
|
||||
import {
|
||||
CircleLight,
|
||||
FilteringOptions,
|
||||
hsl,
|
||||
Renderer,
|
||||
renderNoise,
|
||||
runAnimation,
|
||||
WrapOptions,
|
||||
} from 'sdf-2d';
|
||||
import { settings, PlanetBase, Random } from 'shared';
|
||||
import { settings, PlanetBase, Random, hsl } from 'shared';
|
||||
import { PlanetShape } from './shapes/planet-shape';
|
||||
|
||||
const bluePlanet = 0;
|
||||
const redPlanet = 0.85;
|
||||
|
||||
export class LandingPageBackground {
|
||||
public readonly renderer: Promise<Renderer>;
|
||||
|
||||
private isActive = true;
|
||||
public renderer: Promise<Renderer>;
|
||||
private resolveRenderer!: (r: Renderer) => unknown;
|
||||
private scene?: {
|
||||
canvasSize: vec2;
|
||||
topPlanet: PlanetShape;
|
||||
bottomPlanet: PlanetShape;
|
||||
planetAngle: number;
|
||||
planetDistance: number;
|
||||
};
|
||||
|
||||
constructor(canvas: HTMLCanvasElement) {
|
||||
this.start(canvas);
|
||||
this.renderer = new Promise((r) => (this.resolveRenderer = r));
|
||||
this.start(canvas);
|
||||
}
|
||||
|
||||
private async start(canvas: HTMLCanvasElement): Promise<void> {
|
||||
|
|
@ -30,14 +37,8 @@ export class LandingPageBackground {
|
|||
runAnimation(
|
||||
canvas,
|
||||
[
|
||||
{
|
||||
...PlanetShape.descriptor,
|
||||
shaderCombinationSteps: [0, 1, 2],
|
||||
},
|
||||
{
|
||||
...CircleLight.descriptor,
|
||||
shaderCombinationSteps: [0, 2],
|
||||
},
|
||||
{ ...PlanetShape.descriptor, shaderCombinationSteps: [0, 1, 2] },
|
||||
{ ...CircleLight.descriptor, shaderCombinationSteps: [0, 2] },
|
||||
],
|
||||
this.gameLoop.bind(this),
|
||||
{
|
||||
|
|
@ -59,79 +60,77 @@ export class LandingPageBackground {
|
|||
);
|
||||
}
|
||||
|
||||
private gameLoop(renderer: Renderer, time: DOMHighResTimeStamp, _: number): boolean {
|
||||
private buildScene(canvasSize: ReadonlyVec2) {
|
||||
Random.seed = 42;
|
||||
|
||||
this.resolveRenderer(renderer);
|
||||
|
||||
renderer.setViewArea(vec2.fromValues(0, renderer.canvasSize.y), renderer.canvasSize);
|
||||
|
||||
const topPlanetPosition = vec2.fromValues(
|
||||
0.7 * renderer.canvasSize.x,
|
||||
0.7 * renderer.canvasSize.y,
|
||||
);
|
||||
|
||||
const topPosition = vec2.scale(vec2.create(), canvasSize, 0.7);
|
||||
const topPlanet = new PlanetShape(
|
||||
PlanetBase.createPlanetVertices(
|
||||
topPlanetPosition,
|
||||
topPosition,
|
||||
Random.getRandomInRange(150, 400),
|
||||
Random.getRandomInRange(150, 400),
|
||||
Random.getRandomInRange(10, 20),
|
||||
),
|
||||
redPlanet,
|
||||
);
|
||||
|
||||
topPlanet.randomOffset = Random.getRandom();
|
||||
topPlanet.rotation = (time / 1000) * 0.09;
|
||||
|
||||
const bottomPlanetPosition = vec2.fromValues(
|
||||
0.3 * renderer.canvasSize.x,
|
||||
0.3 * renderer.canvasSize.y,
|
||||
);
|
||||
|
||||
const bottomPosition = vec2.scale(vec2.create(), canvasSize, 0.3);
|
||||
const bottomPlanet = new PlanetShape(
|
||||
PlanetBase.createPlanetVertices(
|
||||
bottomPlanetPosition,
|
||||
bottomPosition,
|
||||
Random.getRandomInRange(150, 800),
|
||||
Random.getRandomInRange(150, 400),
|
||||
Random.getRandomInRange(10, 40),
|
||||
),
|
||||
bluePlanet,
|
||||
);
|
||||
|
||||
bottomPlanet.randomOffset = Random.getRandom();
|
||||
bottomPlanet.rotation = (time / 1000) * -0.06;
|
||||
|
||||
const planetDistance = vec2.subtract(
|
||||
vec2.create(),
|
||||
topPlanetPosition,
|
||||
bottomPlanetPosition,
|
||||
);
|
||||
const planetDistanceLength = vec2.length(planetDistance);
|
||||
const planetDirection = vec2.normalize(planetDistance, planetDistance);
|
||||
const planetAngle = Math.atan2(planetDirection.y, planetDirection.x);
|
||||
const between = vec2.subtract(vec2.create(), topPosition, bottomPosition);
|
||||
this.scene = {
|
||||
canvasSize: vec2.clone(canvasSize),
|
||||
topPlanet,
|
||||
bottomPlanet,
|
||||
planetAngle: Math.atan2(between[1], between[0]),
|
||||
planetDistance: vec2.length(between),
|
||||
};
|
||||
return this.scene;
|
||||
}
|
||||
|
||||
renderer.addDrawable(topPlanet);
|
||||
renderer.addDrawable(bottomPlanet);
|
||||
private gameLoop(renderer: Renderer, time: DOMHighResTimeStamp): boolean {
|
||||
this.resolveRenderer(renderer);
|
||||
renderer.setViewArea(vec2.fromValues(0, renderer.canvasSize[1]), renderer.canvasSize);
|
||||
|
||||
const scene =
|
||||
this.scene && vec2.exactEquals(this.scene.canvasSize, renderer.canvasSize)
|
||||
? this.scene
|
||||
: this.buildScene(renderer.canvasSize);
|
||||
|
||||
scene.topPlanet.rotation = (time / 1000) * 0.09;
|
||||
scene.bottomPlanet.rotation = (time / 1000) * -0.06;
|
||||
renderer.addDrawable(scene.topPlanet);
|
||||
renderer.addDrawable(scene.bottomPlanet);
|
||||
|
||||
const lightDistance = scene.planetDistance * 1.2;
|
||||
renderer.addDrawable(
|
||||
new CircleLight(
|
||||
this.calculateLightPosition(
|
||||
renderer,
|
||||
planetAngle,
|
||||
planetDistanceLength * 1.2,
|
||||
scene.planetAngle,
|
||||
lightDistance,
|
||||
-time / 3000,
|
||||
),
|
||||
hsl(25, 75, 60),
|
||||
0.75,
|
||||
),
|
||||
);
|
||||
|
||||
renderer.addDrawable(
|
||||
new CircleLight(
|
||||
this.calculateLightPosition(
|
||||
renderer,
|
||||
planetAngle,
|
||||
planetDistanceLength * 1.2,
|
||||
scene.planetAngle,
|
||||
lightDistance,
|
||||
time / 2000 + Math.PI,
|
||||
),
|
||||
hsl(249, 79, 70),
|
||||
|
|
@ -152,12 +151,9 @@ export class LandingPageBackground {
|
|||
length * Math.sin(t),
|
||||
length * Math.sin(t) * Math.cos(t),
|
||||
);
|
||||
|
||||
const canvasCenter = vec2.scale(vec2.create(), renderer.canvasSize, 0.5);
|
||||
|
||||
vec2.add(lightPosition, lightPosition, canvasCenter);
|
||||
vec2.rotate(lightPosition, lightPosition, canvasCenter, angle);
|
||||
return lightPosition;
|
||||
return vec2.rotate(lightPosition, lightPosition, canvasCenter, angle);
|
||||
}
|
||||
|
||||
public destroy() {
|
||||
|
|
|
|||
|
|
@ -1,18 +1,11 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
import { CharacterTeam, Id, settings } from 'shared';
|
||||
|
||||
export interface MinimapBlip {
|
||||
id: Id;
|
||||
position: vec2;
|
||||
team: CharacterTeam;
|
||||
}
|
||||
import { CharacterTeam, Id, MinimapPlayer, settings } from 'shared';
|
||||
|
||||
export class Minimap {
|
||||
public readonly element = document.createElement('canvas');
|
||||
|
||||
private readonly ctx: CanvasRenderingContext2D;
|
||||
private readonly colors: Record<CharacterTeam, string>;
|
||||
// Smoothed per player so the 25 Hz snapshots glide rather than step between frames.
|
||||
private readonly smoothed = new Map<Id, vec2>();
|
||||
private bufferSize = 0;
|
||||
|
||||
|
|
@ -30,7 +23,7 @@ export class Minimap {
|
|||
};
|
||||
}
|
||||
|
||||
public update(localPosition: vec2 | undefined, players: Array<MinimapBlip>) {
|
||||
public update(localPosition: vec2 | undefined, players: Array<MinimapPlayer>) {
|
||||
const size = this.element.clientWidth;
|
||||
if (size === 0) {
|
||||
return;
|
||||
|
|
@ -45,8 +38,8 @@ export class Minimap {
|
|||
const scale = innerRadius / settings.worldRadius;
|
||||
|
||||
const toMap = (world: vec2): { x: number; y: number } => {
|
||||
let dx = world.x * scale;
|
||||
let dy = -world.y * scale; // world Y points up, canvas Y points down
|
||||
let dx = world[0] * scale;
|
||||
let dy = -world[1] * scale;
|
||||
const distance = Math.hypot(dx, dy);
|
||||
if (distance > innerRadius) {
|
||||
dx = (dx / distance) * innerRadius;
|
||||
|
|
@ -109,8 +102,6 @@ export class Minimap {
|
|||
this.element.width = target;
|
||||
this.element.height = target;
|
||||
}
|
||||
// Setting the buffer size resets the transform, so (re)establish it every
|
||||
// frame and draw in CSS pixels regardless of the device pixel ratio.
|
||||
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,38 +1,35 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
import { Renderer } from 'sdf-2d';
|
||||
import {
|
||||
Circle,
|
||||
Command,
|
||||
CommandExecutors,
|
||||
CommandReceiver,
|
||||
CreateObjectsCommand,
|
||||
CreatePlayerCommand,
|
||||
DeleteObjectsCommand,
|
||||
GameObject,
|
||||
Id,
|
||||
PropertyUpdatesForObjects,
|
||||
RemoteCallsForObjects,
|
||||
settings,
|
||||
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';
|
||||
import { serverTimeline } from '../helper/server-timeline';
|
||||
import { PredictablePlanet } from '../helper/prediction/client-character-world';
|
||||
import { localCharacterPredictor } from '../helper/prediction/local-character-predictor';
|
||||
import { Camera } from './types/camera';
|
||||
import { CharacterView } from './types/character-view';
|
||||
import { PlanetView } from './types/planet-view';
|
||||
import { View } from './view';
|
||||
|
||||
export class GameObjectContainer extends CommandReceiver {
|
||||
protected objects: Map<Id, GameObject> = new Map();
|
||||
public player!: CharacterView;
|
||||
public camera: Camera = new Camera(this.game);
|
||||
public player?: CharacterView;
|
||||
public readonly camera: Camera;
|
||||
public planets: Array<PlanetView> = [];
|
||||
|
||||
private objects: Map<Id, View> = new Map();
|
||||
private wasLocalPlayerAlive = false;
|
||||
|
||||
// Create/delete are admitted/retired on the render timeline (not the wire) so interpolated objects don't pop at the muzzle.
|
||||
// Creates and deletes take effect on the render timeline, not on arrival.
|
||||
private visibleFrom = new Map<Id, number>();
|
||||
private deleteAt = new Map<Id, number>();
|
||||
private awaitingCreateStamp: Array<Id> = [];
|
||||
|
|
@ -50,42 +47,10 @@ export class GameObjectContainer extends CommandReceiver {
|
|||
|
||||
[CreateObjectsCommand.type]: (c: CreateObjectsCommand) =>
|
||||
c.objects.forEach((o) => {
|
||||
this.addObject(o as GameObject);
|
||||
this.awaitingCreateStamp.push((o as GameObject).id);
|
||||
this.addObject(o as View);
|
||||
this.awaitingCreateStamp.push(o.id);
|
||||
}),
|
||||
|
||||
[StepCommand.type]: (c: StepCommand) => {
|
||||
// Fall back to newest snapshot if a batch had no property updates, so nothing is stranded.
|
||||
this.stampPending(serverTimeline.snapshotTime);
|
||||
this.retireDueObjects();
|
||||
this.defaultCommandExecutor(c);
|
||||
|
||||
// Alive = object exists AND health > 0 (player pointer outlives the body).
|
||||
const bodyPresent = !!this.player && this.objects.has(this.player.id);
|
||||
const alive = bodyPresent && this.player.health > 0;
|
||||
|
||||
if (this.wasLocalPlayerAlive && !alive) {
|
||||
FeedbackHud.showElimination();
|
||||
}
|
||||
this.wasLocalPlayerAlive = alive;
|
||||
|
||||
if (bodyPresent) {
|
||||
// Override the local player's interpolated pose with the predicted one; suppressed while dead (server ignores dead input).
|
||||
localCharacterPredictor.setAlive(alive);
|
||||
localCharacterPredictor.setStrength(
|
||||
this.player.strengthFraction * settings.playerMaxStrength,
|
||||
);
|
||||
if (
|
||||
localCharacterPredictor.update(this.predictablePlanets(), c.deltaTimeInSeconds)
|
||||
) {
|
||||
this.player.head = localCharacterPredictor.head;
|
||||
this.player.leftFoot = localCharacterPredictor.leftFoot;
|
||||
this.player.rightFoot = localCharacterPredictor.rightFoot;
|
||||
}
|
||||
this.camera.follow(this.player.position, c.deltaTimeInSeconds);
|
||||
}
|
||||
},
|
||||
|
||||
[RemoteCallsForObjects.type]: (c: RemoteCallsForObjects) =>
|
||||
c.callsForObjects.forEach((c) =>
|
||||
this.objects.get(c.id)?.processRemoteCalls(c.calls),
|
||||
|
|
@ -93,11 +58,11 @@ export class GameObjectContainer extends CommandReceiver {
|
|||
|
||||
[PropertyUpdatesForObjects.type]: (c: PropertyUpdatesForObjects) => {
|
||||
serverTimeline.onSnapshot(c.timestamp);
|
||||
// Create/delete precede property updates in a batch — timestamp is known here.
|
||||
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) {
|
||||
const object = this.objects.get(u.id);
|
||||
u.updates.forEach((au) => object?.updateProperty?.(au));
|
||||
if (object && object === this.player) {
|
||||
this.feedPredictor(u.updates);
|
||||
}
|
||||
});
|
||||
|
|
@ -107,19 +72,15 @@ export class GameObjectContainer extends CommandReceiver {
|
|||
c.ids.forEach((id: Id) => this.awaitingDeleteStamp.push(id)),
|
||||
};
|
||||
|
||||
constructor(private game: Game) {
|
||||
constructor(game: Game) {
|
||||
super();
|
||||
this.camera = new Camera(game);
|
||||
}
|
||||
|
||||
/**
|
||||
* A reconnect is a new server-side player that never sends deletes for the
|
||||
* old session's objects — clear them or they stay as frozen ghosts. `player`
|
||||
* is left stale: reads are gated on `objects`, and the next CreatePlayerCommand
|
||||
* replaces it.
|
||||
*/
|
||||
public reset() {
|
||||
this.objects.forEach((o) => o.handleCommand(new BeforeDestroyCommand()));
|
||||
this.objects.forEach((o) => o.beforeDestroy?.());
|
||||
this.objects.clear();
|
||||
this.planets = [];
|
||||
this.visibleFrom.clear();
|
||||
this.deleteAt.clear();
|
||||
this.awaitingCreateStamp = [];
|
||||
|
|
@ -127,32 +88,43 @@ export class GameObjectContainer extends CommandReceiver {
|
|||
this.wasLocalPlayerAlive = false;
|
||||
}
|
||||
|
||||
public get localPlayerPosition(): vec2 | undefined {
|
||||
return this.player && this.objects.has(this.player.id)
|
||||
? this.player.position
|
||||
: undefined;
|
||||
public get localPlayer(): CharacterView | undefined {
|
||||
return this.player && this.objects.has(this.player.id) ? this.player : undefined;
|
||||
}
|
||||
|
||||
public get planets(): Array<PlanetView> {
|
||||
const planets: Array<PlanetView> = [];
|
||||
this.objects.forEach((o) => {
|
||||
if (o instanceof PlanetView) {
|
||||
planets.push(o);
|
||||
public step(deltaTimeInSeconds: number) {
|
||||
this.stampPending(serverTimeline.snapshotTime);
|
||||
this.retireDueObjects();
|
||||
this.objects.forEach((o) => o.step(deltaTimeInSeconds));
|
||||
|
||||
const player = this.localPlayer;
|
||||
const alive = !!player && player.health > 0;
|
||||
if (this.wasLocalPlayerAlive && !alive) {
|
||||
FeedbackHud.showElimination();
|
||||
}
|
||||
this.wasLocalPlayerAlive = alive;
|
||||
|
||||
if (player) {
|
||||
localCharacterPredictor.setAlive(alive);
|
||||
localCharacterPredictor.setStrength(
|
||||
player.strengthFraction * settings.playerMaxStrength,
|
||||
);
|
||||
if (localCharacterPredictor.update(this.planets, deltaTimeInSeconds)) {
|
||||
player.head = localCharacterPredictor.head;
|
||||
player.leftFoot = localCharacterPredictor.leftFoot;
|
||||
player.rightFoot = localCharacterPredictor.rightFoot;
|
||||
}
|
||||
});
|
||||
return planets;
|
||||
this.camera.follow(player.position, deltaTimeInSeconds);
|
||||
}
|
||||
}
|
||||
|
||||
protected defaultCommandExecutor(c: Command) {
|
||||
const isRender = c.type === RenderCommand.type;
|
||||
public render(renderer: Renderer, overlay: HTMLElement, shouldChangeLayout: boolean) {
|
||||
this.objects.forEach((o) => {
|
||||
// Stepping runs even for not-yet-visible objects so they enter the scene already moving.
|
||||
if (isRender && !this.isVisible(o.id)) {
|
||||
return;
|
||||
if (this.isVisible(o.id)) {
|
||||
o.render(renderer, overlay, shouldChangeLayout);
|
||||
}
|
||||
o.handleCommand(c);
|
||||
});
|
||||
this.camera.handleCommand(c);
|
||||
this.camera.draw(renderer);
|
||||
}
|
||||
|
||||
private isVisible(id: Id): boolean {
|
||||
|
|
@ -173,52 +145,41 @@ export class GameObjectContainer extends CommandReceiver {
|
|||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Feed the predictor the raw (not interpolated) authoritative pose.
|
||||
private feedPredictor(updates: Array<UpdatePropertyCommand>) {
|
||||
let head: Circle | undefined;
|
||||
let leftFoot: Circle | undefined;
|
||||
let rightFoot: Circle | undefined;
|
||||
const pose: Partial<Record<'head' | 'leftFoot' | 'rightFoot', Circle>> = {};
|
||||
for (const u of updates) {
|
||||
if (u.propertyKey === 'head') head = u.propertyValue as Circle;
|
||||
else if (u.propertyKey === 'leftFoot') leftFoot = u.propertyValue as Circle;
|
||||
else if (u.propertyKey === 'rightFoot') rightFoot = u.propertyValue as Circle;
|
||||
if (
|
||||
u.propertyKey === 'head' ||
|
||||
u.propertyKey === 'leftFoot' ||
|
||||
u.propertyKey === 'rightFoot'
|
||||
) {
|
||||
pose[u.propertyKey] = u.propertyValue;
|
||||
}
|
||||
}
|
||||
if (head && leftFoot && rightFoot) {
|
||||
localCharacterPredictor.setAuthoritative(head, leftFoot, rightFoot);
|
||||
if (pose.head && pose.leftFoot && pose.rightFoot) {
|
||||
localCharacterPredictor.setAuthoritative(pose.head, pose.leftFoot, pose.rightFoot);
|
||||
}
|
||||
}
|
||||
|
||||
private predictablePlanets(): Array<PredictablePlanet> {
|
||||
return this.planets.map((p) => ({
|
||||
id: p.id,
|
||||
vertices: p.vertices,
|
||||
center: p.center,
|
||||
radius: p.radius,
|
||||
rotation: p.predictionRotation,
|
||||
rotationSpeed: p.predictionRotationSpeed,
|
||||
}));
|
||||
}
|
||||
|
||||
private addObject(object: GameObject) {
|
||||
private addObject(object: View) {
|
||||
this.objects.set(object.id, object);
|
||||
if (object instanceof PlanetView) {
|
||||
this.planets.push(object);
|
||||
}
|
||||
}
|
||||
|
||||
private deleteObject(id: Id) {
|
||||
const object = this.objects.get(id);
|
||||
object?.handleCommand(new BeforeDestroyCommand());
|
||||
this.objects.get(id)?.beforeDestroy?.();
|
||||
this.objects.delete(id);
|
||||
this.planets = this.planets.filter((p) => p.id !== id);
|
||||
this.visibleFrom.delete(id);
|
||||
this.deleteAt.delete(id);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,56 +1,29 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
import {
|
||||
calculateViewArea,
|
||||
CommandExecutors,
|
||||
CommandReceiver,
|
||||
mixRgb,
|
||||
settings,
|
||||
} from 'shared';
|
||||
import { RenderCommand } from '../../commands/types/render';
|
||||
import { Renderer } from 'sdf-2d';
|
||||
import { calculateViewArea, easeVec2, mixRgb, settings } from 'shared';
|
||||
import { Game } from '../../game';
|
||||
import { ScreenShake } from '../../screen-shake';
|
||||
|
||||
export class Camera extends CommandReceiver {
|
||||
public center: vec2 = vec2.create();
|
||||
const followSeconds = 0.08;
|
||||
const snapDistance = 1500;
|
||||
|
||||
export class Camera {
|
||||
public readonly center: vec2 = vec2.create();
|
||||
private aspectRatio?: number;
|
||||
|
||||
// A short exponential lag masks any residual stepping in the followed
|
||||
// position without the camera noticeably trailing during normal movement.
|
||||
private static readonly followSeconds = 0.08;
|
||||
|
||||
// Swooshing across half the map after a respawn would be disorienting;
|
||||
// beyond this distance the camera cuts instead.
|
||||
private static readonly snapDistance = 1500;
|
||||
|
||||
constructor(private game: Game) {
|
||||
super();
|
||||
}
|
||||
constructor(private readonly game: Game) {}
|
||||
|
||||
public follow(target: vec2, deltaTimeInSeconds: number) {
|
||||
if (vec2.distance(target, this.center) > Camera.snapDistance) {
|
||||
vec2.copy(this.center, target);
|
||||
return;
|
||||
}
|
||||
|
||||
const q = 1 - Math.exp(-deltaTimeInSeconds / Camera.followSeconds);
|
||||
vec2.lerp(this.center, this.center, target, q);
|
||||
easeVec2(this.center, target, deltaTimeInSeconds, followSeconds, snapDistance);
|
||||
}
|
||||
|
||||
protected commandExecutors: CommandExecutors = {
|
||||
[RenderCommand.type]: this.draw.bind(this),
|
||||
};
|
||||
|
||||
private draw({ renderer }: RenderCommand) {
|
||||
const canvasAspectRatio = renderer.canvasSize.x / renderer.canvasSize.y;
|
||||
public draw(renderer: Renderer) {
|
||||
const canvasAspectRatio = renderer.canvasSize[0] / renderer.canvasSize[1];
|
||||
if (canvasAspectRatio !== this.aspectRatio) {
|
||||
this.aspectRatio = canvasAspectRatio;
|
||||
this.game.aspectRatioChanged(canvasAspectRatio);
|
||||
}
|
||||
|
||||
// Shake displaces only the rendered view centre and the zoom-punch shrinks
|
||||
// only the rendered view area — neither touches the followed position, so
|
||||
// impacts jolt the frame without nudging the camera off the player. Passing
|
||||
// the zoom as oversizeRatio scales the area about the (shaken) centre.
|
||||
const shakenCenter = vec2.fromValues(
|
||||
this.center[0] + ScreenShake.offsetX,
|
||||
this.center[1] + ScreenShake.offsetY,
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue