This commit is contained in:
Andras Schmelczer 2026-08-22 15:29:33 +01:00
commit b9274a42c7
12 changed files with 480 additions and 457 deletions

View file

@ -1,18 +1,12 @@
import { CharacterTeam } from '../../objects/types/character-base';
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 {
constructor(
public readonly winningTeam: CharacterTeam,
public readonly endCardLengthInSeconds: number,
public readonly shouldReconnect: boolean,
) {
super();
}
public toArray(): Array<any> {
return [this.winningTeam, this.endCardLengthInSeconds, this.shouldReconnect];
return [];
}
}

View file

@ -3,3 +3,13 @@ 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.
export const strengthToCharge = (strength: number): number =>
clamp01(
(strength - settings.chargeShotStrengthMin) /
(settings.chargeShotStrengthMax - settings.chargeShotStrengthMin),
);

View file

@ -1,6 +1,5 @@
import { vec2 } from 'gl-matrix';
export const isFiniteNumber = (value: unknown): value is number =>
typeof value === 'number' && Number.isFinite(value);

View file

@ -35,7 +35,6 @@ export * from './communication/id';
export * from './communication/server-information';
export * from './communication/player-information';
export * from './helper/rotate-90-deg';
export * from './helper/rotate-minus-90-deg';
export * from './objects/game-object';
export * from './serialization/deserialize';
export * from './serialization/serialize';

View file

@ -8,7 +8,7 @@ export class RemoteCall {
constructor(
public readonly functionName: string,
public readonly args: Array<any>,
) { }
) {}
public toArray(): Array<any> {
return [this.functionName, this.args];
@ -49,7 +49,7 @@ export class PropertyUpdatesForObject {
constructor(
public readonly id: Id,
public readonly updates: Array<UpdatePropertyCommand>,
) { }
) {}
public toArray(): Array<any> {
return [this.id, this.updates];
@ -102,7 +102,7 @@ export abstract class GameObject extends CommandReceiver {
});
}
public getPropertyUpdates(): PropertyUpdatesForObject | void { }
public getPropertyUpdates(): PropertyUpdatesForObject | void {}
public getPropertyUpdatesForFrame(): PropertyUpdatesForObject | undefined {
if (this.updateGeneration !== currentUpdateGeneration) {

View file

@ -35,6 +35,17 @@ export class PlanetBase extends GameObject {
this.vertices.length;
}
// Which team controls this planet, with the neutral dead-band around 50%
// applied. Lives here so scoring (server) and every tint the player sees
// (client) read one rule rather than three copies of the same comparison.
public get team(): CharacterTeam {
return Math.abs(this.ownership - 0.5) < settings.planetControlThreshold
? CharacterTeam.neutral
: this.ownership < 0.5
? CharacterTeam.blue
: CharacterTeam.red;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public generatedPoints(value: number) {}
// eslint-disable-next-line @typescript-eslint/no-unused-vars

View file

@ -64,7 +64,14 @@ export interface CharacterWorld {
stepBody(body: PhysicsBody, deltaTimeInSeconds: number): GroundSurface | undefined;
}
const applyForce = (body: PhysicsBody, force: vec2, deltaTimeInSeconds: number) => {
// Integrate a force into a body's velocity for one tick. Exported so the
// backend's CirclePhysical accumulates forces through the exact same
// expression, f32 intermediate included.
export const applyForce = (
body: PhysicsBody,
force: vec2,
deltaTimeInSeconds: number,
) => {
vec2.add(
body.velocity,
body.velocity,
@ -72,11 +79,16 @@ const applyForce = (body: PhysicsBody, force: vec2, deltaTimeInSeconds: number)
);
};
// ((head + leftFoot) + rightFoot) / 3, in the exact association the server uses
// everywhere it reads the character centre — do not reassociate.
export const characterCenter = (state: CharacterMovementState): vec2 => {
const center = vec2.add(vec2.create(), state.head.center, state.leftFoot.center);
vec2.add(center, center, state.rightFoot.center);
// ((head + leftFoot) + rightFoot) / 3, in the exact association every reader of
// the character centre uses — do not reassociate. Takes the three circles
// rather than a whole movement state so the client's view can call it too.
export const characterCenter = (
head: { center: vec2 },
leftFoot: { center: vec2 },
rightFoot: { center: vec2 },
): vec2 => {
const center = vec2.add(vec2.create(), head.center, leftFoot.center);
vec2.add(center, center, rightFoot.center);
return vec2.scale(center, center, 1 / 3);
};
@ -106,7 +118,7 @@ const springMove = (
};
const keepPosture = (state: CharacterMovementState) => {
const center = characterCenter(state);
const center = characterCenter(state.head, state.leftFoot, state.rightFoot);
springMove(
state,
state.leftFoot,
@ -190,7 +202,7 @@ export const applyLeapImpulse = (state: CharacterMovementState, moveDirection: v
// Slingshot: add the tangential velocity of the spinning surface under the
// body (the same motion carryWithRotatingPlanet imparts).
const center = characterCenter(state);
const center = characterCenter(state.head, state.leftFoot, state.rightFoot);
const omega = planet.angularVelocity;
const surfaceVelocity = vec2.fromValues(
omega * (center[1] - planet.center[1]),
@ -255,7 +267,7 @@ export const decayMomentum = (
: settings.airMomentumFriction;
const target = Math.min(
speed * Math.exp(-friction * deltaTimeInSeconds) -
settings.momentumStopDeceleration * deltaTimeInSeconds,
settings.momentumStopDeceleration * deltaTimeInSeconds,
settings.maxBodyMomentum,
);
if (target <= 1) {
@ -297,9 +309,6 @@ export const stepCharacterMovement = (
inputDirection: vec2,
deltaTimeInSeconds: number,
) => {
const center = characterCenter(state);
const grounds = world.groundsNear(center, boundRadius + settings.maxGravityDistance);
const movementForce = vec2.scale(
inputDirection,
inputDirection,
@ -309,6 +318,12 @@ export const stepCharacterMovement = (
applyForce(state.rightFoot, movementForce, deltaTimeInSeconds);
if (!state.currentPlanet) {
// Only the airborne branch needs the surrounding planets, and this is the
// widest query the character makes — running it while grounded (the common
// case) walked the server's spatial tree once per character per tick for a
// result nothing read.
const center = characterCenter(state.head, state.leftFoot, state.rightFoot);
const grounds = world.groundsNear(center, boundRadius + settings.maxGravityDistance);
const leftFootGravity = sumGravity(grounds, state.leftFoot.center);
const rightFootGravity = sumGravity(grounds, state.rightFoot.center);
@ -332,7 +347,7 @@ export const stepCharacterMovement = (
movementLength > 0 &&
gravityLength > 0 &&
vec2.dot(movementForce, gravity) <
-movementLength * gravityLength * settings.climbDotThreshold
-movementLength * gravityLength * settings.climbDotThreshold
) {
vec2.scale(gravity, gravity, settings.climbGravityScale);
}

View file

@ -1,7 +1,17 @@
import { vec2 } from 'gl-matrix';
import { Sdf } from './sdf';
export const evaluateSdf = (target: vec2, objects: Array<Sdf>) =>
objects
.filter((i) => i.canCollide)
.reduce((min, i) => (min = Math.min(min, i.distance(target))), 1000000);
// Distance to the nearest collidable surface. This is the innermost leaf of the
// whole physics stack — every raymarch step, every depenetration pass and four
// times per surface normal — so it is a plain indexed loop rather than
// filter().reduce(), which allocated an intermediate array per call.
export const evaluateSdf = (target: vec2, objects: Array<Sdf>): number => {
let minimum = 1000000;
for (let i = 0; i < objects.length; i++) {
const object = objects[i];
if (object.canCollide) {
minimum = Math.min(minimum, object.distance(target));
}
}
return minimum;
};

View file

@ -20,7 +20,7 @@ const maximumSteps = 256;
// client passes none.
//
// The march advances by the *free gap* (distance minus the circle's radius),
// which is what sphere-tracing a circle of radius r requires.
// which is what sphere-tracing a circle of radius r requires.
export const marchCircle = (
body: PhysicsBody,
delta: vec2,

View file

@ -4,8 +4,10 @@ import { settings } from '../settings';
// Rotate a world point into the planet's own frame: R(-rotation) about the
// centre, matching the shader's localTarget = center + R(rotation)*(target-center).
// cos/sin are passed in so the server can keep memoising them per angle.
// cos/sin are passed in so callers can memoise them per angle. `out` is written
// in place, so the hot path can hand in a scratch vector instead of allocating.
export const toPlanetLocalFrame = (
out: vec2,
target: vec2,
center: vec2,
cos: number,
@ -13,16 +15,27 @@ export const toPlanetLocalFrame = (
): vec2 => {
const dx = target[0] - center[0];
const dy = target[1] - center[1];
return vec2.fromValues(
center[0] + cos * dx - sin * dy,
center[1] + sin * dx + cos * dy,
);
return vec2.set(out, center[0] + cos * dx - sin * dy, center[1] + sin * dx + cos * dy);
};
// Scratch vectors for planetDistance. It is the innermost leaf of the physics
// stack — called for every planet on every SDF sample — and allocating four
// vectors per polygon edge dominated its cost. Nothing re-enters it (callers
// evaluate planets one after another), and none of these outlive the call.
const localPoint = vec2.create();
const targetFromDelta = vec2.create();
const toFromDelta = vec2.create();
const closestOnEdge = vec2.create();
// [distance to the edge, which side of it]. These two stay in a vec2 rather
// than in plain numbers because a vec2 is a Float32Array: rounding them to f32
// is part of the outline the server and the client both have to agree on.
const edgeSample = vec2.create();
// Signed distance to the (smooth, noise-free) planet polygon, evaluated in the
// planet's rotating frame. Extracted verbatim from PlanetPhysical.distance so
// the client collides against the exact same outline the server does. Indexed
// vector access keeps it independent of the .x/.y prototype plugin.
// planet's rotating frame. This is the one and only planet outline: the
// authoritative server and the client predictor both collide against it, which
// is what lets prediction reconcile at all. Indexed vector access keeps it
// independent of the .x/.y prototype plugin.
export const planetDistance = (
target: vec2,
vertices: Array<vec2>,
@ -30,7 +43,7 @@ export const planetDistance = (
cos: number,
sin: number,
): number => {
const local = toPlanetLocalFrame(target, center, cos, sin);
const local = toPlanetLocalFrame(localPoint, target, center, cos, sin);
const startEnd = vertices[0];
let vb = startEnd;
@ -41,32 +54,32 @@ export const planetDistance = (
for (let i = 1; i <= vertices.length; i++) {
const va = vb;
vb = i === vertices.length ? startEnd : vertices[i];
const targetFromDelta = vec2.subtract(vec2.create(), local, va);
const toFromDelta = vec2.subtract(vec2.create(), vb, va);
vec2.subtract(targetFromDelta, local, va);
vec2.subtract(toFromDelta, vb, va);
const h = clamp01(
vec2.dot(targetFromDelta, toFromDelta) / vec2.squaredLength(toFromDelta),
);
const ds = vec2.fromValues(
vec2.dist(targetFromDelta, vec2.scale(vec2.create(), toFromDelta, h)),
vec2.set(
edgeSample,
vec2.dist(targetFromDelta, vec2.scale(closestOnEdge, toFromDelta, h)),
toFromDelta[0] * targetFromDelta[1] - toFromDelta[1] * targetFromDelta[0],
);
if (
(local[1] >= va[1] && local[1] < vb[1] && ds[1] > 0) ||
(local[1] < va[1] && local[1] >= vb[1] && ds[1] <= 0)
(local[1] >= va[1] && local[1] < vb[1] && edgeSample[1] > 0) ||
(local[1] < va[1] && local[1] >= vb[1] && edgeSample[1] <= 0)
) {
sign *= -1;
}
d = Math.min(d, ds[0]);
d = Math.min(d, edgeSample[0]);
}
return sign * d;
};
// Gravity a planet exerts at a world position. Verbatim from
// PlanetPhysical.getForce.
// Gravity a planet exerts at a world position.
export const planetGravity = (center: vec2, radius: number, position: vec2): vec2 => {
const diff = vec2.subtract(vec2.create(), center, position);
const dist = Math.max(settings.minGravityDistance, vec2.length(diff) - radius);

View file

@ -2,17 +2,28 @@ import { vec2 } from 'gl-matrix';
import { Sdf } from './sdf';
import { evaluateSdf } from './evaluate-sdf';
// Reused probe point. sdfNormal is called on every depenetration pass and every
// raymarch hit, so allocating four sample vectors per call was a meaningful
// share of the physics step's garbage. evaluateSdf reads the probe and returns
// before the next vec2.set, so one is enough.
const probe = vec2.create();
const sampleAt = (x: number, y: number, objects: Array<Sdf>): number => {
vec2.set(probe, x, y);
return evaluateSdf(probe, objects);
};
// Central-difference gradient of the combined SDF. Can be zero where the
// samples cancel out (e.g. on the medial axis of a shape) — callers must
// handle that case. Uses indexed access so it never depends on the vec2 .x/.y
// prototype plugin (identical numerically: .x === [0]).
export const sdfNormal = (target: vec2, objects: Array<Sdf>): vec2 => {
const dx =
evaluateSdf(vec2.fromValues(target[0] + 0.01, target[1]), objects) -
evaluateSdf(vec2.fromValues(target[0] - 0.01, target[1]), objects);
sampleAt(target[0] + 0.01, target[1], objects) -
sampleAt(target[0] - 0.01, target[1], objects);
const dy =
evaluateSdf(vec2.fromValues(target[0], target[1] + 0.01), objects) -
evaluateSdf(vec2.fromValues(target[0], target[1] - 0.01), objects);
sampleAt(target[0], target[1] + 0.01, objects) -
sampleAt(target[0], target[1] - 0.01, objects);
const normal = vec2.fromValues(dx, dy);
return vec2.squaredLength(normal) > 0 ? vec2.normalize(normal, normal) : normal;

View file

@ -157,7 +157,6 @@ export const settings = {
// so slower (charged) shots arc. Scale kept tiny: near a surface gravity is
// maxGravityStrength=50000, which at full strength would corkscrew a shot into
// the planet — 0.04 gives a readable bend instead.
projectileGravityEnabled: true,
projectileGravityScale: 0.04,
// Speed the corpse is flung at along the killing shot's direction, lerped by