More fun
This commit is contained in:
parent
a1fb6755c7
commit
fc9df09ee1
36 changed files with 2011 additions and 251 deletions
19
shared/src/commands/types/actions/leap-action.ts
Normal file
19
shared/src/commands/types/actions/leap-action.ts
Normal 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];
|
||||
}
|
||||
}
|
||||
|
|
@ -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];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
28
shared/src/commands/types/input-acknowledgement.ts
Normal file
28
shared/src/commands/types/input-acknowledgement.ts
Normal 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];
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
354
shared/src/physics/character-movement.ts
Normal file
354
shared/src/physics/character-movement.ts
Normal 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);
|
||||
};
|
||||
36
shared/src/physics/depenetrate-circle.ts
Normal file
36
shared/src/physics/depenetrate-circle.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
};
|
||||
7
shared/src/physics/evaluate-sdf.ts
Normal file
7
shared/src/physics/evaluate-sdf.ts
Normal 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);
|
||||
6
shared/src/physics/interpolate-angles.ts
Normal file
6
shared/src/physics/interpolate-angles.ts
Normal 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;
|
||||
};
|
||||
80
shared/src/physics/march-circle.ts
Normal file
80
shared/src/physics/march-circle.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
80
shared/src/physics/planet-sdf.ts
Normal file
80
shared/src/physics/planet-sdf.ts
Normal 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);
|
||||
};
|
||||
58
shared/src/physics/resolve-circle-movement.ts
Normal file
58
shared/src/physics/resolve-circle-movement.ts
Normal 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 };
|
||||
};
|
||||
19
shared/src/physics/sdf-normal.ts
Normal file
19
shared/src/physics/sdf-normal.ts
Normal 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
31
shared/src/physics/sdf.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue