clean up
This commit is contained in:
parent
aa02d0ab6f
commit
b9274a42c7
12 changed files with 480 additions and 457 deletions
|
|
@ -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 [];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
|
||||
|
||||
export const isFiniteNumber = (value: unknown): value is number =>
|
||||
typeof value === 'number' && Number.isFinite(value);
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue