This commit is contained in:
Andras Schmelczer 2026-06-14 20:58:42 +01:00
parent a1fb6755c7
commit fc9df09ee1
36 changed files with 2011 additions and 251 deletions

View file

@ -0,0 +1,19 @@
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.
@serializable
export class LeapActionCommand extends Command {
public constructor(public readonly clientTimeMs: number = 0) {
super();
}
public toArray(): Array<any> {
return [this.clientTimeMs];
}
}

View file

@ -4,11 +4,19 @@ import { Command } from '../../command';
@serializable
export class MoveActionCommand extends Command {
public constructor(public readonly direction: vec2) {
// 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.
public constructor(
public readonly direction: vec2,
public readonly clientTimeMs: number = 0,
) {
super();
}
public toArray(): Array<any> {
return [this.direction];
return [this.direction, this.clientTimeMs];
}
}

View file

@ -0,0 +1,28 @@
import { vec2 } from 'gl-matrix';
import { serializable } from '../../serialization/serializable';
import { Command } from '../command';
// Sent server -> owning client only, alongside that client's own character
// snapshot. Carries the clientTimeMs of the most recent movement 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 launch momentum (so the predictor reproduces a
// leap/slingshot/recoil flight rather than only snapping to it). See
// LocalCharacterPredictor.
@serializable
export class InputAcknowledgement extends Command {
public constructor(
public readonly clientTimeMs: number,
public readonly bodyVelocity: vec2,
// clientTimeMs of the most recent leap the server has received: any leap at
// or before it is already reflected in bodyVelocity, so the predictor must
// not replay it.
public readonly lastLeapClientTimeMs: number,
) {
super();
}
public toArray(): Array<any> {
return [this.clientTimeMs, this.bodyVelocity, this.lastLeapClientTimeMs];
}
}

View file

@ -15,6 +15,7 @@ export * from './commands/command-executors';
export * from './commands/command-generator';
export * from './commands/types/actions/move-action';
export * from './commands/types/actions/primary-action';
export * from './commands/types/actions/leap-action';
export * from './commands/types/actions/set-aspect-ratio-action';
export * from './helper/array';
export * from './helper/last';
@ -46,3 +47,13 @@ export * from './objects/types/planet-base';
export * from './objects/types/projectile-base';
export * from './settings';
export * from './communication/transport-events';
export * from './commands/types/input-acknowledgement';
export * from './physics/sdf';
export * from './physics/evaluate-sdf';
export * from './physics/sdf-normal';
export * from './physics/march-circle';
export * from './physics/depenetrate-circle';
export * from './physics/resolve-circle-movement';
export * from './physics/planet-sdf';
export * from './physics/interpolate-angles';
export * from './physics/character-movement';

View file

@ -28,9 +28,13 @@ export class CharacterBase extends GameObject {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public onShoot(strength: number) { }
public onHitConfirmed() { }
public onLeap() { }
public onKillConfirmed(victimName?: string, streak?: number) { }
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public onHitConfirmed(charge?: number) { }
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public onKillConfirmed(victimName?: string, streak?: number, charge?: number) { }
public setHealth(health: number) {
this.health = health;

View file

@ -15,6 +15,7 @@ export class PlanetBase extends GameObject {
id: Id,
public readonly vertices: Array<vec2>,
public ownership: number = 0.5,
public readonly isKeystone: boolean = false,
) {
super(id);
this.center = vertices.reduce((sum, v) => vec2.add(sum, sum, v), vec2.create());
@ -28,6 +29,8 @@ export class PlanetBase extends GameObject {
public generatedPoints(value: number) {}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public onFlipped(team: CharacterTeam) {}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public setContested(contested: boolean) {}
public static createPlanetVertices(
center: vec2,
@ -54,6 +57,6 @@ export class PlanetBase extends GameObject {
}
public toArray(): Array<any> {
return [this.id, this.vertices];
return [this.id, this.vertices, this.ownership, this.isKeystone];
}
}

View file

@ -0,0 +1,354 @@
import { vec2 } from 'gl-matrix';
import { settings } from '../settings';
import { GroundSurface, PhysicsBody } from './sdf';
import { interpolateAngles } from './interpolate-angles';
// Body layout, copied verbatim from CharacterPhysical so the predicted body
// matches the authoritative one to the bit. The head sits this far above the
// feet; offsets are measured from the centre of mass.
export const headRadius = 50;
export const feetRadius = 20;
const desiredHeadOffset = vec2.fromValues(0, 55);
const desiredLeftFootOffset = vec2.fromValues(-20, 0);
const desiredRightFootOffset = vec2.fromValues(20, 0);
const centerOfMass = vec2.scale(
vec2.create(),
vec2.add(
vec2.create(),
vec2.add(vec2.create(), desiredHeadOffset, desiredLeftFootOffset),
desiredRightFootOffset,
),
1 / 3,
);
export const headOffset = vec2.subtract(vec2.create(), desiredHeadOffset, centerOfMass);
export const leftFootOffset = vec2.subtract(
vec2.create(),
desiredLeftFootOffset,
centerOfMass,
);
export const rightFootOffset = vec2.subtract(
vec2.create(),
desiredRightFootOffset,
centerOfMass,
);
export const boundRadius = (headRadius + feetRadius * 2) * 2;
// The character's movement state: the three body parts plus the small amount of
// carried state the step reads and writes. Backend CharacterPhysical and the
// client predictor both expose this shape.
export interface CharacterMovementState {
readonly head: PhysicsBody;
readonly leftFoot: PhysicsBody;
readonly rightFoot: PhysicsBody;
direction: number;
currentPlanet: GroundSurface | undefined;
secondsSinceOnSurface: number;
// Persistent launch momentum (leap / slingshot / recoil / death throw).
// Walking rebuilds and zeroes each body part's velocity every tick,
// so anything that should carry accumulates here, is injected into the parts
// before they step, and decays by friction. Stays zero for ordinary walking.
// The client predictor leaves this zero and relies on the reconciliation snap
// to follow server-side impulses, but the field is here so the server can
// delegate its full movement to stepCharacterMovement unchanged.
bodyVelocity: vec2;
}
// The collision/gravity world the movement queries. The server backs this with
// its spatial container (planets + dynamics, dispatching collision reactions);
// the client backs it with the planets it knows about, dispatching nothing.
export interface CharacterWorld {
// Planets within `radius` of `center` that exert gravity / can be stood on.
groundsNear(center: vec2, radius: number): Array<GroundSurface>;
// Resolve one body part's motion this tick; returns the ground it landed on.
stepBody(body: PhysicsBody, deltaTimeInSeconds: number): GroundSurface | undefined;
}
const applyForce = (body: PhysicsBody, force: vec2, deltaTimeInSeconds: number) => {
vec2.add(
body.velocity,
body.velocity,
vec2.scale(vec2.create(), force, deltaTimeInSeconds),
);
};
// ((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);
return vec2.scale(center, center, 1 / 3);
};
const setDirection = (state: CharacterMovementState, direction: vec2) => {
state.direction = interpolateAngles(
state.direction,
Math.atan2(direction[1], direction[0]) + Math.PI / 2,
0.2,
);
};
const springMove = (
state: CharacterMovementState,
body: PhysicsBody,
center: vec2,
offset: vec2,
stiffness: number,
) => {
const desiredPosition = vec2.add(vec2.create(), center, offset);
vec2.rotate(desiredPosition, desiredPosition, center, state.direction);
const positionDelta = vec2.subtract(vec2.create(), desiredPosition, body.center);
// First-order velocity relaxation toward the desired posture position, added
// to the gravity/movement velocity already accumulated this tick. The dt
// arrives later when the body integrates velocity, so the per-tick
// displacement is positionDelta * stiffness * dt.
vec2.scaleAndAdd(body.velocity, body.velocity, positionDelta, stiffness);
};
const keepPosture = (state: CharacterMovementState) => {
const center = characterCenter(state);
springMove(state, state.leftFoot, center, leftFootOffset, settings.postureFeetStiffness);
springMove(
state,
state.rightFoot,
center,
rightFootOffset,
settings.postureFeetStiffness,
);
springMove(state, state.head, center, headOffset, settings.postureHeadStiffness);
};
// While standing on a planet, ride its spin: rigidly rotate the whole body
// about the planet centre by the same per-tick angle the collision SDF turns
// by (negative, matching R(-rotation)), so the player is carried with the
// surface instead of sliding across it.
const carryWithRotatingPlanet = (
state: CharacterMovementState,
deltaTimeInSeconds: number,
) => {
const planet = state.currentPlanet;
if (!planet) {
return;
}
const angle = -planet.angularVelocity * deltaTimeInSeconds;
const center = planet.center;
state.head.center = vec2.rotate(vec2.create(), state.head.center, center, angle);
state.leftFoot.center = vec2.rotate(vec2.create(), state.leftFoot.center, center, angle);
state.rightFoot.center = vec2.rotate(
vec2.create(),
state.rightFoot.center,
center,
angle,
);
};
// Launch off the current surface: directed by the foot contact normals plus
// the movement input, slingshotted by the planet's spin. Mutates bodyVelocity
// (which the next tick injects into the body) and detaches. Shared so the
// server's leap() and the client's prediction apply the exact same impulse.
// The caller does the gating (strength, cooldown, alive); this is a no-op when
// not on a surface.
export const applyLeapImpulse = (
state: CharacterMovementState,
moveDirection: vec2,
) => {
const planet = state.currentPlanet;
if (!planet) {
return;
}
const up = vec2.add(
vec2.create(),
state.leftFoot.lastNormal,
state.rightFoot.lastNormal,
);
if (vec2.length(up) === 0) {
vec2.set(up, 0, 1);
} else {
vec2.normalize(up, up);
}
const launch = vec2.scale(vec2.create(), up, settings.leapUpBias);
if (vec2.length(moveDirection) > 0) {
vec2.scaleAndAdd(
launch,
launch,
vec2.normalize(vec2.create(), moveDirection),
settings.leapMoveBias,
);
}
vec2.normalize(launch, launch);
vec2.scaleAndAdd(state.bodyVelocity, state.bodyVelocity, launch, settings.leapSpeed);
// Slingshot: add the tangential velocity of the spinning surface under the
// body (the same motion carryWithRotatingPlanet imparts).
const center = characterCenter(state);
const omega = planet.angularVelocity;
const surfaceVelocity = vec2.fromValues(
omega * (center[1] - planet.center[1]),
-omega * (center[0] - planet.center[0]),
);
vec2.scaleAndAdd(
state.bodyVelocity,
state.bodyVelocity,
surfaceVelocity,
settings.slingshotScale,
);
state.currentPlanet = undefined;
state.secondsSinceOnSurface = settings.planetDetachmentSeconds;
};
// Time-based detachment: a grounded body that hasn't touched a surface for a
// while floats free. Kept as its own step because on the server it runs before
// the ownership/scoring blocks; the client calls it at the head of each tick.
export const tickPlanetDetachment = (
state: CharacterMovementState,
deltaTimeInSeconds: number,
) => {
if ((state.secondsSinceOnSurface += deltaTimeInSeconds) > settings.planetDetachmentSeconds) {
state.currentPlanet = undefined;
}
};
// Inject the persistent launch momentum onto every body part right before they
// step, so the whole body translates rigidly without disturbing the posture
// springs (which only set up relative offsets). No-op while walking.
const applyBodyMomentum = (state: CharacterMovementState) => {
if (vec2.squaredLength(state.bodyVelocity) === 0) {
return;
}
vec2.add(state.leftFoot.velocity, state.leftFoot.velocity, state.bodyVelocity);
vec2.add(state.rightFoot.velocity, state.rightFoot.velocity, state.bodyVelocity);
vec2.add(state.head.velocity, state.head.velocity, state.bodyVelocity);
};
// Decay one launch-momentum vector in place by one tick. Stiff on the ground
// (skid to a stop), gentle in the air so a leap or slingshot still carries
// across the gaps. The gentle exponential only asymptotes, though, so on top of
// it a constant deceleration brakes the momentum to a definite stop in a couple
// of seconds instead of leaving a 15+ second drift, and a hard cap stops stacked
// impulses (rapid recoil, a leap into a slingshot) from building without bound.
// Shared so the living body, the client predictor, and the ragdoll corpse all
// brake identically.
export const decayMomentum = (
bodyVelocity: vec2,
onGround: boolean,
deltaTimeInSeconds: number,
) => {
const speed = vec2.length(bodyVelocity);
if (speed === 0) {
return;
}
const friction = onGround
? settings.groundMomentumFriction
: settings.airMomentumFriction;
const target = Math.min(
speed * Math.exp(-friction * deltaTimeInSeconds) -
settings.momentumStopDeceleration * deltaTimeInSeconds,
settings.maxBodyMomentum,
);
if (target <= 1) {
vec2.zero(bodyVelocity);
} else {
vec2.scale(bodyVelocity, bodyVelocity, target / speed);
}
};
const decayBodyMomentum = (
state: CharacterMovementState,
deltaTimeInSeconds: number,
) => {
decayMomentum(state.bodyVelocity, !!state.currentPlanet, deltaTimeInSeconds);
};
const sumGravity = (grounds: Array<GroundSurface>, position: vec2): vec2 =>
grounds.reduce(
(sum, ground) => vec2.add(sum, sum, ground.gravityAt(position)),
vec2.create(),
);
const latchGround = (
state: CharacterMovementState,
ground: GroundSurface | undefined,
) => {
if (ground) {
state.secondsSinceOnSurface = 0;
state.currentPlanet = ground;
}
};
// One tick of character movement. This is the exact movement block of the
// authoritative CharacterPhysical.step (gravity gather → movement force →
// on/off-planet branch → posture → step the three body parts), with all
// server-only concerns (scoring, health, shooting, spawn/death animation,
// ownership) left to the caller. `inputDirection` is the already-averaged,
// already-normalized movement direction for this tick and is consumed in place.
export const stepCharacterMovement = (
state: CharacterMovementState,
world: CharacterWorld,
inputDirection: vec2,
deltaTimeInSeconds: number,
) => {
const center = characterCenter(state);
const grounds = world.groundsNear(center, boundRadius + settings.maxGravityDistance);
const movementForce = vec2.scale(inputDirection, inputDirection, settings.maxAcceleration);
applyForce(state.leftFoot, movementForce, deltaTimeInSeconds);
applyForce(state.rightFoot, movementForce, deltaTimeInSeconds);
if (!state.currentPlanet) {
const leftFootGravity = sumGravity(grounds, state.leftFoot.center);
const rightFootGravity = sumGravity(grounds, state.rightFoot.center);
applyForce(state.leftFoot, leftFootGravity, deltaTimeInSeconds);
applyForce(state.rightFoot, rightFootGravity, deltaTimeInSeconds);
const sumForce = vec2.subtract(vec2.create(), leftFootGravity, movementForce);
setDirection(state, vec2.length(sumForce) === 0 ? vec2.fromValues(0, -1) : sumForce);
} else {
carryWithRotatingPlanet(state, deltaTimeInSeconds);
const leftFootGravity = state.currentPlanet.gravityAt(state.leftFoot.center);
const rightFootGravity = state.currentPlanet.gravityAt(state.rightFoot.center);
vec2.add(leftFootGravity, leftFootGravity, rightFootGravity);
const gravity = vec2.scale(leftFootGravity, leftFootGravity, 0.5);
if (
vec2.dot(movementForce, gravity) <
-vec2.length(movementForce) * settings.climbDotThreshold
) {
vec2.scale(gravity, gravity, settings.climbGravityScale);
}
const scaledLeftFootGravity = vec2.scale(
vec2.create(),
state.leftFoot.lastNormal,
vec2.dot(state.leftFoot.lastNormal, gravity),
);
applyForce(state.leftFoot, scaledLeftFootGravity, deltaTimeInSeconds);
const scaledRightFootGravity = vec2.scale(
vec2.create(),
state.rightFoot.lastNormal,
vec2.dot(state.rightFoot.lastNormal, gravity),
);
applyForce(state.rightFoot, scaledRightFootGravity, deltaTimeInSeconds);
if (vec2.length(gravity) <= settings.planetDetachmentForceThreshold) {
state.currentPlanet = undefined;
}
setDirection(state, gravity);
}
keepPosture(state);
applyBodyMomentum(state);
latchGround(state, world.stepBody(state.leftFoot, deltaTimeInSeconds));
latchGround(state, world.stepBody(state.rightFoot, deltaTimeInSeconds));
latchGround(state, world.stepBody(state.head, deltaTimeInSeconds));
decayBodyMomentum(state, deltaTimeInSeconds);
};

View file

@ -0,0 +1,36 @@
import { vec2 } from 'gl-matrix';
import { PhysicsBody, Sdf } from './sdf';
import { evaluateSdf } from './evaluate-sdf';
import { sdfNormal } from './sdf-normal';
// Planet collision outlines rotate (see PlanetPhysical.distance), so a surface
// can sweep into a circle that hasn't itself moved. marchCircle assumes an
// overlap-free start — beginning inside, it registers a zero-distance hit and
// never moves again — so any overlap must be resolved here, before marching.
// Iterating handles concave spots, where leaving one face pushes into another;
// if no overlap-free position exists nearby (a crevice narrower than the
// circle), it gives up and leaves the rest to a later tick.
export const depenetrateCircle = (
body: PhysicsBody,
possibleIntersectors: Array<Sdf>,
) => {
for (let i = 0; i < 4; i++) {
const distance = evaluateSdf(body.center, possibleIntersectors);
if (distance >= body.radius) {
return;
}
const normal = sdfNormal(body.center, possibleIntersectors);
if (vec2.squaredLength(normal) === 0) {
return;
}
vec2.copy(body.lastNormal, normal);
body.center = vec2.scaleAndAdd(
vec2.create(),
body.center,
normal,
body.radius - distance + 0.01,
);
}
};

View file

@ -0,0 +1,7 @@
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);

View file

@ -0,0 +1,6 @@
export const interpolateAngles = (from: number, to: number, q: number) => {
const max = Math.PI * 2;
const possibleDistance = (to - from) % max;
const shorterDistance = ((2 * possibleDistance) % max) - possibleDistance;
return from + shorterDistance * q;
};

View file

@ -0,0 +1,80 @@
import { vec2 } from 'gl-matrix';
import { PhysicsBody, Sdf } from './sdf';
import { evaluateSdf } from './evaluate-sdf';
import { sdfNormal } from './sdf-normal';
export interface MarchResult {
hitSurface: boolean;
normal?: vec2;
hitObject?: Sdf;
}
// Raymarch a circle by `delta`, stopping at the first surface it would overlap.
// Extracted verbatim from the backend's move-circle so server and client
// resolve motion identically. The collision *reaction* is no longer dispatched
// here: on a real (non-ignored) hit, `onHit(intersecting)` is invoked at the
// exact point the backend used to dispatch its ReactToCollisionCommands, and
// the backend wrapper supplies that callback. The client passes none.
export const marchCircle = (
body: PhysicsBody,
delta: vec2,
possibleIntersectors: Array<Sdf>,
ignoreCollision = false,
onHit?: (intersecting: Sdf) => void,
): MarchResult => {
const direction = vec2.clone(delta);
if (vec2.length(delta) > 0) {
vec2.normalize(direction, direction);
}
const deltaLength = vec2.length(delta);
let travelled = 0;
const rayEnd = vec2.create();
let prevMinDistance = 0;
while (travelled < deltaLength) {
travelled += prevMinDistance;
vec2.add(
rayEnd,
body.center,
vec2.scale(vec2.create(), direction, Math.min(travelled, deltaLength)),
);
const minDistance = evaluateSdf(rayEnd, possibleIntersectors);
if (minDistance < body.radius) {
const intersecting = possibleIntersectors.find(
(i) => i.distance(rayEnd) <= body.radius,
)!;
if (ignoreCollision) {
body.center = vec2.add(body.center, body.center, delta);
} else {
onHit?.(intersecting);
}
vec2.add(
rayEnd,
body.center,
vec2.scale(vec2.create(), direction, travelled - prevMinDistance),
);
vec2.copy(body.center, rayEnd);
const normal = sdfNormal(rayEnd, [intersecting]);
return {
hitSurface: true,
normal,
hitObject: intersecting,
};
}
prevMinDistance = minDistance;
}
vec2.add(body.center, body.center, delta);
return {
hitSurface: false,
};
};

View file

@ -0,0 +1,80 @@
import { vec2 } from 'gl-matrix';
import { clamp, clamp01 } from '../helper/clamp';
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.
export const toPlanetLocalFrame = (
target: vec2,
center: vec2,
cos: number,
sin: number,
): 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,
);
};
// 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.
export const planetDistance = (
target: vec2,
vertices: Array<vec2>,
center: vec2,
cos: number,
sin: number,
): number => {
const local = toPlanetLocalFrame(target, center, cos, sin);
const startEnd = vertices[0];
let vb = startEnd;
let d = vec2.dist(local, vb);
let sign = 1;
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);
const h = clamp01(
vec2.dot(targetFromDelta, toFromDelta) / vec2.squaredLength(toFromDelta),
);
const ds = vec2.fromValues(
vec2.dist(targetFromDelta, vec2.scale(vec2.create(), 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)
) {
sign *= -1;
}
d = Math.min(d, ds[0]);
}
return sign * d;
};
// Gravity a planet exerts at a world position. Verbatim from
// PlanetPhysical.getForce.
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);
vec2.normalize(diff, diff);
const scale = clamp(
settings.maxGravityQ * ((settings.maxGravityDistance / dist) ** 1.5 - 1),
0,
settings.maxGravityStrength,
);
return vec2.scale(diff, diff, scale);
};

View file

@ -0,0 +1,58 @@
import { vec2 } from 'gl-matrix';
import { PhysicsBody, Sdf } from './sdf';
import { depenetrateCircle } from './depenetrate-circle';
import { marchCircle } from './march-circle';
// The position-resolution half of the backend's CirclePhysical.stepManually,
// extracted so server and client integrate a body identically. The caller is
// responsible for the broadphase (gathering `possibleIntersectors`, including
// the swept-radius bump) because that depends on each side's spatial structure;
// everything from depenetration onward lives here.
//
// `onHit` is threaded through to marchCircle so the backend can dispatch its
// collision reactions at the same points as before (including the second,
// post-bounce slide march); the client passes none. Velocity is reset to zero
// at the end — the character re-applies all forces from zero every tick, so a
// body that retained velocity would double-integrate and diverge.
export const resolveCircleMovement = (
body: PhysicsBody,
deltaTimeInSeconds: number,
possibleIntersectors: Array<Sdf>,
onHit?: (intersecting: Sdf) => void,
): { hitObject?: Sdf; velocity: vec2 } => {
let delta = vec2.scale(vec2.create(), body.velocity, deltaTimeInSeconds);
depenetrateCircle(body, possibleIntersectors);
const { normal, hitSurface, hitObject } = marchCircle(
body,
delta,
possibleIntersectors,
false,
onHit,
);
if (hitSurface) {
vec2.copy(body.lastNormal, normal!);
vec2.subtract(
body.velocity,
body.velocity,
vec2.scale(
normal!,
normal!,
(1 + body.restitution) * vec2.dot(normal!, body.velocity),
),
);
if (vec2.length(body.velocity) > 50) {
delta = vec2.scale(vec2.create(), body.velocity, deltaTimeInSeconds);
marchCircle(body, delta, possibleIntersectors, false, onHit);
}
}
const lastVelocity = vec2.clone(body.velocity);
vec2.zero(body.velocity);
return { hitObject, velocity: lastVelocity };
};

View file

@ -0,0 +1,19 @@
import { vec2 } from 'gl-matrix';
import { Sdf } from './sdf';
import { evaluateSdf } from './evaluate-sdf';
// 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);
const dy =
evaluateSdf(vec2.fromValues(target[0], target[1] + 0.01), objects) -
evaluateSdf(vec2.fromValues(target[0], target[1] - 0.01), objects);
const normal = vec2.fromValues(dx, dy);
return vec2.squaredLength(normal) > 0 ? vec2.normalize(normal, normal) : normal;
};

31
shared/src/physics/sdf.ts Normal file
View file

@ -0,0 +1,31 @@
import { vec2 } from 'gl-matrix';
// Minimal signed-distance collider the geometry functions need. Backend
// Physicals and the client's planet surfaces both satisfy this structurally.
export interface Sdf {
readonly canCollide: boolean;
// Planets set this true so a body landing on one can latch it as ground;
// everything else leaves it falsy. Replaces the server's
// `instanceof PlanetPhysical` check with a structural flag both sides share.
readonly isGround?: boolean;
distance(target: vec2): number;
}
// A movable circle the geometry resolves in place. Backend CirclePhysical and
// the client predictor's plain bodies both satisfy this.
export interface PhysicsBody {
center: vec2;
radius: number;
velocity: vec2;
lastNormal: vec2;
readonly restitution: number;
}
// A planet-like surface: collidable, exerts gravity, and spins. Drives the
// character's on-surface movement branch.
export interface GroundSurface extends Sdf {
readonly isGround: true;
readonly center: vec2;
readonly angularVelocity: number;
gravityAt(target: vec2): vec2;
}

View file

@ -33,7 +33,11 @@ export const settings = {
maxGravityDistance: 800,
minGravityDistance: 1,
maxGravityQ: 5000,
planetControlThreshold: 0.2,
// Half-width of the neutral dead-band around 50% ownership. A planet only
// counts as captured (team(), point generation, flip) once |ownership-0.5|
// exceeds this, and the rendered ownership ring stays neutral until the same
// point — so what you see matches what scores.
planetControlThreshold: 0.12,
playerMaxHealth: 100,
maxGravityStrength: 50000,
planetMinReferenceRadius: 150,
@ -141,4 +145,65 @@ export const settings = {
lampFlareDecaySeconds: 0.6,
maxConcurrentFlipFlares: 3,
announcementVisibleSeconds: 2,
chargedHitThreshold: 0.6,
// Projectiles fall through planetary gravity like a free-falling character,
// 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
// that shot's charge. Added to whatever momentum the victim already carried.
deathImpulseMin: 280,
deathImpulseMax: 1300,
// A planet's net team head-count drives a single capture step per tick; the
// lead multiplier is capped so a zerg can't flip instantly. Equal head-counts
// freeze the planet (contested) instead of silently cancelling.
maxContestLeadMultiplier: 2,
// Persistent body momentum decays per second by these exponents. Airborne is
// near-frictionless so leaps and slingshots carry across the gaps; grounded is
// stiff so you skid to a stop on landing rather than sliding forever.
airMomentumFriction: 0.4,
groundMomentumFriction: 7,
// On top of the exponential frictions above, a constant deceleration (u/s^2)
// applied to body momentum. The exponential alone only asymptotes toward zero,
// so a fast launch keeps a slow tail for 15+ seconds — it reads as drifting
// forever with nothing slowing you. This constant brake brings the momentum to
// a definite stop in a couple of seconds, while the gentle exponential still
// lets the launch cover its distance first.
momentumStopDeceleration: 400,
// Hard ceiling (u/s) on body momentum, so stacked impulses — rapid charged-shot
// recoil, or a leap chained into a spin slingshot — can't build speed without
// bound. Kept above the overcharge fling (1700) so single launches survive.
maxBodyMomentum: 2000,
// Leap: a charged-cost launch off a surface, paid from the shared shooting
// strength pool so it trades against firepower.
leapStrengthCost: 32,
leapSpeed: 1350,
leapUpBias: 1,
leapMoveBias: 0.65,
leapCooldownSeconds: 0.35,
// Fraction of the planet's tangential surface velocity you keep when you leave
// it (slingshot). Leap off a fast spinner to be flung far.
slingshotScale: 1,
// Recoil speed imparted opposite a shot, scaled by its charge (0 for taps).
chargeShotRecoilMax: 650,
// The central giant is a named, always-contested focus. Its neutral decay is
// slowed so control lingers and teams keep fighting over it; flips are
// announced to everyone and an off-screen arrow points the way.
keystoneLoseControlScale: 2.5,
};