This commit is contained in:
Andras Schmelczer 2026-08-23 10:27:53 +01:00
commit 82e092ba55
14 changed files with 373 additions and 539 deletions

View file

@ -1,18 +0,0 @@
import { CommandReceiver } from './command-receiver';
import { Command } from './command';
export abstract class CommandGenerator {
private subscribers: Array<CommandReceiver> = [];
public subscribe(subscriber: CommandReceiver): void {
this.subscribers.push(subscriber);
}
public clearSubscribers(): void {
this.subscribers = [];
}
protected sendCommandToSubscribers(command: Command): void {
this.subscribers.forEach((s) => s.handleCommand(command));
}
}

View file

@ -7,9 +7,9 @@ export class PrimaryActionCommand extends Command {
public constructor(
public readonly position: vec2,
public readonly charge: number = 0,
// 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.
// Client wall-clock at shot release, echoed back via InputAcknowledgement
// like the other actions' — it advances the predictor's replay anchor past
// this shot. Appended last so the existing wire order is unchanged.
public readonly clientTimeMs: number = 0,
) {
super();

View file

@ -1,33 +0,0 @@
declare global {
// `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;
y: number;
}
interface Float32Array {
x: number;
y: number;
}
}
const setIndexAlias = (name: string, index: number, type: any) => {
if (!Object.prototype.hasOwnProperty.call(type.prototype, name)) {
Object.defineProperty(type.prototype, name, {
get() {
return this[index];
},
set(value) {
this[index] = value;
},
});
}
};
export const applyArrayPlugins = () => {
setIndexAlias('x', 0, Array);
setIndexAlias('y', 1, Array);
setIndexAlias('x', 0, Float32Array);
setIndexAlias('y', 1, Float32Array);
};

View file

@ -0,0 +1,14 @@
import { vec3 } from 'gl-matrix';
import { clamp01 } from './clamp';
import { mix } from './mix';
// Component-wise lerp of two colours. Kept as a helper because the alternative
// is the same clamp-plus-three-mixes expansion repeated at every call site.
export const mixRgb = (a: vec3, b: vec3, q: number): vec3 => {
const clampedQ = clamp01(q);
return vec3.fromValues(
mix(a[0], b[0], clampedQ),
mix(a[1], b[1], clampedQ),
mix(a[2], b[2], clampedQ),
);
};

View file

@ -25,6 +25,7 @@ export * from './helper/charge';
export * from './helper/calculate-view-area';
export * from './helper/circle';
export * from './helper/mix';
export * from './helper/mix-rgb';
export * from './helper/random';
export * from './communication/communication';
export * from './objects/game-object';

View file

@ -341,6 +341,10 @@ export const stepCharacterMovement = (
applyBodyMomentum(state);
// Only the feet latch. The grounded branch above projects gravity onto the
// foot contact normals and leap() launches off them, so latching on a head
// contact would ground the body against normals no part of it is touching —
// a head-first tumble would read as standing.
latchGround(state, world.stepBody(state.leftFoot, deltaTimeInSeconds));
latchGround(state, world.stepBody(state.rightFoot, deltaTimeInSeconds));
world.stepBody(state.head, deltaTimeInSeconds);

View file

@ -49,9 +49,16 @@ export const marchCircle = (
)!;
if (ignoreCollision) {
// Pass through, but still report the contact so repeated marches keep progressing.
// Pass through, but still report the contact so repeated marches keep
// progressing. The normal comes along: `hitSurface: true` is the
// contract callers read it under, and one without it would be a
// TypeError in whichever caller first enables pass-through.
vec2.scaleAndAdd(body.center, body.center, direction, deltaLength);
return { hitSurface: true, hitObject: intersecting };
return {
hitSurface: true,
normal: sdfNormal(rayEnd, [intersecting]),
hitObject: intersecting,
};
}
onHit?.(intersecting);

View file

@ -18,13 +18,17 @@ export const toPlanetLocalFrame = (
// Innermost leaf — module-level scratch to avoid per-call allocation. Nothing
// re-enters planetDistance and none of these outlive the call.
const localPoint = vec2.create();
const targetFromDelta = vec2.create();
const toFromDelta = vec2.create();
const closestOnEdge = vec2.create();
// Stays a vec2 (Float32Array) because rounding to f32 is part of the outline
// the server and the client both have to agree on.
const edgeSample = vec2.create();
//
// Plain array literals, NOT vec2.create(): imports are evaluated before either
// entry point reaches its glMatrix.setMatrixArrayType(Array) call, so
// vec2.create() here would hand back Float32Array and quietly run the one
// outline the server and the client must agree on at f32 while every other
// vector in the simulation is f64.
const localPoint: vec2 = [0, 0];
const targetFromDelta: vec2 = [0, 0];
const toFromDelta: vec2 = [0, 0];
const closestOnEdge: vec2 = [0, 0];
const edgeSample: vec2 = [0, 0];
// The one and only planet outline: the authoritative server and the client
// predictor both collide against it, which is what lets prediction reconcile.

View file

@ -3,8 +3,10 @@ import { Sdf } from './sdf';
import { evaluateSdf } from './evaluate-sdf';
// Reused probe point — sdfNormal is called on every depenetration pass and
// raymarch hit.
const probe = vec2.create();
// raymarch hit. A plain array literal for the same reason as planet-sdf's
// scratch: it is allocated before setMatrixArrayType(Array) runs, and the
// gradient must be computed at f64 like the rest of the simulation.
const probe: vec2 = [0, 0];
const sampleAt = (x: number, y: number, objects: Array<Sdf>): number => {
vec2.set(probe, x, y);

View file

@ -1,3 +1,9 @@
export type SerializableClass = { new (...args: Array<any>): unknown; name: string };
// Wire format: the property the decorators stamp on and serialize() looks for
// to recognise a decorated class. Defined once on purpose — per-file copies
// would have to agree byte-for-byte, and a typo in one of them compiles and
// type-checks cleanly while every networked object degrades to a raw array.
export const MANGLE = '__serializable_type';
export const serializableMapping = new Map<string, SerializableClass>();

View file

@ -1,6 +1,4 @@
import { SerializableClass, serializableMapping } from './serializable-mapping';
const MANGLE = '__serializable_type';
import { MANGLE, SerializableClass, serializableMapping } from './serializable-mapping';
export const serializable = (type: SerializableClass): any => {
if (!serializableMapping.has(type.name)) {

View file

@ -1,4 +1,4 @@
const MANGLE = '__serializable_type';
import { MANGLE } from './serializable-mapping';
export const serialize = (object: any): string => {
return JSON.stringify(object, (_, value) => {

View file

@ -1,7 +0,0 @@
// deserialize reconstructs via `new Ctor(...array.slice(1))`, so the field list
// MUST name the constructor's parameters in order. Derived (non-serialized)
// fields are deliberately absent.
export const toArrayFromFields = (
object: any,
fields: ReadonlyArray<string>,
): Array<any> => fields.map((field) => object[field]);

View file

@ -1,16 +0,0 @@
import { SerializableClass, serializableMapping } from './serializable-mapping';
const MANGLE = '__serializable_type';
export const serializesTo = (target: SerializableClass) => {
return (actual: SerializableClass): any => {
if (!serializableMapping.has(target.name)) {
serializableMapping.set(target.name, target);
}
Object.defineProperty(actual, MANGLE, { value: target.name });
Object.defineProperty(actual.prototype, MANGLE, { value: target.name });
return actual;
};
};