From fc9df09ee19e6cab186a721986803b3825a2b575 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sun, 14 Jun 2026 20:58:42 +0100 Subject: [PATCH] More fun --- backend/src/objects/character-physical.ts | 308 ++++++++------- backend/src/objects/circle-physical.ts | 78 ++-- backend/src/objects/planet-physical.ts | 85 ++++- backend/src/objects/projectile-physical.ts | 35 +- .../functions/is-circle-intersecting.ts | 3 +- backend/src/players/npc.ts | 14 + backend/src/players/player.ts | 39 +- frontend/src/main.scss | 72 ++++ .../src/scripts/commands/keyboard-listener.ts | 21 +- .../src/scripts/commands/touch-listener.ts | 30 +- frontend/src/scripts/feedback-hud.ts | 12 +- frontend/src/scripts/game.ts | 76 ++++ .../prediction/client-character-world.ts | 138 +++++++ .../helper/prediction/input-history.ts | 47 +++ .../prediction/local-character-predictor.ts | 328 ++++++++++++++++ .../scripts/objects/game-object-container.ts | 57 ++- .../scripts/objects/types/character-view.ts | 25 +- .../src/scripts/objects/types/planet-view.ts | 39 +- frontend/src/scripts/shapes/planet-shape.ts | 25 +- frontend/src/scripts/tutorial.ts | 9 +- .../src/commands/types/actions/leap-action.ts | 19 + .../src/commands/types/actions/move-action.ts | 12 +- .../commands/types/input-acknowledgement.ts | 28 ++ shared/src/main.ts | 11 + shared/src/objects/types/character-base.ts | 8 +- shared/src/objects/types/planet-base.ts | 5 +- shared/src/physics/character-movement.ts | 354 ++++++++++++++++++ shared/src/physics/depenetrate-circle.ts | 36 ++ shared/src/physics/evaluate-sdf.ts | 7 + shared/src/physics/interpolate-angles.ts | 6 + shared/src/physics/march-circle.ts | 80 ++++ shared/src/physics/planet-sdf.ts | 80 ++++ shared/src/physics/resolve-circle-movement.ts | 58 +++ shared/src/physics/sdf-normal.ts | 19 + shared/src/physics/sdf.ts | 31 ++ shared/src/settings.ts | 67 +++- 36 files changed, 2011 insertions(+), 251 deletions(-) create mode 100644 frontend/src/scripts/helper/prediction/client-character-world.ts create mode 100644 frontend/src/scripts/helper/prediction/input-history.ts create mode 100644 frontend/src/scripts/helper/prediction/local-character-predictor.ts create mode 100644 shared/src/commands/types/actions/leap-action.ts create mode 100644 shared/src/commands/types/input-acknowledgement.ts create mode 100644 shared/src/physics/character-movement.ts create mode 100644 shared/src/physics/depenetrate-circle.ts create mode 100644 shared/src/physics/evaluate-sdf.ts create mode 100644 shared/src/physics/interpolate-angles.ts create mode 100644 shared/src/physics/march-circle.ts create mode 100644 shared/src/physics/planet-sdf.ts create mode 100644 shared/src/physics/resolve-circle-movement.ts create mode 100644 shared/src/physics/sdf-normal.ts create mode 100644 shared/src/physics/sdf.ts diff --git a/backend/src/objects/character-physical.ts b/backend/src/objects/character-physical.ts index 63e0022..2f60597 100644 --- a/backend/src/objects/character-physical.ts +++ b/backend/src/objects/character-physical.ts @@ -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 = []; 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'); diff --git a/backend/src/objects/circle-physical.ts b/backend/src/objects/circle-physical.ts index fcc912f..e1a6abf 100644 --- a/backend/src/objects/circle-physical.ts +++ b/backend/src/objects/circle-physical.ts @@ -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, + ): { 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 { + 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 { diff --git a/backend/src/objects/planet-physical.ts b/backend/src/objects/planet-physical.ts index 95a5e1b..4305b18 100644 --- a/backend/src/objects/planet-physical.ts +++ b/backend/src/objects/planet-physical.ts @@ -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 = []; + 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) { - super(id(), vertices); + constructor(vertices: Array, 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 ${currentTeam} 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; } diff --git a/backend/src/objects/projectile-physical.ts b/backend/src/objects/projectile-physical.ts index 51a7866..8a967f1 100644 --- a/backend/src/objects/projectile-physical.ts +++ b/backend/src/objects/projectile-physical.ts @@ -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); } } diff --git a/backend/src/physics/functions/is-circle-intersecting.ts b/backend/src/physics/functions/is-circle-intersecting.ts index 3f497fa..203eb1d 100644 --- a/backend/src/physics/functions/is-circle-intersecting.ts +++ b/backend/src/physics/functions/is-circle-intersecting.ts @@ -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 = ( diff --git a/backend/src/players/npc.ts b/backend/src/players/npc.ts index 7bcb3f7..23177b6 100644 --- a/backend/src/players/npc.ts +++ b/backend/src/players/npc.ts @@ -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 ) { diff --git a/backend/src/players/player.ts b/backend/src/players/player.ts index f5709bb..ca563fc 100644 --- a/backend/src/players/player.ts +++ b/backend/src/players/player.ts @@ -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 = []; + 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 { diff --git a/frontend/src/main.scss b/frontend/src/main.scss index 2e2e60e..5dcbb56 100644 --- a/frontend/src/main.scss +++ b/frontend/src/main.scss @@ -207,6 +207,14 @@ body { @include square(50px); border-radius: 1000px; mask-image: url('../static/mask.svg'); + + &.contested { + animation: contested-pulse 0.7s ease-in-out infinite; + } + + &.keystone { + @include square(72px); + } } .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 { @include square($large-icon * 1.3); 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 { position: absolute; top: -3px; @@ -572,6 +617,16 @@ body { } animation: hitmarker-pop 250ms ease-out forwards; + + &.charged { + + &::before, + &::after { + width: 18px; + height: 3px; + background-color: $accent; + } + } } .kill-popup { @@ -584,6 +639,10 @@ body { text-shadow: 0 0 6px rgba(0, 0, 0, 0.9); animation: kill-popup-rise 1200ms ease-out forwards; + &.charged { + color: $accent; + } + .heal { color: #6fcf6f; } @@ -626,6 +685,18 @@ body { } } +@keyframes contested-pulse { + + 0%, + 100% { + opacity: 1; + } + + 50% { + opacity: 0.35; + } +} + .charge-ring { position: fixed; @include square(56px); @@ -649,6 +720,7 @@ body { } @keyframes match-point-pulse { + 0%, 100% { box-shadow: 0 0 4px 0 currentColor; diff --git a/frontend/src/scripts/commands/keyboard-listener.ts b/frontend/src/scripts/commands/keyboard-listener.ts index f16ce42..342d851 100644 --- a/frontend/src/scripts/commands/keyboard-listener.ts +++ b/frontend/src/scripts/commands/keyboard-listener.ts @@ -1,5 +1,6 @@ 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 { private keysDown: Set = new Set(); @@ -13,7 +14,14 @@ export class KeyboardListener extends CommandGenerator { } 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(); }; @@ -28,11 +36,7 @@ export class KeyboardListener extends CommandGenerator { }; private generateCommands() { - const up = ~~( - this.keysDown.has('w') || - this.keysDown.has('arrowup') || - this.keysDown.has(' ') - ); + const up = ~~(this.keysDown.has('w') || this.keysDown.has('arrowup')); const down = ~~(this.keysDown.has('s') || this.keysDown.has('arrowdown')); const left = ~~(this.keysDown.has('a') || this.keysDown.has('arrowleft')); const right = ~~(this.keysDown.has('d') || this.keysDown.has('arrowright')); @@ -42,7 +46,8 @@ export class KeyboardListener extends CommandGenerator { vec2.normalize(movement, movement); } - this.sendCommandToSubscribers(new MoveActionCommand(movement)); + const clientTimeMs = localCharacterPredictor.recordInput(movement); + this.sendCommandToSubscribers(new MoveActionCommand(movement, clientTimeMs)); } public destroy() { diff --git a/frontend/src/scripts/commands/touch-listener.ts b/frontend/src/scripts/commands/touch-listener.ts index 4ecb6bf..9dd2cfa 100644 --- a/frontend/src/scripts/commands/touch-listener.ts +++ b/frontend/src/scripts/commands/touch-listener.ts @@ -4,11 +4,13 @@ import { MoveActionCommand, last, PrimaryActionCommand, + LeapActionCommand, holdDurationToCharge, settings, } from 'shared'; import { Game } from '../game'; import { ChargeIndicator } from '../charge-indicator'; +import { localCharacterPredictor } from '../helper/prediction/local-character-predictor'; export class TouchListener extends CommandGenerator { private static readonly deadZone = 8; @@ -22,6 +24,7 @@ export class TouchListener extends CommandGenerator { private fireButton: HTMLElement; private fireStrengthRing: HTMLElement; + private leapButton: HTMLElement; private fireDownAt: number | null = null; @@ -46,7 +49,12 @@ export class TouchListener extends CommandGenerator { this.fireButton.addEventListener('touchstart', this.fireButtonDownListener); 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.leapButton); target.addEventListener('touchstart', this.touchStartListener); target.addEventListener('touchmove', this.touchMoveListener); @@ -101,12 +109,17 @@ export class TouchListener extends CommandGenerator { vec2.set(delta, delta.x, -delta.y); if (deltaLength > TouchListener.deadZone) { const direction = vec2.normalize(delta, delta); - this.sendCommandToSubscribers(new MoveActionCommand(direction)); + this.sendMove(direction); } 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) => { event.preventDefault(); @@ -127,7 +140,7 @@ export class TouchListener extends CommandGenerator { } else if (event.touches.length === 0) { this.isJoystickActive = false; 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(); }; + private leapButtonListener = (event: TouchEvent) => { + this.swallowTouch(event); + const clientTimeMs = localCharacterPredictor.recordLeap(); + this.sendCommandToSubscribers(new LeapActionCommand(clientTimeMs)); + }; + private fireButtonDownListener = (event: TouchEvent) => { this.swallowTouch(event); this.fireDownAt = performance.now(); @@ -170,6 +189,9 @@ export class TouchListener extends CommandGenerator { if (!this.fireButton.parentElement) { this.overlay.appendChild(this.fireButton); } + if (!this.leapButton.parentElement) { + this.overlay.appendChild(this.leapButton); + } const character = this.game.gameObjects.player; if (character) { @@ -186,7 +208,9 @@ export class TouchListener extends CommandGenerator { this.fireButton.removeEventListener('touchstart', this.fireButtonDownListener); this.fireButton.removeEventListener('touchend', this.fireButtonUpListener); + this.leapButton.removeEventListener('touchstart', this.leapButtonListener); this.fireButton.parentElement?.removeChild(this.fireButton); + this.leapButton.parentElement?.removeChild(this.leapButton); } } diff --git a/frontend/src/scripts/feedback-hud.ts b/frontend/src/scripts/feedback-hud.ts index 801828b..316aecf 100644 --- a/frontend/src/scripts/feedback-hud.ts +++ b/frontend/src/scripts/feedback-hud.ts @@ -33,17 +33,19 @@ export abstract class FeedbackHud { setTimeout(() => element.parentElement?.removeChild(element), lifetimeMs); } - public static hitMarker() { + public static hitMarker(charge = 0) { const { x, y } = this.focusPoint(); const marker = document.createElement('div'); - marker.className = 'hitmarker'; + marker.className = + 'hitmarker' + (charge >= settings.chargedHitThreshold ? ' charged' : ''); marker.style.left = `${x}px`; marker.style.top = `${y}px`; 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 charged = charge >= settings.chargedHitThreshold; const entry = document.createElement('div'); entry.className = 'kill-entry'; @@ -53,13 +55,13 @@ export abstract class FeedbackHud { const { x, y } = this.focusPoint(); const popup = document.createElement('div'); - popup.className = 'kill-popup'; + popup.className = 'kill-popup' + (charged ? ' charged' : ''); popup.innerHTML = `+${settings.playerKillPoint} +${settings.playerKillHealthReward}❤`; popup.style.left = `${x}px`; popup.style.top = `${y}px`; this.addTransient(popup, 1200); - const callout = this.streakName(streak); + const callout = this.streakName(streak) ?? (charged ? 'Charged Kill!' : undefined); if (callout) { const el = document.createElement('div'); el.className = 'streak-callout'; diff --git a/frontend/src/scripts/game.ts b/frontend/src/scripts/game.ts index 5afb42b..cad62cd 100644 --- a/frontend/src/scripts/game.ts +++ b/frontend/src/scripts/game.ts @@ -21,6 +21,7 @@ import { CommandExecutors, Command, settings, + InputAcknowledgement, } from 'shared'; import { io, Socket } from 'socket.io-client'; import { KeyboardListener } from './commands/keyboard-listener'; @@ -35,6 +36,7 @@ import { PlanetShape } from './shapes/planet-shape'; import { RenderCommand } from './commands/types/render'; import { StepCommand } from './commands/types/step'; import { serverTimeline } from './helper/server-timeline'; +import { localCharacterPredictor } from './helper/prediction/local-character-predictor'; import { Tutorial } from './tutorial'; import { Scoreboard } from './scoreboard'; @@ -54,6 +56,7 @@ export class Game extends CommandReceiver { private scoreboard = new Scoreboard(); private announcementText = document.createElement('h2'); private arrows: { [id: number]: HTMLElement } = {}; + private keystoneArrow?: HTMLElement; private socketReceiver!: CommandSocket; private tutorial!: Tutorial; @@ -76,9 +79,11 @@ export class Game extends CommandReceiver { this.socket?.close(); serverTimeline.reset(); + localCharacterPredictor.reset(); this.gameObjects = new GameObjectContainer(this); this.overlay.innerHTML = ''; this.arrows = {}; + this.keystoneArrow = undefined; this.isEnding = false; this.lastAnnouncementText = ''; this.overlay.appendChild(this.scoreboard.element); @@ -148,6 +153,12 @@ export class Game extends CommandReceiver { this.timeSinceLastAnnouncement = 0; }, [UpdateGameState.type]: (c: UpdateGameState) => (this.lastGameState = c), + [InputAcknowledgement.type]: (c: InputAcknowledgement) => + localCharacterPredictor.acknowledge( + c.clientTimeMs, + c.bodyVelocity, + c.lastLeapClientTimeMs, + ), [GameEndCommand.type]: () => (this.isEnding = true), [UpdateOtherPlayerDirections.type]: (c: UpdateOtherPlayerDirections) => (this.lastOtherPlayerDirections = c), @@ -325,6 +336,71 @@ export class Game extends CommandReceiver { this.handleOtherPlayerDirections(this.lastOtherPlayerDirections); } + this.handleKeystoneArrow(); + 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)`; + } } diff --git a/frontend/src/scripts/helper/prediction/client-character-world.ts b/frontend/src/scripts/helper/prediction/client-character-world.ts new file mode 100644 index 0000000..a08dbc5 --- /dev/null +++ b/frontend/src/scripts/helper/prediction/client-character-world.ts @@ -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; + 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; + 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(); + private ordered: Array = []; + + // 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) { + const seen = new Set(); + 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 { + 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; + } +} diff --git a/frontend/src/scripts/helper/prediction/input-history.ts b/frontend/src/scripts/helper/prediction/input-history.ts new file mode 100644 index 0000000..3ec2604 --- /dev/null +++ b/frontend/src/scripts/helper/prediction/input-history.ts @@ -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 = []; + + 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 = []; + } +} diff --git a/frontend/src/scripts/helper/prediction/local-character-predictor.ts b/frontend/src/scripts/helper/prediction/local-character-predictor.ts new file mode 100644 index 0000000..c147477 --- /dev/null +++ b/frontend/src/scripts/helper/prediction/local-character-predictor.ts @@ -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 = []; + // 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, 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(); diff --git a/frontend/src/scripts/objects/game-object-container.ts b/frontend/src/scripts/objects/game-object-container.ts index 5abd5ef..81c1e2e 100644 --- a/frontend/src/scripts/objects/game-object-container.ts +++ b/frontend/src/scripts/objects/game-object-container.ts @@ -1,4 +1,5 @@ import { + Circle, Command, CommandExecutors, CommandReceiver, @@ -9,11 +10,15 @@ import { Id, PropertyUpdatesForObjects, RemoteCallsForObjects, + settings, + UpdatePropertyCommand, } from 'shared'; import { BeforeDestroyCommand } from '../commands/types/before-destroy'; import { StepCommand } from '../commands/types/step'; import { Game } from '../game'; 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 { CharacterView } from './types/character-view'; import { PlanetView } from './types/planet-view'; @@ -28,6 +33,9 @@ export class GameObjectContainer extends CommandReceiver { this.player = c.character as CharacterView; this.player.isMainCharacter = true; 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) => @@ -37,6 +45,18 @@ export class GameObjectContainer extends CommandReceiver { this.defaultCommandExecutor(c); 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); } }, @@ -48,9 +68,12 @@ export class GameObjectContainer extends CommandReceiver { [PropertyUpdatesForObjects.type]: (c: PropertyUpdatesForObjects) => { serverTimeline.onSnapshot(c.timestamp); - c.updates.forEach((u) => - u.updates.forEach((au) => this.objects.get(u.id)?.handleCommand(au)), - ); + c.updates.forEach((u) => { + 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) => @@ -76,6 +99,34 @@ export class GameObjectContainer extends CommandReceiver { 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) { + 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 { + 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) { this.objects.set(object.id, object); } diff --git a/frontend/src/scripts/objects/types/character-view.ts b/frontend/src/scripts/objects/types/character-view.ts index 65424d9..1806993 100644 --- a/frontend/src/scripts/objects/types/character-view.ts +++ b/frontend/src/scripts/objects/types/character-view.ts @@ -176,21 +176,32 @@ export class CharacterView extends CharacterBase { } } - public onHitConfirmed() { + public onHitConfirmed(charge = 0) { if (!this.isMainCharacter) { return; } - SoundHandler.play(Sounds.click, 0.4, 1.7); - FeedbackHud.hitMarker(); + // A charged hit lands lower and harder than a panic tap. + 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) { return; } - SoundHandler.play(Sounds.click, 1, 0.7); - VibrationHandler.vibrate(60); - FeedbackHud.killConfirmed(victimName, streak); + SoundHandler.play(Sounds.click, 1, mix(0.7, 0.5, charge)); + VibrationHandler.vibrate(mix(60, 110, charge)); + FeedbackHud.killConfirmed(victimName, streak, charge); + } + + public onLeap() { + if (!this.isMainCharacter) { + return; + } + SoundHandler.play(Sounds.shoot, 0.3, 1.5); } private step({ deltaTimeInSeconds }: StepCommand): void { diff --git a/frontend/src/scripts/objects/types/planet-view.ts b/frontend/src/scripts/objects/types/planet-view.ts index a926237..06e6880 100644 --- a/frontend/src/scripts/objects/types/planet-view.ts +++ b/frontend/src/scripts/objects/types/planet-view.ts @@ -56,17 +56,39 @@ export class PlanetView extends PlanetBase { [UpdatePropertyCommand.type]: this.updateProperty.bind(this), }; - constructor(id: Id, vertices: Array, ownership: number) { - super(id, vertices); + constructor(id: Id, vertices: Array, ownership = 0.5, isKeystone = false) { + super(id, vertices, ownership, isKeystone); this.shape = new PlanetShape(vertices, ownership); this.shape.randomOffset = Random.getRandom(); 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 { - this.shape.rotation = this.rotationInterpolator.getValue(deltaTimeInSeconds); + this.renderedRotation = this.rotationInterpolator.getValue(deltaTimeInSeconds); + this.shape.rotation = this.renderedRotation; this.shape.colorMixQ = this.ownership; if (this.flareIntensity > 0) { @@ -129,6 +151,8 @@ export class PlanetView extends PlanetBase { }: UpdatePropertyCommand): void { if (propertyKey === 'rotation') { this.rotationInterpolator.addFrame(propertyValue, rateOfChange); + this.latestRotation = propertyValue; + this.rotationSpeed = rateOfChange; } else { this.ownership = propertyValue; } @@ -170,7 +194,12 @@ export class PlanetView extends PlanetBase { private getGradient(): string { 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 ? `conic-gradient( var(--bright-blue) ${sidePercent}%, diff --git a/frontend/src/scripts/shapes/planet-shape.ts b/frontend/src/scripts/shapes/planet-shape.ts index 7a3bee5..1d1ec9e 100644 --- a/frontend/src/scripts/shapes/planet-shape.ts +++ b/frontend/src/scripts/shapes/planet-shape.ts @@ -103,8 +103,8 @@ export class PlanetShape extends PolygonFactory(settings.planetEdgeCount, 0) { if (dist < minDistance) { minDistance = dist; color = mix(${colorToString(settings.bluePlanetColor)}, ${colorToString( - settings.redPlanetColor, - )}, planetColorMixQ[j]); + settings.redPlanetColor, + )}, planetColorMixQ[j]); } } @@ -129,11 +129,32 @@ export class PlanetShape extends PolygonFactory(settings.planetEdgeCount, 0) { public randomOffset = 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( public vertices: Array, public colorMixQ: number, ) { 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 { diff --git a/frontend/src/scripts/tutorial.ts b/frontend/src/scripts/tutorial.ts index 64befb5..c0d1155 100644 --- a/frontend/src/scripts/tutorial.ts +++ b/frontend/src/scripts/tutorial.ts @@ -4,17 +4,19 @@ import { CommandExecutors, CommandReceiver, Id, + LeapActionCommand, MoveActionCommand, PrimaryActionCommand, } from 'shared'; import { GameObjectContainer } from './objects/game-object-container'; 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 }> = [ { hint: 'WASD / drag to walk', clearsOn: 'move' }, { 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' }, ]; @@ -42,6 +44,11 @@ export class Tutorial extends CommandReceiver { this.advance(); } }, + [LeapActionCommand.type]: () => { + if (this.clearsOn() === 'leap') { + this.advance(); + } + }, }; constructor(overlay: HTMLElement) { diff --git a/shared/src/commands/types/actions/leap-action.ts b/shared/src/commands/types/actions/leap-action.ts new file mode 100644 index 0000000..0115e73 --- /dev/null +++ b/shared/src/commands/types/actions/leap-action.ts @@ -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 { + return [this.clientTimeMs]; + } +} diff --git a/shared/src/commands/types/actions/move-action.ts b/shared/src/commands/types/actions/move-action.ts index 67674fe..117d3e3 100644 --- a/shared/src/commands/types/actions/move-action.ts +++ b/shared/src/commands/types/actions/move-action.ts @@ -4,11 +4,19 @@ import { Command } from '../../command'; @serializable 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(); } public toArray(): Array { - return [this.direction]; + return [this.direction, this.clientTimeMs]; } } diff --git a/shared/src/commands/types/input-acknowledgement.ts b/shared/src/commands/types/input-acknowledgement.ts new file mode 100644 index 0000000..3f488e7 --- /dev/null +++ b/shared/src/commands/types/input-acknowledgement.ts @@ -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 { + return [this.clientTimeMs, this.bodyVelocity, this.lastLeapClientTimeMs]; + } +} diff --git a/shared/src/main.ts b/shared/src/main.ts index 949cc2d..d9af9ca 100644 --- a/shared/src/main.ts +++ b/shared/src/main.ts @@ -15,6 +15,7 @@ export * from './commands/command-executors'; export * from './commands/command-generator'; export * from './commands/types/actions/move-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 './helper/array'; export * from './helper/last'; @@ -46,3 +47,13 @@ export * from './objects/types/planet-base'; export * from './objects/types/projectile-base'; export * from './settings'; 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'; diff --git a/shared/src/objects/types/character-base.ts b/shared/src/objects/types/character-base.ts index e6414b8..b461127 100644 --- a/shared/src/objects/types/character-base.ts +++ b/shared/src/objects/types/character-base.ts @@ -28,9 +28,13 @@ export class CharacterBase extends GameObject { // eslint-disable-next-line @typescript-eslint/no-unused-vars 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) { this.health = health; diff --git a/shared/src/objects/types/planet-base.ts b/shared/src/objects/types/planet-base.ts index ba90ff2..e3835e1 100644 --- a/shared/src/objects/types/planet-base.ts +++ b/shared/src/objects/types/planet-base.ts @@ -15,6 +15,7 @@ export class PlanetBase extends GameObject { id: Id, public readonly vertices: Array, public ownership: number = 0.5, + public readonly isKeystone: boolean = false, ) { super(id); 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) {} // eslint-disable-next-line @typescript-eslint/no-unused-vars public onFlipped(team: CharacterTeam) {} + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public setContested(contested: boolean) {} public static createPlanetVertices( center: vec2, @@ -54,6 +57,6 @@ export class PlanetBase extends GameObject { } public toArray(): Array { - return [this.id, this.vertices]; + return [this.id, this.vertices, this.ownership, this.isKeystone]; } } diff --git a/shared/src/physics/character-movement.ts b/shared/src/physics/character-movement.ts new file mode 100644 index 0000000..a5744b9 --- /dev/null +++ b/shared/src/physics/character-movement.ts @@ -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; + // 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, 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); +}; diff --git a/shared/src/physics/depenetrate-circle.ts b/shared/src/physics/depenetrate-circle.ts new file mode 100644 index 0000000..7232f73 --- /dev/null +++ b/shared/src/physics/depenetrate-circle.ts @@ -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, +) => { + 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, + ); + } +}; diff --git a/shared/src/physics/evaluate-sdf.ts b/shared/src/physics/evaluate-sdf.ts new file mode 100644 index 0000000..f3c60cf --- /dev/null +++ b/shared/src/physics/evaluate-sdf.ts @@ -0,0 +1,7 @@ +import { vec2 } from 'gl-matrix'; +import { Sdf } from './sdf'; + +export const evaluateSdf = (target: vec2, objects: Array) => + objects + .filter((i) => i.canCollide) + .reduce((min, i) => (min = Math.min(min, i.distance(target))), 1000000); diff --git a/shared/src/physics/interpolate-angles.ts b/shared/src/physics/interpolate-angles.ts new file mode 100644 index 0000000..1fb46d6 --- /dev/null +++ b/shared/src/physics/interpolate-angles.ts @@ -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; +}; diff --git a/shared/src/physics/march-circle.ts b/shared/src/physics/march-circle.ts new file mode 100644 index 0000000..0a8f0c8 --- /dev/null +++ b/shared/src/physics/march-circle.ts @@ -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, + 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, + }; +}; diff --git a/shared/src/physics/planet-sdf.ts b/shared/src/physics/planet-sdf.ts new file mode 100644 index 0000000..24af49d --- /dev/null +++ b/shared/src/physics/planet-sdf.ts @@ -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, + 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); +}; diff --git a/shared/src/physics/resolve-circle-movement.ts b/shared/src/physics/resolve-circle-movement.ts new file mode 100644 index 0000000..47f0c1c --- /dev/null +++ b/shared/src/physics/resolve-circle-movement.ts @@ -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, + 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 }; +}; diff --git a/shared/src/physics/sdf-normal.ts b/shared/src/physics/sdf-normal.ts new file mode 100644 index 0000000..507a8eb --- /dev/null +++ b/shared/src/physics/sdf-normal.ts @@ -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): 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; +}; diff --git a/shared/src/physics/sdf.ts b/shared/src/physics/sdf.ts new file mode 100644 index 0000000..38bb74c --- /dev/null +++ b/shared/src/physics/sdf.ts @@ -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; +} diff --git a/shared/src/settings.ts b/shared/src/settings.ts index 66aa929..61041a5 100644 --- a/shared/src/settings.ts +++ b/shared/src/settings.ts @@ -33,7 +33,11 @@ export const settings = { maxGravityDistance: 800, minGravityDistance: 1, 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, maxGravityStrength: 50000, planetMinReferenceRadius: 150, @@ -141,4 +145,65 @@ export const settings = { lampFlareDecaySeconds: 0.6, maxConcurrentFlipFlares: 3, 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, };