More fun
This commit is contained in:
parent
a1fb6755c7
commit
fc9df09ee1
36 changed files with 2011 additions and 251 deletions
|
|
@ -14,13 +14,18 @@ import {
|
||||||
CommandReceiver,
|
CommandReceiver,
|
||||||
mix,
|
mix,
|
||||||
clamp01,
|
clamp01,
|
||||||
|
stepCharacterMovement,
|
||||||
|
applyLeapImpulse,
|
||||||
|
decayMomentum,
|
||||||
|
CharacterMovementState,
|
||||||
|
CharacterWorld,
|
||||||
|
GroundSurface,
|
||||||
} from 'shared';
|
} from 'shared';
|
||||||
import { DynamicPhysical } from '../physics/physicals/dynamic-physical';
|
import { DynamicPhysical } from '../physics/physicals/dynamic-physical';
|
||||||
import { CirclePhysical } from './circle-physical';
|
import { CirclePhysical } from './circle-physical';
|
||||||
import { PhysicalContainer } from '../physics/containers/physical-container';
|
import { PhysicalContainer } from '../physics/containers/physical-container';
|
||||||
import { BoundingBoxBase } from '../physics/bounding-boxes/bounding-box-base';
|
import { BoundingBoxBase } from '../physics/bounding-boxes/bounding-box-base';
|
||||||
import { ProjectilePhysical } from './projectile-physical';
|
import { ProjectilePhysical } from './projectile-physical';
|
||||||
import { interpolateAngles } from '../helper/interpolate-angles';
|
|
||||||
import { forceAtPosition } from '../physics/functions/force-at-position';
|
import { forceAtPosition } from '../physics/functions/force-at-position';
|
||||||
import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle';
|
import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle';
|
||||||
import { PlanetPhysical } from './planet-physical';
|
import { PlanetPhysical } from './planet-physical';
|
||||||
|
|
@ -92,10 +97,16 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
|
||||||
private currentPlanet?: PlanetPhysical;
|
private currentPlanet?: PlanetPhysical;
|
||||||
private secondsSinceOnSurface = settings.planetDetachmentSeconds;
|
private secondsSinceOnSurface = settings.planetDetachmentSeconds;
|
||||||
|
|
||||||
|
private bodyVelocity = vec2.create();
|
||||||
|
private timeSinceLastLeap = settings.leapCooldownSeconds;
|
||||||
|
|
||||||
public head: CirclePhysical;
|
public head: CirclePhysical;
|
||||||
public leftFoot: CirclePhysical;
|
public leftFoot: CirclePhysical;
|
||||||
public rightFoot: CirclePhysical;
|
public rightFoot: CirclePhysical;
|
||||||
|
|
||||||
|
private movementState!: CharacterMovementState;
|
||||||
|
private movementWorld!: CharacterWorld;
|
||||||
|
|
||||||
private movementActions: Array<MoveActionCommand> = [];
|
private movementActions: Array<MoveActionCommand> = [];
|
||||||
private lastMovementAction: MoveActionCommand = new MoveActionCommand(vec2.create());
|
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.head);
|
||||||
container.addObject(this.leftFoot);
|
container.addObject(this.leftFoot);
|
||||||
container.addObject(this.rightFoot);
|
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 {
|
private get isSpawnProtected(): boolean {
|
||||||
|
|
@ -170,7 +226,13 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
|
||||||
return this.currentPlanet;
|
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.killCount++;
|
||||||
this.killStreak++;
|
this.killStreak++;
|
||||||
this.remoteCall('setKillCount', this.killCount);
|
this.remoteCall('setKillCount', this.killCount);
|
||||||
|
|
@ -180,11 +242,11 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
|
||||||
this.health + settings.playerKillHealthReward,
|
this.health + settings.playerKillHealthReward,
|
||||||
);
|
);
|
||||||
this.syncHealth();
|
this.syncHealth();
|
||||||
this.remoteCall('onKillConfirmed', victimName, this.killStreak);
|
this.remoteCall('onKillConfirmed', victimName, this.killStreak, charge);
|
||||||
}
|
}
|
||||||
|
|
||||||
public registerHit() {
|
public registerHit(charge = 0) {
|
||||||
this.remoteCall('onHitConfirmed');
|
this.remoteCall('onHitConfirmed', charge);
|
||||||
}
|
}
|
||||||
|
|
||||||
private syncHealth() {
|
private syncHealth() {
|
||||||
|
|
@ -197,6 +259,9 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
|
||||||
|
|
||||||
public onCollision({ other }: ReactToCollisionCommand) {
|
public onCollision({ other }: ReactToCollisionCommand) {
|
||||||
if (
|
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 instanceof ProjectilePhysical &&
|
||||||
other.team !== this.team &&
|
other.team !== this.team &&
|
||||||
other.isAlive
|
other.isAlive
|
||||||
|
|
@ -213,10 +278,17 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
|
||||||
this.remoteCall('setHealth', this.health);
|
this.remoteCall('setHealth', this.health);
|
||||||
|
|
||||||
if (this.health <= 0 && this.isAlive) {
|
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();
|
this.onDie();
|
||||||
other.originator.addKill(this.name);
|
other.originator.addKill(this.name, other.charge);
|
||||||
} else {
|
} 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);
|
const direction = vec2.subtract(vec2.create(), position, this.center);
|
||||||
vec2.normalize(direction, direction);
|
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 velocity = vec2.scale(direction, direction, speed);
|
||||||
const projectile = new ProjectilePhysical(
|
const projectile = new ProjectilePhysical(
|
||||||
vec2.clone(this.center),
|
vec2.clone(this.center),
|
||||||
|
|
@ -255,12 +329,42 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
|
||||||
velocity,
|
velocity,
|
||||||
this,
|
this,
|
||||||
this.container,
|
this.container,
|
||||||
|
c,
|
||||||
);
|
);
|
||||||
this.container.addObject(projectile);
|
this.container.addObject(projectile);
|
||||||
|
|
||||||
|
if (c > 0) {
|
||||||
|
vec2.scaleAndAdd(
|
||||||
|
this.bodyVelocity,
|
||||||
|
this.bodyVelocity,
|
||||||
|
shotDirection,
|
||||||
|
-settings.chargeShotRecoilMax * c,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
this.remoteCall('onShoot', strength);
|
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 {
|
public get boundingBox(): BoundingBoxBase {
|
||||||
return getBoundingBoxOfCircle(new Circle(this.center, CharacterPhysical.boundRadius));
|
return getBoundingBoxOfCircle(new Circle(this.center, CharacterPhysical.boundRadius));
|
||||||
}
|
}
|
||||||
|
|
@ -363,6 +467,7 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
|
||||||
private step({ deltaTimeInSeconds, game }: StepCommand) {
|
private step({ deltaTimeInSeconds, game }: StepCommand) {
|
||||||
this.getPoints(game);
|
this.getPoints(game);
|
||||||
this.timeAlive += deltaTimeInSeconds;
|
this.timeAlive += deltaTimeInSeconds;
|
||||||
|
this.timeSinceLastLeap += deltaTimeInSeconds;
|
||||||
const oldHead = new Circle(vec2.clone(this.head.center), this.head.radius);
|
const oldHead = new Circle(vec2.clone(this.head.center), this.head.radius);
|
||||||
const oldLeftFoot = new Circle(
|
const oldLeftFoot = new Circle(
|
||||||
vec2.clone(this.leftFoot.center),
|
vec2.clone(this.leftFoot.center),
|
||||||
|
|
@ -377,6 +482,7 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
|
||||||
if ((this.timeSinceDying += deltaTimeInSeconds) > settings.spawnDespawnTime) {
|
if ((this.timeSinceDying += deltaTimeInSeconds) > settings.spawnDespawnTime) {
|
||||||
this.destroy();
|
this.destroy();
|
||||||
} else {
|
} else {
|
||||||
|
this.freeFallCorpse(deltaTimeInSeconds);
|
||||||
this.animateScaling(1 - this.timeSinceDying / settings.spawnDespawnTime);
|
this.animateScaling(1 - this.timeSinceDying / settings.spawnDespawnTime);
|
||||||
}
|
}
|
||||||
this.setPropertyUpdates(oldHead, oldLeftFoot, oldRightFoot, deltaTimeInSeconds);
|
this.setPropertyUpdates(oldHead, oldLeftFoot, oldRightFoot, deltaTimeInSeconds);
|
||||||
|
|
@ -410,9 +516,28 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
|
||||||
|
|
||||||
this.regenerateHealth(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(
|
getBoundingBoxOfCircle(
|
||||||
new Circle(
|
new Circle(
|
||||||
this.center,
|
this.center,
|
||||||
|
|
@ -420,151 +545,20 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
let grounded = false;
|
||||||
const direction = this.averageAndResetMovementActions();
|
for (const part of [this.leftFoot, this.rightFoot, this.head]) {
|
||||||
const movementForce = vec2.scale(direction, direction, settings.maxAcceleration);
|
part.applyForce(forceAtPosition(part.center, intersecting), deltaTime);
|
||||||
this.leftFoot.applyForce(movementForce, deltaTimeInSeconds);
|
vec2.add(part.velocity, part.velocity, this.bodyVelocity);
|
||||||
this.rightFoot.applyForce(movementForce, deltaTimeInSeconds);
|
const { hitObject } = part.stepManually(deltaTime);
|
||||||
|
if (hitObject instanceof PlanetPhysical) {
|
||||||
if (!this.currentPlanet) {
|
grounded = true;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
// 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
|
||||||
this.keepPosture();
|
// sliding forever.
|
||||||
this.stepBodyPart(this.leftFoot, deltaTimeInSeconds);
|
decayMomentum(this.bodyVelocity, grounded, deltaTime);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private regenerateHealth(deltaTimeInSeconds: number) {
|
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() {
|
public onDie() {
|
||||||
this.isDestroyed = true;
|
this.isDestroyed = true;
|
||||||
this.remoteCall('onDie');
|
this.remoteCall('onDie');
|
||||||
|
|
|
||||||
|
|
@ -4,14 +4,14 @@ import {
|
||||||
CommandExecutors,
|
CommandExecutors,
|
||||||
CommandReceiver,
|
CommandReceiver,
|
||||||
GameObject,
|
GameObject,
|
||||||
|
resolveCircleMovement,
|
||||||
serializesTo,
|
serializesTo,
|
||||||
} from 'shared';
|
} from 'shared';
|
||||||
import { BoundingBox } from '../physics/bounding-boxes/bounding-box';
|
import { BoundingBox } from '../physics/bounding-boxes/bounding-box';
|
||||||
import { BoundingBoxBase } from '../physics/bounding-boxes/bounding-box-base';
|
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 { PhysicalContainer } from '../physics/containers/physical-container';
|
||||||
import { DynamicPhysical } from '../physics/physicals/dynamic-physical';
|
import { DynamicPhysical } from '../physics/physicals/dynamic-physical';
|
||||||
|
import { Physical } from '../physics/physicals/physical';
|
||||||
import { ReactToCollisionCommand } from '../commands/react-to-collision';
|
import { ReactToCollisionCommand } from '../commands/react-to-collision';
|
||||||
|
|
||||||
@serializesTo(Circle)
|
@serializesTo(Circle)
|
||||||
|
|
@ -33,7 +33,9 @@ export class CirclePhysical extends CommandReceiver implements Circle, DynamicPh
|
||||||
private _radius: number,
|
private _radius: number,
|
||||||
public owner: GameObject,
|
public owner: GameObject,
|
||||||
private readonly container: PhysicalContainer,
|
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();
|
super();
|
||||||
this._boundingBox = new BoundingBox();
|
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;
|
hitObject: GameObject | undefined;
|
||||||
velocity: vec2;
|
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 { hitObject, velocity } = resolveCircleMovement(
|
||||||
const intersecting = this.container
|
this,
|
||||||
.findIntersecting(this.boundingBox)
|
deltaTimeInSeconds,
|
||||||
.filter((b) => b.gameObject !== this.gameObject && b.canCollide);
|
intersecting,
|
||||||
this.radius -= vec2.length(delta);
|
(intersected) => {
|
||||||
|
const physical = intersected as Physical;
|
||||||
depenetrateCircle(this, intersecting);
|
physical.handleCommand(new ReactToCollisionCommand(this.gameObject));
|
||||||
|
this.handleCommand(new ReactToCollisionCommand(physical.gameObject));
|
||||||
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) {
|
return { hitObject: (hitObject as Physical | undefined)?.gameObject, velocity };
|
||||||
delta = vec2.scale(vec2.create(), this.velocity, deltaTimeInSeconds);
|
|
||||||
moveCircle(this, delta, intersecting);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const lastVelocity = vec2.clone(this.velocity);
|
// Query the container with the bounding box grown by this tick's travel, so a
|
||||||
vec2.zero(this.velocity);
|
// fast-moving body still sees what it is about to sweep into.
|
||||||
|
private sweptBroadphase(deltaTimeInSeconds: number): Array<Physical> {
|
||||||
return { hitObject, velocity: lastVelocity };
|
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> {
|
public toArray(): Array<any> {
|
||||||
|
|
|
||||||
|
|
@ -16,16 +16,21 @@ import {
|
||||||
CommandReceiver,
|
CommandReceiver,
|
||||||
} from 'shared';
|
} from 'shared';
|
||||||
import { GeneratePointsCommand } from '../commands/generate-points';
|
import { GeneratePointsCommand } from '../commands/generate-points';
|
||||||
|
import { AnnounceCommand } from '../commands/announce';
|
||||||
import { StepCommand } from '../commands/step';
|
import { StepCommand } from '../commands/step';
|
||||||
|
|
||||||
import { ImmutableBoundingBox } from '../physics/bounding-boxes/immutable-bounding-box';
|
import { ImmutableBoundingBox } from '../physics/bounding-boxes/immutable-bounding-box';
|
||||||
import { StaticPhysical } from '../physics/physicals/static-physical';
|
import { StaticPhysical } from '../physics/physicals/static-physical';
|
||||||
import { LampPhysical } from './lamp-physical';
|
import { LampPhysical } from './lamp-physical';
|
||||||
|
import type { CharacterPhysical } from './character-physical';
|
||||||
|
|
||||||
@serializesTo(PlanetBase)
|
@serializesTo(PlanetBase)
|
||||||
export class PlanetPhysical extends PlanetBase implements StaticPhysical {
|
export class PlanetPhysical extends PlanetBase implements StaticPhysical {
|
||||||
public readonly canCollide = true;
|
public readonly canCollide = true;
|
||||||
public readonly canMove = false;
|
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;
|
public readonly sizePointMultiplier: number;
|
||||||
|
|
||||||
|
|
@ -45,6 +50,12 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
|
||||||
|
|
||||||
private lastTeam: CharacterTeam = CharacterTeam.neutral;
|
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 = {
|
protected commandExecutors: CommandExecutors = {
|
||||||
[StepCommand.type]: this.step.bind(this),
|
[StepCommand.type]: this.step.bind(this),
|
||||||
};
|
};
|
||||||
|
|
@ -53,8 +64,8 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
|
||||||
this.lamps.push(lamp);
|
this.lamps.push(lamp);
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(vertices: Array<vec2>) {
|
constructor(vertices: Array<vec2>, isKeystone = false) {
|
||||||
super(id(), vertices);
|
super(id(), vertices, 0.5, isKeystone);
|
||||||
|
|
||||||
const sizeClass = clamp01(
|
const sizeClass = clamp01(
|
||||||
(this.radius - settings.planetMinReferenceRadius) /
|
(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);
|
(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 {
|
public distance(target: vec2): number {
|
||||||
// Evaluate the SDF in the planet's own rotating frame so this collision
|
// 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).
|
// 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
|
// 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 {
|
public get angularVelocity(): number {
|
||||||
return this.rotationSpeed;
|
return this.rotationSpeed;
|
||||||
}
|
}
|
||||||
|
|
||||||
public get team(): CharacterTeam {
|
public get team(): CharacterTeam {
|
||||||
return Math.abs(this.ownership - 0.5) < 0.1
|
return Math.abs(this.ownership - 0.5) < settings.planetControlThreshold
|
||||||
? CharacterTeam.neutral
|
? CharacterTeam.neutral
|
||||||
: this.ownership < 0.5
|
: this.ownership < 0.5
|
||||||
? CharacterTeam.blue
|
? CharacterTeam.blue
|
||||||
|
|
@ -160,8 +177,49 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
|
||||||
|
|
||||||
// In reverse order, so that teams can achieve a 100% control.
|
// In reverse order, so that teams can achieve a 100% control.
|
||||||
this.getPoints(game);
|
this.getPoints(game);
|
||||||
this.takeControl(CharacterTeam.neutral, deltaTimeInSeconds);
|
this.resolveCapture(deltaTimeInSeconds);
|
||||||
this.detectFlip(game);
|
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) {
|
private detectFlip(game: CommandReceiver) {
|
||||||
|
|
@ -182,6 +240,14 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
|
||||||
currentTeam === CharacterTeam.red ? reward : 0,
|
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;
|
const control = Math.abs(this.ownership - 0.5) / 0.5;
|
||||||
|
|
@ -196,8 +262,8 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
|
||||||
public getPropertyUpdates(): PropertyUpdatesForObject {
|
public getPropertyUpdates(): PropertyUpdatesForObject {
|
||||||
return new PropertyUpdatesForObject(this.id, [
|
return new PropertyUpdatesForObject(this.id, [
|
||||||
new UpdatePropertyCommand('ownership', this.ownership, 0),
|
new UpdatePropertyCommand('ownership', this.ownership, 0),
|
||||||
// Stream rotation with rotationSpeed as the rate-of-change so the client
|
// Stream the spin rate as the rate-of-change so the client can keep the
|
||||||
// can keep the angle moving when snapshots run late (see planet-view.ts).
|
// angle moving when snapshots run late (see planet-view.ts).
|
||||||
new UpdatePropertyCommand('rotation', this.rotation, this.rotationSpeed),
|
new UpdatePropertyCommand('rotation', this.rotation, this.rotationSpeed),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
@ -257,6 +323,11 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
|
||||||
return vec2.scale(diff, diff, scale);
|
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 {
|
public get gameObject(): this {
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,13 +8,16 @@ import {
|
||||||
PropertyUpdatesForObject,
|
PropertyUpdatesForObject,
|
||||||
UpdatePropertyCommand,
|
UpdatePropertyCommand,
|
||||||
CommandExecutors,
|
CommandExecutors,
|
||||||
|
Circle,
|
||||||
|
marchCircle,
|
||||||
} from 'shared';
|
} from 'shared';
|
||||||
import { ImmutableBoundingBox } from '../physics/bounding-boxes/immutable-bounding-box';
|
import { ImmutableBoundingBox } from '../physics/bounding-boxes/immutable-bounding-box';
|
||||||
import { CirclePhysical } from './circle-physical';
|
import { CirclePhysical } from './circle-physical';
|
||||||
import { DynamicPhysical } from '../physics/physicals/dynamic-physical';
|
import { DynamicPhysical } from '../physics/physicals/dynamic-physical';
|
||||||
import { PhysicalContainer } from '../physics/containers/physical-container';
|
import { PhysicalContainer } from '../physics/containers/physical-container';
|
||||||
import { CharacterPhysical } from './character-physical';
|
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 { StepCommand } from '../commands/step';
|
||||||
import { ReactToCollisionCommand } from '../commands/react-to-collision';
|
import { ReactToCollisionCommand } from '../commands/react-to-collision';
|
||||||
|
|
||||||
|
|
@ -42,6 +45,9 @@ export class ProjectilePhysical extends ProjectileBase implements DynamicPhysica
|
||||||
private velocity: vec2,
|
private velocity: vec2,
|
||||||
public readonly originator: CharacterPhysical,
|
public readonly originator: CharacterPhysical,
|
||||||
readonly container: PhysicalContainer,
|
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);
|
super(id(), center, radius, team, strength);
|
||||||
this.object = new CirclePhysical(center, radius, this, container, 0.9);
|
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
|
const intersecting = this.container
|
||||||
.findIntersecting(this.boundingBox)
|
.findIntersecting(this.boundingBox)
|
||||||
.filter((g) => g instanceof CharacterPhysical && g.team === this.team);
|
.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;
|
wasCollision = hitSurface;
|
||||||
}
|
}
|
||||||
vec2.add(this.center, this.center, delta);
|
vec2.add(this.center, this.center, delta);
|
||||||
|
|
@ -124,8 +130,31 @@ export class ProjectilePhysical extends ProjectileBase implements DynamicPhysica
|
||||||
return;
|
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);
|
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);
|
vec2.copy(this.velocity, velocity);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { Circle } from 'shared';
|
import { Circle, evaluateSdf } from 'shared';
|
||||||
import { evaluateSdf } from './evaluate-sdf';
|
|
||||||
import { PhysicalBase } from '../physicals/physical-base';
|
import { PhysicalBase } from '../physicals/physical-base';
|
||||||
|
|
||||||
export const isCircleIntersecting = (
|
export const isCircleIntersecting = (
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,10 @@ const npcTuning = {
|
||||||
spreadBase: 60,
|
spreadBase: 60,
|
||||||
spreadPerDistance: 0.08,
|
spreadPerDistance: 0.08,
|
||||||
spreadAggressionFalloff: 1.3,
|
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 {
|
export class NPC extends PlayerBase {
|
||||||
|
|
@ -141,6 +145,16 @@ export class NPC extends PlayerBase {
|
||||||
const movement = this.decideMovement(this.nearObjects);
|
const movement = this.decideMovement(this.nearObjects);
|
||||||
this.character.handleMovementAction(new MoveActionCommand(movement));
|
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 (
|
if (
|
||||||
(this.timeSinceLastShoot += deltaTimeInSeconds) > npcTuning.shootIntervalSeconds
|
(this.timeSinceLastShoot += deltaTimeInSeconds) > npcTuning.shootIntervalSeconds
|
||||||
) {
|
) {
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,8 @@ import {
|
||||||
PropertyUpdatesForObjects,
|
PropertyUpdatesForObjects,
|
||||||
PropertyUpdatesForObject,
|
PropertyUpdatesForObject,
|
||||||
PrimaryActionCommand,
|
PrimaryActionCommand,
|
||||||
|
LeapActionCommand,
|
||||||
|
InputAcknowledgement,
|
||||||
} from 'shared';
|
} from 'shared';
|
||||||
import { Socket } from 'socket.io';
|
import { Socket } from 'socket.io';
|
||||||
import { BoundingBox } from '../physics/bounding-boxes/bounding-box';
|
import { BoundingBox } from '../physics/bounding-boxes/bounding-box';
|
||||||
|
|
@ -37,14 +39,27 @@ export class Player extends PlayerBase {
|
||||||
private timeUntilRespawn = 0;
|
private timeUntilRespawn = 0;
|
||||||
private timeSinceLastMessage = 0;
|
private timeSinceLastMessage = 0;
|
||||||
private objectsPreviouslyInViewArea: Array<GameObject> = [];
|
private objectsPreviouslyInViewArea: Array<GameObject> = [];
|
||||||
|
private lastInputClientTimeMs = 0;
|
||||||
|
private lastLeapClientTimeMs = 0;
|
||||||
|
|
||||||
protected commandExecutors: CommandExecutors = {
|
protected commandExecutors: CommandExecutors = {
|
||||||
[SetAspectRatioActionCommand.type]: (v: SetAspectRatioActionCommand) =>
|
[SetAspectRatioActionCommand.type]: (v: SetAspectRatioActionCommand) =>
|
||||||
(this.aspectRatio = v.aspectRatio),
|
(this.aspectRatio = v.aspectRatio),
|
||||||
[MoveActionCommand.type]: (c: MoveActionCommand) =>
|
[MoveActionCommand.type]: (c: MoveActionCommand) => {
|
||||||
this.character?.handleMovementAction(c),
|
// 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) =>
|
[PrimaryActionCommand.type]: (c: PrimaryActionCommand) =>
|
||||||
this.character?.shootTowards(c.position, c.charge),
|
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(
|
constructor(
|
||||||
|
|
@ -100,6 +115,13 @@ export class Player extends PlayerBase {
|
||||||
new Set(this.objectContainer.findIntersecting(bb).map((o) => o.gameObject)),
|
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(
|
const newlyIntersecting = objectsInViewArea.filter(
|
||||||
(o) => !this.objectsPreviouslyInViewArea.includes(o),
|
(o) => !this.objectsPreviouslyInViewArea.includes(o),
|
||||||
);
|
);
|
||||||
|
|
@ -130,6 +152,19 @@ export class Player extends PlayerBase {
|
||||||
performance.now() / 1000,
|
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> {
|
private getOtherPlayers(): Array<OtherPlayerDirection> {
|
||||||
|
|
|
||||||
|
|
@ -207,6 +207,14 @@ body {
|
||||||
@include square(50px);
|
@include square(50px);
|
||||||
border-radius: 1000px;
|
border-radius: 1000px;
|
||||||
mask-image: url('../static/mask.svg');
|
mask-image: url('../static/mask.svg');
|
||||||
|
|
||||||
|
&.contested {
|
||||||
|
animation: contested-pulse 0.7s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.keystone {
|
||||||
|
@include square(72px);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.falling-point {
|
.falling-point {
|
||||||
|
|
@ -244,6 +252,34 @@ body {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Off-screen pointer to the keystone "Heart", tinted by its current owner.
|
||||||
|
.keystone-arrow {
|
||||||
|
transition: transform 150ms;
|
||||||
|
opacity: 0.9;
|
||||||
|
|
||||||
|
@include square($large-icon);
|
||||||
|
|
||||||
|
@media (max-width: $breakpoint) {
|
||||||
|
@include square($small-icon);
|
||||||
|
}
|
||||||
|
|
||||||
|
mask-image: url('../static/chevron.svg');
|
||||||
|
mask-size: contain;
|
||||||
|
background-color: $bright-neutral;
|
||||||
|
|
||||||
|
&.blue {
|
||||||
|
background-color: $bright-blue;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.red {
|
||||||
|
background-color: $bright-red;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.neutral {
|
||||||
|
background-color: $bright-neutral;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.joystick {
|
.joystick {
|
||||||
@include square($large-icon * 1.3);
|
@include square($large-icon * 1.3);
|
||||||
background-color: white;
|
background-color: white;
|
||||||
|
|
@ -296,6 +332,15 @@ body {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.leap {
|
||||||
|
right: $medium-padding;
|
||||||
|
bottom: $medium-padding + $large-icon * 1.6 + $small-padding;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '\25B2';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.strength-ring {
|
.strength-ring {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -3px;
|
top: -3px;
|
||||||
|
|
@ -572,6 +617,16 @@ body {
|
||||||
}
|
}
|
||||||
|
|
||||||
animation: hitmarker-pop 250ms ease-out forwards;
|
animation: hitmarker-pop 250ms ease-out forwards;
|
||||||
|
|
||||||
|
&.charged {
|
||||||
|
|
||||||
|
&::before,
|
||||||
|
&::after {
|
||||||
|
width: 18px;
|
||||||
|
height: 3px;
|
||||||
|
background-color: $accent;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.kill-popup {
|
.kill-popup {
|
||||||
|
|
@ -584,6 +639,10 @@ body {
|
||||||
text-shadow: 0 0 6px rgba(0, 0, 0, 0.9);
|
text-shadow: 0 0 6px rgba(0, 0, 0, 0.9);
|
||||||
animation: kill-popup-rise 1200ms ease-out forwards;
|
animation: kill-popup-rise 1200ms ease-out forwards;
|
||||||
|
|
||||||
|
&.charged {
|
||||||
|
color: $accent;
|
||||||
|
}
|
||||||
|
|
||||||
.heal {
|
.heal {
|
||||||
color: #6fcf6f;
|
color: #6fcf6f;
|
||||||
}
|
}
|
||||||
|
|
@ -626,6 +685,18 @@ body {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes contested-pulse {
|
||||||
|
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
opacity: 0.35;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.charge-ring {
|
.charge-ring {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@include square(56px);
|
@include square(56px);
|
||||||
|
|
@ -649,6 +720,7 @@ body {
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes match-point-pulse {
|
@keyframes match-point-pulse {
|
||||||
|
|
||||||
0%,
|
0%,
|
||||||
100% {
|
100% {
|
||||||
box-shadow: 0 0 4px 0 currentColor;
|
box-shadow: 0 0 4px 0 currentColor;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { vec2 } from 'gl-matrix';
|
import { vec2 } from 'gl-matrix';
|
||||||
import { CommandGenerator, MoveActionCommand } from 'shared';
|
import { CommandGenerator, LeapActionCommand, MoveActionCommand } from 'shared';
|
||||||
|
import { localCharacterPredictor } from '../helper/prediction/local-character-predictor';
|
||||||
|
|
||||||
export class KeyboardListener extends CommandGenerator {
|
export class KeyboardListener extends CommandGenerator {
|
||||||
private keysDown: Set<string> = new Set();
|
private keysDown: Set<string> = new Set();
|
||||||
|
|
@ -13,7 +14,14 @@ export class KeyboardListener extends CommandGenerator {
|
||||||
}
|
}
|
||||||
|
|
||||||
private keyDownListener = (event: KeyboardEvent) => {
|
private keyDownListener = (event: KeyboardEvent) => {
|
||||||
this.keysDown.add(event.key.toLowerCase());
|
const key = event.key.toLowerCase();
|
||||||
|
// Space leaps (W / ArrowUp already cover walking up). Edge-triggered so a
|
||||||
|
// held key's auto-repeat doesn't spam leaps.
|
||||||
|
if ((key === ' ' || key === 'shift') && !this.keysDown.has(key)) {
|
||||||
|
const clientTimeMs = localCharacterPredictor.recordLeap();
|
||||||
|
this.sendCommandToSubscribers(new LeapActionCommand(clientTimeMs));
|
||||||
|
}
|
||||||
|
this.keysDown.add(key);
|
||||||
this.generateCommands();
|
this.generateCommands();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -28,11 +36,7 @@ export class KeyboardListener extends CommandGenerator {
|
||||||
};
|
};
|
||||||
|
|
||||||
private generateCommands() {
|
private generateCommands() {
|
||||||
const up = ~~(
|
const up = ~~(this.keysDown.has('w') || this.keysDown.has('arrowup'));
|
||||||
this.keysDown.has('w') ||
|
|
||||||
this.keysDown.has('arrowup') ||
|
|
||||||
this.keysDown.has(' ')
|
|
||||||
);
|
|
||||||
const down = ~~(this.keysDown.has('s') || this.keysDown.has('arrowdown'));
|
const down = ~~(this.keysDown.has('s') || this.keysDown.has('arrowdown'));
|
||||||
const left = ~~(this.keysDown.has('a') || this.keysDown.has('arrowleft'));
|
const left = ~~(this.keysDown.has('a') || this.keysDown.has('arrowleft'));
|
||||||
const right = ~~(this.keysDown.has('d') || this.keysDown.has('arrowright'));
|
const right = ~~(this.keysDown.has('d') || this.keysDown.has('arrowright'));
|
||||||
|
|
@ -42,7 +46,8 @@ export class KeyboardListener extends CommandGenerator {
|
||||||
vec2.normalize(movement, movement);
|
vec2.normalize(movement, movement);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.sendCommandToSubscribers(new MoveActionCommand(movement));
|
const clientTimeMs = localCharacterPredictor.recordInput(movement);
|
||||||
|
this.sendCommandToSubscribers(new MoveActionCommand(movement, clientTimeMs));
|
||||||
}
|
}
|
||||||
|
|
||||||
public destroy() {
|
public destroy() {
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,13 @@ import {
|
||||||
MoveActionCommand,
|
MoveActionCommand,
|
||||||
last,
|
last,
|
||||||
PrimaryActionCommand,
|
PrimaryActionCommand,
|
||||||
|
LeapActionCommand,
|
||||||
holdDurationToCharge,
|
holdDurationToCharge,
|
||||||
settings,
|
settings,
|
||||||
} from 'shared';
|
} from 'shared';
|
||||||
import { Game } from '../game';
|
import { Game } from '../game';
|
||||||
import { ChargeIndicator } from '../charge-indicator';
|
import { ChargeIndicator } from '../charge-indicator';
|
||||||
|
import { localCharacterPredictor } from '../helper/prediction/local-character-predictor';
|
||||||
|
|
||||||
export class TouchListener extends CommandGenerator {
|
export class TouchListener extends CommandGenerator {
|
||||||
private static readonly deadZone = 8;
|
private static readonly deadZone = 8;
|
||||||
|
|
@ -22,6 +24,7 @@ export class TouchListener extends CommandGenerator {
|
||||||
|
|
||||||
private fireButton: HTMLElement;
|
private fireButton: HTMLElement;
|
||||||
private fireStrengthRing: HTMLElement;
|
private fireStrengthRing: HTMLElement;
|
||||||
|
private leapButton: HTMLElement;
|
||||||
|
|
||||||
private fireDownAt: number | null = null;
|
private fireDownAt: number | null = null;
|
||||||
|
|
||||||
|
|
@ -46,7 +49,12 @@ export class TouchListener extends CommandGenerator {
|
||||||
this.fireButton.addEventListener('touchstart', this.fireButtonDownListener);
|
this.fireButton.addEventListener('touchstart', this.fireButtonDownListener);
|
||||||
this.fireButton.addEventListener('touchend', this.fireButtonUpListener);
|
this.fireButton.addEventListener('touchend', this.fireButtonUpListener);
|
||||||
|
|
||||||
|
this.leapButton = document.createElement('div');
|
||||||
|
this.leapButton.className = 'touch-button leap';
|
||||||
|
this.leapButton.addEventListener('touchstart', this.leapButtonListener);
|
||||||
|
|
||||||
this.overlay.appendChild(this.fireButton);
|
this.overlay.appendChild(this.fireButton);
|
||||||
|
this.overlay.appendChild(this.leapButton);
|
||||||
|
|
||||||
target.addEventListener('touchstart', this.touchStartListener);
|
target.addEventListener('touchstart', this.touchStartListener);
|
||||||
target.addEventListener('touchmove', this.touchMoveListener);
|
target.addEventListener('touchmove', this.touchMoveListener);
|
||||||
|
|
@ -101,12 +109,17 @@ export class TouchListener extends CommandGenerator {
|
||||||
vec2.set(delta, delta.x, -delta.y);
|
vec2.set(delta, delta.x, -delta.y);
|
||||||
if (deltaLength > TouchListener.deadZone) {
|
if (deltaLength > TouchListener.deadZone) {
|
||||||
const direction = vec2.normalize(delta, delta);
|
const direction = vec2.normalize(delta, delta);
|
||||||
this.sendCommandToSubscribers(new MoveActionCommand(direction));
|
this.sendMove(direction);
|
||||||
} else {
|
} else {
|
||||||
this.sendCommandToSubscribers(new MoveActionCommand(vec2.create()));
|
this.sendMove(vec2.create());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private sendMove(direction: vec2) {
|
||||||
|
const clientTimeMs = localCharacterPredictor.recordInput(direction);
|
||||||
|
this.sendCommandToSubscribers(new MoveActionCommand(direction, clientTimeMs));
|
||||||
|
}
|
||||||
|
|
||||||
private touchEndListener = (event: TouchEvent) => {
|
private touchEndListener = (event: TouchEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
|
|
@ -127,7 +140,7 @@ export class TouchListener extends CommandGenerator {
|
||||||
} else if (event.touches.length === 0) {
|
} else if (event.touches.length === 0) {
|
||||||
this.isJoystickActive = false;
|
this.isJoystickActive = false;
|
||||||
this.joystick.parentElement?.removeChild(this.joystick);
|
this.joystick.parentElement?.removeChild(this.joystick);
|
||||||
this.sendCommandToSubscribers(new MoveActionCommand(vec2.create()));
|
this.sendMove(vec2.create());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -136,6 +149,12 @@ export class TouchListener extends CommandGenerator {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private leapButtonListener = (event: TouchEvent) => {
|
||||||
|
this.swallowTouch(event);
|
||||||
|
const clientTimeMs = localCharacterPredictor.recordLeap();
|
||||||
|
this.sendCommandToSubscribers(new LeapActionCommand(clientTimeMs));
|
||||||
|
};
|
||||||
|
|
||||||
private fireButtonDownListener = (event: TouchEvent) => {
|
private fireButtonDownListener = (event: TouchEvent) => {
|
||||||
this.swallowTouch(event);
|
this.swallowTouch(event);
|
||||||
this.fireDownAt = performance.now();
|
this.fireDownAt = performance.now();
|
||||||
|
|
@ -170,6 +189,9 @@ export class TouchListener extends CommandGenerator {
|
||||||
if (!this.fireButton.parentElement) {
|
if (!this.fireButton.parentElement) {
|
||||||
this.overlay.appendChild(this.fireButton);
|
this.overlay.appendChild(this.fireButton);
|
||||||
}
|
}
|
||||||
|
if (!this.leapButton.parentElement) {
|
||||||
|
this.overlay.appendChild(this.leapButton);
|
||||||
|
}
|
||||||
|
|
||||||
const character = this.game.gameObjects.player;
|
const character = this.game.gameObjects.player;
|
||||||
if (character) {
|
if (character) {
|
||||||
|
|
@ -186,7 +208,9 @@ export class TouchListener extends CommandGenerator {
|
||||||
|
|
||||||
this.fireButton.removeEventListener('touchstart', this.fireButtonDownListener);
|
this.fireButton.removeEventListener('touchstart', this.fireButtonDownListener);
|
||||||
this.fireButton.removeEventListener('touchend', this.fireButtonUpListener);
|
this.fireButton.removeEventListener('touchend', this.fireButtonUpListener);
|
||||||
|
this.leapButton.removeEventListener('touchstart', this.leapButtonListener);
|
||||||
|
|
||||||
this.fireButton.parentElement?.removeChild(this.fireButton);
|
this.fireButton.parentElement?.removeChild(this.fireButton);
|
||||||
|
this.leapButton.parentElement?.removeChild(this.leapButton);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,17 +33,19 @@ export abstract class FeedbackHud {
|
||||||
setTimeout(() => element.parentElement?.removeChild(element), lifetimeMs);
|
setTimeout(() => element.parentElement?.removeChild(element), lifetimeMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static hitMarker() {
|
public static hitMarker(charge = 0) {
|
||||||
const { x, y } = this.focusPoint();
|
const { x, y } = this.focusPoint();
|
||||||
const marker = document.createElement('div');
|
const marker = document.createElement('div');
|
||||||
marker.className = 'hitmarker';
|
marker.className =
|
||||||
|
'hitmarker' + (charge >= settings.chargedHitThreshold ? ' charged' : '');
|
||||||
marker.style.left = `${x}px`;
|
marker.style.left = `${x}px`;
|
||||||
marker.style.top = `${y}px`;
|
marker.style.top = `${y}px`;
|
||||||
this.addTransient(marker, 250);
|
this.addTransient(marker, 250);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static killConfirmed(victimName?: string, streak = 1) {
|
public static killConfirmed(victimName?: string, streak = 1, charge = 0) {
|
||||||
const { killfeed } = this.ensureRoot();
|
const { killfeed } = this.ensureRoot();
|
||||||
|
const charged = charge >= settings.chargedHitThreshold;
|
||||||
|
|
||||||
const entry = document.createElement('div');
|
const entry = document.createElement('div');
|
||||||
entry.className = 'kill-entry';
|
entry.className = 'kill-entry';
|
||||||
|
|
@ -53,13 +55,13 @@ export abstract class FeedbackHud {
|
||||||
|
|
||||||
const { x, y } = this.focusPoint();
|
const { x, y } = this.focusPoint();
|
||||||
const popup = document.createElement('div');
|
const popup = document.createElement('div');
|
||||||
popup.className = 'kill-popup';
|
popup.className = 'kill-popup' + (charged ? ' charged' : '');
|
||||||
popup.innerHTML = `+${settings.playerKillPoint} <span class="heal">+${settings.playerKillHealthReward}❤</span>`;
|
popup.innerHTML = `+${settings.playerKillPoint} <span class="heal">+${settings.playerKillHealthReward}❤</span>`;
|
||||||
popup.style.left = `${x}px`;
|
popup.style.left = `${x}px`;
|
||||||
popup.style.top = `${y}px`;
|
popup.style.top = `${y}px`;
|
||||||
this.addTransient(popup, 1200);
|
this.addTransient(popup, 1200);
|
||||||
|
|
||||||
const callout = this.streakName(streak);
|
const callout = this.streakName(streak) ?? (charged ? 'Charged Kill!' : undefined);
|
||||||
if (callout) {
|
if (callout) {
|
||||||
const el = document.createElement('div');
|
const el = document.createElement('div');
|
||||||
el.className = 'streak-callout';
|
el.className = 'streak-callout';
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import {
|
||||||
CommandExecutors,
|
CommandExecutors,
|
||||||
Command,
|
Command,
|
||||||
settings,
|
settings,
|
||||||
|
InputAcknowledgement,
|
||||||
} from 'shared';
|
} from 'shared';
|
||||||
import { io, Socket } from 'socket.io-client';
|
import { io, Socket } from 'socket.io-client';
|
||||||
import { KeyboardListener } from './commands/keyboard-listener';
|
import { KeyboardListener } from './commands/keyboard-listener';
|
||||||
|
|
@ -35,6 +36,7 @@ import { PlanetShape } from './shapes/planet-shape';
|
||||||
import { RenderCommand } from './commands/types/render';
|
import { RenderCommand } from './commands/types/render';
|
||||||
import { StepCommand } from './commands/types/step';
|
import { StepCommand } from './commands/types/step';
|
||||||
import { serverTimeline } from './helper/server-timeline';
|
import { serverTimeline } from './helper/server-timeline';
|
||||||
|
import { localCharacterPredictor } from './helper/prediction/local-character-predictor';
|
||||||
import { Tutorial } from './tutorial';
|
import { Tutorial } from './tutorial';
|
||||||
import { Scoreboard } from './scoreboard';
|
import { Scoreboard } from './scoreboard';
|
||||||
|
|
||||||
|
|
@ -54,6 +56,7 @@ export class Game extends CommandReceiver {
|
||||||
private scoreboard = new Scoreboard();
|
private scoreboard = new Scoreboard();
|
||||||
private announcementText = document.createElement('h2');
|
private announcementText = document.createElement('h2');
|
||||||
private arrows: { [id: number]: HTMLElement } = {};
|
private arrows: { [id: number]: HTMLElement } = {};
|
||||||
|
private keystoneArrow?: HTMLElement;
|
||||||
private socketReceiver!: CommandSocket;
|
private socketReceiver!: CommandSocket;
|
||||||
private tutorial!: Tutorial;
|
private tutorial!: Tutorial;
|
||||||
|
|
||||||
|
|
@ -76,9 +79,11 @@ export class Game extends CommandReceiver {
|
||||||
|
|
||||||
this.socket?.close();
|
this.socket?.close();
|
||||||
serverTimeline.reset();
|
serverTimeline.reset();
|
||||||
|
localCharacterPredictor.reset();
|
||||||
this.gameObjects = new GameObjectContainer(this);
|
this.gameObjects = new GameObjectContainer(this);
|
||||||
this.overlay.innerHTML = '';
|
this.overlay.innerHTML = '';
|
||||||
this.arrows = {};
|
this.arrows = {};
|
||||||
|
this.keystoneArrow = undefined;
|
||||||
this.isEnding = false;
|
this.isEnding = false;
|
||||||
this.lastAnnouncementText = '';
|
this.lastAnnouncementText = '';
|
||||||
this.overlay.appendChild(this.scoreboard.element);
|
this.overlay.appendChild(this.scoreboard.element);
|
||||||
|
|
@ -148,6 +153,12 @@ export class Game extends CommandReceiver {
|
||||||
this.timeSinceLastAnnouncement = 0;
|
this.timeSinceLastAnnouncement = 0;
|
||||||
},
|
},
|
||||||
[UpdateGameState.type]: (c: UpdateGameState) => (this.lastGameState = c),
|
[UpdateGameState.type]: (c: UpdateGameState) => (this.lastGameState = c),
|
||||||
|
[InputAcknowledgement.type]: (c: InputAcknowledgement) =>
|
||||||
|
localCharacterPredictor.acknowledge(
|
||||||
|
c.clientTimeMs,
|
||||||
|
c.bodyVelocity,
|
||||||
|
c.lastLeapClientTimeMs,
|
||||||
|
),
|
||||||
[GameEndCommand.type]: () => (this.isEnding = true),
|
[GameEndCommand.type]: () => (this.isEnding = true),
|
||||||
[UpdateOtherPlayerDirections.type]: (c: UpdateOtherPlayerDirections) =>
|
[UpdateOtherPlayerDirections.type]: (c: UpdateOtherPlayerDirections) =>
|
||||||
(this.lastOtherPlayerDirections = c),
|
(this.lastOtherPlayerDirections = c),
|
||||||
|
|
@ -325,6 +336,71 @@ export class Game extends CommandReceiver {
|
||||||
this.handleOtherPlayerDirections(this.lastOtherPlayerDirections);
|
this.handleOtherPlayerDirections(this.lastOtherPlayerDirections);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.handleKeystoneArrow();
|
||||||
|
|
||||||
this.announcementText.innerHTML = this.lastAnnouncementText;
|
this.announcementText.innerHTML = this.lastAnnouncementText;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Points an off-screen chevron toward the keystone "Heart" planet, tinted by
|
||||||
|
// who currently holds it, so the match's focal objective is always findable.
|
||||||
|
private handleKeystoneArrow() {
|
||||||
|
if (!this.renderer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const keystone = this.gameObjects.planets.find((p) => p.isKeystone);
|
||||||
|
if (!keystone) {
|
||||||
|
if (this.keystoneArrow) {
|
||||||
|
this.keystoneArrow.style.display = 'none';
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.keystoneArrow) {
|
||||||
|
this.keystoneArrow = document.createElement('div');
|
||||||
|
this.overlay.appendChild(this.keystoneArrow);
|
||||||
|
}
|
||||||
|
|
||||||
|
const width = this.renderer.canvasSize.x;
|
||||||
|
const height = this.renderer.canvasSize.y;
|
||||||
|
const display = this.renderer.worldToDisplayCoordinates(keystone.center);
|
||||||
|
const margin = 48;
|
||||||
|
const onScreen =
|
||||||
|
display.x >= margin &&
|
||||||
|
display.x <= width - margin &&
|
||||||
|
display.y >= margin &&
|
||||||
|
display.y <= height - margin;
|
||||||
|
|
||||||
|
const control = Math.abs(keystone.ownership - 0.5);
|
||||||
|
const team =
|
||||||
|
control < settings.planetControlThreshold
|
||||||
|
? 'neutral'
|
||||||
|
: keystone.ownership < 0.5
|
||||||
|
? 'blue'
|
||||||
|
: 'red';
|
||||||
|
this.keystoneArrow.className = 'keystone-arrow ' + team;
|
||||||
|
|
||||||
|
if (onScreen) {
|
||||||
|
this.keystoneArrow.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.keystoneArrow.style.display = 'block';
|
||||||
|
|
||||||
|
const center = vec2.fromValues(width / 2, height / 2);
|
||||||
|
const dir = vec2.fromValues(display.x - center.x, display.y - center.y);
|
||||||
|
const angle = Math.atan2(dir.y, dir.x);
|
||||||
|
|
||||||
|
const aspectRatio = width / height;
|
||||||
|
const directionRatio = dir.x / dir.y;
|
||||||
|
let deltaX: number, deltaY: number;
|
||||||
|
if (aspectRatio < Math.abs(directionRatio)) {
|
||||||
|
deltaX = (width / 2 - margin) * Math.sign(dir.x);
|
||||||
|
deltaY = deltaX / directionRatio;
|
||||||
|
} else {
|
||||||
|
deltaY = (height / 2 - margin) * Math.sign(dir.y);
|
||||||
|
deltaX = deltaY * directionRatio;
|
||||||
|
}
|
||||||
|
|
||||||
|
const p = vec2.add(center, center, vec2.fromValues(deltaX, deltaY));
|
||||||
|
this.keystoneArrow.style.transform = `translateX(${p.x}px) translateY(${p.y}px) translateX(-50%) translateY(-50%) rotate(${angle + Math.PI / 2}rad)`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
138
frontend/src/scripts/helper/prediction/client-character-world.ts
Normal file
138
frontend/src/scripts/helper/prediction/client-character-world.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
import { vec2 } from 'gl-matrix';
|
||||||
|
import {
|
||||||
|
CharacterWorld,
|
||||||
|
GroundSurface,
|
||||||
|
Id,
|
||||||
|
PhysicsBody,
|
||||||
|
planetDistance,
|
||||||
|
planetGravity,
|
||||||
|
resolveCircleMovement,
|
||||||
|
} from 'shared';
|
||||||
|
|
||||||
|
// What the predictor needs to know about a planet to collide and be pulled by
|
||||||
|
// it. The client reads this off its PlanetViews.
|
||||||
|
export interface PredictablePlanet {
|
||||||
|
id: Id;
|
||||||
|
vertices: Array<vec2>;
|
||||||
|
center: vec2;
|
||||||
|
radius: number;
|
||||||
|
rotation: number;
|
||||||
|
rotationSpeed: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A planet collision/gravity surface whose rotation can be advanced during
|
||||||
|
// replay, so its outline turns in lockstep with the body the carry term moves —
|
||||||
|
// exactly as the server steps the planet before the character each tick.
|
||||||
|
class PlanetSurface implements GroundSurface {
|
||||||
|
public readonly canCollide = true;
|
||||||
|
public readonly isGround = true;
|
||||||
|
public readonly id: Id;
|
||||||
|
public center: vec2;
|
||||||
|
public angularVelocity: number;
|
||||||
|
private vertices: Array<vec2>;
|
||||||
|
private radius: number;
|
||||||
|
private rotation = 0;
|
||||||
|
private cos = 1;
|
||||||
|
private sin = 0;
|
||||||
|
|
||||||
|
constructor(planet: PredictablePlanet) {
|
||||||
|
this.id = planet.id;
|
||||||
|
this.center = planet.center;
|
||||||
|
this.vertices = planet.vertices;
|
||||||
|
this.radius = planet.radius;
|
||||||
|
this.angularVelocity = planet.rotationSpeed;
|
||||||
|
this.setRotation(planet.rotation);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sync(planet: PredictablePlanet) {
|
||||||
|
this.center = planet.center;
|
||||||
|
this.vertices = planet.vertices;
|
||||||
|
this.radius = planet.radius;
|
||||||
|
this.angularVelocity = planet.rotationSpeed;
|
||||||
|
this.setRotation(planet.rotation);
|
||||||
|
}
|
||||||
|
|
||||||
|
private setRotation(rotation: number) {
|
||||||
|
this.rotation = rotation;
|
||||||
|
this.cos = Math.cos(rotation);
|
||||||
|
this.sin = Math.sin(rotation);
|
||||||
|
}
|
||||||
|
|
||||||
|
public advance(deltaTimeInSeconds: number) {
|
||||||
|
this.setRotation(this.rotation + this.angularVelocity * deltaTimeInSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
public distance(target: vec2): number {
|
||||||
|
return planetDistance(target, this.vertices, this.center, this.cos, this.sin);
|
||||||
|
}
|
||||||
|
|
||||||
|
public gravityAt(target: vec2): vec2 {
|
||||||
|
return planetGravity(this.center, this.radius, target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The planets-only collision world the local predictor runs against. It holds
|
||||||
|
// persistent surfaces keyed by planet id (so a `currentPlanet` reference stays
|
||||||
|
// valid across frames) and never dispatches collision reactions — damage,
|
||||||
|
// scoring and the like are server-authoritative.
|
||||||
|
export class ClientCharacterWorld implements CharacterWorld {
|
||||||
|
private surfaces = new Map<Id, PlanetSurface>();
|
||||||
|
private ordered: Array<PlanetSurface> = [];
|
||||||
|
|
||||||
|
// Refresh from the current PlanetViews. Far planets contribute zero gravity
|
||||||
|
// (the falloff clamps to 0 past maxGravityDistance) and never collide, so the
|
||||||
|
// whole set can be handed to every query without a range filter. Ordered by
|
||||||
|
// id so the (rare) two-surface contact picks a stable surface.
|
||||||
|
public sync(planets: Array<PredictablePlanet>) {
|
||||||
|
const seen = new Set<Id>();
|
||||||
|
for (const planet of planets) {
|
||||||
|
seen.add(planet.id);
|
||||||
|
const existing = this.surfaces.get(planet.id);
|
||||||
|
if (existing) {
|
||||||
|
existing.sync(planet);
|
||||||
|
} else {
|
||||||
|
this.surfaces.set(planet.id, new PlanetSurface(planet));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const id of [...this.surfaces.keys()]) {
|
||||||
|
if (!seen.has(id)) {
|
||||||
|
this.surfaces.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.ordered = [...this.surfaces.entries()]
|
||||||
|
.sort((a, b) => Number(a[0]) - Number(b[0]))
|
||||||
|
.map((e) => e[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Advance every planet's collision frame by one replay substep, so surfaces
|
||||||
|
// and the carried body rotate together. The surfaces are re-synced to the
|
||||||
|
// newest snapshot rotation each frame (see sync), so the replay only ever
|
||||||
|
// steps forward from there.
|
||||||
|
public advance(deltaTimeInSeconds: number) {
|
||||||
|
for (const surface of this.ordered) {
|
||||||
|
surface.advance(deltaTimeInSeconds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public surfaceById(id: Id | undefined): GroundSurface | undefined {
|
||||||
|
return id == null ? undefined : this.surfaces.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public idOf(surface: GroundSurface | undefined): Id | undefined {
|
||||||
|
return surface instanceof PlanetSurface ? surface.id : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
public groundsNear(): Array<GroundSurface> {
|
||||||
|
return this.ordered;
|
||||||
|
}
|
||||||
|
|
||||||
|
public stepBody(
|
||||||
|
body: PhysicsBody,
|
||||||
|
deltaTimeInSeconds: number,
|
||||||
|
): GroundSurface | undefined {
|
||||||
|
const { hitObject } = resolveCircleMovement(body, deltaTimeInSeconds, this.ordered);
|
||||||
|
return hitObject && (hitObject as GroundSurface).isGround
|
||||||
|
? (hitObject as GroundSurface)
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
47
frontend/src/scripts/helper/prediction/input-history.ts
Normal file
47
frontend/src/scripts/helper/prediction/input-history.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
import { vec2 } from 'gl-matrix';
|
||||||
|
|
||||||
|
interface InputSample {
|
||||||
|
timeMs: number;
|
||||||
|
direction: vec2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep a little over a second of input — far more than any sane reconciliation
|
||||||
|
// window — so a brief stall (or a backgrounded tab catching up) can still be
|
||||||
|
// replayed, while old samples are pruned to bound memory.
|
||||||
|
const retainMs = 1500;
|
||||||
|
|
||||||
|
// A timeline of the local player's movement directions in client-clock time.
|
||||||
|
// Each generated MoveActionCommand is stamped with the time this hands out, and
|
||||||
|
// the same sample is recorded here so the predictor replays exactly the input
|
||||||
|
// the server will eventually receive. Direction is piecewise-constant: the
|
||||||
|
// active direction at any instant is the most recent sample at or before it.
|
||||||
|
export class InputHistory {
|
||||||
|
private samples: Array<InputSample> = [];
|
||||||
|
|
||||||
|
public record(direction: vec2, timeMs: number): void {
|
||||||
|
this.samples.push({ timeMs, direction: vec2.clone(direction) });
|
||||||
|
|
||||||
|
const cutoff = timeMs - retainMs;
|
||||||
|
while (this.samples.length > 1 && this.samples[1].timeMs <= cutoff) {
|
||||||
|
this.samples.shift();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The held direction at `timeMs`: the latest sample at or before it, or zero
|
||||||
|
// before any input exists.
|
||||||
|
public directionAt(timeMs: number): vec2 {
|
||||||
|
let direction = vec2.create();
|
||||||
|
for (const sample of this.samples) {
|
||||||
|
if (sample.timeMs <= timeMs) {
|
||||||
|
direction = sample.direction;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return vec2.clone(direction);
|
||||||
|
}
|
||||||
|
|
||||||
|
public reset(): void {
|
||||||
|
this.samples = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,328 @@
|
||||||
|
import { vec2 } from 'gl-matrix';
|
||||||
|
import {
|
||||||
|
Circle,
|
||||||
|
CharacterMovementState,
|
||||||
|
Id,
|
||||||
|
PhysicsBody,
|
||||||
|
settings,
|
||||||
|
stepCharacterMovement,
|
||||||
|
applyLeapImpulse,
|
||||||
|
tickPlanetDetachment,
|
||||||
|
feetRadius,
|
||||||
|
headRadius,
|
||||||
|
} from 'shared';
|
||||||
|
import { ClientCharacterWorld, PredictablePlanet } from './client-character-world';
|
||||||
|
import { InputHistory } from './input-history';
|
||||||
|
|
||||||
|
const stepMs = 1000 / 200; // match the server's 200 Hz fixed tick
|
||||||
|
const stepSeconds = 1 / 200;
|
||||||
|
|
||||||
|
// Don't replay more than this far back: if the last acknowledged input is older
|
||||||
|
// (a stall, or a backgrounded tab catching up) snap to the authoritative pose
|
||||||
|
// instead of grinding through hundreds of steps.
|
||||||
|
const maxReplayMs = 300;
|
||||||
|
|
||||||
|
// Render-side easing of the correction the reconciliation produces each frame,
|
||||||
|
// so a snapshot that disagrees with the prediction is smoothed out instead of
|
||||||
|
// popping. Short, so the local player still feels immediate.
|
||||||
|
const smoothSeconds = 0.06;
|
||||||
|
|
||||||
|
// A correction bigger than this isn't prediction error — it's a respawn,
|
||||||
|
// teleport, or a server-side impulse (leap / slingshot / recoil / death throw)
|
||||||
|
// the predictor doesn't model. Snap to it rather than gliding across the gap.
|
||||||
|
const snapDistance = 250;
|
||||||
|
|
||||||
|
const makeBody = (center: vec2, radius: number): PhysicsBody => ({
|
||||||
|
center: vec2.clone(center),
|
||||||
|
radius,
|
||||||
|
velocity: vec2.create(),
|
||||||
|
lastNormal: vec2.fromValues(0, 1),
|
||||||
|
restitution: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Predicts the local player's character so it responds to input immediately,
|
||||||
|
// instead of lagging ~100 ms + RTT behind like the interpolated remote objects.
|
||||||
|
// Each frame it resets to the latest authoritative snapshot and replays the
|
||||||
|
// player's own un-acknowledged input through the SAME movement simulation the
|
||||||
|
// server runs (shared/stepCharacterMovement), then eases the rendered pose
|
||||||
|
// toward the result. Discrete server-side impulses it can't model show up as a
|
||||||
|
// large correction and snap rather than rubber-band.
|
||||||
|
export class LocalCharacterPredictor {
|
||||||
|
private readonly inputHistory = new InputHistory();
|
||||||
|
private readonly world = new ClientCharacterWorld();
|
||||||
|
|
||||||
|
private authoritative?: { head: Circle; leftFoot: Circle; rightFoot: Circle };
|
||||||
|
private lastAckClientTimeMs?: number;
|
||||||
|
// Wall-clock (client) time the latest authoritative snapshot was received. The
|
||||||
|
// replay predicts forward from HERE by the snapshot's age, so the local pose
|
||||||
|
// advances smoothly with real time between the 25 Hz snapshots. Anchoring to
|
||||||
|
// the last *acked input* time instead breaks when input is sent only on change
|
||||||
|
// (a held key sends nothing): that time freezes, the window pins to the
|
||||||
|
// maxReplayMs clamp, the replay displacement goes constant, and the pose
|
||||||
|
// stair-steps at the snapshot rate.
|
||||||
|
private authReceiptMs = 0;
|
||||||
|
// Authoritative launch momentum at the last snapshot — seeds each replay so a
|
||||||
|
// leap/slingshot/recoil flight is reproduced and continuously corrected.
|
||||||
|
private authoritativeBodyVelocity = vec2.create();
|
||||||
|
// Wall-clock times the player issued a leap, replayed (with the impulse
|
||||||
|
// applied locally) so the launch is felt immediately, not after a round trip.
|
||||||
|
private leapHistory: Array<number> = [];
|
||||||
|
// clientTimeMs of the last leap the server has folded into the streamed
|
||||||
|
// momentum. Leaps at or before this are already in authoritativeBodyVelocity;
|
||||||
|
// only newer ones are replayed, so a leap is never applied twice.
|
||||||
|
private lastLeapAckMs = -Infinity;
|
||||||
|
// Latest streamed shooting-strength, to gate predicted leaps as the server does.
|
||||||
|
private currentStrength = settings.playerMaxStrength;
|
||||||
|
|
||||||
|
// Continuous state carried between replays (the snapshot carries only poses).
|
||||||
|
// The facing direction is NOT carried — it is re-derived from the pose each
|
||||||
|
// frame (see directionFromPose / simulate); only the latched planet and the
|
||||||
|
// time-since-surface persist.
|
||||||
|
private carriedPlanetId?: Id;
|
||||||
|
private carriedSecondsSinceSurface = 1;
|
||||||
|
|
||||||
|
// The eased, rendered pose handed to the view.
|
||||||
|
private renderHead = new Circle(vec2.create(), headRadius);
|
||||||
|
private renderLeftFoot = new Circle(vec2.create(), feetRadius);
|
||||||
|
private renderRightFoot = new Circle(vec2.create(), feetRadius);
|
||||||
|
private hasRender = false;
|
||||||
|
|
||||||
|
public get head(): Circle {
|
||||||
|
return this.renderHead;
|
||||||
|
}
|
||||||
|
public get leftFoot(): Circle {
|
||||||
|
return this.renderLeftFoot;
|
||||||
|
}
|
||||||
|
public get rightFoot(): Circle {
|
||||||
|
return this.renderRightFoot;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stamp a movement command and record it for replay. Returns the wall-clock
|
||||||
|
// time the command should carry so the server can echo it back.
|
||||||
|
public recordInput(direction: vec2): number {
|
||||||
|
const timeMs = Math.round(performance.now());
|
||||||
|
this.inputHistory.record(direction, timeMs);
|
||||||
|
return timeMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
public acknowledge(
|
||||||
|
clientTimeMs: number,
|
||||||
|
bodyVelocity: vec2,
|
||||||
|
lastLeapClientTimeMs: number,
|
||||||
|
): void {
|
||||||
|
// Inputs only advance the acknowledgement forward; the launch momentum and
|
||||||
|
// leap boundary always adopt the latest authoritative values.
|
||||||
|
if (this.lastAckClientTimeMs === undefined || clientTimeMs > this.lastAckClientTimeMs) {
|
||||||
|
this.lastAckClientTimeMs = clientTimeMs;
|
||||||
|
}
|
||||||
|
vec2.set(this.authoritativeBodyVelocity, bodyVelocity[0], bodyVelocity[1]);
|
||||||
|
this.lastLeapAckMs = lastLeapClientTimeMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
public setAuthoritative(head: Circle, leftFoot: Circle, rightFoot: Circle): void {
|
||||||
|
this.authoritative = { head, leftFoot, rightFoot };
|
||||||
|
this.authReceiptMs = Math.round(performance.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
// The player pressed leap; remember when, so the replay applies the same
|
||||||
|
// impulse the server will. Recorded regardless of whether the server accepts
|
||||||
|
// it — a rejected leap (no strength/cooldown) self-corrects via the streamed
|
||||||
|
// authoritative momentum.
|
||||||
|
public recordLeap(): number {
|
||||||
|
const timeMs = Math.round(performance.now());
|
||||||
|
this.leapHistory.push(timeMs);
|
||||||
|
const cutoff = timeMs - 1500;
|
||||||
|
while (this.leapHistory.length > 0 && this.leapHistory[0] <= cutoff) {
|
||||||
|
this.leapHistory.shift();
|
||||||
|
}
|
||||||
|
return timeMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
public setStrength(strength: number): void {
|
||||||
|
this.currentStrength = strength;
|
||||||
|
}
|
||||||
|
|
||||||
|
public reset(): void {
|
||||||
|
this.inputHistory.reset();
|
||||||
|
this.leapHistory = [];
|
||||||
|
this.lastLeapAckMs = -Infinity;
|
||||||
|
this.authoritative = undefined;
|
||||||
|
this.lastAckClientTimeMs = undefined;
|
||||||
|
this.authReceiptMs = 0;
|
||||||
|
vec2.zero(this.authoritativeBodyVelocity);
|
||||||
|
this.currentStrength = settings.playerMaxStrength;
|
||||||
|
this.carriedPlanetId = undefined;
|
||||||
|
this.carriedSecondsSinceSurface = 1;
|
||||||
|
this.hasRender = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get canPredict(): boolean {
|
||||||
|
return this.authoritative !== undefined && this.lastAckClientTimeMs !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// During spawn-in and death the server freezes walking and only scales the
|
||||||
|
// body (CharacterPhysical.step returns early), so its head radius is below
|
||||||
|
// nominal. Predicting then would walk the body off a position the server is
|
||||||
|
// holding still — let interpolation show the animation instead.
|
||||||
|
private get isAnimatingInOrOut(): boolean {
|
||||||
|
return (
|
||||||
|
this.authoritative !== undefined &&
|
||||||
|
this.authoritative.head.radius < headRadius - 0.5
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run one frame of prediction. Returns true if it produced a rendered pose the
|
||||||
|
// caller should use (otherwise fall back to interpolation). `planets` is the
|
||||||
|
// current collision world; `frameSeconds` is the render delta.
|
||||||
|
public update(planets: Array<PredictablePlanet>, frameSeconds: number): boolean {
|
||||||
|
if (!this.canPredict || this.isAnimatingInOrOut) {
|
||||||
|
// Resume from the authoritative pose with a snap rather than gliding from
|
||||||
|
// a stale rendered one.
|
||||||
|
this.hasRender = false;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.world.sync(planets);
|
||||||
|
const predicted = this.simulate();
|
||||||
|
|
||||||
|
if (!this.hasRender) {
|
||||||
|
this.snapRenderTo(predicted);
|
||||||
|
this.hasRender = true;
|
||||||
|
} else {
|
||||||
|
this.easeRenderTo(predicted, frameSeconds);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The body's facing angle is encoded in the pose: each part is sprung toward
|
||||||
|
// center + R(direction)*offset and the head's offset points +y, so
|
||||||
|
// direction = atan2(head - center) - PI/2. Re-deriving it from the snapshot
|
||||||
|
// each frame (rather than carrying the previous frame's evolved value onto
|
||||||
|
// this past pose, re-evolved by a variable substep count) keeps the posture
|
||||||
|
// seed a pure function of the snapshot — carrying it fed a frame-rate-dependent
|
||||||
|
// loop that wobbled the rendered limbs.
|
||||||
|
private directionFromPose(head: Circle, leftFoot: Circle, rightFoot: Circle): number {
|
||||||
|
const cx = (head.center[0] + leftFoot.center[0] + rightFoot.center[0]) / 3;
|
||||||
|
const cy = (head.center[1] + leftFoot.center[1] + rightFoot.center[1]) / 3;
|
||||||
|
return Math.atan2(head.center[1] - cy, head.center[0] - cx) - Math.PI / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
private simulate(): CharacterMovementState {
|
||||||
|
const auth = this.authoritative!;
|
||||||
|
const now = Math.round(performance.now());
|
||||||
|
// Predict forward from the latest snapshot by its age, clamped so a stall
|
||||||
|
// (or a backgrounded tab catching up) snaps instead of grinding hundreds of
|
||||||
|
// steps. This advances with wall-clock time even while a held key sends no
|
||||||
|
// fresh input, so the pose no longer pins to the 25 Hz snapshot cadence.
|
||||||
|
const startMs = Math.max(this.authReceiptMs, now - maxReplayMs);
|
||||||
|
const windowMs = Math.max(0, now - startMs);
|
||||||
|
const steps = Math.floor(windowMs / stepMs);
|
||||||
|
const remainderSeconds = (windowMs - steps * stepMs) / 1000;
|
||||||
|
|
||||||
|
const state: CharacterMovementState = {
|
||||||
|
head: makeBody(auth.head.center, auth.head.radius),
|
||||||
|
leftFoot: makeBody(auth.leftFoot.center, auth.leftFoot.radius),
|
||||||
|
rightFoot: makeBody(auth.rightFoot.center, auth.rightFoot.radius),
|
||||||
|
direction: this.directionFromPose(auth.head, auth.leftFoot, auth.rightFoot),
|
||||||
|
currentPlanet: this.world.surfaceById(this.carriedPlanetId),
|
||||||
|
secondsSinceOnSurface: this.carriedSecondsSinceSurface,
|
||||||
|
// Leaps before the replay window are already baked into this; leaps inside
|
||||||
|
// the window are re-applied below, so neither is double-counted.
|
||||||
|
bodyVelocity: vec2.clone(this.authoritativeBodyVelocity),
|
||||||
|
};
|
||||||
|
|
||||||
|
// The planet collision frames were synced (in update(), just before this)
|
||||||
|
// to the NEWEST snapshot's rotation — the same instant the authoritative
|
||||||
|
// body pose is from — so the body and the surface start the replay at the
|
||||||
|
// same phase. The loop below advances the surfaces FORWARD in lockstep with
|
||||||
|
// the body's carry from that shared phase, so no rewind is needed (and the
|
||||||
|
// persistent surfaces are re-synced next frame, so this never accumulates).
|
||||||
|
|
||||||
|
const cooldownMs = settings.leapCooldownSeconds * 1000;
|
||||||
|
// Mirror the server: each accepted leap spends leapStrengthCost, so a burst
|
||||||
|
// of leaps in one replay window is gated by the running strength rather than
|
||||||
|
// a single up-front affordability check.
|
||||||
|
let availableStrength = this.currentStrength;
|
||||||
|
let lastLeapMs = -Infinity;
|
||||||
|
|
||||||
|
let t = startMs;
|
||||||
|
for (let i = 0; i < steps; i++) {
|
||||||
|
const input = this.inputHistory.directionAt(t);
|
||||||
|
tickPlanetDetachment(state, stepSeconds);
|
||||||
|
stepCharacterMovement(state, this.world, input, stepSeconds);
|
||||||
|
this.world.advance(stepSeconds);
|
||||||
|
|
||||||
|
// A leap issued during this step launches now (the next step injects the
|
||||||
|
// momentum), gated like the server: on a surface, off cooldown, with
|
||||||
|
// strength. applyLeapImpulse is a no-op off-surface.
|
||||||
|
for (const leapMs of this.leapHistory) {
|
||||||
|
if (
|
||||||
|
leapMs >= t &&
|
||||||
|
leapMs < t + stepMs &&
|
||||||
|
// Only leaps the server hasn't yet folded into the seed, so a leap
|
||||||
|
// is never both seeded and replayed.
|
||||||
|
leapMs > this.lastLeapAckMs &&
|
||||||
|
state.currentPlanet &&
|
||||||
|
leapMs - lastLeapMs >= cooldownMs &&
|
||||||
|
availableStrength >= settings.leapStrengthCost
|
||||||
|
) {
|
||||||
|
applyLeapImpulse(state, this.inputHistory.directionAt(leapMs));
|
||||||
|
availableStrength -= settings.leapStrengthCost;
|
||||||
|
lastLeapMs = leapMs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t += stepMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final sub-tick of the leftover (< stepMs) so the predicted pose is a
|
||||||
|
// continuous function of the window length rather than advancing in 5 ms
|
||||||
|
// quanta — the floor() above would otherwise surface that as a per-frame
|
||||||
|
// wobble on top of the ~16.7 ms render cadence.
|
||||||
|
if (remainderSeconds > 0) {
|
||||||
|
tickPlanetDetachment(state, remainderSeconds);
|
||||||
|
stepCharacterMovement(
|
||||||
|
state,
|
||||||
|
this.world,
|
||||||
|
this.inputHistory.directionAt(now),
|
||||||
|
remainderSeconds,
|
||||||
|
);
|
||||||
|
this.world.advance(remainderSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.carriedPlanetId = this.world.idOf(state.currentPlanet);
|
||||||
|
this.carriedSecondsSinceSurface = state.secondsSinceOnSurface;
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
private snapRenderTo(state: CharacterMovementState): void {
|
||||||
|
this.renderHead = new Circle(vec2.clone(state.head.center), state.head.radius);
|
||||||
|
this.renderLeftFoot = new Circle(
|
||||||
|
vec2.clone(state.leftFoot.center),
|
||||||
|
state.leftFoot.radius,
|
||||||
|
);
|
||||||
|
this.renderRightFoot = new Circle(
|
||||||
|
vec2.clone(state.rightFoot.center),
|
||||||
|
state.rightFoot.radius,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private easeRenderTo(state: CharacterMovementState, frameSeconds: number): void {
|
||||||
|
const q = 1 - Math.exp(-frameSeconds / smoothSeconds);
|
||||||
|
this.easePart(this.renderHead, state.head, q);
|
||||||
|
this.easePart(this.renderLeftFoot, state.leftFoot, q);
|
||||||
|
this.easePart(this.renderRightFoot, state.rightFoot, q);
|
||||||
|
}
|
||||||
|
|
||||||
|
private easePart(render: Circle, target: PhysicsBody, q: number): void {
|
||||||
|
if (vec2.distance(render.center, target.center) > snapDistance) {
|
||||||
|
vec2.copy(render.center, target.center);
|
||||||
|
} else {
|
||||||
|
vec2.lerp(render.center, render.center, target.center, q);
|
||||||
|
}
|
||||||
|
render.radius = target.radius;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const localCharacterPredictor = new LocalCharacterPredictor();
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import {
|
import {
|
||||||
|
Circle,
|
||||||
Command,
|
Command,
|
||||||
CommandExecutors,
|
CommandExecutors,
|
||||||
CommandReceiver,
|
CommandReceiver,
|
||||||
|
|
@ -9,11 +10,15 @@ import {
|
||||||
Id,
|
Id,
|
||||||
PropertyUpdatesForObjects,
|
PropertyUpdatesForObjects,
|
||||||
RemoteCallsForObjects,
|
RemoteCallsForObjects,
|
||||||
|
settings,
|
||||||
|
UpdatePropertyCommand,
|
||||||
} from 'shared';
|
} from 'shared';
|
||||||
import { BeforeDestroyCommand } from '../commands/types/before-destroy';
|
import { BeforeDestroyCommand } from '../commands/types/before-destroy';
|
||||||
import { StepCommand } from '../commands/types/step';
|
import { StepCommand } from '../commands/types/step';
|
||||||
import { Game } from '../game';
|
import { Game } from '../game';
|
||||||
import { serverTimeline } from '../helper/server-timeline';
|
import { serverTimeline } from '../helper/server-timeline';
|
||||||
|
import { PredictablePlanet } from '../helper/prediction/client-character-world';
|
||||||
|
import { localCharacterPredictor } from '../helper/prediction/local-character-predictor';
|
||||||
import { Camera } from './types/camera';
|
import { Camera } from './types/camera';
|
||||||
import { CharacterView } from './types/character-view';
|
import { CharacterView } from './types/character-view';
|
||||||
import { PlanetView } from './types/planet-view';
|
import { PlanetView } from './types/planet-view';
|
||||||
|
|
@ -28,6 +33,9 @@ export class GameObjectContainer extends CommandReceiver {
|
||||||
this.player = c.character as CharacterView;
|
this.player = c.character as CharacterView;
|
||||||
this.player.isMainCharacter = true;
|
this.player.isMainCharacter = true;
|
||||||
this.addObject(this.player);
|
this.addObject(this.player);
|
||||||
|
// Fresh character (first spawn or respawn at a far planet): drop any
|
||||||
|
// prediction state so it snaps to the new body instead of gliding across.
|
||||||
|
localCharacterPredictor.reset();
|
||||||
},
|
},
|
||||||
|
|
||||||
[CreateObjectsCommand.type]: (c: CreateObjectsCommand) =>
|
[CreateObjectsCommand.type]: (c: CreateObjectsCommand) =>
|
||||||
|
|
@ -37,6 +45,18 @@ export class GameObjectContainer extends CommandReceiver {
|
||||||
this.defaultCommandExecutor(c);
|
this.defaultCommandExecutor(c);
|
||||||
|
|
||||||
if (this.player) {
|
if (this.player) {
|
||||||
|
// Override the interpolated pose of the local player with the predicted
|
||||||
|
// one so it responds to input immediately. Remote objects keep
|
||||||
|
// interpolating. A large correction (respawn / death) snaps inside the
|
||||||
|
// predictor rather than rubber-banding.
|
||||||
|
localCharacterPredictor.setStrength(
|
||||||
|
this.player.strengthFraction * settings.playerMaxStrength,
|
||||||
|
);
|
||||||
|
if (localCharacterPredictor.update(this.predictablePlanets(), c.deltaTimeInSeconds)) {
|
||||||
|
this.player.head = localCharacterPredictor.head;
|
||||||
|
this.player.leftFoot = localCharacterPredictor.leftFoot;
|
||||||
|
this.player.rightFoot = localCharacterPredictor.rightFoot;
|
||||||
|
}
|
||||||
this.camera.follow(this.player.position, c.deltaTimeInSeconds);
|
this.camera.follow(this.player.position, c.deltaTimeInSeconds);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -48,9 +68,12 @@ export class GameObjectContainer extends CommandReceiver {
|
||||||
|
|
||||||
[PropertyUpdatesForObjects.type]: (c: PropertyUpdatesForObjects) => {
|
[PropertyUpdatesForObjects.type]: (c: PropertyUpdatesForObjects) => {
|
||||||
serverTimeline.onSnapshot(c.timestamp);
|
serverTimeline.onSnapshot(c.timestamp);
|
||||||
c.updates.forEach((u) =>
|
c.updates.forEach((u) => {
|
||||||
u.updates.forEach((au) => this.objects.get(u.id)?.handleCommand(au)),
|
u.updates.forEach((au) => this.objects.get(u.id)?.handleCommand(au));
|
||||||
);
|
if (this.player && u.id === this.player.id) {
|
||||||
|
this.feedPredictor(u.updates);
|
||||||
|
}
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
[DeleteObjectsCommand.type]: (c: DeleteObjectsCommand) =>
|
[DeleteObjectsCommand.type]: (c: DeleteObjectsCommand) =>
|
||||||
|
|
@ -76,6 +99,34 @@ export class GameObjectContainer extends CommandReceiver {
|
||||||
this.camera.handleCommand(c);
|
this.camera.handleCommand(c);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hand the local player's raw authoritative pose to the predictor (the
|
||||||
|
// interpolated pose would already be ~100 ms stale). The three body parts
|
||||||
|
// arrive together in one snapshot.
|
||||||
|
private feedPredictor(updates: Array<UpdatePropertyCommand>) {
|
||||||
|
let head: Circle | undefined;
|
||||||
|
let leftFoot: Circle | undefined;
|
||||||
|
let rightFoot: Circle | undefined;
|
||||||
|
for (const u of updates) {
|
||||||
|
if (u.propertyKey === 'head') head = u.propertyValue as Circle;
|
||||||
|
else if (u.propertyKey === 'leftFoot') leftFoot = u.propertyValue as Circle;
|
||||||
|
else if (u.propertyKey === 'rightFoot') rightFoot = u.propertyValue as Circle;
|
||||||
|
}
|
||||||
|
if (head && leftFoot && rightFoot) {
|
||||||
|
localCharacterPredictor.setAuthoritative(head, leftFoot, rightFoot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private predictablePlanets(): Array<PredictablePlanet> {
|
||||||
|
return this.planets.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
vertices: p.vertices,
|
||||||
|
center: p.center,
|
||||||
|
radius: p.radius,
|
||||||
|
rotation: p.predictionRotation,
|
||||||
|
rotationSpeed: p.predictionRotationSpeed,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
private addObject(object: GameObject) {
|
private addObject(object: GameObject) {
|
||||||
this.objects.set(object.id, object);
|
this.objects.set(object.id, object);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -176,21 +176,32 @@ export class CharacterView extends CharacterBase {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public onHitConfirmed() {
|
public onHitConfirmed(charge = 0) {
|
||||||
if (!this.isMainCharacter) {
|
if (!this.isMainCharacter) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
SoundHandler.play(Sounds.click, 0.4, 1.7);
|
// A charged hit lands lower and harder than a panic tap.
|
||||||
FeedbackHud.hitMarker();
|
SoundHandler.play(Sounds.click, mix(0.4, 0.75, charge), mix(1.7, 1.05, charge));
|
||||||
|
if (charge >= settings.chargedHitThreshold) {
|
||||||
|
VibrationHandler.vibrate(25);
|
||||||
|
}
|
||||||
|
FeedbackHud.hitMarker(charge);
|
||||||
}
|
}
|
||||||
|
|
||||||
public onKillConfirmed(victimName?: string, streak = 1) {
|
public onKillConfirmed(victimName?: string, streak = 1, charge = 0) {
|
||||||
if (!this.isMainCharacter) {
|
if (!this.isMainCharacter) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
SoundHandler.play(Sounds.click, 1, 0.7);
|
SoundHandler.play(Sounds.click, 1, mix(0.7, 0.5, charge));
|
||||||
VibrationHandler.vibrate(60);
|
VibrationHandler.vibrate(mix(60, 110, charge));
|
||||||
FeedbackHud.killConfirmed(victimName, streak);
|
FeedbackHud.killConfirmed(victimName, streak, charge);
|
||||||
|
}
|
||||||
|
|
||||||
|
public onLeap() {
|
||||||
|
if (!this.isMainCharacter) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
SoundHandler.play(Sounds.shoot, 0.3, 1.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
private step({ deltaTimeInSeconds }: StepCommand): void {
|
private step({ deltaTimeInSeconds }: StepCommand): void {
|
||||||
|
|
|
||||||
|
|
@ -56,17 +56,39 @@ export class PlanetView extends PlanetBase {
|
||||||
[UpdatePropertyCommand.type]: this.updateProperty.bind(this),
|
[UpdatePropertyCommand.type]: this.updateProperty.bind(this),
|
||||||
};
|
};
|
||||||
|
|
||||||
constructor(id: Id, vertices: Array<vec2>, ownership: number) {
|
constructor(id: Id, vertices: Array<vec2>, ownership = 0.5, isKeystone = false) {
|
||||||
super(id, vertices);
|
super(id, vertices, ownership, isKeystone);
|
||||||
this.shape = new PlanetShape(vertices, ownership);
|
this.shape = new PlanetShape(vertices, ownership);
|
||||||
this.shape.randomOffset = Random.getRandom();
|
this.shape.randomOffset = Random.getRandom();
|
||||||
|
|
||||||
this.ownershipProgress = document.createElement('div');
|
this.ownershipProgress = document.createElement('div');
|
||||||
this.ownershipProgress.className = 'ownership';
|
this.ownershipProgress.className = 'ownership' + (isKeystone ? ' keystone' : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
public setContested(contested: boolean) {
|
||||||
|
this.ownershipProgress.classList.toggle('contested', contested);
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderedRotation = 0;
|
||||||
|
private rotationSpeed = 0;
|
||||||
|
// Newest streamed rotation VALUE — the server-current angle at the latest
|
||||||
|
// snapshot — as opposed to renderedRotation, which the interpolator holds
|
||||||
|
// ~interpolationDelaySeconds in the PAST for drawing. The predictor seeds the
|
||||||
|
// local body from the same snapshot's pose, so it must collide against the
|
||||||
|
// planet at THIS (newest) phase and advance forward from it; using the drawn
|
||||||
|
// lagged angle biases the body off the surface by omega*delay*radius and
|
||||||
|
// wobbles it whenever the spin or the interpolator's rate cursor varies.
|
||||||
|
private latestRotation = 0;
|
||||||
|
public get predictionRotation(): number {
|
||||||
|
return this.latestRotation;
|
||||||
|
}
|
||||||
|
public get predictionRotationSpeed(): number {
|
||||||
|
return this.rotationSpeed;
|
||||||
}
|
}
|
||||||
|
|
||||||
private step({ deltaTimeInSeconds }: StepCommand): void {
|
private step({ deltaTimeInSeconds }: StepCommand): void {
|
||||||
this.shape.rotation = this.rotationInterpolator.getValue(deltaTimeInSeconds);
|
this.renderedRotation = this.rotationInterpolator.getValue(deltaTimeInSeconds);
|
||||||
|
this.shape.rotation = this.renderedRotation;
|
||||||
this.shape.colorMixQ = this.ownership;
|
this.shape.colorMixQ = this.ownership;
|
||||||
|
|
||||||
if (this.flareIntensity > 0) {
|
if (this.flareIntensity > 0) {
|
||||||
|
|
@ -129,6 +151,8 @@ export class PlanetView extends PlanetBase {
|
||||||
}: UpdatePropertyCommand): void {
|
}: UpdatePropertyCommand): void {
|
||||||
if (propertyKey === 'rotation') {
|
if (propertyKey === 'rotation') {
|
||||||
this.rotationInterpolator.addFrame(propertyValue, rateOfChange);
|
this.rotationInterpolator.addFrame(propertyValue, rateOfChange);
|
||||||
|
this.latestRotation = propertyValue;
|
||||||
|
this.rotationSpeed = rateOfChange;
|
||||||
} else {
|
} else {
|
||||||
this.ownership = propertyValue;
|
this.ownership = propertyValue;
|
||||||
}
|
}
|
||||||
|
|
@ -170,7 +194,12 @@ export class PlanetView extends PlanetBase {
|
||||||
|
|
||||||
private getGradient(): string {
|
private getGradient(): string {
|
||||||
const sideBlue = this.ownership < 0.5;
|
const sideBlue = this.ownership < 0.5;
|
||||||
const sidePercent = (Math.abs(this.ownership - 0.5) / 0.5) * 100;
|
// Keep the ring neutral through the same dead-band that gates scoring
|
||||||
|
// (settings.planetControlThreshold), so "the ring fills" and "this planet
|
||||||
|
// pays my team" happen together rather than disagreeing.
|
||||||
|
const control = Math.abs(this.ownership - 0.5);
|
||||||
|
const t = settings.planetControlThreshold;
|
||||||
|
const sidePercent = control <= t ? 0 : ((control - t) / (0.5 - t)) * 100;
|
||||||
return sideBlue
|
return sideBlue
|
||||||
? `conic-gradient(
|
? `conic-gradient(
|
||||||
var(--bright-blue) ${sidePercent}%,
|
var(--bright-blue) ${sidePercent}%,
|
||||||
|
|
|
||||||
|
|
@ -129,11 +129,32 @@ export class PlanetShape extends PolygonFactory(settings.planetEdgeCount, 0) {
|
||||||
public randomOffset = 0;
|
public randomOffset = 0;
|
||||||
public rotation = 0;
|
public rotation = 0;
|
||||||
|
|
||||||
|
// Circle about the rotation centre (the vertex centroid, which is what the
|
||||||
|
// shader spins around — see planetMinDistance above). The vertices never
|
||||||
|
// change after construction, so cache it once.
|
||||||
|
private readonly cullCenter: vec2;
|
||||||
|
private readonly cullRadius: number;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
public vertices: Array<vec2>,
|
public vertices: Array<vec2>,
|
||||||
public colorMixQ: number,
|
public colorMixQ: number,
|
||||||
) {
|
) {
|
||||||
super(vertices);
|
super(vertices);
|
||||||
|
|
||||||
|
this.cullCenter = vertices.reduce(
|
||||||
|
(sum, v) => vec2.add(sum, sum, v),
|
||||||
|
vec2.create(),
|
||||||
|
);
|
||||||
|
vec2.scale(this.cullCenter, this.cullCenter, 1 / vertices.length);
|
||||||
|
|
||||||
|
this.cullRadius = vertices.reduce(
|
||||||
|
(max, v) => Math.max(max, vec2.distance(this.cullCenter, v)),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public minDistance(target: vec2): number {
|
||||||
|
return vec2.distance(target, this.cullCenter) - this.cullRadius;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected getObjectToSerialize(transform2d: mat2d, _: number): any {
|
protected getObjectToSerialize(transform2d: mat2d, _: number): any {
|
||||||
|
|
|
||||||
|
|
@ -4,17 +4,19 @@ import {
|
||||||
CommandExecutors,
|
CommandExecutors,
|
||||||
CommandReceiver,
|
CommandReceiver,
|
||||||
Id,
|
Id,
|
||||||
|
LeapActionCommand,
|
||||||
MoveActionCommand,
|
MoveActionCommand,
|
||||||
PrimaryActionCommand,
|
PrimaryActionCommand,
|
||||||
} from 'shared';
|
} from 'shared';
|
||||||
import { GameObjectContainer } from './objects/game-object-container';
|
import { GameObjectContainer } from './objects/game-object-container';
|
||||||
import { PlanetView } from './objects/types/planet-view';
|
import { PlanetView } from './objects/types/planet-view';
|
||||||
|
|
||||||
type StageTrigger = 'move' | 'shoot' | 'capture';
|
type StageTrigger = 'move' | 'shoot' | 'leap' | 'capture';
|
||||||
|
|
||||||
const stages: ReadonlyArray<{ hint: string; clearsOn: StageTrigger }> = [
|
const stages: ReadonlyArray<{ hint: string; clearsOn: StageTrigger }> = [
|
||||||
{ hint: 'WASD / drag to walk', clearsOn: 'move' },
|
{ hint: 'WASD / drag to walk', clearsOn: 'move' },
|
||||||
{ hint: 'Click / tap to shoot', clearsOn: 'shoot' },
|
{ hint: 'Click / tap to shoot', clearsOn: 'shoot' },
|
||||||
|
{ hint: 'Space / leap button to launch off a planet', clearsOn: 'leap' },
|
||||||
{ hint: 'Stand on a planet to capture it', clearsOn: 'capture' },
|
{ hint: 'Stand on a planet to capture it', clearsOn: 'capture' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -42,6 +44,11 @@ export class Tutorial extends CommandReceiver {
|
||||||
this.advance();
|
this.advance();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
[LeapActionCommand.type]: () => {
|
||||||
|
if (this.clearsOn() === 'leap') {
|
||||||
|
this.advance();
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
constructor(overlay: HTMLElement) {
|
constructor(overlay: HTMLElement) {
|
||||||
|
|
|
||||||
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
|
@serializable
|
||||||
export class MoveActionCommand extends Command {
|
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();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
public toArray(): Array<any> {
|
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/command-generator';
|
||||||
export * from './commands/types/actions/move-action';
|
export * from './commands/types/actions/move-action';
|
||||||
export * from './commands/types/actions/primary-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 './commands/types/actions/set-aspect-ratio-action';
|
||||||
export * from './helper/array';
|
export * from './helper/array';
|
||||||
export * from './helper/last';
|
export * from './helper/last';
|
||||||
|
|
@ -46,3 +47,13 @@ export * from './objects/types/planet-base';
|
||||||
export * from './objects/types/projectile-base';
|
export * from './objects/types/projectile-base';
|
||||||
export * from './settings';
|
export * from './settings';
|
||||||
export * from './communication/transport-events';
|
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
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
public onShoot(strength: number) { }
|
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) {
|
public setHealth(health: number) {
|
||||||
this.health = health;
|
this.health = health;
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ export class PlanetBase extends GameObject {
|
||||||
id: Id,
|
id: Id,
|
||||||
public readonly vertices: Array<vec2>,
|
public readonly vertices: Array<vec2>,
|
||||||
public ownership: number = 0.5,
|
public ownership: number = 0.5,
|
||||||
|
public readonly isKeystone: boolean = false,
|
||||||
) {
|
) {
|
||||||
super(id);
|
super(id);
|
||||||
this.center = vertices.reduce((sum, v) => vec2.add(sum, sum, v), vec2.create());
|
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) {}
|
public generatedPoints(value: number) {}
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
public onFlipped(team: CharacterTeam) {}
|
public onFlipped(team: CharacterTeam) {}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
public setContested(contested: boolean) {}
|
||||||
|
|
||||||
public static createPlanetVertices(
|
public static createPlanetVertices(
|
||||||
center: vec2,
|
center: vec2,
|
||||||
|
|
@ -54,6 +57,6 @@ export class PlanetBase extends GameObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
public toArray(): Array<any> {
|
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,
|
maxGravityDistance: 800,
|
||||||
minGravityDistance: 1,
|
minGravityDistance: 1,
|
||||||
maxGravityQ: 5000,
|
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,
|
playerMaxHealth: 100,
|
||||||
maxGravityStrength: 50000,
|
maxGravityStrength: 50000,
|
||||||
planetMinReferenceRadius: 150,
|
planetMinReferenceRadius: 150,
|
||||||
|
|
@ -141,4 +145,65 @@ export const settings = {
|
||||||
lampFlareDecaySeconds: 0.6,
|
lampFlareDecaySeconds: 0.6,
|
||||||
maxConcurrentFlipFlares: 3,
|
maxConcurrentFlipFlares: 3,
|
||||||
announcementVisibleSeconds: 2,
|
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