From b9274a42c72f6e458fad8a9f78da1f8898a304ec Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sat, 22 Aug 2026 15:29:33 +0100 Subject: [PATCH] clean up --- backend/src/game-server.ts | 25 +-- backend/src/objects/character-physical.ts | 203 +++++++----------- backend/src/objects/circle-physical.ts | 17 +- backend/src/objects/lamp-physical.ts | 10 +- backend/src/objects/planet-physical.ts | 100 +++------ backend/src/objects/projectile-physical.ts | 40 ++-- .../bounding-boxes/bounding-box-base.ts | 10 - .../physics/bounding-boxes/bounding-box.ts | 5 - .../functions/get-bounding-box-of-circle.ts | 3 +- backend/src/players/npc.ts | 34 +-- backend/src/players/player-base.ts | 45 +++- backend/src/players/player-container.ts | 6 +- backend/src/players/player.ts | 85 ++++---- frontend/src/index.ts | 2 +- .../src/scripts/commands/touch-listener.ts | 7 +- frontend/src/scripts/configuration.ts | 12 +- frontend/src/scripts/game.ts | 15 +- .../prediction/local-character-predictor.ts | 7 +- frontend/src/scripts/join-form-handler.ts | 8 +- .../scripts/objects/types/character-view.ts | 24 +-- .../src/scripts/objects/types/planet-view.ts | 14 +- shared/src/commands/types/game-end.ts | 14 +- shared/src/helper/charge.ts | 10 + shared/src/helper/finite.ts | 1 - shared/src/main.ts | 1 - shared/src/objects/game-object.ts | 6 +- shared/src/objects/types/planet-base.ts | 11 + shared/src/physics/character-movement.ts | 41 ++-- shared/src/physics/evaluate-sdf.ts | 18 +- shared/src/physics/march-circle.ts | 2 +- shared/src/physics/planet-sdf.ts | 49 +++-- shared/src/physics/sdf-normal.ts | 19 +- shared/src/settings.ts | 1 - .../physics-determinism.test.mjs.snap | 2 + test/physics-determinism.test.mjs | 88 ++++++++ 35 files changed, 479 insertions(+), 456 deletions(-) diff --git a/backend/src/game-server.ts b/backend/src/game-server.ts index ce02d58..795eb34 100644 --- a/backend/src/game-server.ts +++ b/backend/src/game-server.ts @@ -48,9 +48,6 @@ export class GameServer extends CommandReceiver { private isInEndGame = false; private timeScaling = 1; - private serverName: string; - private playerLimit: number; - private initialize() { const previousPlayers = this.players; this.releaseJoinedSockets(); @@ -85,9 +82,6 @@ export class GameServer extends CommandReceiver { ) { super(); - this.serverName = options.name; - this.playerLimit = options.playerLimit; - this.initialize(); io.on('connection', (socket: Socket) => { @@ -274,14 +268,9 @@ export class GameServer extends CommandReceiver { this.isInEndGame = true; const endTitleLength = 6; this.players.endGame(winningTeam); - this.players.queueCommandForEachClient( - new GameEndCommand(winningTeam, endTitleLength, true), - ); - setTimeout(() => this.destroy(), endTitleLength * 1000 * 1.1); - } - - private destroy() { - this.initialize(); + this.players.queueCommandForEachClient(new GameEndCommand()); + // Rebuild the world for the next round once the end card has been shown. + setTimeout(() => this.initialize(), endTitleLength * 1000 * 1.1); } private timeSinceLastPointUpdate = 0; @@ -377,14 +366,14 @@ export class GameServer extends CommandReceiver { rtts.sort((a, b) => a - b); console.info( `Player RTT median ${rtts[Math.floor(rtts.length / 2)].toFixed(0)} ms ` + - `(min ${rtts[0].toFixed(0)}, max ${rtts[rtts.length - 1].toFixed(0)}, n=${rtts.length})`, + `(min ${rtts[0].toFixed(0)}, max ${rtts[rtts.length - 1].toFixed(0)}, n=${rtts.length})`, ); } if (this.droppedInboundMessages > 0) { console.warn( `Rate limited ${this.droppedInboundMessages} inbound message(s) since last report — ` + - 'client input was discarded', + 'client input was discarded', ); this.droppedInboundMessages = 0; } @@ -406,9 +395,9 @@ export class GameServer extends CommandReceiver { public get serverInfo(): ServerInformation { return { - serverName: this.serverName, + serverName: this.options.name, playerCount: this.players.count, - playerLimit: this.playerLimit, + playerLimit: this.options.playerLimit, gameStatePercent: this.gameProgress, }; } diff --git a/backend/src/objects/character-physical.ts b/backend/src/objects/character-physical.ts index ae72c0f..5eb57ca 100644 --- a/backend/src/objects/character-physical.ts +++ b/backend/src/objects/character-physical.ts @@ -15,10 +15,12 @@ import { CharacterMovementSnapshot, mix, clamp01, + strengthToCharge, stepCharacterMovement, applyLeapImpulse, decayMomentum, - CharacterMovementState, + tickPlanetDetachment, + characterCenter, CharacterWorld, GroundSurface, headRadius, @@ -40,6 +42,14 @@ import { StepCommand } from '../commands/step'; import { ReactToCollisionCommand } from '../commands/react-to-collision'; import { GeneratePointsCommand } from '../commands/generate-points'; +// The three body circles, as a value — used to diff a tick's movement into the +// rate of change the client interpolates on. +interface BodyPose { + head: Circle; + leftFoot: Circle; + rightFoot: Circle; +} + @serializesTo(CharacterBase) export class CharacterPhysical extends CharacterBase implements DynamicPhysical { public readonly canCollide = true; @@ -47,13 +57,6 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical private projectileStrength = settings.playerMaxStrength; - // Body geometry (head/foot radii, posture offsets, bound radius) is defined - // once in the shared movement module and imported here, so the authoritative - // body and the client's predicted body are bit-identical by construction - // instead of by a hand-synced "copied verbatim" duplicate. Re-exposed as a - // static only because external callers reference CharacterPhysical.boundRadius. - public static readonly boundRadius = boundRadius; - private timeSinceDying = 0; private isDestroyed = false; private timeSinceBorn = 0; @@ -66,20 +69,20 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical private killStreak = 0; - private direction = 0; - private currentPlanet?: PlanetPhysical; - private secondsSinceOnSurface = settings.planetDetachmentSeconds; + // CharacterMovementState, held directly rather than mirrored through an + // adapter: the shared simulation reads and writes these in place, so the + // character simply IS the state it is stepped as. + public direction = 0; + public currentPlanet: GroundSurface | undefined; + public secondsSinceOnSurface = settings.planetDetachmentSeconds; + public readonly bodyVelocity = vec2.create(); - 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()); @@ -122,62 +125,29 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical container.addObject(this.head); container.addObject(this.leftFoot); container.addObject(this.rightFoot); - - this.initMovementBridge(); } - private initMovementBridge() { - // The movementState object-literal getters/setters below can't use `this` - // (it would bind to the literal), so alias the character instance. - // eslint-disable-next-line @typescript-eslint/no-this-alias - 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; - }, - }; - } + // The container, presented as the collision/gravity world the shared movement + // queries. + private readonly movementWorld: CharacterWorld = { + // 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) => + this.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 hasFiredSinceSpawn = false; private get isSpawnProtected(): boolean { return ( !this.hasFiredSinceSpawn && this.timeAlive < - settings.spawnDespawnTime + settings.spawnInvulnerabilityExtraSeconds + settings.spawnDespawnTime + settings.spawnInvulnerabilityExtraSeconds ); } @@ -206,8 +176,10 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical this.movementActions.push(c); } + // The world only ever hands back planets, so narrowing the shared + // GroundSurface is safe here. public get groundPlanet(): PlanetPhysical | undefined { - return this.currentPlanet; + return this.currentPlanet as PlanetPhysical | undefined; } // The continuous movement state streamed to the owning client, so its @@ -219,7 +191,7 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical vec2.clone(this.bodyVelocity), vec2.clone(this.leftFoot.lastNormal), vec2.clone(this.rightFoot.lastNormal), - this.currentPlanet?.id ?? null, + this.groundPlanet?.id ?? null, this.secondsSinceOnSurface, ); } @@ -311,10 +283,7 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical const strength = Math.min(desiredStrength, this.projectileStrength); this.projectileStrength -= strength; - const c = clamp01( - (strength - settings.chargeShotStrengthMin) / - (settings.chargeShotStrengthMax - settings.chargeShotStrengthMin), - ); + const c = strengthToCharge(strength); const radius = mix(settings.chargeShotRadiusMin, settings.chargeShotRadiusMax, c); const speed = mix(settings.chargeShotSpeedMin, settings.chargeShotSpeedMax, c); @@ -367,12 +336,12 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical // Same impulse the client predicts with (shared), so a leap launches // identically on both sides. - applyLeapImpulse(this.movementState, this.lastMovementAction.direction); + applyLeapImpulse(this, this.lastMovementAction.direction); this.remoteCall('onLeap'); } public get boundingBox(): BoundingBoxBase { - return getBoundingBoxOfCircle(new Circle(this.center, CharacterPhysical.boundRadius)); + return getBoundingBoxOfCircle(new Circle(this.center, boundRadius)); } public get gameObject(): this { @@ -380,9 +349,7 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical } public get center(): vec2 { - const bodyCenter = vec2.add(vec2.create(), this.head.center, this.leftFoot.center); - vec2.add(bodyCenter, bodyCenter, this.rightFoot.center); - return vec2.scale(bodyCenter, bodyCenter, 1 / 3); + return characterCenter(this.head, this.leftFoot, this.rightFoot); } public distance(target: vec2): number { @@ -434,53 +401,42 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical ]); } - private setPropertyUpdates( - oldHead: Circle, - oldLeftFoot: Circle, - oldRightFoot: Circle, - deltaTime: number, - ) { - this.headVelocity = new Circle( + // Rate of change of one body part over the tick, which the client's + // interpolator coasts on when a snapshot is late. `previous` is consumed as + // scratch so this allocates nothing beyond the Circle it returns. + private static rateOfChange(previous: Circle, current: Circle, deltaTime: number) { + return new Circle( vec2.scale( - oldHead.center, - vec2.subtract(oldHead.center, this.head.center, oldHead.center), + previous.center, + vec2.subtract(previous.center, current.center, previous.center), 1 / deltaTime, ), - (this.head.radius - oldHead.radius) / deltaTime, + (current.radius - previous.radius) / deltaTime, ); + } - this.leftFootVelocity = new Circle( - vec2.scale( - oldLeftFoot.center, - vec2.subtract(oldLeftFoot.center, this.leftFoot.center, oldLeftFoot.center), - 1 / deltaTime, - ), - (this.leftFoot.radius - oldLeftFoot.radius) / deltaTime, - ); + private setPropertyUpdates(previous: BodyPose, deltaTime: number) { + const rate = CharacterPhysical.rateOfChange; + this.headVelocity = rate(previous.head, this.head, deltaTime); + this.leftFootVelocity = rate(previous.leftFoot, this.leftFoot, deltaTime); + this.rightFootVelocity = rate(previous.rightFoot, this.rightFoot, deltaTime); + } - this.rightFootVelocity = new Circle( - vec2.scale( - oldRightFoot.center, - vec2.subtract(oldRightFoot.center, this.rightFoot.center, oldRightFoot.center), - 1 / deltaTime, - ), - (this.rightFoot.radius - oldRightFoot.radius) / deltaTime, - ); + private get pose(): BodyPose { + const snapshot = (circle: Circle) => + new Circle(vec2.clone(circle.center), circle.radius); + return { + head: snapshot(this.head), + leftFoot: snapshot(this.leftFoot), + rightFoot: snapshot(this.rightFoot), + }; } 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), - this.leftFoot.radius, - ); - const oldRightFoot = new Circle( - vec2.clone(this.rightFoot.center), - this.rightFoot.radius, - ); + const previousPose = this.pose; if (this.isDestroyed) { if ((this.timeSinceDying += deltaTimeInSeconds) > settings.spawnDespawnTime) { @@ -489,7 +445,7 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical this.freeFallCorpse(deltaTimeInSeconds); this.animateScaling(1 - this.timeSinceDying / settings.spawnDespawnTime); } - this.setPropertyUpdates(oldHead, oldLeftFoot, oldRightFoot, deltaTimeInSeconds); + this.setPropertyUpdates(previousPose, deltaTimeInSeconds); return; } @@ -500,30 +456,25 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical } else { this.animateScaling(this.timeSinceBorn / settings.spawnDespawnTime); } - this.setPropertyUpdates(oldHead, oldLeftFoot, oldRightFoot, deltaTimeInSeconds); + this.setPropertyUpdates(previousPose, deltaTimeInSeconds); return; } - if ( - (this.secondsSinceOnSurface += deltaTimeInSeconds) > - settings.planetDetachmentSeconds - ) { - this.currentPlanet = undefined; - } + tickPlanetDetachment(this, deltaTimeInSeconds); this.timeSinceLastShot += deltaTimeInSeconds; this.projectileStrength = Math.min( settings.playerMaxStrength, this.projectileStrength + - settings.playerStrengthRegenerationPerSeconds * deltaTimeInSeconds, + settings.playerStrengthRegenerationPerSeconds * deltaTimeInSeconds, ); this.regenerateHealth(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); + this.groundPlanet?.registerPresence(this); // The whole walking model — gravity gather, movement force, on/off-planet // branch, posture springs, body-momentum, and stepping the three parts — @@ -531,23 +482,15 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical // 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, - ); + stepCharacterMovement(this, this.movementWorld, direction, deltaTimeInSeconds); - this.setPropertyUpdates(oldHead, oldLeftFoot, oldRightFoot, deltaTimeInSeconds); + this.setPropertyUpdates(previousPose, deltaTimeInSeconds); } private freeFallCorpse(deltaTime: number) { const intersecting = this.container.findIntersecting( getBoundingBoxOfCircle( - new Circle( - this.center, - CharacterPhysical.boundRadius + settings.maxGravityDistance, - ), + new Circle(this.center, boundRadius + settings.maxGravityDistance), ), ); let grounded = false; diff --git a/backend/src/objects/circle-physical.ts b/backend/src/objects/circle-physical.ts index 037708f..9554ace 100644 --- a/backend/src/objects/circle-physical.ts +++ b/backend/src/objects/circle-physical.ts @@ -1,5 +1,6 @@ import { vec2 } from 'gl-matrix'; import { + applyForce, Circle, CommandExecutors, CommandReceiver, @@ -12,6 +13,7 @@ import { BoundingBoxBase } from '../physics/bounding-boxes/bounding-box-base'; import { PhysicalContainer } from '../physics/containers/physical-container'; import { DynamicPhysical } from '../physics/physicals/dynamic-physical'; import { Physical } from '../physics/physicals/physical'; +import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle'; import { ReactToCollisionCommand } from '../commands/react-to-collision'; @serializesTo(Circle) @@ -84,11 +86,7 @@ export class CirclePhysical extends CommandReceiver implements Circle, DynamicPh } public applyForce(force: vec2, timeInSeconds: number) { - vec2.add( - this.velocity, - this.velocity, - vec2.scale(vec2.create(), force, timeInSeconds), - ); + applyForce(this, force, timeInSeconds); } // Position-resolution for one tick. Delegates to the shared @@ -133,10 +131,11 @@ export class CirclePhysical extends CommandReceiver implements Circle, DynamicPh 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; + // Grow the query, not the object: inflating and deflating this.radius ran + // the setter (and so re-registered the body's extent) twice mid-tick. + return this.container.findIntersecting( + getBoundingBoxOfCircle(new Circle(this.center, this.radius + sweep)), + ); } public toArray(): Array { diff --git a/backend/src/objects/lamp-physical.ts b/backend/src/objects/lamp-physical.ts index 020c412..7438f7d 100644 --- a/backend/src/objects/lamp-physical.ts +++ b/backend/src/objects/lamp-physical.ts @@ -1,7 +1,8 @@ import { vec2, vec3 } from 'gl-matrix'; -import { LampBase, settings, id, serializesTo } from 'shared'; +import { Circle, LampBase, settings, id, serializesTo } from 'shared'; import { ImmutableBoundingBox } from '../physics/bounding-boxes/immutable-bounding-box'; +import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle'; import { StaticPhysical } from '../physics/physicals/static-physical'; @serializesTo(LampBase) @@ -17,11 +18,8 @@ export class LampPhysical extends LampBase implements StaticPhysical { public get boundingBox(): ImmutableBoundingBox { if (!this._boundingBox) { - this._boundingBox = new ImmutableBoundingBox( - this.center.x - settings.lightCutoffDistance, - this.center.x + settings.lightCutoffDistance, - this.center.y - settings.lightCutoffDistance, - this.center.y + settings.lightCutoffDistance, + this._boundingBox = getBoundingBoxOfCircle( + new Circle(this.center, settings.lightCutoffDistance), ); } diff --git a/backend/src/objects/planet-physical.ts b/backend/src/objects/planet-physical.ts index d6ea396..511049f 100644 --- a/backend/src/objects/planet-physical.ts +++ b/backend/src/objects/planet-physical.ts @@ -1,7 +1,7 @@ import { vec2 } from 'gl-matrix'; import { - clamp, + Circle, clamp01, id, mix, @@ -12,6 +12,8 @@ import { CharacterTeam, PropertyUpdatesForObject, UpdatePropertyCommand, + planetDistance, + planetGravity, CommandExecutors, CommandReceiver, } from 'shared'; @@ -20,6 +22,7 @@ import { AnnounceCommand } from '../commands/announce'; import { StepCommand } from '../commands/step'; import { ImmutableBoundingBox } from '../physics/bounding-boxes/immutable-bounding-box'; +import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle'; import { StaticPhysical } from '../physics/physicals/static-physical'; import { LampPhysical } from './lamp-physical'; import type { CharacterPhysical } from './character-physical'; @@ -69,7 +72,7 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical { const sizeClass = clamp01( (this.radius - settings.planetMinReferenceRadius) / - (settings.planetMaxReferenceRadius - settings.planetMinReferenceRadius), + (settings.planetMaxReferenceRadius - settings.planetMinReferenceRadius), ); this.sizePointMultiplier = mix(1, settings.planetSizePointMultiplierMax, sizeClass); @@ -85,59 +88,26 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical { } 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). - const local = this.toLocalFrame(target); - - const startEnd = this.vertices[0]; - let vb = startEnd; - - let d = vec2.dist(local, vb); - let sign = 1; - - for (let i = 1; i <= this.vertices.length; i++) { - const va = vb; - vb = i === this.vertices.length ? startEnd : this.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.x * targetFromDelta.y - toFromDelta.y * targetFromDelta.x, - ); - - if ( - (local.y >= va.y && local.y < vb.y && ds.y > 0) || - (local.y < va.y && local.y >= vb.y && ds.y <= 0) - ) { - sign *= -1; - } - - d = Math.min(d, ds.x); - } - - return sign * d; + // The one shared planet outline — the same function the client predictor + // collides against, which is what lets prediction reconcile. Only the + // cos/sin memo lives here, because distance() is called many times per tick + // by the raymarcher and the angle rarely changes between those calls. + this.syncRotationTrigonometry(); + return planetDistance( + target, + this.vertices, + this.center, + this.cosRotation, + this.sinRotation, + ); } - // Rotate a world point by -rotation about the centre, matching the shader's - // `localTarget = center + R(rotation) * (target - center)` transform exactly. - private toLocalFrame(target: vec2): vec2 { + private syncRotationTrigonometry() { if (this.rotation !== this.cachedRotation) { this.cachedRotation = this.rotation; this.cosRotation = Math.cos(this.rotation); this.sinRotation = Math.sin(this.rotation); } - - const dx = target.x - this.center.x; - const dy = target.y - this.center.y; - - return vec2.fromValues( - this.center.x + this.cosRotation * dx - this.sinRotation * dy, - this.center.y + this.sinRotation * dx + this.cosRotation * dy, - ); } // Signed angular velocity in rad/s, exposed so a character standing on the @@ -146,14 +116,6 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical { return this.rotationSpeed; } - public get team(): CharacterTeam { - return Math.abs(this.ownership - 0.5) < settings.planetControlThreshold - ? CharacterTeam.neutral - : this.ownership < 0.5 - ? CharacterTeam.blue - : CharacterTeam.red; - } - private timeSinceLastPointGeneration = 0; private getPoints(game: CommandReceiver) { if (this.timeSinceLastPointGeneration > settings.planetPointGenerationInterval) { @@ -317,32 +279,22 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical { 0, ); - this._boundingBox = new ImmutableBoundingBox( - this.center.x - maxVertexDistance, - this.center.x + maxVertexDistance, - this.center.y - maxVertexDistance, - this.center.y + maxVertexDistance, + this._boundingBox = getBoundingBoxOfCircle( + new Circle(this.center, maxVertexDistance), ); } return this._boundingBox; } - public getForce(position: vec2): vec2 { - const diff = vec2.subtract(vec2.create(), this.center, position); - const dist = Math.max(settings.minGravityDistance, vec2.length(diff) - this.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); + // GroundSurface gravity, shared with the client predictor. + public gravityAt(position: vec2): vec2 { + return planetGravity(this.center, this.radius, position); } - // GroundSurface alias the shared movement simulation calls for gravity. - public gravityAt(position: vec2): vec2 { - return this.getForce(position); + // forceAtPosition's name for the same thing. + public getForce(position: vec2): vec2 { + return this.gravityAt(position); } public get gameObject(): this { diff --git a/backend/src/objects/projectile-physical.ts b/backend/src/objects/projectile-physical.ts index 1de3872..c0d676f 100644 --- a/backend/src/objects/projectile-physical.ts +++ b/backend/src/objects/projectile-physical.ts @@ -11,7 +11,7 @@ import { Circle, marchCircle, } from 'shared'; -import { ImmutableBoundingBox } from '../physics/bounding-boxes/immutable-bounding-box'; +import { BoundingBoxBase } from '../physics/bounding-boxes/bounding-box-base'; import { CirclePhysical } from './circle-physical'; import { DynamicPhysical } from '../physics/physicals/dynamic-physical'; import { PhysicalContainer } from '../physics/containers/physical-container'; @@ -28,7 +28,6 @@ export class ProjectilePhysical extends ProjectileBase implements DynamicPhysica private isDestroyed = false; private bounceCount = 0; - private _boundingBox?: ImmutableBoundingBox; public object: CirclePhysical; @@ -107,12 +106,11 @@ export class ProjectilePhysical extends ProjectileBase implements DynamicPhysica } } - public get boundingBox(): ImmutableBoundingBox { - if (!this._boundingBox) { - this._boundingBox = (this.object as CirclePhysical).boundingBox; - } - - return this._boundingBox; + // The circle keeps its own box in sync as it moves, so this is a live view of + // it, not a snapshot — caching it saved nothing and mistyped a mutable box as + // immutable. + public get boundingBox(): BoundingBoxBase { + return this.object.boundingBox; } public get gameObject(): this { @@ -163,22 +161,18 @@ export class ProjectilePhysical extends ProjectileBase implements DynamicPhysica // 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; + const intersecting = this.container.findIntersecting( + getBoundingBoxOfCircle( + new Circle(this.center, this.object.radius + settings.maxGravityDistance), + ), + ); - if (intersecting) { - vec2.scaleAndAdd( - this.velocity, - this.velocity, - forceAtPosition(this.center, intersecting), - settings.projectileGravityScale * deltaTimeInSeconds, - ); - } + 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, intersecting); diff --git a/backend/src/physics/bounding-boxes/bounding-box-base.ts b/backend/src/physics/bounding-boxes/bounding-box-base.ts index 91946c9..ee881cf 100644 --- a/backend/src/physics/bounding-boxes/bounding-box-base.ts +++ b/backend/src/physics/bounding-boxes/bounding-box-base.ts @@ -1,5 +1,3 @@ -import { vec2 } from 'gl-matrix'; - // axis-aligned export abstract class BoundingBoxBase { constructor( @@ -43,14 +41,6 @@ export abstract class BoundingBoxBase { return this._yMax; } - public get topLeft(): vec2 { - return vec2.fromValues(this._xMin, this._yMax); - } - - public get size(): vec2 { - return vec2.fromValues(this._xMax - this._xMin, this._yMax - this._yMin); - } - public intersects(other: BoundingBoxBase): boolean { return ( this._xMin < other._xMax && diff --git a/backend/src/physics/bounding-boxes/bounding-box.ts b/backend/src/physics/bounding-boxes/bounding-box.ts index 562794f..1a00fb3 100644 --- a/backend/src/physics/bounding-boxes/bounding-box.ts +++ b/backend/src/physics/bounding-boxes/bounding-box.ts @@ -1,6 +1,5 @@ import { vec2 } from 'gl-matrix'; import { BoundingBoxBase } from './bounding-box-base'; -import { ImmutableBoundingBox } from './immutable-bounding-box'; export class BoundingBox extends BoundingBoxBase { public get xMin(): number { @@ -52,8 +51,4 @@ export class BoundingBox extends BoundingBoxBase { public get size(): vec2 { return vec2.fromValues(this._xMax - this._xMin, this._yMax - this._yMin); } - - public cloneAsImmutable(): ImmutableBoundingBox { - return new ImmutableBoundingBox(this.xMin, this.xMax, this.yMin, this.yMax); - } } diff --git a/backend/src/physics/functions/get-bounding-box-of-circle.ts b/backend/src/physics/functions/get-bounding-box-of-circle.ts index 9c5b2eb..43ef937 100644 --- a/backend/src/physics/functions/get-bounding-box-of-circle.ts +++ b/backend/src/physics/functions/get-bounding-box-of-circle.ts @@ -1,8 +1,7 @@ import { Circle } from 'shared'; -import { BoundingBoxBase } from '../bounding-boxes/bounding-box-base'; import { ImmutableBoundingBox } from '../bounding-boxes/immutable-bounding-box'; -export const getBoundingBoxOfCircle = (circle: Circle): BoundingBoxBase => +export const getBoundingBoxOfCircle = (circle: Circle): ImmutableBoundingBox => new ImmutableBoundingBox( circle.center.x - circle.radius, circle.center.x + circle.radius, diff --git a/backend/src/players/npc.ts b/backend/src/players/npc.ts index 655ade7..f7b4ffb 100644 --- a/backend/src/players/npc.ts +++ b/backend/src/players/npc.ts @@ -7,6 +7,7 @@ import { MoveActionCommand, CharacterTeam, Id, + rotate90Deg, } from 'shared'; import { PhysicalContainer } from '../physics/containers/physical-container'; import { PlayerContainer } from './player-container'; @@ -100,23 +101,9 @@ export class NPC extends PlayerBase { this.step(0); } - private timeUntilRespawn = 0; public step(deltaTimeInSeconds: number) { - if (this.character) { - this.center = this.character.center; - - if (!this.character.isAlive) { - this.sumDeaths++; - this.sumKills = this.character.killCount; - this.character = null; - this.timeUntilRespawn = settings.playerDiedTimeout; - return; - } - } else { - if ((this.timeUntilRespawn -= deltaTimeInSeconds) < 0) { - this.createCharacter(); - this.center = this.character!.center; - } + const character = this.stepLifecycle(deltaTimeInSeconds); + if (!character) { return; } @@ -143,16 +130,16 @@ export class NPC extends PlayerBase { this.dodgeCommitRemaining -= deltaTimeInSeconds; this.dodgeCooldownRemaining -= deltaTimeInSeconds; const movement = this.decideMovement(this.nearObjects); - this.character.handleMovementAction(new MoveActionCommand(movement)); + character.handleMovementAction(new MoveActionCommand(movement)); if ( !this.isComingBack && - this.character.groundPlanet && + character.groundPlanet && vec2.length(movement) > 0 && Random.getRandom() < npcTuning.leapChancePerSecond * this.aggression * deltaTimeInSeconds ) { - this.character.leap(); + character.leap(); } if ( @@ -178,18 +165,15 @@ export class NPC extends PlayerBase { const nearObjects = this.observe(npcTuning.planScanRadius); const enemies = this.enemiesByDistance(nearObjects); - if (enemies.length > 0) { - const nearest = enemies[0]; + const nearest = enemies[0]; + if (nearest) { const fleeRange = npcTuning.fleeBaseRange * (npcTuning.fleeAggressionFalloff - this.aggression); if (nearest.distance < fleeRange) { vec2.subtract(this.direction, this.center, nearest.character.center); return; } - } - if (enemies.length > 0) { - const nearest = enemies[0]; const chaseRange = npcTuning.chaseBaseRange + npcTuning.chaseAggressionRange * this.aggression; if (nearest.distance < chaseRange) { @@ -275,7 +259,7 @@ export class NPC extends PlayerBase { return undefined; } - const perpendicular = vec2.fromValues(-threat.direction.y, threat.direction.x); + const perpendicular = rotate90Deg(threat.direction); const toMe = vec2.subtract(vec2.create(), this.center, threat.center); if (vec2.dot(perpendicular, toMe) < 0) { vec2.negate(perpendicular, perpendicular); diff --git a/backend/src/players/player-base.ts b/backend/src/players/player-base.ts index c69e7ae..ba8fb43 100644 --- a/backend/src/players/player-base.ts +++ b/backend/src/players/player-base.ts @@ -7,6 +7,7 @@ import { Random, settings, sanitizeName, + boundRadius, } from 'shared'; import { PhysicalContainer } from '../physics/containers/physical-container'; import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle'; @@ -23,6 +24,7 @@ export abstract class PlayerBase extends CommandReceiver { protected sumKills = 0; protected sumDeaths = 0; + protected timeUntilRespawn = 0; constructor( protected readonly playerInfo: PlayerInformation, @@ -53,6 +55,44 @@ export abstract class PlayerBase extends CommandReceiver { public abstract step(deltaTimeInSeconds: number): void; + /** + * The death/respawn lifecycle both a connected player and an NPC go through: + * bank the score off a body that just died, hold the corpse for the respawn + * timeout, then build a new one. Returns whether there is a living character + * to act with this tick — returned as the character itself, so a subclass can + * `const character = this.stepLifecycle(dt); if (!character) return;` and go + * on to use it without re-narrowing. + */ + protected stepLifecycle(deltaTimeInSeconds: number): CharacterPhysical | undefined { + if (this.character) { + this.center = this.character.center; + + if (this.character.isAlive) { + return this.character; + } + + this.sumDeaths++; + this.sumKills = this.character.killCount; + this.onCharacterDied(this.character); + this.character = null; + this.timeUntilRespawn = settings.playerDiedTimeout; + return undefined; + } + + if ((this.timeUntilRespawn -= deltaTimeInSeconds) < 0) { + this.onBeforeRespawn(); + this.createCharacter(); + this.center = this.character!.center; + } + return undefined; + } + + // Hooks for what a subclass wants to do with the body it just lost, and just + // before a new one is built. Both are no-ops by default. + // eslint-disable-next-line @typescript-eslint/no-unused-vars + protected onCharacterDied(character: CharacterPhysical) {} + protected onBeforeRespawn() {} + private findSpawnCenter(): vec2 { const planets = this.objectContainer .findIntersecting( @@ -96,10 +136,7 @@ export abstract class PlayerBase extends CommandReceiver { radius * Math.sin(rotation) + preferredCenter.y, ); - const playerBoundingCircle = new Circle( - playerPosition, - CharacterPhysical.boundRadius, - ); + const playerBoundingCircle = new Circle(playerPosition, boundRadius); const playerBoundingBox = getBoundingBoxOfCircle(playerBoundingCircle); const possibleIntersectors = diff --git a/backend/src/players/player-container.ts b/backend/src/players/player-container.ts index 1df8720..c5412e9 100644 --- a/backend/src/players/player-container.ts +++ b/backend/src/players/player-container.ts @@ -135,7 +135,11 @@ export class PlayerContainer { } public step(deltaTimeInSeconds: number) { - this.players.forEach((p) => p.step(deltaTimeInSeconds)); + // Iterates the two backing arrays rather than the merged `players` getter: + // this runs once per 200 Hz physics substep, and the getter builds a new + // array every call. + this._players.forEach((p) => p.step(deltaTimeInSeconds)); + this._npcs.forEach((p) => p.step(deltaTimeInSeconds)); } public stepCommunication(deltaTimeInSeconds: number) { diff --git a/backend/src/players/player.ts b/backend/src/players/player.ts index 0a2ca8b..22cd9a3 100644 --- a/backend/src/players/player.ts +++ b/backend/src/players/player.ts @@ -52,9 +52,11 @@ const maximumBufferedBytes = settings.maxBufferedBytesPerClient; export class Player extends PlayerBase { // default, until the clients sends its real value private aspectRatio: number = 16 / 9; - private timeUntilRespawn = 0; private timeSinceLastMessage = 0; - private objectsPreviouslyInViewArea: Array = []; + // What this client has already been told about, in the order it was told. + // A Set because the per-snapshot diff below is membership-heavy; iteration + // order is insertion order, so the streamed object order is unchanged. + private objectsInViewArea = new Set(); private lastInputClientTimeMs = 0; private lastInputReceiptMs = 0; private lastLeapClientTimeMs = 0; @@ -190,7 +192,7 @@ export class Player extends PlayerBase { protected createCharacter() { super.createCharacter(); - this.objectsPreviouslyInViewArea.push(this.character!); + this.objectsInViewArea.add(this.character!); this.queueCommandSend(new CreatePlayerCommand(this.character!)); } @@ -205,26 +207,17 @@ export class Player extends PlayerBase { private dyingCharacter?: CharacterPhysical | null; public step(deltaTimeInSeconds: number) { - if (this.character) { - this.center = this.character?.center; + this.stepLifecycle(deltaTimeInSeconds); + } - if (!this.character.isAlive) { - this.sumDeaths++; - this.sumKills = this.character.killCount; + protected onCharacterDied(character: CharacterPhysical) { + this.dyingCharacter = character; + } - this.dyingCharacter = this.character; - this.character = null; - this.timeUntilRespawn = settings.playerDiedTimeout; - } - } else { - if ((this.timeUntilRespawn -= deltaTimeInSeconds) < 0) { - if (this.dyingCharacter) { - this.sumKills = Math.max(this.sumKills, this.dyingCharacter.killCount); - this.dyingCharacter = null; - } - this.createCharacter(); - this.center = this.character!.center; - } + protected onBeforeRespawn() { + if (this.dyingCharacter) { + this.sumKills = Math.max(this.sumKills, this.dyingCharacter.killCount); + this.dyingCharacter = null; } } @@ -234,26 +227,30 @@ export class Player extends PlayerBase { bb.topLeft = viewArea.topLeft; bb.size = viewArea.size; - const objectsInViewArea = Array.from( - new Set(this.objectContainer.findIntersecting(bb).map((o) => o.gameObject)), + const inViewArea = 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); + if (this.character) { + inViewArea.add(this.character); } - const newlyIntersecting = objectsInViewArea.filter( - (o) => !this.objectsPreviouslyInViewArea.includes(o), + // Set membership rather than Array.includes: this diff runs per player per + // snapshot over everything on their screen, and was quadratic in that. + // Set membership rather than Array.includes: this diff runs per player per + // snapshot over everything on their screen, and was quadratic in that. + const newlyIntersecting = [...inViewArea].filter( + (o) => !this.objectsInViewArea.has(o), ); - const noLongerIntersecting = this.objectsPreviouslyInViewArea.filter( - (o) => !objectsInViewArea.includes(o), + const noLongerIntersecting = [...this.objectsInViewArea].filter( + (o) => !inViewArea.has(o), ); - this.objectsPreviouslyInViewArea = objectsInViewArea; + this.objectsInViewArea = inViewArea; if (noLongerIntersecting.length > 0) { this.queueCommandSend( @@ -267,13 +264,15 @@ export class Player extends PlayerBase { this.queueCommandSend(new UpdateMinimap(this.getMinimapPlayers())); + const propertyUpdates: Array = []; + for (const object of this.objectsInViewArea) { + const update = object.getPropertyUpdatesForFrame(); + if (update) { + propertyUpdates.push(update); + } + } this.queueCommandSend( - new PropertyUpdatesForObjects( - this.objectsPreviouslyInViewArea - .map((o) => o.getPropertyUpdatesForFrame()) - .filter((u) => u) as Array, - performance.now() / 1000, - ), + new PropertyUpdatesForObjects(propertyUpdates, performance.now() / 1000), ); // Tell the client how much of its own input is reflected in the snapshot it @@ -310,11 +309,17 @@ export class Player extends PlayerBase { } public stepCommunications(deltaTime: number) { - const remoteCalls = this.objectsPreviouslyInViewArea - .map((g) => new RemoteCallsForObject(g.id, g.getRemoteCalls())) - .filter((c) => c.calls.length > 0); + // Runs at the physics rate, and on most ticks nothing has fired — so test + // first and only build the command when there is something to send. + let remoteCalls: Array | undefined; + for (const object of this.objectsInViewArea) { + const calls = object.getRemoteCalls(); + if (calls.length > 0) { + (remoteCalls ??= []).push(new RemoteCallsForObject(object.id, calls)); + } + } - if (remoteCalls.length > 0) { + if (remoteCalls) { this.queueCommandSend(new RemoteCallsForObjects(remoteCalls)); } diff --git a/frontend/src/index.ts b/frontend/src/index.ts index 5dad0b3..272c3b9 100644 --- a/frontend/src/index.ts +++ b/frontend/src/index.ts @@ -129,7 +129,7 @@ const main = async () => { }); window.onpopstate = () => game.destroy(); - for (; ;) { + for (;;) { show(spinner); hide(logoutButton, true); show(landingUI, true, 'flex'); diff --git a/frontend/src/scripts/commands/touch-listener.ts b/frontend/src/scripts/commands/touch-listener.ts index ae3a827..25c6463 100644 --- a/frontend/src/scripts/commands/touch-listener.ts +++ b/frontend/src/scripts/commands/touch-listener.ts @@ -295,7 +295,7 @@ export class TouchListener extends CommandGenerator { ); }; - public update(_deltaTimeInSeconds: number) { + public update() { if (!this.fireButton.parentElement) { this.overlay.appendChild(this.fireButton); } @@ -305,8 +305,9 @@ export class TouchListener extends CommandGenerator { const character = this.game.gameObjects.player; if (character) { - this.fireStrengthRing.style.background = `conic-gradient(rgba(255, 255, 255, 0.75) ${character.strengthFraction * 360 - }deg, transparent 0deg)`; + this.fireStrengthRing.style.background = `conic-gradient(rgba(255, 255, 255, 0.75) ${ + character.strengthFraction * 360 + }deg, transparent 0deg)`; } } diff --git a/frontend/src/scripts/configuration.ts b/frontend/src/scripts/configuration.ts index 9ab3fb6..e254dcb 100644 --- a/frontend/src/scripts/configuration.ts +++ b/frontend/src/scripts/configuration.ts @@ -20,16 +20,6 @@ const isDevelopment = typeof location !== 'undefined' && (location.hostname === 'localhost' || location.hostname === '127.0.0.1'); -const servers: Array = isDevelopment +export const servers: Array = isDevelopment ? [`http://${location.hostname}:3000`, ...productionServers] : productionServers; - -export abstract class Configuration { - public static async initialize(): Promise { - // Kept async for call-site compatibility; the server list is static now. - } - - public static get servers(): Array { - return servers; - } -} diff --git a/frontend/src/scripts/game.ts b/frontend/src/scripts/game.ts index 8fb4379..ca08a83 100644 --- a/frontend/src/scripts/game.ts +++ b/frontend/src/scripts/game.ts @@ -384,7 +384,7 @@ export class Game extends CommandReceiver { new RenderCommand(this.renderer, this.overlay, shouldChangeLayout), ); - this.touchListener.update(deltaTime); + this.touchListener.update(); this.tutorial.step(this.gameObjects); @@ -406,7 +406,9 @@ export class Game extends CommandReceiver { this.handleKeystoneArrow(); - this.announcementText.innerHTML = this.lastAnnouncementText; + if (this.announcementText.innerHTML !== this.lastAnnouncementText) { + this.announcementText.innerHTML = this.lastAnnouncementText; + } } // Points an off-screen chevron toward the keystone "Heart" planet, tinted by @@ -438,14 +440,7 @@ export class Game extends CommandReceiver { 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; + this.keystoneArrow.className = 'keystone-arrow ' + keystone.team; if (onScreen) { this.keystoneArrow.style.display = 'none'; diff --git a/frontend/src/scripts/helper/prediction/local-character-predictor.ts b/frontend/src/scripts/helper/prediction/local-character-predictor.ts index 5363224..e5e53c2 100644 --- a/frontend/src/scripts/helper/prediction/local-character-predictor.ts +++ b/frontend/src/scripts/helper/prediction/local-character-predictor.ts @@ -14,8 +14,11 @@ import { 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; +// The server's fixed tick, read from the same place the server reads it — the +// replay integrates over this dt, so a comment promising they match is not +// enough. +const stepSeconds = settings.targetPhysicsDeltaTimeInSeconds; +const stepMs = stepSeconds * 1000; // The client's clock: the timestamp of the frame currently being rendered, set // once per frame by the game loop (requestAnimationFrame hands it to us diff --git a/frontend/src/scripts/join-form-handler.ts b/frontend/src/scripts/join-form-handler.ts index c2bd37f..f18a746 100644 --- a/frontend/src/scripts/join-form-handler.ts +++ b/frontend/src/scripts/join-form-handler.ts @@ -1,6 +1,6 @@ import { ServerInformation, serverInformationEndpoint, TransportEvents } from 'shared'; import { io, Socket } from 'socket.io-client'; -import { Configuration } from './configuration'; +import { servers } from './configuration'; import parser from 'socket.io-msgpack-parser'; import { SoundHandler, Sounds } from './sound-handler'; @@ -69,11 +69,7 @@ export class JoinFormHandler { private servers: Array = []; private async loadServers() { - await Configuration.initialize(); - - const serverList = Configuration.servers.filter( - (u) => !this.servers.find((s) => s.url === u), - ); + const serverList = servers.filter((u) => !this.servers.find((s) => s.url === u)); serverList.map(async (url) => { const controller = new AbortController(); diff --git a/frontend/src/scripts/objects/types/character-view.ts b/frontend/src/scripts/objects/types/character-view.ts index 5c0d751..d23e420 100644 --- a/frontend/src/scripts/objects/types/character-view.ts +++ b/frontend/src/scripts/objects/types/character-view.ts @@ -2,6 +2,7 @@ import { vec2, vec3 } from 'gl-matrix'; import { CircleLight, Renderer } from 'sdf-2d'; import { + characterCenter, Circle, Id, CharacterBase, @@ -10,6 +11,7 @@ import { clamp, clamp01, mix, + strengthToCharge, CommandExecutors, UpdatePropertyCommand, } from 'shared'; @@ -123,9 +125,7 @@ export class CharacterView extends CharacterBase { } public get bodyCenter(): vec2 { - const center = vec2.add(vec2.create(), this.head!.center, this.leftFoot!.center); - vec2.add(center, center, this.rightFoot!.center); - return vec2.scale(center, center, 1 / 3); + return characterCenter(this.head!, this.leftFoot!, this.rightFoot!); } public get facingDirection(): vec2 { @@ -271,10 +271,7 @@ export class CharacterView extends CharacterBase { } public onShoot(strength: number) { - const q = clamp01( - (strength - settings.chargeShotStrengthMin) / - (settings.chargeShotStrengthMax - settings.chargeShotStrengthMin), - ); + const q = strengthToCharge(strength); SoundHandler.play(Sounds.shoot, mix(0.55, 1, q), mix(1.15, 0.8, q)); this.muzzleFlashIntensity = mix(0.35, 1, q); } @@ -332,16 +329,11 @@ export class CharacterView extends CharacterBase { } private calculateTextPosition(): vec2 { - const footAverage = vec2.add( + return vec2.scaleAndAdd( vec2.create(), - this.leftFoot!.center, - this.rightFoot!.center, + this.head!.center, + this.facingDirection, + this.head!.radius + 80, ); - vec2.scale(footAverage, footAverage, 0.5); - - const headFeetDelta = vec2.subtract(footAverage, this.head!.center, footAverage); - vec2.normalize(headFeetDelta, headFeetDelta); - const textOffset = vec2.scale(headFeetDelta, headFeetDelta, this.head!.radius + 80); - return vec2.add(textOffset, this.head!.center, textOffset); } } diff --git a/frontend/src/scripts/objects/types/planet-view.ts b/frontend/src/scripts/objects/types/planet-view.ts index 06e6880..03b6582 100644 --- a/frontend/src/scripts/objects/types/planet-view.ts +++ b/frontend/src/scripts/objects/types/planet-view.ts @@ -69,7 +69,6 @@ export class PlanetView extends PlanetBase { 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 @@ -87,8 +86,7 @@ export class PlanetView extends PlanetBase { } private step({ deltaTimeInSeconds }: StepCommand): void { - this.renderedRotation = this.rotationInterpolator.getValue(deltaTimeInSeconds); - this.shape.rotation = this.renderedRotation; + this.shape.rotation = this.rotationInterpolator.getValue(deltaTimeInSeconds); this.shape.colorMixQ = this.ownership; if (this.flareIntensity > 0) { @@ -167,7 +165,14 @@ export class PlanetView extends PlanetBase { const screenPosition = renderer.worldToDisplayCoordinates(this.center); this.ownershipProgress.style.transform = `translateX(${screenPosition.x}px) translateY(${screenPosition.y}px) translateX(-50%) translateY(-50%)`; - this.ownershipProgress.style.background = this.getGradient(); + // Re-parsing a conic-gradient for every planet on screen every layout + // frame is not free, and ownership moves far more slowly than the ring + // can show. + const gradient = this.getGradient(); + if (gradient !== this.lastGradient) { + this.lastGradient = gradient; + this.ownershipProgress.style.background = gradient; + } if (this.lastGeneratedPoint !== undefined) { const element = document.createElement('div'); @@ -192,6 +197,7 @@ export class PlanetView extends PlanetBase { } } + private lastGradient?: string; private getGradient(): string { const sideBlue = this.ownership < 0.5; // Keep the ring neutral through the same dead-band that gates scoring diff --git a/shared/src/commands/types/game-end.ts b/shared/src/commands/types/game-end.ts index 32bb84c..c209942 100644 --- a/shared/src/commands/types/game-end.ts +++ b/shared/src/commands/types/game-end.ts @@ -1,18 +1,12 @@ -import { CharacterTeam } from '../../objects/types/character-base'; import { serializable } from '../../serialization/serializable'; import { Command } from '../command'; +// "The round is over" — a bare signal, like GameStartCommand. The winner is +// announced through ServerAnnouncement and the end-card length is the server's +// own restart timer, so neither has to travel here. @serializable export class GameEndCommand extends Command { - constructor( - public readonly winningTeam: CharacterTeam, - public readonly endCardLengthInSeconds: number, - public readonly shouldReconnect: boolean, - ) { - super(); - } - public toArray(): Array { - return [this.winningTeam, this.endCardLengthInSeconds, this.shouldReconnect]; + return []; } } diff --git a/shared/src/helper/charge.ts b/shared/src/helper/charge.ts index 1ab6ece..145de12 100644 --- a/shared/src/helper/charge.ts +++ b/shared/src/helper/charge.ts @@ -3,3 +3,13 @@ import { settings } from '../settings'; export const holdDurationToCharge = (heldSeconds: number): number => clamp01(heldSeconds / settings.chargeShotFullHoldSeconds); + +// The charge a shot of this strength represents — the inverse of the mix() the +// server uses to size a projectile. The server needs it to scale radius, speed +// and recoil; the client needs the same number to pitch the sound and the +// muzzle flash, so it lives here rather than being derived twice. +export const strengthToCharge = (strength: number): number => + clamp01( + (strength - settings.chargeShotStrengthMin) / + (settings.chargeShotStrengthMax - settings.chargeShotStrengthMin), + ); diff --git a/shared/src/helper/finite.ts b/shared/src/helper/finite.ts index f5c79d8..00d0880 100644 --- a/shared/src/helper/finite.ts +++ b/shared/src/helper/finite.ts @@ -1,6 +1,5 @@ import { vec2 } from 'gl-matrix'; - export const isFiniteNumber = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value); diff --git a/shared/src/main.ts b/shared/src/main.ts index a26a4ce..ba7ee41 100644 --- a/shared/src/main.ts +++ b/shared/src/main.ts @@ -35,7 +35,6 @@ export * from './communication/id'; export * from './communication/server-information'; export * from './communication/player-information'; export * from './helper/rotate-90-deg'; -export * from './helper/rotate-minus-90-deg'; export * from './objects/game-object'; export * from './serialization/deserialize'; export * from './serialization/serialize'; diff --git a/shared/src/objects/game-object.ts b/shared/src/objects/game-object.ts index beb03b9..1372c99 100644 --- a/shared/src/objects/game-object.ts +++ b/shared/src/objects/game-object.ts @@ -8,7 +8,7 @@ export class RemoteCall { constructor( public readonly functionName: string, public readonly args: Array, - ) { } + ) {} public toArray(): Array { return [this.functionName, this.args]; @@ -49,7 +49,7 @@ export class PropertyUpdatesForObject { constructor( public readonly id: Id, public readonly updates: Array, - ) { } + ) {} public toArray(): Array { return [this.id, this.updates]; @@ -102,7 +102,7 @@ export abstract class GameObject extends CommandReceiver { }); } - public getPropertyUpdates(): PropertyUpdatesForObject | void { } + public getPropertyUpdates(): PropertyUpdatesForObject | void {} public getPropertyUpdatesForFrame(): PropertyUpdatesForObject | undefined { if (this.updateGeneration !== currentUpdateGeneration) { diff --git a/shared/src/objects/types/planet-base.ts b/shared/src/objects/types/planet-base.ts index 2f90eb1..f28ca4e 100644 --- a/shared/src/objects/types/planet-base.ts +++ b/shared/src/objects/types/planet-base.ts @@ -35,6 +35,17 @@ export class PlanetBase extends GameObject { this.vertices.length; } + // Which team controls this planet, with the neutral dead-band around 50% + // applied. Lives here so scoring (server) and every tint the player sees + // (client) read one rule rather than three copies of the same comparison. + public get team(): CharacterTeam { + return Math.abs(this.ownership - 0.5) < settings.planetControlThreshold + ? CharacterTeam.neutral + : this.ownership < 0.5 + ? CharacterTeam.blue + : CharacterTeam.red; + } + // eslint-disable-next-line @typescript-eslint/no-unused-vars public generatedPoints(value: number) {} // eslint-disable-next-line @typescript-eslint/no-unused-vars diff --git a/shared/src/physics/character-movement.ts b/shared/src/physics/character-movement.ts index 084ce65..d447cb5 100644 --- a/shared/src/physics/character-movement.ts +++ b/shared/src/physics/character-movement.ts @@ -64,7 +64,14 @@ export interface CharacterWorld { stepBody(body: PhysicsBody, deltaTimeInSeconds: number): GroundSurface | undefined; } -const applyForce = (body: PhysicsBody, force: vec2, deltaTimeInSeconds: number) => { +// Integrate a force into a body's velocity for one tick. Exported so the +// backend's CirclePhysical accumulates forces through the exact same +// expression, f32 intermediate included. +export const applyForce = ( + body: PhysicsBody, + force: vec2, + deltaTimeInSeconds: number, +) => { vec2.add( body.velocity, body.velocity, @@ -72,11 +79,16 @@ const applyForce = (body: PhysicsBody, force: vec2, deltaTimeInSeconds: number) ); }; -// ((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); +// ((head + leftFoot) + rightFoot) / 3, in the exact association every reader of +// the character centre uses — do not reassociate. Takes the three circles +// rather than a whole movement state so the client's view can call it too. +export const characterCenter = ( + head: { center: vec2 }, + leftFoot: { center: vec2 }, + rightFoot: { center: vec2 }, +): vec2 => { + const center = vec2.add(vec2.create(), head.center, leftFoot.center); + vec2.add(center, center, rightFoot.center); return vec2.scale(center, center, 1 / 3); }; @@ -106,7 +118,7 @@ const springMove = ( }; const keepPosture = (state: CharacterMovementState) => { - const center = characterCenter(state); + const center = characterCenter(state.head, state.leftFoot, state.rightFoot); springMove( state, state.leftFoot, @@ -190,7 +202,7 @@ export const applyLeapImpulse = (state: CharacterMovementState, moveDirection: v // Slingshot: add the tangential velocity of the spinning surface under the // body (the same motion carryWithRotatingPlanet imparts). - const center = characterCenter(state); + const center = characterCenter(state.head, state.leftFoot, state.rightFoot); const omega = planet.angularVelocity; const surfaceVelocity = vec2.fromValues( omega * (center[1] - planet.center[1]), @@ -255,7 +267,7 @@ export const decayMomentum = ( : settings.airMomentumFriction; const target = Math.min( speed * Math.exp(-friction * deltaTimeInSeconds) - - settings.momentumStopDeceleration * deltaTimeInSeconds, + settings.momentumStopDeceleration * deltaTimeInSeconds, settings.maxBodyMomentum, ); if (target <= 1) { @@ -297,9 +309,6 @@ export const stepCharacterMovement = ( inputDirection: vec2, deltaTimeInSeconds: number, ) => { - const center = characterCenter(state); - const grounds = world.groundsNear(center, boundRadius + settings.maxGravityDistance); - const movementForce = vec2.scale( inputDirection, inputDirection, @@ -309,6 +318,12 @@ export const stepCharacterMovement = ( applyForce(state.rightFoot, movementForce, deltaTimeInSeconds); if (!state.currentPlanet) { + // Only the airborne branch needs the surrounding planets, and this is the + // widest query the character makes — running it while grounded (the common + // case) walked the server's spatial tree once per character per tick for a + // result nothing read. + const center = characterCenter(state.head, state.leftFoot, state.rightFoot); + const grounds = world.groundsNear(center, boundRadius + settings.maxGravityDistance); const leftFootGravity = sumGravity(grounds, state.leftFoot.center); const rightFootGravity = sumGravity(grounds, state.rightFoot.center); @@ -332,7 +347,7 @@ export const stepCharacterMovement = ( movementLength > 0 && gravityLength > 0 && vec2.dot(movementForce, gravity) < - -movementLength * gravityLength * settings.climbDotThreshold + -movementLength * gravityLength * settings.climbDotThreshold ) { vec2.scale(gravity, gravity, settings.climbGravityScale); } diff --git a/shared/src/physics/evaluate-sdf.ts b/shared/src/physics/evaluate-sdf.ts index f3c60cf..af0dc29 100644 --- a/shared/src/physics/evaluate-sdf.ts +++ b/shared/src/physics/evaluate-sdf.ts @@ -1,7 +1,17 @@ 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); +// Distance to the nearest collidable surface. This is the innermost leaf of the +// whole physics stack — every raymarch step, every depenetration pass and four +// times per surface normal — so it is a plain indexed loop rather than +// filter().reduce(), which allocated an intermediate array per call. +export const evaluateSdf = (target: vec2, objects: Array): number => { + let minimum = 1000000; + for (let i = 0; i < objects.length; i++) { + const object = objects[i]; + if (object.canCollide) { + minimum = Math.min(minimum, object.distance(target)); + } + } + return minimum; +}; diff --git a/shared/src/physics/march-circle.ts b/shared/src/physics/march-circle.ts index bd8d9d2..0c90b3c 100644 --- a/shared/src/physics/march-circle.ts +++ b/shared/src/physics/march-circle.ts @@ -20,7 +20,7 @@ const maximumSteps = 256; // client passes none. // // The march advances by the *free gap* (distance minus the circle's radius), -// which is what sphere-tracing a circle of radius r requires. +// which is what sphere-tracing a circle of radius r requires. export const marchCircle = ( body: PhysicsBody, delta: vec2, diff --git a/shared/src/physics/planet-sdf.ts b/shared/src/physics/planet-sdf.ts index 24af49d..bbe1364 100644 --- a/shared/src/physics/planet-sdf.ts +++ b/shared/src/physics/planet-sdf.ts @@ -4,8 +4,10 @@ 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. +// cos/sin are passed in so callers can memoise them per angle. `out` is written +// in place, so the hot path can hand in a scratch vector instead of allocating. export const toPlanetLocalFrame = ( + out: vec2, target: vec2, center: vec2, cos: number, @@ -13,16 +15,27 @@ export const toPlanetLocalFrame = ( ): 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, - ); + return vec2.set(out, center[0] + cos * dx - sin * dy, center[1] + sin * dx + cos * dy); }; +// Scratch vectors for planetDistance. It is the innermost leaf of the physics +// stack — called for every planet on every SDF sample — and allocating four +// vectors per polygon edge dominated its cost. Nothing re-enters it (callers +// evaluate planets one after another), and none of these outlive the call. +const localPoint = vec2.create(); +const targetFromDelta = vec2.create(); +const toFromDelta = vec2.create(); +const closestOnEdge = vec2.create(); +// [distance to the edge, which side of it]. These two stay in a vec2 rather +// than in plain numbers because a vec2 is a Float32Array: rounding them to f32 +// is part of the outline the server and the client both have to agree on. +const edgeSample = vec2.create(); + // 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. +// planet's rotating frame. This is the one and only planet outline: the +// authoritative server and the client predictor both collide against it, which +// is what lets prediction reconcile at all. Indexed vector access keeps it +// independent of the .x/.y prototype plugin. export const planetDistance = ( target: vec2, vertices: Array, @@ -30,7 +43,7 @@ export const planetDistance = ( cos: number, sin: number, ): number => { - const local = toPlanetLocalFrame(target, center, cos, sin); + const local = toPlanetLocalFrame(localPoint, target, center, cos, sin); const startEnd = vertices[0]; let vb = startEnd; @@ -41,32 +54,32 @@ export const planetDistance = ( 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); + vec2.subtract(targetFromDelta, local, va); + vec2.subtract(toFromDelta, 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)), + vec2.set( + edgeSample, + vec2.dist(targetFromDelta, vec2.scale(closestOnEdge, 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) + (local[1] >= va[1] && local[1] < vb[1] && edgeSample[1] > 0) || + (local[1] < va[1] && local[1] >= vb[1] && edgeSample[1] <= 0) ) { sign *= -1; } - d = Math.min(d, ds[0]); + d = Math.min(d, edgeSample[0]); } return sign * d; }; -// Gravity a planet exerts at a world position. Verbatim from -// PlanetPhysical.getForce. +// Gravity a planet exerts at a world position. 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); diff --git a/shared/src/physics/sdf-normal.ts b/shared/src/physics/sdf-normal.ts index 507a8eb..56ad90b 100644 --- a/shared/src/physics/sdf-normal.ts +++ b/shared/src/physics/sdf-normal.ts @@ -2,17 +2,28 @@ import { vec2 } from 'gl-matrix'; import { Sdf } from './sdf'; import { evaluateSdf } from './evaluate-sdf'; +// Reused probe point. sdfNormal is called on every depenetration pass and every +// raymarch hit, so allocating four sample vectors per call was a meaningful +// share of the physics step's garbage. evaluateSdf reads the probe and returns +// before the next vec2.set, so one is enough. +const probe = vec2.create(); + +const sampleAt = (x: number, y: number, objects: Array): number => { + vec2.set(probe, x, y); + return evaluateSdf(probe, objects); +}; + // 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); + sampleAt(target[0] + 0.01, target[1], objects) - + sampleAt(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); + sampleAt(target[0], target[1] + 0.01, objects) - + sampleAt(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/settings.ts b/shared/src/settings.ts index 3de1367..23811b3 100644 --- a/shared/src/settings.ts +++ b/shared/src/settings.ts @@ -157,7 +157,6 @@ export const settings = { // 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 diff --git a/test/__snapshots__/physics-determinism.test.mjs.snap b/test/__snapshots__/physics-determinism.test.mjs.snap index 50ab1c3..8c0ab06 100644 --- a/test/__snapshots__/physics-determinism.test.mjs.snap +++ b/test/__snapshots__/physics-determinism.test.mjs.snap @@ -1,3 +1,5 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`shared character simulation determinism > matches the pinned reference pose (changes only with intentional physics edits) 1`] = `"{"head":[76.14,123.412],"leftFoot":[83.214,105.036],"rightFoot":[79.974,123.01],"bodyVelocity":[0,0]}"`; + +exports[`shared character simulation determinism > matches the pinned reference pose on a spinning planet 1`] = `"{"head":[320.9,-33.986],"leftFoot":[248.603,-50.839],"rightFoot":[281.287,-71.994],"direction":-0.314,"onSurface":true,"secondsSinceOnSurface":0}"`; diff --git a/test/physics-determinism.test.mjs b/test/physics-determinism.test.mjs index d0fee1a..d2ca282 100644 --- a/test/physics-determinism.test.mjs +++ b/test/physics-determinism.test.mjs @@ -13,7 +13,12 @@ const { vec2 } = require('../shared/node_modules/gl-matrix'); const { stepCharacterMovement, + tickPlanetDetachment, resolveCircleMovement, + planetDistance, + planetGravity, + PlanetBase, + Random, headRadius, feetRadius, headOffset, @@ -42,6 +47,79 @@ const emptyWorld = { const stepSeconds = 1 / 200; +// A spinning planet, built exactly the way create-world builds one, wired up as +// the GroundSurface the shared movement collides against and is pulled by. The +// free-space run above deliberately avoids planet geometry, so without this the +// SDF, the gravity falloff and the on-surface branch — the parts most likely to +// be touched by a perf rewrite — have no pin at all. +const makeSpinningPlanetWorld = () => { + Random.seed = 20; + const vertices = PlanetBase.createPlanetVertices( + vec2.fromValues(0, -900), + 1700, + 1900, + 70, + ); + const base = new PlanetBase(1, vertices); + let rotation = 0; + const surface = { + canCollide: true, + isGround: true, + center: base.center, + angularVelocity: 0.09, + advance: (dt) => (rotation += surface.angularVelocity * dt), + distance: (target) => + planetDistance(target, vertices, base.center, Math.cos(rotation), Math.sin(rotation)), + gravityAt: (target) => planetGravity(base.center, base.radius, target), + }; + const grounds = [surface]; + return { + surface, + world: { + groundsNear: () => grounds, + stepBody: (body, dt) => { + const { hitObject } = resolveCircleMovement(body, dt, grounds); + return hitObject && hitObject.isGround ? hitObject : undefined; + }, + }, + }; +}; + +// Walk a body across a spinning planet: gravity, the SDF outline, collision +// resolution, surface latching and the rotation carry all participate. +const runPlanetSimulation = () => { + const { surface, world } = makeSpinningPlanetWorld(); + const start = vec2.fromValues(0, 250); + const state = { + head: makeBody(vec2.add(vec2.create(), start, headOffset), headRadius), + leftFoot: makeBody(vec2.add(vec2.create(), start, leftFootOffset), feetRadius), + rightFoot: makeBody(vec2.add(vec2.create(), start, rightFootOffset), feetRadius), + direction: 0, + currentPlanet: undefined, + secondsSinceOnSurface: 1, + bodyVelocity: vec2.create(), + }; + + for (let i = 0; i < 900; i++) { + // Fall in, then walk right, then left. + const input = + i < 200 ? vec2.create() : vec2.fromValues(i < 600 ? 1 : -1, 0); + tickPlanetDetachment(state, stepSeconds); + stepCharacterMovement(state, world, input, stepSeconds); + surface.advance(stepSeconds); + } + + const round = (v) => Math.round(v * 1000) / 1000; + return JSON.stringify({ + head: [round(state.head.center[0]), round(state.head.center[1])], + leftFoot: [round(state.leftFoot.center[0]), round(state.leftFoot.center[1])], + rightFoot: [round(state.rightFoot.center[0]), round(state.rightFoot.center[1])], + direction: round(state.direction), + onSurface: !!state.currentPlanet, + secondsSinceOnSurface: round(state.secondsSinceOnSurface), + }); +}; + // Run a fixed, scripted input sequence through the shared sim and return a // stable string snapshot of the final pose + carried momentum. const runSimulation = () => { @@ -80,4 +158,14 @@ describe('shared character simulation determinism', () => { // Regression pin — update deliberately when physics behaviour changes. expect(runSimulation()).toMatchSnapshot(); }); + + it('is deterministic on a spinning planet too', () => { + expect(runPlanetSimulation()).toBe(runPlanetSimulation()); + }); + + it('matches the pinned reference pose on a spinning planet', () => { + // Covers the planet SDF, the gravity falloff, collision resolution and the + // rotation carry, none of which the free-space run above touches. + expect(runPlanetSimulation()).toMatchSnapshot(); + }); });