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

@ -14,13 +14,18 @@ import {
CommandReceiver,
mix,
clamp01,
stepCharacterMovement,
applyLeapImpulse,
decayMomentum,
CharacterMovementState,
CharacterWorld,
GroundSurface,
} from 'shared';
import { DynamicPhysical } from '../physics/physicals/dynamic-physical';
import { CirclePhysical } from './circle-physical';
import { PhysicalContainer } from '../physics/containers/physical-container';
import { BoundingBoxBase } from '../physics/bounding-boxes/bounding-box-base';
import { ProjectilePhysical } from './projectile-physical';
import { interpolateAngles } from '../helper/interpolate-angles';
import { forceAtPosition } from '../physics/functions/force-at-position';
import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle';
import { PlanetPhysical } from './planet-physical';
@ -92,10 +97,16 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
private currentPlanet?: PlanetPhysical;
private secondsSinceOnSurface = settings.planetDetachmentSeconds;
private bodyVelocity = vec2.create();
private timeSinceLastLeap = settings.leapCooldownSeconds;
public head: CirclePhysical;
public leftFoot: CirclePhysical;
public rightFoot: CirclePhysical;
private movementState!: CharacterMovementState;
private movementWorld!: CharacterWorld;
private movementActions: Array<MoveActionCommand> = [];
private lastMovementAction: MoveActionCommand = new MoveActionCommand(vec2.create());
@ -138,6 +149,51 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
container.addObject(this.head);
container.addObject(this.leftFoot);
container.addObject(this.rightFoot);
this.initMovementBridge();
}
private initMovementBridge() {
const self = this;
this.movementState = {
head: this.head,
leftFoot: this.leftFoot,
rightFoot: this.rightFoot,
get direction() {
return self.direction;
},
set direction(value: number) {
self.direction = value;
},
get currentPlanet() {
return self.currentPlanet;
},
set currentPlanet(value: GroundSurface | undefined) {
// On the server every ground is a PlanetPhysical (the world only ever
// hands back planets), so this narrowing is safe.
self.currentPlanet = value as PlanetPhysical | undefined;
},
get secondsSinceOnSurface() {
return self.secondsSinceOnSurface;
},
set secondsSinceOnSurface(value: number) {
self.secondsSinceOnSurface = value;
},
bodyVelocity: this.bodyVelocity,
};
this.movementWorld = {
// Same set and order forceAtPosition used: planets in the force field,
// in container-traversal order (so the f64 gravity sum is unchanged).
groundsNear: (center, radius) =>
self.container
.findIntersecting(getBoundingBoxOfCircle(new Circle(center, radius)))
.filter((o): o is PlanetPhysical => o instanceof PlanetPhysical),
stepBody: (body, deltaTimeInSeconds) => {
const { hitObject } = (body as CirclePhysical).stepManually(deltaTimeInSeconds);
return hitObject instanceof PlanetPhysical ? hitObject : undefined;
},
};
}
private get isSpawnProtected(): boolean {
@ -170,7 +226,13 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
return this.currentPlanet;
}
public addKill(victimName: string) {
// Persistent launch momentum, streamed to the owning client so its predictor
// can reproduce a leap/slingshot/recoil flight instead of only snapping to it.
public get launchMomentum(): vec2 {
return this.bodyVelocity;
}
public addKill(victimName: string, charge = 0) {
this.killCount++;
this.killStreak++;
this.remoteCall('setKillCount', this.killCount);
@ -180,11 +242,11 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
this.health + settings.playerKillHealthReward,
);
this.syncHealth();
this.remoteCall('onKillConfirmed', victimName, this.killStreak);
this.remoteCall('onKillConfirmed', victimName, this.killStreak, charge);
}
public registerHit() {
this.remoteCall('onHitConfirmed');
public registerHit(charge = 0) {
this.remoteCall('onHitConfirmed', charge);
}
private syncHealth() {
@ -197,6 +259,9 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
public onCollision({ other }: ReactToCollisionCommand) {
if (
// A corpse keeps its collidable circles for the despawn animation; the
// isAlive guard stops a flying corpse from eating shots aimed past it.
this.isAlive &&
other instanceof ProjectilePhysical &&
other.team !== this.team &&
other.isAlive
@ -213,10 +278,17 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
this.remoteCall('setHealth', this.health);
if (this.health <= 0 && this.isAlive) {
// Throw the corpse along the killing shot, harder for charged hits.
vec2.scaleAndAdd(
this.bodyVelocity,
this.bodyVelocity,
other.direction,
mix(settings.deathImpulseMin, settings.deathImpulseMax, other.charge),
);
this.onDie();
other.originator.addKill(this.name);
other.originator.addKill(this.name, other.charge);
} else {
other.originator.registerHit();
other.originator.registerHit(other.charge);
}
}
}
@ -246,6 +318,8 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
const direction = vec2.subtract(vec2.create(), position, this.center);
vec2.normalize(direction, direction);
// Keep the unit direction before vec2.scale repurposes it as the velocity.
const shotDirection = vec2.clone(direction);
const velocity = vec2.scale(direction, direction, speed);
const projectile = new ProjectilePhysical(
vec2.clone(this.center),
@ -255,12 +329,42 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
velocity,
this,
this.container,
c,
);
this.container.addObject(projectile);
if (c > 0) {
vec2.scaleAndAdd(
this.bodyVelocity,
this.bodyVelocity,
shotDirection,
-settings.chargeShotRecoilMax * c,
);
}
this.remoteCall('onShoot', strength);
}
public leap() {
if (
!this.isAlive ||
this.hasJustBorn ||
!this.currentPlanet ||
this.timeSinceLastLeap < settings.leapCooldownSeconds ||
this.projectileStrength < settings.leapStrengthCost
) {
return;
}
this.timeSinceLastLeap = 0;
this.projectileStrength -= settings.leapStrengthCost;
// Same impulse the client predicts with (shared), so a leap launches
// identically on both sides.
applyLeapImpulse(this.movementState, this.lastMovementAction.direction);
this.remoteCall('onLeap');
}
public get boundingBox(): BoundingBoxBase {
return getBoundingBoxOfCircle(new Circle(this.center, CharacterPhysical.boundRadius));
}
@ -363,6 +467,7 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
private step({ deltaTimeInSeconds, game }: StepCommand) {
this.getPoints(game);
this.timeAlive += deltaTimeInSeconds;
this.timeSinceLastLeap += deltaTimeInSeconds;
const oldHead = new Circle(vec2.clone(this.head.center), this.head.radius);
const oldLeftFoot = new Circle(
vec2.clone(this.leftFoot.center),
@ -377,6 +482,7 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
if ((this.timeSinceDying += deltaTimeInSeconds) > settings.spawnDespawnTime) {
this.destroy();
} else {
this.freeFallCorpse(deltaTimeInSeconds);
this.animateScaling(1 - this.timeSinceDying / settings.spawnDespawnTime);
}
this.setPropertyUpdates(oldHead, oldLeftFoot, oldRightFoot, deltaTimeInSeconds);
@ -405,14 +511,33 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
this.projectileStrength = Math.min(
settings.playerMaxStrength,
this.projectileStrength +
settings.playerStrengthRegenerationPerSeconds * deltaTimeInSeconds,
settings.playerStrengthRegenerationPerSeconds * deltaTimeInSeconds,
);
this.regenerateHealth(deltaTimeInSeconds);
this.currentPlanet?.takeControl(this.team, deltaTimeInSeconds);
// The planet tallies who is standing on it and resolves capture itself, so
// a contested rock can freeze instead of two squads silently cancelling.
this.currentPlanet?.registerPresence(this);
const intersectingWithForceField = this.container.findIntersecting(
// The whole walking model — gravity gather, movement force, on/off-planet
// branch, posture springs, body-momentum, and stepping the three parts —
// is the shared simulation the client predicts with, so the two can never
// drift. Server-only concerns (scoring, health, shooting, spawn/death,
// ownership) stay here around it.
const direction = this.averageAndResetMovementActions();
stepCharacterMovement(
this.movementState,
this.movementWorld,
direction,
deltaTimeInSeconds,
);
this.setPropertyUpdates(oldHead, oldLeftFoot, oldRightFoot, deltaTimeInSeconds);
}
private freeFallCorpse(deltaTime: number) {
const intersecting = this.container.findIntersecting(
getBoundingBoxOfCircle(
new Circle(
this.center,
@ -420,151 +545,20 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
),
),
);
const direction = this.averageAndResetMovementActions();
const movementForce = vec2.scale(direction, direction, settings.maxAcceleration);
this.leftFoot.applyForce(movementForce, deltaTimeInSeconds);
this.rightFoot.applyForce(movementForce, deltaTimeInSeconds);
if (!this.currentPlanet) {
const leftFootGravity = forceAtPosition(
this.leftFoot.center,
intersectingWithForceField,
);
const rightFootGravity = forceAtPosition(
this.rightFoot.center,
intersectingWithForceField,
);
this.leftFoot.applyForce(leftFootGravity, deltaTimeInSeconds);
this.rightFoot.applyForce(rightFootGravity, deltaTimeInSeconds);
const sumForce = vec2.subtract(vec2.create(), leftFootGravity, movementForce);
this.setDirection(vec2.length(sumForce) === 0 ? vec2.fromValues(0, -1) : sumForce);
} else {
this.carryWithRotatingPlanet(deltaTimeInSeconds);
const leftFootGravity = this.currentPlanet!.getForce(this.leftFoot.center);
const rightFootGravity = this.currentPlanet!.getForce(this.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);
let grounded = false;
for (const part of [this.leftFoot, this.rightFoot, this.head]) {
part.applyForce(forceAtPosition(part.center, intersecting), deltaTime);
vec2.add(part.velocity, part.velocity, this.bodyVelocity);
const { hitObject } = part.stepManually(deltaTime);
if (hitObject instanceof PlanetPhysical) {
grounded = true;
}
const scaledLeftFootGravity = vec2.scale(
vec2.create(),
this.leftFoot.lastNormal,
vec2.dot(this.leftFoot.lastNormal, gravity),
);
this.leftFoot.applyForce(scaledLeftFootGravity, deltaTimeInSeconds);
const scaledRightFootGravity = vec2.scale(
vec2.create(),
this.rightFoot.lastNormal,
vec2.dot(this.rightFoot.lastNormal, gravity),
);
this.rightFoot.applyForce(scaledRightFootGravity, deltaTimeInSeconds);
if (vec2.length(gravity) <= settings.planetDetachmentForceThreshold) {
this.currentPlanet = undefined;
}
this.setDirection(gravity);
}
this.keepPosture();
this.stepBodyPart(this.leftFoot, deltaTimeInSeconds);
this.stepBodyPart(this.rightFoot, deltaTimeInSeconds);
this.stepBodyPart(this.head, deltaTimeInSeconds);
this.setPropertyUpdates(oldHead, oldLeftFoot, oldRightFoot, deltaTimeInSeconds);
}
private setDirection(direction: vec2) {
this.direction = interpolateAngles(
this.direction,
Math.atan2(direction.y, direction.x) + Math.PI / 2,
0.2,
);
}
// While standing on a planet, ride its spin: rigidly rotate the whole body
// around the planet centre by the same per-tick angle the renderer and the
// collision SDF use, so the player is carried around and turned with the
// surface instead of it sliding frictionlessly underneath. The positional
// change becomes velocity in setPropertyUpdates, keeping the streamed
// rate-of-change consistent with the streamed positions.
private carryWithRotatingPlanet(deltaTimeInSeconds: number) {
const planet = this.currentPlanet;
if (!planet) {
return;
}
// Negative: the rendered/collidable surface turns by R(-rotation) — see
// PlanetPhysical.distance (toLocalFrame) and planet-shape.ts.
const angle = -planet.angularVelocity * deltaTimeInSeconds;
const center = planet.center;
this.head.center = vec2.rotate(vec2.create(), this.head.center, center, angle);
this.leftFoot.center = vec2.rotate(
vec2.create(),
this.leftFoot.center,
center,
angle,
);
this.rightFoot.center = vec2.rotate(
vec2.create(),
this.rightFoot.center,
center,
angle,
);
}
private keepPosture() {
const center = this.center;
this.springMove(
this.leftFoot,
center,
CharacterPhysical.leftFootOffset,
settings.postureFeetStiffness,
);
this.springMove(
this.rightFoot,
center,
CharacterPhysical.rightFootOffset,
settings.postureFeetStiffness,
);
this.springMove(
this.head,
center,
CharacterPhysical.headOffset,
settings.postureHeadStiffness,
);
}
private springMove(
object: CirclePhysical,
center: vec2,
offset: vec2,
stiffness: number,
) {
const desiredPosition = vec2.add(vec2.create(), center, offset);
vec2.rotate(desiredPosition, desiredPosition, center, this.direction);
const positionDelta = vec2.subtract(vec2.create(), desiredPosition, object.center);
// First-order velocity relaxation toward the desired posture position,
// added to (not replacing) the gravity/movement velocity accumulated
// earlier this tick. stepManually applies it over deltaTime, so the
// per-tick displacement is positionDelta * stiffness * deltaTime.
vec2.scaleAndAdd(object.velocity, object.velocity, positionDelta, stiffness);
// Brake with the exact shared model the living body uses: stiff on contact
// so the corpse skids to rest, gentle in the air, plus the constant stop and
// the speed cap — so a flung corpse comes to a definite stop instead of
// sliding forever.
decayMomentum(this.bodyVelocity, grounded, deltaTime);
}
private regenerateHealth(deltaTimeInSeconds: number) {
@ -581,14 +575,6 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
}
}
private stepBodyPart(part: CirclePhysical, deltaTime: number) {
const { hitObject } = part.stepManually(deltaTime);
if (hitObject instanceof PlanetPhysical) {
this.secondsSinceOnSurface = 0;
this.currentPlanet = hitObject;
}
}
public onDie() {
this.isDestroyed = true;
this.remoteCall('onDie');

View file

@ -4,14 +4,14 @@ import {
CommandExecutors,
CommandReceiver,
GameObject,
resolveCircleMovement,
serializesTo,
} from 'shared';
import { BoundingBox } from '../physics/bounding-boxes/bounding-box';
import { BoundingBoxBase } from '../physics/bounding-boxes/bounding-box-base';
import { depenetrateCircle } from '../physics/functions/depenetrate-circle';
import { moveCircle } from '../physics/functions/move-circle';
import { PhysicalContainer } from '../physics/containers/physical-container';
import { DynamicPhysical } from '../physics/physicals/dynamic-physical';
import { Physical } from '../physics/physicals/physical';
import { ReactToCollisionCommand } from '../commands/react-to-collision';
@serializesTo(Circle)
@ -33,7 +33,9 @@ export class CirclePhysical extends CommandReceiver implements Circle, DynamicPh
private _radius: number,
public owner: GameObject,
private readonly container: PhysicalContainer,
private restitution = 0,
// Public + readonly so a CirclePhysical structurally satisfies the shared
// PhysicsBody interface the movement simulation operates on.
public readonly restitution = 0,
) {
super();
this._boundingBox = new BoundingBox();
@ -89,45 +91,49 @@ export class CirclePhysical extends CommandReceiver implements Circle, DynamicPh
);
}
public stepManually(deltaTimeInSeconds: number): {
// Position-resolution for one tick. Delegates to the shared
// resolveCircleMovement so the server integrates a body with the exact same
// geometry the client predictor runs (shared/physics) — no parallel copy to
// keep in sync. The onHit callback dispatches the collision reactions at the
// same points the old inline move-circle did (both the initial march and the
// post-bounce slide). `possibleIntersectors`, when supplied, lets a caller
// that already broadphased (e.g. a projectile's gravity query) avoid a second
// container query; otherwise it is self-gathered from the swept bounding box.
public stepManually(
deltaTimeInSeconds: number,
possibleIntersectors?: Array<Physical>,
): {
hitObject: GameObject | undefined;
velocity: vec2;
} {
let delta = vec2.scale(vec2.create(), this.velocity, deltaTimeInSeconds);
const intersecting = (
possibleIntersectors ?? this.sweptBroadphase(deltaTimeInSeconds)
).filter((b) => b.gameObject !== this.gameObject && b.canCollide);
this.radius += vec2.length(delta);
const intersecting = this.container
.findIntersecting(this.boundingBox)
.filter((b) => b.gameObject !== this.gameObject && b.canCollide);
this.radius -= vec2.length(delta);
const { hitObject, velocity } = resolveCircleMovement(
this,
deltaTimeInSeconds,
intersecting,
(intersected) => {
const physical = intersected as Physical;
physical.handleCommand(new ReactToCollisionCommand(this.gameObject));
this.handleCommand(new ReactToCollisionCommand(physical.gameObject));
},
);
depenetrateCircle(this, intersecting);
return { hitObject: (hitObject as Physical | undefined)?.gameObject, velocity };
}
const { normal, hitSurface, hitObject } = moveCircle(this, delta, intersecting);
if (hitSurface) {
vec2.copy(this.lastNormal, normal!);
vec2.subtract(
this.velocity,
this.velocity,
vec2.scale(
normal!,
normal!,
(1 + this.restitution) * vec2.dot(normal!, this.velocity),
),
);
if (vec2.length(this.velocity) > 50) {
delta = vec2.scale(vec2.create(), this.velocity, deltaTimeInSeconds);
moveCircle(this, delta, intersecting);
}
}
const lastVelocity = vec2.clone(this.velocity);
vec2.zero(this.velocity);
return { hitObject, velocity: lastVelocity };
// Query the container with the bounding box grown by this tick's travel, so a
// fast-moving body still sees what it is about to sweep into.
private sweptBroadphase(deltaTimeInSeconds: number): Array<Physical> {
const sweep = vec2.length(
vec2.scale(vec2.create(), this.velocity, deltaTimeInSeconds),
);
this.radius += sweep;
const intersecting = this.container.findIntersecting(this.boundingBox);
this.radius -= sweep;
return intersecting;
}
public toArray(): Array<any> {

View file

@ -16,16 +16,21 @@ import {
CommandReceiver,
} from 'shared';
import { GeneratePointsCommand } from '../commands/generate-points';
import { AnnounceCommand } from '../commands/announce';
import { StepCommand } from '../commands/step';
import { ImmutableBoundingBox } from '../physics/bounding-boxes/immutable-bounding-box';
import { StaticPhysical } from '../physics/physicals/static-physical';
import { LampPhysical } from './lamp-physical';
import type { CharacterPhysical } from './character-physical';
@serializesTo(PlanetBase)
export class PlanetPhysical extends PlanetBase implements StaticPhysical {
public readonly canCollide = true;
public readonly canMove = false;
// Marks this as standable ground for the shared movement simulation (a body
// landing on it latches it as currentPlanet). See shared GroundSurface.
public readonly isGround = true;
public readonly sizePointMultiplier: number;
@ -45,6 +50,12 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
private lastTeam: CharacterTeam = CharacterTeam.neutral;
// Characters standing on the planet this tick. Filled by registerPresence as
// each grounded character steps, drained when the planet resolves capture in
// its own step(). Drives the head-count tug-of-war.
private presentCharacters: Array<CharacterPhysical> = [];
private isContested = false;
protected commandExecutors: CommandExecutors = {
[StepCommand.type]: this.step.bind(this),
};
@ -53,8 +64,8 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
this.lamps.push(lamp);
}
constructor(vertices: Array<vec2>) {
super(id(), vertices);
constructor(vertices: Array<vec2>, isKeystone = false) {
super(id(), vertices, 0.5, isKeystone);
const sizeClass = clamp01(
(this.radius - settings.planetMinReferenceRadius) /
@ -67,6 +78,12 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
(0.05 + Random.getRandom() * 0.07) * (Random.getRandom() < 0.5 ? -1 : 1);
}
// A grounded character announces itself each tick so the planet can resolve
// contested capture from the net head-count.
public registerPresence(character: CharacterPhysical) {
this.presentCharacters.push(character);
}
public distance(target: vec2): number {
// Evaluate the SDF in the planet's own rotating frame so this collision
// outline turns in lockstep with the rendered planet (see planet-shape.ts).
@ -124,13 +141,13 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
}
// Signed angular velocity in rad/s, exposed so a character standing on the
// planet can ride its spin (see CharacterPhysical.carryWithRotatingPlanet).
// planet can ride its spin (see carryWithRotatingPlanet in shared).
public get angularVelocity(): number {
return this.rotationSpeed;
}
public get team(): CharacterTeam {
return Math.abs(this.ownership - 0.5) < 0.1
return Math.abs(this.ownership - 0.5) < settings.planetControlThreshold
? CharacterTeam.neutral
: this.ownership < 0.5
? CharacterTeam.blue
@ -160,8 +177,49 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
// In reverse order, so that teams can achieve a 100% control.
this.getPoints(game);
this.takeControl(CharacterTeam.neutral, deltaTimeInSeconds);
this.resolveCapture(deltaTimeInSeconds);
this.detectFlip(game);
this.presentCharacters = [];
}
// One capture step per tick driven by the net team head-count, so grouping up
// pays off and an equal standoff freezes the planet (contested) instead of
// both sides silently cancelling with no feedback.
private resolveCapture(deltaTime: number) {
let blue = 0;
let red = 0;
for (const c of this.presentCharacters) {
if (c.team === CharacterTeam.blue) {
blue++;
} else if (c.team === CharacterTeam.red) {
red++;
}
}
const net = red - blue;
const occupied = blue + red > 0;
if (net !== 0) {
const lead = Math.min(Math.abs(net), settings.maxContestLeadMultiplier);
this.takeControl(
net > 0 ? CharacterTeam.red : CharacterTeam.blue,
deltaTime * lead,
);
} else if (!occupied) {
// Empty planets drift back to neutral; the keystone drifts much slower so
// it lingers as a live flashpoint.
this.takeControl(
CharacterTeam.neutral,
this.isKeystone ? deltaTime / settings.keystoneLoseControlScale : deltaTime,
);
}
// occupied tie -> frozen tug-of-war: no ownership change, ring pulses.
const contested = occupied && net === 0;
if (contested !== this.isContested) {
this.isContested = contested;
this.remoteCall('setContested', contested);
}
}
private detectFlip(game: CommandReceiver) {
@ -182,6 +240,14 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
currentTeam === CharacterTeam.red ? reward : 0,
),
);
if (this.isKeystone) {
game.handleCommand(
new AnnounceCommand(
`Team <span class="${currentTeam}">${currentTeam}</span> captured the Heart`,
),
);
}
}
const control = Math.abs(this.ownership - 0.5) / 0.5;
@ -196,8 +262,8 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
public getPropertyUpdates(): PropertyUpdatesForObject {
return new PropertyUpdatesForObject(this.id, [
new UpdatePropertyCommand('ownership', this.ownership, 0),
// Stream rotation with rotationSpeed as the rate-of-change so the client
// can keep the angle moving when snapshots run late (see planet-view.ts).
// Stream the spin rate as the rate-of-change so the client can keep the
// angle moving when snapshots run late (see planet-view.ts).
new UpdatePropertyCommand('rotation', this.rotation, this.rotationSpeed),
]);
}
@ -257,6 +323,11 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
return vec2.scale(diff, diff, scale);
}
// GroundSurface alias the shared movement simulation calls for gravity.
public gravityAt(position: vec2): vec2 {
return this.getForce(position);
}
public get gameObject(): this {
return this;
}

View file

@ -8,13 +8,16 @@ import {
PropertyUpdatesForObject,
UpdatePropertyCommand,
CommandExecutors,
Circle,
marchCircle,
} from 'shared';
import { ImmutableBoundingBox } from '../physics/bounding-boxes/immutable-bounding-box';
import { CirclePhysical } from './circle-physical';
import { DynamicPhysical } from '../physics/physicals/dynamic-physical';
import { PhysicalContainer } from '../physics/containers/physical-container';
import { CharacterPhysical } from './character-physical';
import { moveCircle } from '../physics/functions/move-circle';
import { forceAtPosition } from '../physics/functions/force-at-position';
import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle';
import { StepCommand } from '../commands/step';
import { ReactToCollisionCommand } from '../commands/react-to-collision';
@ -42,6 +45,9 @@ export class ProjectilePhysical extends ProjectileBase implements DynamicPhysica
private velocity: vec2,
public readonly originator: CharacterPhysical,
readonly container: PhysicalContainer,
// Normalised charge (0..1) of the shot that fired this, used by the victim
// to scale hit/kill feedback and the death fling.
public readonly charge: number = 0,
) {
super(id(), center, radius, team, strength);
this.object = new CirclePhysical(center, radius, this, container, 0.9);
@ -71,7 +77,7 @@ export class ProjectilePhysical extends ProjectileBase implements DynamicPhysica
const intersecting = this.container
.findIntersecting(this.boundingBox)
.filter((g) => g instanceof CharacterPhysical && g.team === this.team);
const { hitSurface } = moveCircle(this.object, delta, intersecting, true);
const { hitSurface } = marchCircle(this.object, delta, intersecting, true);
wasCollision = hitSurface;
}
vec2.add(this.center, this.center, delta);
@ -124,8 +130,31 @@ export class ProjectilePhysical extends ProjectileBase implements DynamicPhysica
return;
}
// Curveball: the same planetary gravity that pulls on a free-falling
// character bends the shot, so slower (charged) shots arc and can be lobbed
// over a planet's horizon. Scale is tiny — near-surface gravity is huge.
// This single broadphase is reused for the step below: the gravity radius
// (maxGravityDistance) dwarfs one tick's travel, so the set is a superset of
// the swept-collision box and the step needn't query the container again.
const intersecting = settings.projectileGravityEnabled
? this.container.findIntersecting(
getBoundingBoxOfCircle(
new Circle(this.center, this.object.radius + settings.maxGravityDistance),
),
)
: undefined;
if (intersecting) {
vec2.scaleAndAdd(
this.velocity,
this.velocity,
forceAtPosition(this.center, intersecting),
settings.projectileGravityScale * deltaTimeInSeconds,
);
}
vec2.copy(this.object.velocity, this.velocity);
const { velocity } = this.object.stepManually(deltaTimeInSeconds);
const { velocity } = this.object.stepManually(deltaTimeInSeconds, intersecting);
vec2.copy(this.velocity, velocity);
}
}

View file

@ -1,5 +1,4 @@
import { Circle } from 'shared';
import { evaluateSdf } from './evaluate-sdf';
import { Circle, evaluateSdf } from 'shared';
import { PhysicalBase } from '../physicals/physical-base';
export const isCircleIntersecting = (

View file

@ -60,6 +60,10 @@ const npcTuning = {
spreadBase: 60,
spreadPerDistance: 0.08,
spreadAggressionFalloff: 1.3,
// Per-second chance to hop while grounded and on the move, so bots use the
// leap verb too instead of being grounded targets.
leapChancePerSecond: 0.35,
};
export class NPC extends PlayerBase {
@ -141,6 +145,16 @@ export class NPC extends PlayerBase {
const movement = this.decideMovement(this.nearObjects);
this.character.handleMovementAction(new MoveActionCommand(movement));
if (
!this.isComingBack &&
this.character.groundPlanet &&
vec2.length(movement) > 0 &&
Random.getRandom() <
npcTuning.leapChancePerSecond * this.aggression * deltaTimeInSeconds
) {
this.character.leap();
}
if (
(this.timeSinceLastShoot += deltaTimeInSeconds) > npcTuning.shootIntervalSeconds
) {

View file

@ -23,6 +23,8 @@ import {
PropertyUpdatesForObjects,
PropertyUpdatesForObject,
PrimaryActionCommand,
LeapActionCommand,
InputAcknowledgement,
} from 'shared';
import { Socket } from 'socket.io';
import { BoundingBox } from '../physics/bounding-boxes/bounding-box';
@ -37,14 +39,27 @@ export class Player extends PlayerBase {
private timeUntilRespawn = 0;
private timeSinceLastMessage = 0;
private objectsPreviouslyInViewArea: Array<GameObject> = [];
private lastInputClientTimeMs = 0;
private lastLeapClientTimeMs = 0;
protected commandExecutors: CommandExecutors = {
[SetAspectRatioActionCommand.type]: (v: SetAspectRatioActionCommand) =>
(this.aspectRatio = v.aspectRatio),
[MoveActionCommand.type]: (c: MoveActionCommand) =>
this.character?.handleMovementAction(c),
[MoveActionCommand.type]: (c: MoveActionCommand) => {
// Remember how far into this client's input timeline we've consumed, to
// echo back for client-side prediction reconciliation.
this.lastInputClientTimeMs = c.clientTimeMs;
this.character?.handleMovementAction(c);
},
[PrimaryActionCommand.type]: (c: PrimaryActionCommand) =>
this.character?.shootTowards(c.position, c.charge),
[LeapActionCommand.type]: (c: LeapActionCommand) => {
// Record receipt (whether or not leap() accepts it): either way its effect
// on bodyVelocity is now reflected in the streamed launch momentum, so the
// predictor must stop replaying this leap.
this.lastLeapClientTimeMs = c.clientTimeMs;
this.character?.leap();
},
};
constructor(
@ -100,6 +115,13 @@ export class Player extends PlayerBase {
new Set(this.objectContainer.findIntersecting(bb).map((o) => o.gameObject)),
);
// The owning character must always be in its own snapshot, regardless of the
// view-area query, so the client predictor never loses its authoritative
// anchor (the body can ride a fast spinner to the very edge of the box).
if (this.character && !objectsInViewArea.includes(this.character)) {
objectsInViewArea.push(this.character);
}
const newlyIntersecting = objectsInViewArea.filter(
(o) => !this.objectsPreviouslyInViewArea.includes(o),
);
@ -130,6 +152,19 @@ export class Player extends PlayerBase {
performance.now() / 1000,
),
);
// Tell the client how much of its own input is reflected in the snapshot it
// just received, so its predictor can replay the rest. Only while alive —
// a dead player isn't predicting.
if (this.character) {
this.queueCommandSend(
new InputAcknowledgement(
this.lastInputClientTimeMs,
this.character.launchMomentum,
this.lastLeapClientTimeMs,
),
);
}
}
private getOtherPlayers(): Array<OtherPlayerDirection> {