This commit is contained in:
Andras Schmelczer 2026-08-22 18:35:53 +01:00
commit 5017ff168a
53 changed files with 1004 additions and 2007 deletions

View file

@ -1,3 +0,0 @@
export type CommandExecutors = {
[type: string]: (command: any) => unknown;
};

View file

@ -1,6 +1,9 @@
import { CommandExecutors } from './command-executors';
import { Command } from './command';
export type CommandExecutors = {
[type: string]: (command: any) => unknown;
};
export abstract class CommandReceiver {
protected commandExecutors: CommandExecutors = {};

View file

@ -1,12 +1,10 @@
import { serializable } from '../../../serialization/serializable';
import { Command } from '../../command';
// Appended to every client -> server batch. Movement is only transmitted when it
// changes, so on its own the server's "newest input I have applied" marker
// freezes whenever a key is simply held down — which pins the client predictor's
// replay window and stops reconciliation from covering the un-acknowledged
// input. This carries the client's wall-clock for the batch, so the marker keeps
// advancing at the send rate whether or not the input changed.
// Appended to every client -> server batch. Movement is only sent on change,
// so without this the server's "newest input applied" marker freezes while a
// key is held, pinning the predictor's replay window. This carries the client's
// wall-clock so the marker advances at the send rate regardless of input change.
@serializable
export class ClientHeartbeatCommand extends Command {
public constructor(public readonly clientTimeMs: number = 0) {

View file

@ -1,12 +1,9 @@
import { serializable } from '../../../serialization/serializable';
import { Command } from '../../command';
// Sent client -> server when the player triggers a leap (Space / leap button).
// The launch direction is derived server-side from the character's surface
// normal and current movement input. clientTimeMs is the client's wall-clock at
// the press, echoed back via InputAcknowledgement so the predictor knows which
// leaps the server has already folded into the streamed launch momentum and
// must not replay again.
// clientTimeMs is echoed back via InputAcknowledgement so the predictor knows
// which leaps the server has already folded into the streamed launch momentum
// and must not replay again.
@serializable
export class LeapActionCommand extends Command {
public constructor(public readonly clientTimeMs: number = 0) {

View file

@ -4,11 +4,10 @@ import { Command } from '../../command';
@serializable
export class MoveActionCommand extends Command {
// clientTimeMs is the client's wall-clock (integer ms, survives the
// serializer's toFixed(3)) when the input was generated. The server echoes
// the latest one back via InputAcknowledgement so the client knows how much
// of its input timeline is already baked into a snapshot and can replay the
// rest for prediction. Defaults to 0 for inputs the server itself synthesises.
// clientTimeMs (integer ms, survives the serializer's toFixed(3)) is echoed
// back via InputAcknowledgement so the client knows how much of its input
// timeline is baked into a snapshot and can replay the rest. Defaults to 0
// for inputs the server itself synthesises.
public constructor(
public readonly direction: vec2,
public readonly clientTimeMs: number = 0,

View file

@ -7,11 +7,9 @@ export class PrimaryActionCommand extends Command {
public constructor(
public readonly position: vec2,
public readonly charge: number = 0,
// Client wall-clock at the moment the shot was released. Appended last so
// the existing wire order is unchanged. The server uses it (together with
// the measured RTT) to fast-forward the projectile by the time the command
// spent in flight, so the shooter does not have to lead by their own
// latency on top of the projectile's travel time.
// Client wall-clock at shot release. Appended last so the existing wire
// order is unchanged. The server fast-forwards the projectile by its
// in-flight time so the shooter need not lead by their own latency.
public readonly clientTimeMs: number = 0,
) {
super();

View file

@ -1,4 +1,4 @@
import { Id } from '../../communication/id';
import { Id } from '../../communication/communication';
import { serializable } from '../../serialization/serializable';
import { Command } from '../command';

View file

@ -1,9 +1,6 @@
import { serializable } from '../../serialization/serializable';
import { Command } from '../command';
// "The round is over" — a bare signal, like GameStartCommand. The winner is
// announced through ServerAnnouncement and the end-card length is the server's
// own restart timer, so neither has to travel here.
@serializable
export class GameEndCommand extends Command {
public toArray(): Array<any> {

View file

@ -1,35 +1,25 @@
import { vec2 } from 'gl-matrix';
import { Id } from '../../communication/id';
import { Id } from '../../communication/communication';
import { serializable } from '../../serialization/serializable';
import { Command } from '../command';
/**
* Everything the character's movement simulation carries from one tick to the
* next that is *not* in the pose.
* Movement state carried tick-to-tick that is NOT in the pose. Together with
* the pose (which travels as ordinary property updates) this is the complete
* state `stepCharacterMovement` needs, so the predictor resumes the server's
* simulation rather than approximating it.
*
* The pose (head/feet circles) travels as ordinary property updates because
* every viewer needs it; this travels to the owning client only. Together they
* are the complete state `stepCharacterMovement` needs, so the predictor can
* resume the server's simulation rather than approximate it.
*
* It all has to be here. `direction` in particular is not recoverable from the
* pose: the body is sprung *towards* the posture that direction implies and
* lags it while walking, so deriving the angle back out of a walking pose is
* off by tens of degrees — and every replay then spent its first ticks
* twisting the body back, by an amount that varied with the replay length.
* `direction` is not recoverable from the pose: the body is sprung *towards*
* the posture that direction implies and lags it while walking, so deriving the
* angle back out is off by tens of degrees.
*/
@serializable
export class CharacterMovementSnapshot {
public constructor(
public readonly direction: number,
// Persistent launch momentum (leap / slingshot / recoil), so the predictor
// reproduces a flight rather than only snapping to it.
public readonly bodyVelocity: vec2,
// Foot contact normals: the on-surface branch projects gravity onto these,
// so a body standing on a slope needs the real ones.
public readonly leftFootNormal: vec2,
public readonly rightFootNormal: vec2,
// The planet being stood on, or null while airborne.
public readonly groundPlanetId: Id,
public readonly secondsSinceOnSurface: number,
) {}
@ -46,28 +36,21 @@ export class CharacterMovementSnapshot {
}
}
// Sent server -> owning client only, alongside that client's own character
// snapshot. Carries the clientTimeMs of the most recent input the server had
// received when the snapshot was taken (so the client's predictor can reset to
// the snapshot and replay just the inputs the server hasn't seen yet) and the
// authoritative movement state the replay resumes from. See
// LocalCharacterPredictor.
// Sent server -> owning client only. Carries the clientTimeMs of the most
// recent input the server had received when the snapshot was taken (so the
// predictor replays only the inputs the server hasn't seen) and the movement
// state the replay resumes from. See LocalCharacterPredictor.
@serializable
export class InputAcknowledgement extends Command {
public constructor(
public readonly clientTimeMs: number,
public readonly movement: CharacterMovementSnapshot,
// clientTimeMs of the most recent leap the server has received: any leap at
// or before it is already reflected in movement.bodyVelocity, so the
// predictor must not replay it.
// or before it is already in movement.bodyVelocity and must not be replayed.
public readonly lastLeapClientTimeMs: number,
// How long before this snapshot the acknowledged input arrived. The client
// sends at a fixed cadence, so by the time a snapshot is taken the newest
// input it has is already 0..1 send interval old — and that age varies with
// where the client's sends happened to fall between two snapshots. Adding
// it back places the replay anchor at the snapshot instant itself instead
// of at whichever input happened to land last, which is what keeps the
// predicted pose free of the send-cadence beat. See LocalCharacterPredictor.
// Age of the acknowledged input at snapshot time. Adding it back places the
// replay anchor at the snapshot instant instead of whichever input landed
// last, keeping the predicted pose free of the send-cadence beat.
public readonly ackAgeMs: number = 0,
) {
super();

View file

@ -6,8 +6,7 @@ import { Command } from '../command';
export class PropertyUpdatesForObjects extends Command {
constructor(
public readonly updates: Array<PropertyUpdatesForObject>,
// Seconds on the server's monotonic clock at the moment this state was
// captured. Only differences between timestamps are meaningful.
// Only differences between timestamps are meaningful.
public readonly timestamp: number,
) {
super();

View file

@ -1,5 +1,5 @@
import { vec2 } from 'gl-matrix';
import { Id } from '../../communication/id';
import { Id } from '../../communication/communication';
import { CharacterTeam } from '../../objects/types/character-base';
import { serializable } from '../../serialization/serializable';
import { Command } from '../command';

View file

@ -0,0 +1,20 @@
export type Id = number | null;
let currentId = 0;
export const id = (): number => {
return currentId++;
};
export interface PlayerInformation {
name: string;
reconnectToken?: string;
}
export interface ServerInformation {
playerLimit: number;
playerCount: number;
serverName: string;
gameStatePercent: number;
}
export const serverInformationEndpoint = '/state';

View file

@ -1,7 +0,0 @@
export type Id = number | null;
let currentId = 0;
export const id = (): number => {
return currentId++;
};

View file

@ -1,6 +0,0 @@
export interface PlayerInformation {
name: string;
// Issued by the server on a previous join. Presented on reconnect to reclaim
// the team and score held during the grace window; absent on a first join.
reconnectToken?: string;
}

View file

@ -1,8 +0,0 @@
export interface ServerInformation {
playerLimit: number;
playerCount: number;
serverName: string;
gameStatePercent: number;
}
export const serverInformationEndpoint = '/state';

View file

@ -1,6 +1,5 @@
declare global {
// `T` must stay to merge with the global `Array<T>` (otherwise TS2428), but it
// is unused by the x/y aliases below.
// `T` must stay to merge with the global `Array<T>` (otherwise TS2428).
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface Array<T> {
x: number;

View file

@ -1,22 +1,17 @@
import { vec2 } from 'gl-matrix';
import { Rectangle, settings } from '../main';
import { settings } from '../main';
export const calculateViewArea = (
center: vec2,
aspectRatio: number,
oversizeRatio = 1,
): Rectangle => {
const viewArea = new Rectangle();
viewArea.size = vec2.fromValues(
) => {
const size = vec2.fromValues(
Math.sqrt(settings.inViewAreaSize * oversizeRatio * aspectRatio),
Math.sqrt((settings.inViewAreaSize * oversizeRatio) / aspectRatio),
);
viewArea.topLeft = vec2.fromValues(
center[0] - viewArea.size[0] / 2,
center[1] + viewArea.size[1] / 2,
);
const topLeft = vec2.fromValues(center[0] - size[0] / 2, center[1] + size[1] / 2);
return viewArea;
return { topLeft, size };
};

View file

@ -4,10 +4,8 @@ import { settings } from '../settings';
export const holdDurationToCharge = (heldSeconds: number): number =>
clamp01(heldSeconds / settings.chargeShotFullHoldSeconds);
// The charge a shot of this strength represents — the inverse of the mix() the
// server uses to size a projectile. The server needs it to scale radius, speed
// and recoil; the client needs the same number to pitch the sound and the
// muzzle flash, so it lives here rather than being derived twice.
// Inverse of the server's projectile mix(); both sides need this number, so it
// lives here rather than being derived twice.
export const strengthToCharge = (strength: number): number =>
clamp01(
(strength - settings.chargeShotStrengthMin) /

View file

@ -48,10 +48,8 @@ export const finiteVec2 = (value: unknown, maxMagnitude: number): vec2 | undefin
};
/**
* A display name that is safe to store and re-serialize. Anything that is not a
* string is coerced, because a non-string survives `.slice()` — an array name
* round-trips through the serializer and is revived as a class on every peer
* that receives it, throwing inside the deserializer's reviver.
* Coerces non-strings: a non-string survives `.slice()` and round-trips through
* the serializer revived as a class on every peer, throwing in the reviver.
*/
export const sanitizeName = (value: unknown, maxLength: number): string => {
const text = typeof value === 'string' ? value : String(value ?? '');

View file

@ -1,7 +1,5 @@
import { vec3 } from 'gl-matrix';
import { rgb } from './rgb';
// source: https://stackoverflow.com/questions/2353211/hsl-to-rgb-color-conversion
export const hsl = (hue: number, saturation: number, lightness: number): vec3 => {
hue /= 360;
saturation /= 100;
@ -31,5 +29,5 @@ export const hsl = (hue: number, saturation: number, lightness: number): vec3 =>
b = hue2rgb(p, q, hue - 1 / 3);
}
return rgb(r, g, b);
return vec3.fromValues(r, g, b);
};

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