This commit is contained in:
Andras Schmelczer 2026-09-14 21:29:18 +01:00
commit 244620105a
9 changed files with 2080 additions and 518 deletions

View file

@ -12,7 +12,7 @@ export abstract class CommandReceiver {
public handleCommand(command: Command) {
const commandType = command.constructor.name;
if (commandType in this.commandExecutors) {
if (Object.prototype.hasOwnProperty.call(this.commandExecutors, commandType)) {
this.commandExecutors[commandType]!(command);
} else {
this.defaultCommandExecutor(command);

View file

@ -13,6 +13,7 @@ export class CharacterMovementSnapshot {
public readonly rightFootNormal: vec2,
public readonly groundPlanetId: Id | null,
public readonly secondsSinceOnSurface: number,
public readonly leapCooldownRemaining: number = 0,
) {}
public toArray(): Array<any> {
@ -23,6 +24,7 @@ export class CharacterMovementSnapshot {
this.rightFootNormal,
this.groundPlanetId,
this.secondsSinceOnSurface,
this.leapCooldownRemaining,
];
}
}

View file

@ -1,34 +1,2 @@
import { vec2 } from 'gl-matrix';
export const smoothing = (deltaTimeInSeconds: number, seconds: number): number =>
1 - Math.exp(-deltaTimeInSeconds / seconds);
export const easeVec2 = (
out: vec2,
target: vec2,
deltaTimeInSeconds: number,
seconds: number,
snapDistance: number,
): vec2 =>
vec2.distance(out, target) > snapDistance
? vec2.copy(out, target)
: vec2.lerp(out, out, target, smoothing(deltaTimeInSeconds, seconds));
// Follows a *moving* target. `easeVec2` alone lags one by an amount that depends on the frame
// duration, so an uneven frame — or a dropped one — changes the lag and the follower appears to
// speed up or stall. Carrying the target's own displacement first leaves only the residual to
// decay: a target at a steady speed is tracked exactly, whatever the frame times do, and a
// correction still fades out over `seconds`.
export const followVec2 = (
out: vec2,
target: vec2,
previousTarget: vec2,
deltaTimeInSeconds: number,
seconds: number,
snapDistance: number,
): vec2 => {
out[0] += target[0] - previousTarget[0];
out[1] += target[1] - previousTarget[1];
vec2.copy(previousTarget, target);
return easeVec2(out, target, deltaTimeInSeconds, seconds, snapDistance);
};

View file

@ -32,16 +32,15 @@ export const finiteVec2 = (value: unknown, maxMagnitude: number): vec2 | undefin
return undefined;
}
const result = vec2.fromValues(x, y);
const length = vec2.length(result);
const length = Math.hypot(x, y);
if (length > maxMagnitude) {
vec2.scale(result, result, maxMagnitude / length);
return vec2.fromValues((x / length) * maxMagnitude, (y / length) * maxMagnitude);
}
return result;
return vec2.fromValues(x, y);
};
export const sanitizeName = (value: unknown, maxLength: number): string => {
const text = typeof value === 'string' ? value : String(value ?? '');
const text = typeof value === 'string' ? value : '';
return text.slice(0, maxLength);
};

View file

@ -1,7 +1,7 @@
let seed = Math.random();
let seed = (Math.random() * 0x100000000) >>> 0;
function getRandom(): number {
let t = (seed += 0x6d2b79f5);
let t = (seed = (seed + 0x6d2b79f5) | 0);
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
@ -9,7 +9,7 @@ function getRandom(): number {
export const Random = {
set seed(value: number) {
seed = value;
seed = value | 0;
},
getRandom,
getRandomInRange(from: number, to: number): number {

View file

@ -83,7 +83,7 @@ export abstract class GameObject {
}
}
public getPropertyUpdates(): PropertyUpdatesForObject | undefined {
public getPropertyUpdates(_timeScale = 1): PropertyUpdatesForObject | undefined {
return undefined;
}

View file

@ -7,29 +7,11 @@ import { smoothing } from '../helper/ease';
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,
);
// Imports run before setMatrixArrayType(Array): keep these fractional constants in f64.
const centerHeight = 55 * (1 / 3);
export const headOffset: vec2 = [0, 55 - centerHeight];
export const leftFootOffset: vec2 = [-20, -centerHeight];
export const rightFootOffset: vec2 = [20, -centerHeight];
export const boundRadius = (headRadius + feetRadius * 2) * 2;
export interface CharacterMovementState {
@ -52,11 +34,7 @@ export const applyForce = (
force: vec2,
deltaTimeInSeconds: number,
) => {
vec2.add(
body.velocity,
body.velocity,
vec2.scale(vec2.create(), force, deltaTimeInSeconds),
);
vec2.scaleAndAdd(body.velocity, body.velocity, force, deltaTimeInSeconds);
};
// ((head + leftFoot) + rightFoot) / 3 — do not reassociate.
@ -128,20 +106,9 @@ const carryWithRotatingPlanet = (
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,
);
for (const body of [state.head, state.leftFoot, state.rightFoot]) {
vec2.rotate(body.center, body.center, planet.center, angle);
}
};
export const applyLeapImpulse = (state: CharacterMovementState, moveDirection: vec2) => {
@ -234,10 +201,6 @@ export const decayMomentum = (
}
};
const decayBodyMomentum = (state: CharacterMovementState, deltaTimeInSeconds: number) => {
decayMomentum(state.bodyVelocity, !!state.currentPlanet, deltaTimeInSeconds);
};
export const sumGravity = (grounds: Array<GroundSurface>, position: vec2): vec2 =>
grounds.reduce(
(sum, ground) => vec2.add(sum, sum, ground.gravityAt(position)),
@ -335,5 +298,5 @@ export const stepCharacterMovement = (
latchGround(state, world.stepBody(state.rightFoot, deltaTimeInSeconds));
world.stepBody(state.head, deltaTimeInSeconds);
decayBodyMomentum(state, deltaTimeInSeconds);
decayMomentum(state.bodyVelocity, !!state.currentPlanet, deltaTimeInSeconds);
};

View file

@ -4,7 +4,7 @@ import { evaluateSdf } from './evaluate-sdf';
import { sdfNormal } from './sdf-normal';
export interface MarchResult {
hitSurface: boolean;
travelled: number;
normal?: vec2;
hitObject?: Sdf;
}
@ -20,7 +20,7 @@ export const marchCircle = (
): MarchResult => {
const deltaLength = vec2.length(delta);
if (!(deltaLength > 0)) {
return { hitSurface: false };
return { travelled: 0 };
}
const direction = vec2.normalize(vec2.create(), delta);
@ -35,17 +35,17 @@ export const marchCircle = (
if (minDistance < body.radius) {
const intersecting = possibleIntersectors.find(
(i) => i.distance(rayEnd) <= body.radius,
(i) => i.canCollide && i.distance(rayEnd) < body.radius,
)!;
onHit?.(intersecting);
vec2.scaleAndAdd(rayEnd, body.center, direction, lastFreeDistance);
vec2.copy(body.center, rayEnd);
const normal = sdfNormal(rayEnd, [intersecting]);
onHit?.(intersecting);
return {
hitSurface: true,
normal: sdfNormal(rayEnd, [intersecting]),
travelled: lastFreeDistance,
normal,
hitObject: intersecting,
};
}
@ -64,5 +64,5 @@ export const marchCircle = (
vec2.scaleAndAdd(body.center, body.center, direction, lastFreeDistance);
return { hitSurface: false };
return { travelled: lastFreeDistance };
};

View file

@ -9,35 +9,39 @@ export const resolveCircleMovement = (
possibleIntersectors: Array<Sdf>,
onHit?: (intersecting: Sdf) => void,
): Sdf | undefined => {
let delta = vec2.scale(vec2.create(), body.velocity, deltaTimeInSeconds);
depenetrateCircle(body, possibleIntersectors);
let remainingSeconds = deltaTimeInSeconds;
let firstHit: Sdf | undefined;
const { normal, hitSurface, hitObject } = marchCircle(
body,
delta,
possibleIntersectors,
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),
),
// Resolve at most two contacts per step, spending only the time left after each hit.
for (let pass = 0; pass < 2 && remainingSeconds > 0; pass++) {
const speed = vec2.length(body.velocity);
if (speed === 0) {
break;
}
const delta = vec2.scale(vec2.create(), body.velocity, remainingSeconds);
const { normal, hitObject, travelled } = marchCircle(
body,
delta,
possibleIntersectors,
onHit,
);
if (vec2.length(body.velocity) > 50) {
delta = vec2.scale(vec2.create(), body.velocity, deltaTimeInSeconds);
marchCircle(body, delta, possibleIntersectors, onHit);
if (!hitObject || !normal) {
break;
}
firstHit ??= hitObject;
vec2.copy(body.lastNormal, normal);
remainingSeconds -= travelled / speed;
const inwardSpeed = vec2.dot(normal, body.velocity);
if (inwardSpeed < 0) {
vec2.scaleAndAdd(
body.velocity,
body.velocity,
normal,
-(1 + body.restitution) * inwardSpeed,
);
}
}
return hitObject;
return firstHit;
};