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,
|
||||
mix,
|
||||
clamp01,
|
||||
stepCharacterMovement,
|
||||
applyLeapImpulse,
|
||||
decayMomentum,
|
||||
CharacterMovementState,
|
||||
CharacterWorld,
|
||||
GroundSurface,
|
||||
} from 'shared';
|
||||
import { DynamicPhysical } from '../physics/physicals/dynamic-physical';
|
||||
import { CirclePhysical } from './circle-physical';
|
||||
import { PhysicalContainer } from '../physics/containers/physical-container';
|
||||
import { BoundingBoxBase } from '../physics/bounding-boxes/bounding-box-base';
|
||||
import { ProjectilePhysical } from './projectile-physical';
|
||||
import { interpolateAngles } from '../helper/interpolate-angles';
|
||||
import { forceAtPosition } from '../physics/functions/force-at-position';
|
||||
import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle';
|
||||
import { PlanetPhysical } from './planet-physical';
|
||||
|
|
@ -92,10 +97,16 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
|
|||
private currentPlanet?: PlanetPhysical;
|
||||
private secondsSinceOnSurface = settings.planetDetachmentSeconds;
|
||||
|
||||
private bodyVelocity = vec2.create();
|
||||
private timeSinceLastLeap = settings.leapCooldownSeconds;
|
||||
|
||||
public head: CirclePhysical;
|
||||
public leftFoot: CirclePhysical;
|
||||
public rightFoot: CirclePhysical;
|
||||
|
||||
private movementState!: CharacterMovementState;
|
||||
private movementWorld!: CharacterWorld;
|
||||
|
||||
private movementActions: Array<MoveActionCommand> = [];
|
||||
private lastMovementAction: MoveActionCommand = new MoveActionCommand(vec2.create());
|
||||
|
||||
|
|
@ -138,6 +149,51 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
|
|||
container.addObject(this.head);
|
||||
container.addObject(this.leftFoot);
|
||||
container.addObject(this.rightFoot);
|
||||
|
||||
this.initMovementBridge();
|
||||
}
|
||||
|
||||
private initMovementBridge() {
|
||||
const self = this;
|
||||
this.movementState = {
|
||||
head: this.head,
|
||||
leftFoot: this.leftFoot,
|
||||
rightFoot: this.rightFoot,
|
||||
get direction() {
|
||||
return self.direction;
|
||||
},
|
||||
set direction(value: number) {
|
||||
self.direction = value;
|
||||
},
|
||||
get currentPlanet() {
|
||||
return self.currentPlanet;
|
||||
},
|
||||
set currentPlanet(value: GroundSurface | undefined) {
|
||||
// On the server every ground is a PlanetPhysical (the world only ever
|
||||
// hands back planets), so this narrowing is safe.
|
||||
self.currentPlanet = value as PlanetPhysical | undefined;
|
||||
},
|
||||
get secondsSinceOnSurface() {
|
||||
return self.secondsSinceOnSurface;
|
||||
},
|
||||
set secondsSinceOnSurface(value: number) {
|
||||
self.secondsSinceOnSurface = value;
|
||||
},
|
||||
bodyVelocity: this.bodyVelocity,
|
||||
};
|
||||
|
||||
this.movementWorld = {
|
||||
// Same set and order forceAtPosition used: planets in the force field,
|
||||
// in container-traversal order (so the f64 gravity sum is unchanged).
|
||||
groundsNear: (center, radius) =>
|
||||
self.container
|
||||
.findIntersecting(getBoundingBoxOfCircle(new Circle(center, radius)))
|
||||
.filter((o): o is PlanetPhysical => o instanceof PlanetPhysical),
|
||||
stepBody: (body, deltaTimeInSeconds) => {
|
||||
const { hitObject } = (body as CirclePhysical).stepManually(deltaTimeInSeconds);
|
||||
return hitObject instanceof PlanetPhysical ? hitObject : undefined;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private get isSpawnProtected(): boolean {
|
||||
|
|
@ -170,7 +226,13 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
|
|||
return this.currentPlanet;
|
||||
}
|
||||
|
||||
public addKill(victimName: string) {
|
||||
// Persistent launch momentum, streamed to the owning client so its predictor
|
||||
// can reproduce a leap/slingshot/recoil flight instead of only snapping to it.
|
||||
public get launchMomentum(): vec2 {
|
||||
return this.bodyVelocity;
|
||||
}
|
||||
|
||||
public addKill(victimName: string, charge = 0) {
|
||||
this.killCount++;
|
||||
this.killStreak++;
|
||||
this.remoteCall('setKillCount', this.killCount);
|
||||
|
|
@ -180,11 +242,11 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
|
|||
this.health + settings.playerKillHealthReward,
|
||||
);
|
||||
this.syncHealth();
|
||||
this.remoteCall('onKillConfirmed', victimName, this.killStreak);
|
||||
this.remoteCall('onKillConfirmed', victimName, this.killStreak, charge);
|
||||
}
|
||||
|
||||
public registerHit() {
|
||||
this.remoteCall('onHitConfirmed');
|
||||
public registerHit(charge = 0) {
|
||||
this.remoteCall('onHitConfirmed', charge);
|
||||
}
|
||||
|
||||
private syncHealth() {
|
||||
|
|
@ -197,6 +259,9 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
|
|||
|
||||
public onCollision({ other }: ReactToCollisionCommand) {
|
||||
if (
|
||||
// A corpse keeps its collidable circles for the despawn animation; the
|
||||
// isAlive guard stops a flying corpse from eating shots aimed past it.
|
||||
this.isAlive &&
|
||||
other instanceof ProjectilePhysical &&
|
||||
other.team !== this.team &&
|
||||
other.isAlive
|
||||
|
|
@ -213,10 +278,17 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
|
|||
this.remoteCall('setHealth', this.health);
|
||||
|
||||
if (this.health <= 0 && this.isAlive) {
|
||||
// Throw the corpse along the killing shot, harder for charged hits.
|
||||
vec2.scaleAndAdd(
|
||||
this.bodyVelocity,
|
||||
this.bodyVelocity,
|
||||
other.direction,
|
||||
mix(settings.deathImpulseMin, settings.deathImpulseMax, other.charge),
|
||||
);
|
||||
this.onDie();
|
||||
other.originator.addKill(this.name);
|
||||
other.originator.addKill(this.name, other.charge);
|
||||
} else {
|
||||
other.originator.registerHit();
|
||||
other.originator.registerHit(other.charge);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -246,6 +318,8 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
|
|||
|
||||
const direction = vec2.subtract(vec2.create(), position, this.center);
|
||||
vec2.normalize(direction, direction);
|
||||
// Keep the unit direction before vec2.scale repurposes it as the velocity.
|
||||
const shotDirection = vec2.clone(direction);
|
||||
const velocity = vec2.scale(direction, direction, speed);
|
||||
const projectile = new ProjectilePhysical(
|
||||
vec2.clone(this.center),
|
||||
|
|
@ -255,12 +329,42 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
|
|||
velocity,
|
||||
this,
|
||||
this.container,
|
||||
c,
|
||||
);
|
||||
this.container.addObject(projectile);
|
||||
|
||||
if (c > 0) {
|
||||
vec2.scaleAndAdd(
|
||||
this.bodyVelocity,
|
||||
this.bodyVelocity,
|
||||
shotDirection,
|
||||
-settings.chargeShotRecoilMax * c,
|
||||
);
|
||||
}
|
||||
|
||||
this.remoteCall('onShoot', strength);
|
||||
}
|
||||
|
||||
public leap() {
|
||||
if (
|
||||
!this.isAlive ||
|
||||
this.hasJustBorn ||
|
||||
!this.currentPlanet ||
|
||||
this.timeSinceLastLeap < settings.leapCooldownSeconds ||
|
||||
this.projectileStrength < settings.leapStrengthCost
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.timeSinceLastLeap = 0;
|
||||
this.projectileStrength -= settings.leapStrengthCost;
|
||||
|
||||
// Same impulse the client predicts with (shared), so a leap launches
|
||||
// identically on both sides.
|
||||
applyLeapImpulse(this.movementState, this.lastMovementAction.direction);
|
||||
this.remoteCall('onLeap');
|
||||
}
|
||||
|
||||
public get boundingBox(): BoundingBoxBase {
|
||||
return getBoundingBoxOfCircle(new Circle(this.center, CharacterPhysical.boundRadius));
|
||||
}
|
||||
|
|
@ -363,6 +467,7 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
|
|||
private step({ deltaTimeInSeconds, game }: StepCommand) {
|
||||
this.getPoints(game);
|
||||
this.timeAlive += deltaTimeInSeconds;
|
||||
this.timeSinceLastLeap += deltaTimeInSeconds;
|
||||
const oldHead = new Circle(vec2.clone(this.head.center), this.head.radius);
|
||||
const oldLeftFoot = new Circle(
|
||||
vec2.clone(this.leftFoot.center),
|
||||
|
|
@ -377,6 +482,7 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
|
|||
if ((this.timeSinceDying += deltaTimeInSeconds) > settings.spawnDespawnTime) {
|
||||
this.destroy();
|
||||
} else {
|
||||
this.freeFallCorpse(deltaTimeInSeconds);
|
||||
this.animateScaling(1 - this.timeSinceDying / settings.spawnDespawnTime);
|
||||
}
|
||||
this.setPropertyUpdates(oldHead, oldLeftFoot, oldRightFoot, deltaTimeInSeconds);
|
||||
|
|
@ -405,14 +511,33 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
|
|||
this.projectileStrength = Math.min(
|
||||
settings.playerMaxStrength,
|
||||
this.projectileStrength +
|
||||
settings.playerStrengthRegenerationPerSeconds * deltaTimeInSeconds,
|
||||
settings.playerStrengthRegenerationPerSeconds * deltaTimeInSeconds,
|
||||
);
|
||||
|
||||
this.regenerateHealth(deltaTimeInSeconds);
|
||||
|
||||
this.currentPlanet?.takeControl(this.team, deltaTimeInSeconds);
|
||||
// The planet tallies who is standing on it and resolves capture itself, so
|
||||
// a contested rock can freeze instead of two squads silently cancelling.
|
||||
this.currentPlanet?.registerPresence(this);
|
||||
|
||||
const intersectingWithForceField = this.container.findIntersecting(
|
||||
// The whole walking model — gravity gather, movement force, on/off-planet
|
||||
// branch, posture springs, body-momentum, and stepping the three parts —
|
||||
// is the shared simulation the client predicts with, so the two can never
|
||||
// drift. Server-only concerns (scoring, health, shooting, spawn/death,
|
||||
// ownership) stay here around it.
|
||||
const direction = this.averageAndResetMovementActions();
|
||||
stepCharacterMovement(
|
||||
this.movementState,
|
||||
this.movementWorld,
|
||||
direction,
|
||||
deltaTimeInSeconds,
|
||||
);
|
||||
|
||||
this.setPropertyUpdates(oldHead, oldLeftFoot, oldRightFoot, deltaTimeInSeconds);
|
||||
}
|
||||
|
||||
private freeFallCorpse(deltaTime: number) {
|
||||
const intersecting = this.container.findIntersecting(
|
||||
getBoundingBoxOfCircle(
|
||||
new Circle(
|
||||
this.center,
|
||||
|
|
@ -420,151 +545,20 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
|
|||
),
|
||||
),
|
||||
);
|
||||
|
||||
const direction = this.averageAndResetMovementActions();
|
||||
const movementForce = vec2.scale(direction, direction, settings.maxAcceleration);
|
||||
this.leftFoot.applyForce(movementForce, deltaTimeInSeconds);
|
||||
this.rightFoot.applyForce(movementForce, deltaTimeInSeconds);
|
||||
|
||||
if (!this.currentPlanet) {
|
||||
const leftFootGravity = forceAtPosition(
|
||||
this.leftFoot.center,
|
||||
intersectingWithForceField,
|
||||
);
|
||||
const rightFootGravity = forceAtPosition(
|
||||
this.rightFoot.center,
|
||||
intersectingWithForceField,
|
||||
);
|
||||
|
||||
this.leftFoot.applyForce(leftFootGravity, deltaTimeInSeconds);
|
||||
this.rightFoot.applyForce(rightFootGravity, deltaTimeInSeconds);
|
||||
|
||||
const sumForce = vec2.subtract(vec2.create(), leftFootGravity, movementForce);
|
||||
|
||||
this.setDirection(vec2.length(sumForce) === 0 ? vec2.fromValues(0, -1) : sumForce);
|
||||
} else {
|
||||
this.carryWithRotatingPlanet(deltaTimeInSeconds);
|
||||
|
||||
const leftFootGravity = this.currentPlanet!.getForce(this.leftFoot.center);
|
||||
const rightFootGravity = this.currentPlanet!.getForce(this.rightFoot.center);
|
||||
|
||||
vec2.add(leftFootGravity, leftFootGravity, rightFootGravity);
|
||||
const gravity = vec2.scale(leftFootGravity, leftFootGravity, 0.5);
|
||||
|
||||
if (
|
||||
vec2.dot(movementForce, gravity) <
|
||||
-vec2.length(movementForce) * settings.climbDotThreshold
|
||||
) {
|
||||
vec2.scale(gravity, gravity, settings.climbGravityScale);
|
||||
let grounded = false;
|
||||
for (const part of [this.leftFoot, this.rightFoot, this.head]) {
|
||||
part.applyForce(forceAtPosition(part.center, intersecting), deltaTime);
|
||||
vec2.add(part.velocity, part.velocity, this.bodyVelocity);
|
||||
const { hitObject } = part.stepManually(deltaTime);
|
||||
if (hitObject instanceof PlanetPhysical) {
|
||||
grounded = true;
|
||||
}
|
||||
|
||||
const scaledLeftFootGravity = vec2.scale(
|
||||
vec2.create(),
|
||||
this.leftFoot.lastNormal,
|
||||
vec2.dot(this.leftFoot.lastNormal, gravity),
|
||||
);
|
||||
this.leftFoot.applyForce(scaledLeftFootGravity, deltaTimeInSeconds);
|
||||
|
||||
const scaledRightFootGravity = vec2.scale(
|
||||
vec2.create(),
|
||||
this.rightFoot.lastNormal,
|
||||
vec2.dot(this.rightFoot.lastNormal, gravity),
|
||||
);
|
||||
|
||||
this.rightFoot.applyForce(scaledRightFootGravity, deltaTimeInSeconds);
|
||||
|
||||
if (vec2.length(gravity) <= settings.planetDetachmentForceThreshold) {
|
||||
this.currentPlanet = undefined;
|
||||
}
|
||||
this.setDirection(gravity);
|
||||
}
|
||||
|
||||
this.keepPosture();
|
||||
this.stepBodyPart(this.leftFoot, deltaTimeInSeconds);
|
||||
this.stepBodyPart(this.rightFoot, deltaTimeInSeconds);
|
||||
this.stepBodyPart(this.head, deltaTimeInSeconds);
|
||||
|
||||
this.setPropertyUpdates(oldHead, oldLeftFoot, oldRightFoot, deltaTimeInSeconds);
|
||||
}
|
||||
|
||||
private setDirection(direction: vec2) {
|
||||
this.direction = interpolateAngles(
|
||||
this.direction,
|
||||
Math.atan2(direction.y, direction.x) + Math.PI / 2,
|
||||
0.2,
|
||||
);
|
||||
}
|
||||
|
||||
// While standing on a planet, ride its spin: rigidly rotate the whole body
|
||||
// around the planet centre by the same per-tick angle the renderer and the
|
||||
// collision SDF use, so the player is carried around and turned with the
|
||||
// surface instead of it sliding frictionlessly underneath. The positional
|
||||
// change becomes velocity in setPropertyUpdates, keeping the streamed
|
||||
// rate-of-change consistent with the streamed positions.
|
||||
private carryWithRotatingPlanet(deltaTimeInSeconds: number) {
|
||||
const planet = this.currentPlanet;
|
||||
if (!planet) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Negative: the rendered/collidable surface turns by R(-rotation) — see
|
||||
// PlanetPhysical.distance (toLocalFrame) and planet-shape.ts.
|
||||
const angle = -planet.angularVelocity * deltaTimeInSeconds;
|
||||
const center = planet.center;
|
||||
|
||||
this.head.center = vec2.rotate(vec2.create(), this.head.center, center, angle);
|
||||
this.leftFoot.center = vec2.rotate(
|
||||
vec2.create(),
|
||||
this.leftFoot.center,
|
||||
center,
|
||||
angle,
|
||||
);
|
||||
this.rightFoot.center = vec2.rotate(
|
||||
vec2.create(),
|
||||
this.rightFoot.center,
|
||||
center,
|
||||
angle,
|
||||
);
|
||||
}
|
||||
|
||||
private keepPosture() {
|
||||
const center = this.center;
|
||||
this.springMove(
|
||||
this.leftFoot,
|
||||
center,
|
||||
CharacterPhysical.leftFootOffset,
|
||||
settings.postureFeetStiffness,
|
||||
);
|
||||
this.springMove(
|
||||
this.rightFoot,
|
||||
center,
|
||||
CharacterPhysical.rightFootOffset,
|
||||
settings.postureFeetStiffness,
|
||||
);
|
||||
|
||||
this.springMove(
|
||||
this.head,
|
||||
center,
|
||||
CharacterPhysical.headOffset,
|
||||
settings.postureHeadStiffness,
|
||||
);
|
||||
}
|
||||
|
||||
private springMove(
|
||||
object: CirclePhysical,
|
||||
center: vec2,
|
||||
offset: vec2,
|
||||
stiffness: number,
|
||||
) {
|
||||
const desiredPosition = vec2.add(vec2.create(), center, offset);
|
||||
vec2.rotate(desiredPosition, desiredPosition, center, this.direction);
|
||||
const positionDelta = vec2.subtract(vec2.create(), desiredPosition, object.center);
|
||||
|
||||
// First-order velocity relaxation toward the desired posture position,
|
||||
// added to (not replacing) the gravity/movement velocity accumulated
|
||||
// earlier this tick. stepManually applies it over deltaTime, so the
|
||||
// per-tick displacement is positionDelta * stiffness * deltaTime.
|
||||
vec2.scaleAndAdd(object.velocity, object.velocity, positionDelta, stiffness);
|
||||
// Brake with the exact shared model the living body uses: stiff on contact
|
||||
// so the corpse skids to rest, gentle in the air, plus the constant stop and
|
||||
// the speed cap — so a flung corpse comes to a definite stop instead of
|
||||
// sliding forever.
|
||||
decayMomentum(this.bodyVelocity, grounded, deltaTime);
|
||||
}
|
||||
|
||||
private regenerateHealth(deltaTimeInSeconds: number) {
|
||||
|
|
@ -581,14 +575,6 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
|
|||
}
|
||||
}
|
||||
|
||||
private stepBodyPart(part: CirclePhysical, deltaTime: number) {
|
||||
const { hitObject } = part.stepManually(deltaTime);
|
||||
if (hitObject instanceof PlanetPhysical) {
|
||||
this.secondsSinceOnSurface = 0;
|
||||
this.currentPlanet = hitObject;
|
||||
}
|
||||
}
|
||||
|
||||
public onDie() {
|
||||
this.isDestroyed = true;
|
||||
this.remoteCall('onDie');
|
||||
|
|
|
|||
|
|
@ -4,14 +4,14 @@ import {
|
|||
CommandExecutors,
|
||||
CommandReceiver,
|
||||
GameObject,
|
||||
resolveCircleMovement,
|
||||
serializesTo,
|
||||
} from 'shared';
|
||||
import { BoundingBox } from '../physics/bounding-boxes/bounding-box';
|
||||
import { BoundingBoxBase } from '../physics/bounding-boxes/bounding-box-base';
|
||||
import { depenetrateCircle } from '../physics/functions/depenetrate-circle';
|
||||
import { moveCircle } from '../physics/functions/move-circle';
|
||||
import { PhysicalContainer } from '../physics/containers/physical-container';
|
||||
import { DynamicPhysical } from '../physics/physicals/dynamic-physical';
|
||||
import { Physical } from '../physics/physicals/physical';
|
||||
import { ReactToCollisionCommand } from '../commands/react-to-collision';
|
||||
|
||||
@serializesTo(Circle)
|
||||
|
|
@ -33,7 +33,9 @@ export class CirclePhysical extends CommandReceiver implements Circle, DynamicPh
|
|||
private _radius: number,
|
||||
public owner: GameObject,
|
||||
private readonly container: PhysicalContainer,
|
||||
private restitution = 0,
|
||||
// Public + readonly so a CirclePhysical structurally satisfies the shared
|
||||
// PhysicsBody interface the movement simulation operates on.
|
||||
public readonly restitution = 0,
|
||||
) {
|
||||
super();
|
||||
this._boundingBox = new BoundingBox();
|
||||
|
|
@ -89,45 +91,49 @@ export class CirclePhysical extends CommandReceiver implements Circle, DynamicPh
|
|||
);
|
||||
}
|
||||
|
||||
public stepManually(deltaTimeInSeconds: number): {
|
||||
// Position-resolution for one tick. Delegates to the shared
|
||||
// resolveCircleMovement so the server integrates a body with the exact same
|
||||
// geometry the client predictor runs (shared/physics) — no parallel copy to
|
||||
// keep in sync. The onHit callback dispatches the collision reactions at the
|
||||
// same points the old inline move-circle did (both the initial march and the
|
||||
// post-bounce slide). `possibleIntersectors`, when supplied, lets a caller
|
||||
// that already broadphased (e.g. a projectile's gravity query) avoid a second
|
||||
// container query; otherwise it is self-gathered from the swept bounding box.
|
||||
public stepManually(
|
||||
deltaTimeInSeconds: number,
|
||||
possibleIntersectors?: Array<Physical>,
|
||||
): {
|
||||
hitObject: GameObject | undefined;
|
||||
velocity: vec2;
|
||||
} {
|
||||
let delta = vec2.scale(vec2.create(), this.velocity, deltaTimeInSeconds);
|
||||
const intersecting = (
|
||||
possibleIntersectors ?? this.sweptBroadphase(deltaTimeInSeconds)
|
||||
).filter((b) => b.gameObject !== this.gameObject && b.canCollide);
|
||||
|
||||
this.radius += vec2.length(delta);
|
||||
const intersecting = this.container
|
||||
.findIntersecting(this.boundingBox)
|
||||
.filter((b) => b.gameObject !== this.gameObject && b.canCollide);
|
||||
this.radius -= vec2.length(delta);
|
||||
const { hitObject, velocity } = resolveCircleMovement(
|
||||
this,
|
||||
deltaTimeInSeconds,
|
||||
intersecting,
|
||||
(intersected) => {
|
||||
const physical = intersected as Physical;
|
||||
physical.handleCommand(new ReactToCollisionCommand(this.gameObject));
|
||||
this.handleCommand(new ReactToCollisionCommand(physical.gameObject));
|
||||
},
|
||||
);
|
||||
|
||||
depenetrateCircle(this, intersecting);
|
||||
return { hitObject: (hitObject as Physical | undefined)?.gameObject, velocity };
|
||||
}
|
||||
|
||||
const { normal, hitSurface, hitObject } = moveCircle(this, delta, intersecting);
|
||||
|
||||
if (hitSurface) {
|
||||
vec2.copy(this.lastNormal, normal!);
|
||||
|
||||
vec2.subtract(
|
||||
this.velocity,
|
||||
this.velocity,
|
||||
vec2.scale(
|
||||
normal!,
|
||||
normal!,
|
||||
(1 + this.restitution) * vec2.dot(normal!, this.velocity),
|
||||
),
|
||||
);
|
||||
|
||||
if (vec2.length(this.velocity) > 50) {
|
||||
delta = vec2.scale(vec2.create(), this.velocity, deltaTimeInSeconds);
|
||||
moveCircle(this, delta, intersecting);
|
||||
}
|
||||
}
|
||||
|
||||
const lastVelocity = vec2.clone(this.velocity);
|
||||
vec2.zero(this.velocity);
|
||||
|
||||
return { hitObject, velocity: lastVelocity };
|
||||
// Query the container with the bounding box grown by this tick's travel, so a
|
||||
// fast-moving body still sees what it is about to sweep into.
|
||||
private sweptBroadphase(deltaTimeInSeconds: number): Array<Physical> {
|
||||
const sweep = vec2.length(
|
||||
vec2.scale(vec2.create(), this.velocity, deltaTimeInSeconds),
|
||||
);
|
||||
this.radius += sweep;
|
||||
const intersecting = this.container.findIntersecting(this.boundingBox);
|
||||
this.radius -= sweep;
|
||||
return intersecting;
|
||||
}
|
||||
|
||||
public toArray(): Array<any> {
|
||||
|
|
|
|||
|
|
@ -16,16 +16,21 @@ import {
|
|||
CommandReceiver,
|
||||
} from 'shared';
|
||||
import { GeneratePointsCommand } from '../commands/generate-points';
|
||||
import { AnnounceCommand } from '../commands/announce';
|
||||
import { StepCommand } from '../commands/step';
|
||||
|
||||
import { ImmutableBoundingBox } from '../physics/bounding-boxes/immutable-bounding-box';
|
||||
import { StaticPhysical } from '../physics/physicals/static-physical';
|
||||
import { LampPhysical } from './lamp-physical';
|
||||
import type { CharacterPhysical } from './character-physical';
|
||||
|
||||
@serializesTo(PlanetBase)
|
||||
export class PlanetPhysical extends PlanetBase implements StaticPhysical {
|
||||
public readonly canCollide = true;
|
||||
public readonly canMove = false;
|
||||
// Marks this as standable ground for the shared movement simulation (a body
|
||||
// landing on it latches it as currentPlanet). See shared GroundSurface.
|
||||
public readonly isGround = true;
|
||||
|
||||
public readonly sizePointMultiplier: number;
|
||||
|
||||
|
|
@ -45,6 +50,12 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
|
|||
|
||||
private lastTeam: CharacterTeam = CharacterTeam.neutral;
|
||||
|
||||
// Characters standing on the planet this tick. Filled by registerPresence as
|
||||
// each grounded character steps, drained when the planet resolves capture in
|
||||
// its own step(). Drives the head-count tug-of-war.
|
||||
private presentCharacters: Array<CharacterPhysical> = [];
|
||||
private isContested = false;
|
||||
|
||||
protected commandExecutors: CommandExecutors = {
|
||||
[StepCommand.type]: this.step.bind(this),
|
||||
};
|
||||
|
|
@ -53,8 +64,8 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
|
|||
this.lamps.push(lamp);
|
||||
}
|
||||
|
||||
constructor(vertices: Array<vec2>) {
|
||||
super(id(), vertices);
|
||||
constructor(vertices: Array<vec2>, isKeystone = false) {
|
||||
super(id(), vertices, 0.5, isKeystone);
|
||||
|
||||
const sizeClass = clamp01(
|
||||
(this.radius - settings.planetMinReferenceRadius) /
|
||||
|
|
@ -67,6 +78,12 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
|
|||
(0.05 + Random.getRandom() * 0.07) * (Random.getRandom() < 0.5 ? -1 : 1);
|
||||
}
|
||||
|
||||
// A grounded character announces itself each tick so the planet can resolve
|
||||
// contested capture from the net head-count.
|
||||
public registerPresence(character: CharacterPhysical) {
|
||||
this.presentCharacters.push(character);
|
||||
}
|
||||
|
||||
public distance(target: vec2): number {
|
||||
// Evaluate the SDF in the planet's own rotating frame so this collision
|
||||
// outline turns in lockstep with the rendered planet (see planet-shape.ts).
|
||||
|
|
@ -124,13 +141,13 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
|
|||
}
|
||||
|
||||
// Signed angular velocity in rad/s, exposed so a character standing on the
|
||||
// planet can ride its spin (see CharacterPhysical.carryWithRotatingPlanet).
|
||||
// planet can ride its spin (see carryWithRotatingPlanet in shared).
|
||||
public get angularVelocity(): number {
|
||||
return this.rotationSpeed;
|
||||
}
|
||||
|
||||
public get team(): CharacterTeam {
|
||||
return Math.abs(this.ownership - 0.5) < 0.1
|
||||
return Math.abs(this.ownership - 0.5) < settings.planetControlThreshold
|
||||
? CharacterTeam.neutral
|
||||
: this.ownership < 0.5
|
||||
? CharacterTeam.blue
|
||||
|
|
@ -160,8 +177,49 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
|
|||
|
||||
// In reverse order, so that teams can achieve a 100% control.
|
||||
this.getPoints(game);
|
||||
this.takeControl(CharacterTeam.neutral, deltaTimeInSeconds);
|
||||
this.resolveCapture(deltaTimeInSeconds);
|
||||
this.detectFlip(game);
|
||||
|
||||
this.presentCharacters = [];
|
||||
}
|
||||
|
||||
// One capture step per tick driven by the net team head-count, so grouping up
|
||||
// pays off and an equal standoff freezes the planet (contested) instead of
|
||||
// both sides silently cancelling with no feedback.
|
||||
private resolveCapture(deltaTime: number) {
|
||||
let blue = 0;
|
||||
let red = 0;
|
||||
for (const c of this.presentCharacters) {
|
||||
if (c.team === CharacterTeam.blue) {
|
||||
blue++;
|
||||
} else if (c.team === CharacterTeam.red) {
|
||||
red++;
|
||||
}
|
||||
}
|
||||
const net = red - blue;
|
||||
const occupied = blue + red > 0;
|
||||
|
||||
if (net !== 0) {
|
||||
const lead = Math.min(Math.abs(net), settings.maxContestLeadMultiplier);
|
||||
this.takeControl(
|
||||
net > 0 ? CharacterTeam.red : CharacterTeam.blue,
|
||||
deltaTime * lead,
|
||||
);
|
||||
} else if (!occupied) {
|
||||
// Empty planets drift back to neutral; the keystone drifts much slower so
|
||||
// it lingers as a live flashpoint.
|
||||
this.takeControl(
|
||||
CharacterTeam.neutral,
|
||||
this.isKeystone ? deltaTime / settings.keystoneLoseControlScale : deltaTime,
|
||||
);
|
||||
}
|
||||
// occupied tie -> frozen tug-of-war: no ownership change, ring pulses.
|
||||
|
||||
const contested = occupied && net === 0;
|
||||
if (contested !== this.isContested) {
|
||||
this.isContested = contested;
|
||||
this.remoteCall('setContested', contested);
|
||||
}
|
||||
}
|
||||
|
||||
private detectFlip(game: CommandReceiver) {
|
||||
|
|
@ -182,6 +240,14 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
|
|||
currentTeam === CharacterTeam.red ? reward : 0,
|
||||
),
|
||||
);
|
||||
|
||||
if (this.isKeystone) {
|
||||
game.handleCommand(
|
||||
new AnnounceCommand(
|
||||
`Team <span class="${currentTeam}">${currentTeam}</span> captured the Heart`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const control = Math.abs(this.ownership - 0.5) / 0.5;
|
||||
|
|
@ -196,8 +262,8 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
|
|||
public getPropertyUpdates(): PropertyUpdatesForObject {
|
||||
return new PropertyUpdatesForObject(this.id, [
|
||||
new UpdatePropertyCommand('ownership', this.ownership, 0),
|
||||
// Stream rotation with rotationSpeed as the rate-of-change so the client
|
||||
// can keep the angle moving when snapshots run late (see planet-view.ts).
|
||||
// Stream the spin rate as the rate-of-change so the client can keep the
|
||||
// angle moving when snapshots run late (see planet-view.ts).
|
||||
new UpdatePropertyCommand('rotation', this.rotation, this.rotationSpeed),
|
||||
]);
|
||||
}
|
||||
|
|
@ -257,6 +323,11 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
|
|||
return vec2.scale(diff, diff, scale);
|
||||
}
|
||||
|
||||
// GroundSurface alias the shared movement simulation calls for gravity.
|
||||
public gravityAt(position: vec2): vec2 {
|
||||
return this.getForce(position);
|
||||
}
|
||||
|
||||
public get gameObject(): this {
|
||||
return this;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,13 +8,16 @@ import {
|
|||
PropertyUpdatesForObject,
|
||||
UpdatePropertyCommand,
|
||||
CommandExecutors,
|
||||
Circle,
|
||||
marchCircle,
|
||||
} from 'shared';
|
||||
import { ImmutableBoundingBox } from '../physics/bounding-boxes/immutable-bounding-box';
|
||||
import { CirclePhysical } from './circle-physical';
|
||||
import { DynamicPhysical } from '../physics/physicals/dynamic-physical';
|
||||
import { PhysicalContainer } from '../physics/containers/physical-container';
|
||||
import { CharacterPhysical } from './character-physical';
|
||||
import { moveCircle } from '../physics/functions/move-circle';
|
||||
import { forceAtPosition } from '../physics/functions/force-at-position';
|
||||
import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle';
|
||||
import { StepCommand } from '../commands/step';
|
||||
import { ReactToCollisionCommand } from '../commands/react-to-collision';
|
||||
|
||||
|
|
@ -42,6 +45,9 @@ export class ProjectilePhysical extends ProjectileBase implements DynamicPhysica
|
|||
private velocity: vec2,
|
||||
public readonly originator: CharacterPhysical,
|
||||
readonly container: PhysicalContainer,
|
||||
// Normalised charge (0..1) of the shot that fired this, used by the victim
|
||||
// to scale hit/kill feedback and the death fling.
|
||||
public readonly charge: number = 0,
|
||||
) {
|
||||
super(id(), center, radius, team, strength);
|
||||
this.object = new CirclePhysical(center, radius, this, container, 0.9);
|
||||
|
|
@ -71,7 +77,7 @@ export class ProjectilePhysical extends ProjectileBase implements DynamicPhysica
|
|||
const intersecting = this.container
|
||||
.findIntersecting(this.boundingBox)
|
||||
.filter((g) => g instanceof CharacterPhysical && g.team === this.team);
|
||||
const { hitSurface } = moveCircle(this.object, delta, intersecting, true);
|
||||
const { hitSurface } = marchCircle(this.object, delta, intersecting, true);
|
||||
wasCollision = hitSurface;
|
||||
}
|
||||
vec2.add(this.center, this.center, delta);
|
||||
|
|
@ -124,8 +130,31 @@ export class ProjectilePhysical extends ProjectileBase implements DynamicPhysica
|
|||
return;
|
||||
}
|
||||
|
||||
// Curveball: the same planetary gravity that pulls on a free-falling
|
||||
// character bends the shot, so slower (charged) shots arc and can be lobbed
|
||||
// over a planet's horizon. Scale is tiny — near-surface gravity is huge.
|
||||
// This single broadphase is reused for the step below: the gravity radius
|
||||
// (maxGravityDistance) dwarfs one tick's travel, so the set is a superset of
|
||||
// the swept-collision box and the step needn't query the container again.
|
||||
const intersecting = settings.projectileGravityEnabled
|
||||
? this.container.findIntersecting(
|
||||
getBoundingBoxOfCircle(
|
||||
new Circle(this.center, this.object.radius + settings.maxGravityDistance),
|
||||
),
|
||||
)
|
||||
: undefined;
|
||||
|
||||
if (intersecting) {
|
||||
vec2.scaleAndAdd(
|
||||
this.velocity,
|
||||
this.velocity,
|
||||
forceAtPosition(this.center, intersecting),
|
||||
settings.projectileGravityScale * deltaTimeInSeconds,
|
||||
);
|
||||
}
|
||||
|
||||
vec2.copy(this.object.velocity, this.velocity);
|
||||
const { velocity } = this.object.stepManually(deltaTimeInSeconds);
|
||||
const { velocity } = this.object.stepManually(deltaTimeInSeconds, intersecting);
|
||||
vec2.copy(this.velocity, velocity);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { Circle } from 'shared';
|
||||
import { evaluateSdf } from './evaluate-sdf';
|
||||
import { Circle, evaluateSdf } from 'shared';
|
||||
import { PhysicalBase } from '../physicals/physical-base';
|
||||
|
||||
export const isCircleIntersecting = (
|
||||
|
|
|
|||
|
|
@ -60,6 +60,10 @@ const npcTuning = {
|
|||
spreadBase: 60,
|
||||
spreadPerDistance: 0.08,
|
||||
spreadAggressionFalloff: 1.3,
|
||||
|
||||
// Per-second chance to hop while grounded and on the move, so bots use the
|
||||
// leap verb too instead of being grounded targets.
|
||||
leapChancePerSecond: 0.35,
|
||||
};
|
||||
|
||||
export class NPC extends PlayerBase {
|
||||
|
|
@ -141,6 +145,16 @@ export class NPC extends PlayerBase {
|
|||
const movement = this.decideMovement(this.nearObjects);
|
||||
this.character.handleMovementAction(new MoveActionCommand(movement));
|
||||
|
||||
if (
|
||||
!this.isComingBack &&
|
||||
this.character.groundPlanet &&
|
||||
vec2.length(movement) > 0 &&
|
||||
Random.getRandom() <
|
||||
npcTuning.leapChancePerSecond * this.aggression * deltaTimeInSeconds
|
||||
) {
|
||||
this.character.leap();
|
||||
}
|
||||
|
||||
if (
|
||||
(this.timeSinceLastShoot += deltaTimeInSeconds) > npcTuning.shootIntervalSeconds
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ import {
|
|||
PropertyUpdatesForObjects,
|
||||
PropertyUpdatesForObject,
|
||||
PrimaryActionCommand,
|
||||
LeapActionCommand,
|
||||
InputAcknowledgement,
|
||||
} from 'shared';
|
||||
import { Socket } from 'socket.io';
|
||||
import { BoundingBox } from '../physics/bounding-boxes/bounding-box';
|
||||
|
|
@ -37,14 +39,27 @@ export class Player extends PlayerBase {
|
|||
private timeUntilRespawn = 0;
|
||||
private timeSinceLastMessage = 0;
|
||||
private objectsPreviouslyInViewArea: Array<GameObject> = [];
|
||||
private lastInputClientTimeMs = 0;
|
||||
private lastLeapClientTimeMs = 0;
|
||||
|
||||
protected commandExecutors: CommandExecutors = {
|
||||
[SetAspectRatioActionCommand.type]: (v: SetAspectRatioActionCommand) =>
|
||||
(this.aspectRatio = v.aspectRatio),
|
||||
[MoveActionCommand.type]: (c: MoveActionCommand) =>
|
||||
this.character?.handleMovementAction(c),
|
||||
[MoveActionCommand.type]: (c: MoveActionCommand) => {
|
||||
// Remember how far into this client's input timeline we've consumed, to
|
||||
// echo back for client-side prediction reconciliation.
|
||||
this.lastInputClientTimeMs = c.clientTimeMs;
|
||||
this.character?.handleMovementAction(c);
|
||||
},
|
||||
[PrimaryActionCommand.type]: (c: PrimaryActionCommand) =>
|
||||
this.character?.shootTowards(c.position, c.charge),
|
||||
[LeapActionCommand.type]: (c: LeapActionCommand) => {
|
||||
// Record receipt (whether or not leap() accepts it): either way its effect
|
||||
// on bodyVelocity is now reflected in the streamed launch momentum, so the
|
||||
// predictor must stop replaying this leap.
|
||||
this.lastLeapClientTimeMs = c.clientTimeMs;
|
||||
this.character?.leap();
|
||||
},
|
||||
};
|
||||
|
||||
constructor(
|
||||
|
|
@ -100,6 +115,13 @@ export class Player extends PlayerBase {
|
|||
new Set(this.objectContainer.findIntersecting(bb).map((o) => o.gameObject)),
|
||||
);
|
||||
|
||||
// The owning character must always be in its own snapshot, regardless of the
|
||||
// view-area query, so the client predictor never loses its authoritative
|
||||
// anchor (the body can ride a fast spinner to the very edge of the box).
|
||||
if (this.character && !objectsInViewArea.includes(this.character)) {
|
||||
objectsInViewArea.push(this.character);
|
||||
}
|
||||
|
||||
const newlyIntersecting = objectsInViewArea.filter(
|
||||
(o) => !this.objectsPreviouslyInViewArea.includes(o),
|
||||
);
|
||||
|
|
@ -130,6 +152,19 @@ export class Player extends PlayerBase {
|
|||
performance.now() / 1000,
|
||||
),
|
||||
);
|
||||
|
||||
// Tell the client how much of its own input is reflected in the snapshot it
|
||||
// just received, so its predictor can replay the rest. Only while alive —
|
||||
// a dead player isn't predicting.
|
||||
if (this.character) {
|
||||
this.queueCommandSend(
|
||||
new InputAcknowledgement(
|
||||
this.lastInputClientTimeMs,
|
||||
this.character.launchMomentum,
|
||||
this.lastLeapClientTimeMs,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private getOtherPlayers(): Array<OtherPlayerDirection> {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue