diff --git a/backend/src/create-world.ts b/backend/src/create-world.ts index a25093c..ace7180 100644 --- a/backend/src/create-world.ts +++ b/backend/src/create-world.ts @@ -2,16 +2,14 @@ import { vec2 } from 'gl-matrix'; import { Random, PlanetBase, hsl, settings, evaluateSdf } from 'shared'; import { LampPhysical } from './objects/lamp-physical'; import { PlanetPhysical } from './objects/planet-physical'; -import { PhysicalContainer } from './physics/containers/physical-container'; -import { Physical } from './physics/physicals/physical'; +import { PhysicalContainer } from './physics/physical-container'; export const createWorld = (objectContainer: PhysicalContainer) => { - const objects: Array = []; - const lights: Array = []; + const planets: Array = []; + const lamps: Array = []; for (let r = 0; r < settings.worldRadius; r += settings.radiusSteps) { - const circumference = 2 * Math.PI * r; - const stepCount = circumference * settings.objectsOnCircleLength; + const stepCount = 2 * Math.PI * r * settings.objectsOnCircleLength; for (let rad = 0; rad < 2 * Math.PI; rad += (2 * Math.PI) / stepCount) { const position = vec2.rotate( vec2.create(), @@ -20,12 +18,12 @@ export const createWorld = (objectContainer: PhysicalContainer) => { rad, ); - if (objects.length !== 0 && Random.getRandom() > 0.5) { + if (planets.length !== 0 && Random.getRandom() > 0.5) { if ( - evaluateSdf(position, objects) > 200 && - !lights.find((l) => l.distance(position) < 2500) + evaluateSdf(position, planets) > 200 && + !lamps.find((l) => l.distance(position) < 2500) ) { - lights.push( + lamps.push( new LampPhysical( position, hsl( @@ -37,56 +35,43 @@ export const createWorld = (objectContainer: PhysicalContainer) => { ), ); } - } else { - if ( - evaluateSdf(position, objects) > 1400 && - !lights.find((l) => l.distance(position) < 1700) - ) { - const planet = - objects.length === 0 - ? new PlanetPhysical( - PlanetBase.createPlanetVertices( - position, - Random.getRandomInRange(1600, 2400), - Random.getRandomInRange(1600, 2400), - Random.getRandomInRange(80, 300), - ), - true, - ) - : new PlanetPhysical( - PlanetBase.createPlanetVertices( - position, - Random.getRandomInRange(300, 1600), - Random.getRandomInRange(300, 1600), - Random.getRandomInRange(20, 100), - ), - ); - - objects.push(planet); - } + } else if ( + evaluateSdf(position, planets) > 1400 && + !lamps.find((l) => l.distance(position) < 1700) + ) { + const isKeystone = planets.length === 0; + const [sizeMin, sizeMax, roughMin, roughMax] = isKeystone + ? [1600, 2400, 80, 300] + : [300, 1600, 20, 100]; + planets.push( + new PlanetPhysical( + PlanetBase.createPlanetVertices( + position, + Random.getRandomInRange(sizeMin, sizeMax), + Random.getRandomInRange(sizeMin, sizeMax), + Random.getRandomInRange(roughMin, roughMax), + ), + isKeystone, + objectContainer.game, + ), + ); } } } - console.info(`Generated ${objects.length} planets`); - console.info(`Generated ${lights.length} light`); + console.info(`Generated ${planets.length} planets and ${lamps.length} lamps`); - // Each lamp is associated with its nearest planet via the planet SDF - // (negative inside), so it advertises that planet's capture. - const planets = objects.filter((o): o is PlanetPhysical => o instanceof PlanetPhysical); - lights - .filter((l): l is LampPhysical => l instanceof LampPhysical) - .forEach((lamp) => { - let nearest: PlanetPhysical | undefined; - let nearestDistance = Infinity; - planets.forEach((planet) => { - const distance = planet.distance(lamp.center); - if (distance < nearestDistance) { - nearestDistance = distance; - nearest = planet; - } - }); - nearest?.addLamp(lamp); - }); + for (const lamp of lamps) { + let nearest: PlanetPhysical | undefined; + let nearestDistance = Infinity; + for (const planet of planets) { + const distance = planet.distance(lamp.center); + if (distance < nearestDistance) { + nearestDistance = distance; + nearest = planet; + } + } + nearest?.addLamp(lamp); + } - [...objects, ...lights].forEach((o) => objectContainer.addObject(o)); + [...planets, ...lamps].forEach((o) => objectContainer.addObject(o)); }; diff --git a/backend/src/game-events.ts b/backend/src/game-events.ts new file mode 100644 index 0000000..58475fc --- /dev/null +++ b/backend/src/game-events.ts @@ -0,0 +1,4 @@ +export interface GameEvents { + addPoints(blue: number, red: number): void; + announce(text: string): void; +} diff --git a/backend/src/game-server.ts b/backend/src/game-server.ts index 9d5ec8f..9bfac97 100644 --- a/backend/src/game-server.ts +++ b/backend/src/game-server.ts @@ -1,4 +1,3 @@ -import { PhysicalContainer } from './physics/containers/physical-container'; import { Server, Socket } from 'socket.io'; import { randomUUID } from 'node:crypto'; import { performance } from 'node:perf_hooks'; @@ -13,39 +12,33 @@ import { GameEndCommand, GameStartCommand, Command, - CommandReceiver, - CommandExecutors, ServerAnnouncement, JoinRejectionReason, - beginPropertyUpdateGeneration, + GameObject, + PropertyUpdatesForObject, } from 'shared'; +import { PhysicalContainer } from './physics/physical-container'; import { createWorld } from './create-world'; import { Options } from './options'; +import { GameEvents } from './game-events'; import { CarriedScore, PlayerContainer } from './players/player-container'; -import { ServerFullError } from './players/server-full-error'; import { Player } from './players/player'; -import { StepCommand, GeneratePointsCommand, AnnounceCommand } from './commands/commands'; const gameStateSubscribedRoom = 'gameStateSubscribedRoom'; +const endTitleSeconds = 6; -// The payload is whatever the client sent: `socket.emit('PlayerJoining')` with -// no argument arrives as undefined. socket.io dispatches listeners inside a -// `process.nextTick` with no try/catch of its own, so a dereference of an -// unchecked payload here would surface as an uncaught exception and take the -// whole match down with the process. const asPlayerInformation = (payload: unknown): PlayerInformation | undefined => { if (!payload || typeof payload !== 'object') { return undefined; } const { name, reconnectToken } = payload as Record; return { - // Coerced and length-capped downstream, in PlayerBase.createCharacter. name: name as string, reconnectToken: typeof reconnectToken === 'string' ? reconnectToken : undefined, }; }; -export class GameServer extends CommandReceiver { +export class GameServer implements GameEvents { private objects!: PhysicalContainer; private players!: PlayerContainer; private lastPhysicsBase = process.hrtime.bigint(); @@ -57,45 +50,24 @@ export class GameServer extends CommandReceiver { private isInEndGame = false; private timeScaling = 1; private statReportMs = Date.now(); + private timeSinceLastServerStateUpdate = 0; + private timeSinceLastPointUpdate = 0; + private physicsAccumulator = 0; + private saturatedFrames = 0; + private droppedInboundMessages = 0; - private initialize() { - const previousPlayers = this.players; - this.releaseJoinedSockets(); - this.objects = new PhysicalContainer(); - createWorld(this.objects); - this.objects.initialize(); - this.players = new PlayerContainer( - this.objects, - this.options.playerLimit, - this.options.npcCount, - ); - this.lastPhysicsBase = process.hrtime.bigint(); - this.bluePoints = 0; - this.redPoints = 0; - this.matchPointAnnounced = {}; - this.isInEndGame = false; - this.timeScaling = 1; - previousPlayers?.queueCommandForEachClient(new GameStartCommand()); - previousPlayers?.sendQueuedCommands(); - } - - protected commandExecutors: CommandExecutors = { - [GeneratePointsCommand.type]: this.addPoints.bind(this), - [AnnounceCommand.type]: ({ text }: AnnounceCommand) => - this.players.queueCommandForEachClient(new ServerAnnouncement(text)), - }; + private readonly joinedSockets = new Map< + Socket, + { player: Player; release: () => void } + >(); + private inboundBudget = new WeakMap(); constructor( private readonly io: Server, private options: Options, ) { - super(); - this.initialize(); - // Both listeners are metered: they exist before any join, so the limiter - // inside onPlayerToServer cannot see them, and each one costs a reply or an - // allocation an unauthenticated client could otherwise ask for at line rate. io.on('connection', (socket: Socket) => { socket.on(TransportEvents.PlayerJoining, (payload: unknown) => { if (!this.allowInboundMessage(socket)) { @@ -111,18 +83,35 @@ export class GameServer extends CommandReceiver { }); socket.on(TransportEvents.SubscribeForServerInfoUpdates, () => { - if (!this.allowInboundMessage(socket)) { - return; + if (this.allowInboundMessage(socket)) { + socket.join(gameStateSubscribedRoom); } - socket.join(gameStateSubscribedRoom); }); }); } - private readonly joinedSockets = new Map< - Socket, - { player: Player; release: () => void } - >(); + private initialize() { + const previousPlayers = this.players; + this.joinedSockets.forEach(({ release }) => release()); + this.joinedSockets.clear(); + this.inboundBudget = new WeakMap(); + + this.objects = new PhysicalContainer(this); + createWorld(this.objects); + this.players = new PlayerContainer( + this.objects, + this.options.playerLimit, + this.options.npcCount, + ); + this.lastPhysicsBase = process.hrtime.bigint(); + this.bluePoints = 0; + this.redPoints = 0; + this.matchPointAnnounced = {}; + this.isInEndGame = false; + this.timeScaling = 1; + previousPlayers?.queueCommandForEachClient(new GameStartCommand()); + previousPlayers?.sendQueuedCommands(); + } private handleJoin(socket: Socket, payload: unknown) { const playerInfo = asPlayerInformation(payload); @@ -141,9 +130,6 @@ export class GameServer extends CommandReceiver { return; } - // A reconnect arrives seconds after the drop, long before engine.io times - // the dead socket out. Retire that ghost first so its slot, team and score - // go back to the returning client. const carried = this.retireGhost(playerInfo.reconnectToken); if (this.players.isFull) { @@ -151,20 +137,7 @@ export class GameServer extends CommandReceiver { return; } - let player: Player; - try { - player = this.players.createPlayer(playerInfo, socket, carried); - } catch (e) { - console.error('Failed to register joining player', e); - socket.emit( - TransportEvents.JoinRejected, - e instanceof ServerFullError - ? JoinRejectionReason.ServerFull - : JoinRejectionReason.InvalidRequest, - ); - socket.disconnect(); - return; - } + const player = this.players.createPlayer(playerInfo, socket, carried); const onPlayerToServer = (json: string) => { try { @@ -203,14 +176,8 @@ export class GameServer extends CommandReceiver { this.sendServerStateUpdate(); } - // A WeakMap, not a Map: sockets that never join are metered too, and they - // have no join-scoped teardown to remove their entry. - private inboundBudget = new WeakMap(); - private droppedInboundMessages = 0; private allowInboundMessage(socket: Socket): boolean { - // performance.now(), never Date.now(): a backwards wall-clock step (an NTP - // correction, a VM resync) would make the refill negative and lock the - // client out of sending input for as long as the step was. + // performance.now(), not Date.now(): a backwards wall-clock step would lock the client out. const nowMs = performance.now(); const burst = settings.maxInboundMessageBurst; const perSecond = settings.maxInboundMessagesPerSecond; @@ -238,7 +205,7 @@ export class GameServer extends CommandReceiver { } for (const [socket, { player }] of this.joinedSockets) { if (player.reconnectToken === token) { - const carried = { team: player.team, ...player.scoreSnapshot }; + const carried = { team: player.team, ...player.score }; this.removeJoined(socket); return carried; } @@ -253,26 +220,17 @@ export class GameServer extends CommandReceiver { } this.joinedSockets.delete(socket); this.inboundBudget.delete(socket); - // Only the listeners this join added: socket.io keeps its own internal - // 'error' guard and the connection-scoped PlayerJoining handler here. joined.release(); joined.player.destroy(); this.players.deletePlayer(joined.player); } - private releaseJoinedSockets() { - this.joinedSockets.forEach(({ release }) => release()); - this.joinedSockets.clear(); - this.inboundBudget = new WeakMap(); - } - - private timeSinceLastServerStateUpdate = 0; public sendServerStateUpdate() { this.io .to(gameStateSubscribedRoom) .emit(TransportEvents.ServerInfoUpdate, [ this.players.count, - (Math.max(this.bluePoints, this.redPoints) / this.options.scoreLimit) * 100, + this.serverInfo.gameStatePercent, ]); } @@ -280,7 +238,7 @@ export class GameServer extends CommandReceiver { this.handlePhysics(); } - private addPoints({ blue, red }: GeneratePointsCommand) { + public addPoints(blue: number, red: number) { if (this.isInEndGame) { return; } @@ -297,31 +255,49 @@ export class GameServer extends CommandReceiver { } } + public announce(text: string) { + this.players.queueCommandForEachClient(new ServerAnnouncement(text)); + } + private announceMatchPointOnce(team: CharacterTeam, points: number) { if ( !this.matchPointAnnounced[team] && points >= this.options.scoreLimit * settings.matchPointScoreRatio ) { this.matchPointAnnounced[team] = true; - this.players.queueCommandForEachClient( - new ServerAnnouncement( - `Match point — team ${team}!`, - ), - ); + this.announce(`Match point — team ${team}!`); } } private endGame(winningTeam: CharacterTeam) { this.isInEndGame = true; - const endTitleLength = 6; this.players.endGame(winningTeam); this.players.queueCommandForEachClient(new GameEndCommand()); - setTimeout(() => this.initialize(), endTitleLength * 1000 * 1.1); + setTimeout(() => this.initialize(), endTitleSeconds * 1000 * 1.1); } - private timeSinceLastPointUpdate = 0; - private physicsAccumulator = 0; - private saturatedFrames = 0; + private reportStats() { + const mem = `${(process.memoryUsage().rss / 1024 / 1024).toFixed(2)} MB`; + console.info(`Memory: ${mem}, Players: ${this.players.count}`); + + const rtts = this.players.connectedPlayerRttsMs.filter((r) => r > 0); + if (rtts.length > 0) { + rtts.sort((a, b) => a - b); + console.info( + `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})`, + ); + } + + if (this.droppedInboundMessages > 0) { + console.warn(`Rate limited ${this.droppedInboundMessages} inbound msg(s)`); + this.droppedInboundMessages = 0; + } + if (this.saturatedFrames > 0) { + console.warn(`Physics saturated on ${this.saturatedFrames} frame(s)`); + this.saturatedFrames = 0; + } + } private handlePhysics() { const frameStart = process.hrtime.bigint(); @@ -330,26 +306,7 @@ export class GameServer extends CommandReceiver { if (Date.now() - this.statReportMs > 30000) { this.statReportMs = Date.now(); - const mem = `${(process.memoryUsage().rss / 1024 / 1024).toFixed(2)} MB`; - console.info(`Memory: ${mem}, Players: ${this.players.count}`); - - const rtts = this.players.connectedPlayerRttsMs.filter((r) => r > 0); - if (rtts.length > 0) { - rtts.sort((a, b) => a - b); - console.info( - `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})`, - ); - } - - if (this.droppedInboundMessages > 0) { - console.warn(`Rate limited ${this.droppedInboundMessages} inbound msg(s)`); - this.droppedInboundMessages = 0; - } - if (this.saturatedFrames > 0) { - console.warn(`Physics saturated on ${this.saturatedFrames} frame(s)`); - this.saturatedFrames = 0; - } + this.reportStats(); } if ((this.timeSinceLastServerStateUpdate += delta) > 4) { @@ -387,17 +344,21 @@ export class GameServer extends CommandReceiver { this.timeScaling *= Math.pow(settings.endGameDeltaScaling, fixedDelta); scaledDelta /= this.timeScaling; } - this.objects.handleCommand(new StepCommand(scaledDelta, this)); + this.objects.step(scaledDelta); this.players.step(scaledDelta); } - beginPropertyUpdateGeneration(); - this.players.stepCommunication(delta); + const propertyUpdates = new Map(); + this.players.stepCommunication(delta, (object) => { + if (!propertyUpdates.has(object)) { + propertyUpdates.set(object, object.getPropertyUpdates()); + } + return propertyUpdates.get(object); + }); this.objects.resetRemoteCalls(); const elapsed = Number(process.hrtime.bigint() - frameStart) / 1e9; - - setTimeout(this.handlePhysics.bind(this), Math.max(0, fixedDelta - elapsed) * 1000); + setTimeout(() => this.handlePhysics(), Math.max(0, fixedDelta - elapsed) * 1000); } public get serverInfo(): ServerInformation { diff --git a/backend/src/main.ts b/backend/src/main.ts index 6905969..1f7115e 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -2,7 +2,7 @@ import { Server as IoServer } from 'socket.io'; import express from 'express'; import { Server } from 'http'; import cors from 'cors'; -import { applyArrayPlugins, Random, serverInformationEndpoint, settings } from 'shared'; +import { Random, serverInformationEndpoint, settings } from 'shared'; import minimist from 'minimist'; import { glMatrix } from 'gl-matrix'; import { GameServer } from './game-server'; @@ -10,7 +10,6 @@ import { defaultOptions } from './options'; import parser from 'socket.io-msgpack-parser'; glMatrix.setMatrixArrayType(Array); -applyArrayPlugins(); const optionOverrides = minimist(process.argv.slice(2)); const options = { diff --git a/backend/src/objects/character-physical.ts b/backend/src/objects/character-physical.ts index 5fba09a..fd870d0 100644 --- a/backend/src/objects/character-physical.ts +++ b/backend/src/objects/character-physical.ts @@ -2,16 +2,13 @@ import { vec2 } from 'gl-matrix'; import { id, settings, - MoveActionCommand, - serializesTo, Circle, CharacterBase, CharacterTeam, PropertyUpdatesForObject, UpdatePropertyCommand, - CommandExecutors, - CommandReceiver, CharacterMovementSnapshot, + GameObject, mix, clamp01, strengthToCharge, @@ -20,6 +17,7 @@ import { decayMomentum, tickPlanetDetachment, characterCenter, + sumGravity, CharacterWorld, GroundSurface, headRadius, @@ -29,69 +27,45 @@ import { rightFootOffset, boundRadius, } from 'shared'; -import { DynamicPhysical } from '../physics/physicals/dynamic-physical'; +import { Physical } from '../physics/physical'; import { CirclePhysical } from './circle-physical'; -import { PhysicalContainer } from '../physics/containers/physical-container'; -import { BoundingBoxBase } from '../physics/bounding-boxes/bounding-box-base'; +import { PhysicalContainer } from '../physics/physical-container'; +import { BoundingBox } from '../physics/bounding-box'; import { ProjectilePhysical } from './projectile-physical'; -import { forceAtPosition } from '../physics/functions/force-at-position'; -import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle'; -import { PlanetPhysical } from './planet-physical'; -import { - StepCommand, - ReactToCollisionCommand, - GeneratePointsCommand, -} from '../commands/commands'; +import { PlanetPhysical, planetsIn } from './planet-physical'; -interface BodyPose { - head: Circle; - leftFoot: Circle; - rightFoot: Circle; -} +const placeholder = new Circle(vec2.create(), 0); -@serializesTo(CharacterBase) -export class CharacterPhysical extends CharacterBase implements DynamicPhysical { +export class CharacterPhysical extends CharacterBase implements Physical { public readonly canCollide = true; - public readonly canMove = true; - - private projectileStrength = settings.playerMaxStrength; - - private timeSinceDying = 0; - private isDestroyed = false; - private timeSinceBorn = 0; - private hasJustBorn = true; - private timeAlive = 0; - - private timeSinceLastShot = settings.projectileCreationInterval; - private timeSinceLastDamage = settings.playerOutOfCombatDelaySeconds; - private lastSyncedHealth = settings.playerMaxHealth; - - private killStreak = 0; - - // Held in place: the shared simulation reads and writes these directly, so - // the character IS the state it is stepped as (client predictor agrees). - public direction = 0; - public currentPlanet: GroundSurface | undefined; - public secondsSinceOnSurface = settings.planetDetachmentSeconds; - public readonly bodyVelocity = vec2.create(); - - private timeSinceLastLeap = settings.leapCooldownSeconds; public head: CirclePhysical; public leftFoot: CirclePhysical; public rightFoot: CirclePhysical; - private movementActions: Array = []; - private lastMovementAction: MoveActionCommand = new MoveActionCommand(vec2.create()); + public direction = 0; + public currentPlanet: GroundSurface | undefined; + public secondsSinceOnSurface = settings.planetDetachmentSeconds; + public readonly bodyVelocity = vec2.create(); - private headVelocity = new Circle(vec2.create(), 0); - private leftFootVelocity = new Circle(vec2.create(), 0); - private rightFootVelocity = new Circle(vec2.create(), 0); + private projectileStrength = settings.playerMaxStrength; + private timeAlive = 0; + private timeSinceDying = 0; + private isDestroyed = false; + private hasJustBorn = true; + private hasFiredSinceSpawn = false; + private timeSinceLastShot = settings.projectileCreationInterval; + private timeSinceLastLeap = settings.leapCooldownSeconds; + private timeSinceLastDamage = settings.playerOutOfCombatDelaySeconds; + private lastSyncedHealth = settings.playerMaxHealth; + private killStreak = 0; - protected commandExecutors: CommandExecutors = { - [StepCommand.type]: this.step.bind(this), - [ReactToCollisionCommand.type]: this.onCollision.bind(this), - }; + private movementDirections: Array = []; + private lastMovementDirection = vec2.create(); + + private readonly previousPose = [0, 1, 2].map(() => new Circle(vec2.create(), 0)); + private readonly velocities = [0, 1, 2].map(() => new Circle(vec2.create(), 0)); + private readonly box = new BoundingBox(); constructor( name: string, @@ -101,43 +75,42 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical private readonly container: PhysicalContainer, startPosition: vec2, ) { - super(id(), name, killCount, deathCount, team, settings.playerMaxHealth); - this.head = new CirclePhysical( - vec2.add(vec2.create(), startPosition, headOffset), - headRadius, - this, - container, + super( + id(), + name, + killCount, + deathCount, + team, + settings.playerMaxHealth, + placeholder, + placeholder, + placeholder, ); - this.leftFoot = new CirclePhysical( - vec2.add(vec2.create(), startPosition, leftFootOffset), - feetRadius, - this, - container, - ); - this.rightFoot = new CirclePhysical( - vec2.add(vec2.create(), startPosition, rightFootOffset), - feetRadius, - this, - container, - ); - container.addObject(this.head); - container.addObject(this.leftFoot); - container.addObject(this.rightFoot); + const part = (offset: vec2, radius: number) => + new CirclePhysical( + vec2.add(vec2.create(), startPosition, offset), + radius, + this, + container, + ); + this.head = part(headOffset, headRadius); + this.leftFoot = part(leftFootOffset, feetRadius); + this.rightFoot = part(rightFootOffset, feetRadius); } private readonly movementWorld: CharacterWorld = { - // Same set and order forceAtPosition uses, so the f64 gravity sum matches. groundsNear: (center, radius) => - this.container - .findIntersecting(getBoundingBoxOfCircle(new Circle(center, radius))) - .filter((o): o is PlanetPhysical => o instanceof PlanetPhysical), + planetsIn(this.container.findIntersecting(BoundingBox.ofCircle(center, radius))), stepBody: (body, deltaTimeInSeconds) => { - const { hitObject } = (body as CirclePhysical).stepManually(deltaTimeInSeconds); - return hitObject instanceof PlanetPhysical ? hitObject : undefined; + const hit = (body as CirclePhysical).stepManually(deltaTimeInSeconds); + return hit instanceof PlanetPhysical ? hit : undefined; }, }; - private hasFiredSinceSpawn = false; + private get parts(): Array { + return [this.head, this.leftFoot, this.rightFoot]; + } + private get isSpawnProtected(): boolean { return ( !this.hasFiredSinceSpawn && @@ -146,38 +119,14 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical ); } - private hasGeneratedPoints = false; - private wasKilledInCombat = false; - private getPoints(game: CommandReceiver) { - if (!this.isAlive && !this.hasGeneratedPoints) { - this.hasGeneratedPoints = true; - - if (!this.wasKilledInCombat) { - return; - } - - const blue = this.team === CharacterTeam.blue ? 0 : settings.playerKillPoint; - const red = this.team === CharacterTeam.red ? 0 : settings.playerKillPoint; - - game.handleCommand(new GeneratePointsCommand(blue, red)); - } - } - public get isAlive(): boolean { return !this.isDestroyed; } - public handleMovementAction(c: MoveActionCommand) { - this.movementActions.push(c); - } - - // The world only ever hands back planets, so narrowing GroundSurface is safe. public get groundPlanet(): PlanetPhysical | undefined { return this.currentPlanet as PlanetPhysical | undefined; } - // Continuous movement state streamed to the owning client so its predictor - // resumes this simulation instead of guessing. See CharacterMovementSnapshot. public get movementSnapshot(): CharacterMovementSnapshot { return new CharacterMovementSnapshot( this.direction, @@ -189,6 +138,37 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical ); } + public get boundingBox(): BoundingBox { + const center = this.center; + this.box.xMin = center[0] - boundRadius; + this.box.xMax = center[0] + boundRadius; + this.box.yMin = center[1] - boundRadius; + this.box.yMax = center[1] + boundRadius; + return this.box; + } + + public get gameObject(): this { + return this; + } + + public get center(): vec2 { + return characterCenter(this.head, this.leftFoot, this.rightFoot); + } + + public distance(target: vec2): number { + return ( + Math.min( + this.head.distance(target), + this.leftFoot.distance(target), + this.rightFoot.distance(target), + ) - 5 + ); + } + + public setMoveDirection(direction: vec2) { + this.movementDirections.push(direction); + } + public addKill(victimName: string, charge = 0) { this.killCount++; this.killStreak++; @@ -214,38 +194,38 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical } } - public onCollision({ other }: ReactToCollisionCommand) { + public onCollision(other: GameObject) { 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 + !this.isAlive || + !(other instanceof ProjectilePhysical) || + other.team === this.team || + !other.isAlive ) { - other.destroy(); + return; + } - if (this.isSpawnProtected) { - return; - } + other.destroy(); - this.timeSinceLastDamage = 0; - this.health -= other.strength; - this.lastSyncedHealth = Math.round(this.health); - this.remoteCall('setHealth', this.health); + if (this.isSpawnProtected) { + return; + } - if (this.health <= 0 && this.isAlive) { - vec2.scaleAndAdd( - this.bodyVelocity, - this.bodyVelocity, - other.direction, - mix(settings.deathImpulseMin, settings.deathImpulseMax, other.charge), - ); - this.onDie(true); - other.originator.addKill(this.name, other.charge); - } else { - other.originator.registerHit(other.charge); - } + this.timeSinceLastDamage = 0; + this.health -= other.strength; + this.lastSyncedHealth = Math.round(this.health); + this.remoteCall('setHealth', this.health); + + if (this.health <= 0) { + vec2.scaleAndAdd( + this.bodyVelocity, + this.bodyVelocity, + other.direction, + mix(settings.deathImpulseMin, settings.deathImpulseMax, other.charge), + ); + this.onDie(true); + other.originator.addKill(this.name, other.charge); + } else { + other.originator.registerHit(other.charge); } } @@ -266,41 +246,37 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical this.timeSinceLastShot = 0; this.hasFiredSinceSpawn = true; - const requestedCharge = clamp01(charge); const desiredStrength = mix( settings.chargeShotStrengthMin, settings.chargeShotStrengthMax, - requestedCharge, + clamp01(charge), ); const strength = Math.min(desiredStrength, this.projectileStrength); this.projectileStrength -= strength; const c = strengthToCharge(strength); - const radius = mix(settings.chargeShotRadiusMin, settings.chargeShotRadiusMax, c); const speed = mix(settings.chargeShotSpeedMin, settings.chargeShotSpeedMax, c); 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), - radius, - strength, - this.team, - velocity, - this, - this.container, - c, + this.container.addObject( + new ProjectilePhysical( + vec2.clone(this.center), + radius, + strength, + this.team, + vec2.scale(vec2.create(), direction, speed), + this, + this.container, + c, + ), ); - this.container.addObject(projectile); if (c > 0) { vec2.scaleAndAdd( this.bodyVelocity, this.bodyVelocity, - shotDirection, + direction, -settings.chargeShotRecoilMax * c, ); } @@ -321,67 +297,32 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical this.timeSinceLastLeap = 0; this.projectileStrength -= settings.leapStrengthCost; - - // Same impulse the client predicts with (shared), so a leap launches - // identically on both sides. - applyLeapImpulse(this, this.lastMovementAction.direction); + applyLeapImpulse(this, this.lastMovementDirection); this.remoteCall('onLeap'); } - public get boundingBox(): BoundingBoxBase { - return getBoundingBoxOfCircle(new Circle(this.center, boundRadius)); - } - - public get gameObject(): this { - return this; - } - - public get center(): vec2 { - return characterCenter(this.head, this.leftFoot, this.rightFoot); - } - - public distance(target: vec2): number { - return ( - Math.min( - this.head.distance(target), - this.leftFoot.distance(target), - this.rightFoot.distance(target), - ) - 5 - ); - } - - private averageAndResetMovementActions(): vec2 { - let direction: vec2; - if (this.movementActions.length === 0) { - direction = vec2.clone(this.lastMovementAction.direction); - } else { - direction = this.movementActions.reduce( - (sum, current) => vec2.add(sum, sum, current.direction), - vec2.create(), - ); - - vec2.scale(direction, direction, 1 / this.movementActions.length); - - const actions = this.movementActions; - this.lastMovementAction = actions[actions.length - 1]!; - this.movementActions = []; + public onDie(killedInCombat = false) { + if (this.isDestroyed) { + return; } + this.isDestroyed = true; + this.remoteCall('onDie'); - return vec2.length(direction) > 0 - ? vec2.normalize(direction, direction) - : vec2.create(); - } - - private animateScaling(q: number) { - this.head.radius = headRadius * q; - this.leftFoot.radius = this.rightFoot.radius = feetRadius * q; + if (killedInCombat) { + const points = settings.playerKillPoint; + this.container.game.addPoints( + this.team === CharacterTeam.blue ? 0 : points, + this.team === CharacterTeam.red ? 0 : points, + ); + } } public getPropertyUpdates(): PropertyUpdatesForObject { + const [headVelocity, leftFootVelocity, rightFootVelocity] = this.velocities; return new PropertyUpdatesForObject(this.id, [ - new UpdatePropertyCommand('head', this.head, this.headVelocity), - new UpdatePropertyCommand('leftFoot', this.leftFoot, this.leftFootVelocity), - new UpdatePropertyCommand('rightFoot', this.rightFoot, this.rightFootVelocity), + new UpdatePropertyCommand('head', this.head, headVelocity), + new UpdatePropertyCommand('leftFoot', this.leftFoot, leftFootVelocity), + new UpdatePropertyCommand('rightFoot', this.rightFoot, rightFootVelocity), new UpdatePropertyCommand( 'strength', this.projectileStrength, @@ -390,107 +331,51 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical ]); } - // `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( - previous.center, - vec2.subtract(previous.center, current.center, previous.center), - 1 / deltaTime, - ), - (current.radius - previous.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); - } - - 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); + public step(deltaTimeInSeconds: number) { this.timeAlive += deltaTimeInSeconds; this.timeSinceLastLeap += deltaTimeInSeconds; - const previousPose = this.pose; + this.parts.forEach((part, i) => { + vec2.copy(this.previousPose[i].center, part.center); + this.previousPose[i].radius = part.radius; + }); if (this.isDestroyed) { if ((this.timeSinceDying += deltaTimeInSeconds) > settings.spawnDespawnTime) { - this.destroy(); + this.container.removeObject(this); } else { this.freeFallCorpse(deltaTimeInSeconds); this.animateScaling(1 - this.timeSinceDying / settings.spawnDespawnTime); } - this.setPropertyUpdates(previousPose, deltaTimeInSeconds); - return; - } - - if (this.hasJustBorn) { - if ((this.timeSinceBorn += deltaTimeInSeconds) > settings.spawnDespawnTime) { + } else if (this.hasJustBorn) { + if (this.timeAlive > settings.spawnDespawnTime) { this.hasJustBorn = false; this.animateScaling(1); } else { - this.animateScaling(this.timeSinceBorn / settings.spawnDespawnTime); + this.animateScaling(this.timeAlive / settings.spawnDespawnTime); } - this.setPropertyUpdates(previousPose, deltaTimeInSeconds); - return; + } else { + this.stepAlive(deltaTimeInSeconds); } + this.parts.forEach((part, i) => { + const velocity = this.velocities[i]; + const previous = this.previousPose[i]; + vec2.subtract(velocity.center, part.center, previous.center); + vec2.scale(velocity.center, velocity.center, 1 / deltaTimeInSeconds); + velocity.radius = (part.radius - previous.radius) / deltaTimeInSeconds; + }); + } + + private stepAlive(deltaTimeInSeconds: number) { tickPlanetDetachment(this, deltaTimeInSeconds); this.timeSinceLastShot += deltaTimeInSeconds; - this.projectileStrength = Math.min( settings.playerMaxStrength, this.projectileStrength + settings.playerStrengthRegenerationPerSeconds * deltaTimeInSeconds, ); - this.regenerateHealth(deltaTimeInSeconds); - - this.groundPlanet?.registerPresence(this); - - // stepCharacterMovement is the shared simulation the client predicts with, - // so the two can never drift. - const direction = this.averageAndResetMovementActions(); - stepCharacterMovement(this, this.movementWorld, direction, deltaTimeInSeconds); - - this.setPropertyUpdates(previousPose, deltaTimeInSeconds); - } - - private freeFallCorpse(deltaTime: number) { - const intersecting = this.container.findIntersecting( - getBoundingBoxOfCircle( - new Circle(this.center, boundRadius + settings.maxGravityDistance), - ), - ); - 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; - } - } - // Brake with the shared model the living body uses, so a flung corpse - // comes to a definite stop instead of sliding forever. - decayMomentum(this.bodyVelocity, grounded, deltaTime); - } - - private regenerateHealth(deltaTimeInSeconds: number) { this.timeSinceLastDamage += deltaTimeInSeconds; if ( this.timeSinceLastDamage > settings.playerOutOfCombatDelaySeconds && @@ -502,18 +387,51 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical ); this.syncHealth(); } + + this.groundPlanet?.registerPresence(this); + + stepCharacterMovement( + this, + this.movementWorld, + this.averageAndResetMovementDirections(), + deltaTimeInSeconds, + ); } - public onDie(killedInCombat = false) { - this.wasKilledInCombat = killedInCombat; - this.isDestroyed = true; - this.remoteCall('onDie'); + private averageAndResetMovementDirections(): vec2 { + const direction = vec2.create(); + if (this.movementDirections.length === 0) { + vec2.copy(direction, this.lastMovementDirection); + } else { + for (const d of this.movementDirections) { + vec2.add(direction, direction, d); + } + vec2.scale(direction, direction, 1 / this.movementDirections.length); + this.lastMovementDirection = + this.movementDirections[this.movementDirections.length - 1]; + this.movementDirections = []; + } + return vec2.normalize(direction, direction); } - private destroy() { - this.container.removeObject(this); - this.container.removeObject(this.head); - this.container.removeObject(this.leftFoot); - this.container.removeObject(this.rightFoot); + private animateScaling(q: number) { + this.head.radius = headRadius * q; + this.leftFoot.radius = this.rightFoot.radius = feetRadius * q; + } + + private freeFallCorpse(deltaTime: number) { + const planets = this.movementWorld.groundsNear( + this.center, + boundRadius + settings.maxGravityDistance, + ); + let grounded = false; + for (const part of this.parts) { + vec2.copy(part.velocity, this.bodyVelocity); + part.applyForce(sumGravity(planets, part.center), deltaTime); + if (part.stepManually(deltaTime) instanceof PlanetPhysical) { + grounded = true; + } + } + decayMomentum(this.bodyVelocity, grounded, deltaTime); } } diff --git a/backend/src/objects/circle-physical.ts b/backend/src/objects/circle-physical.ts index bf8f716..7cccc47 100644 --- a/backend/src/objects/circle-physical.ts +++ b/backend/src/objects/circle-physical.ts @@ -1,138 +1,72 @@ import { vec2 } from 'gl-matrix'; -import { - applyForce, - Circle, - 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 { 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/commands'; - -@serializesTo(Circle) -export class CirclePhysical extends CommandReceiver implements Circle, DynamicPhysical { - readonly canCollide = true; - readonly canMove = true; +import { applyForce, Circle, GameObject, resolveCircleMovement } from 'shared'; +import { BoundingBox } from '../physics/bounding-box'; +import { PhysicalContainer } from '../physics/physical-container'; +import { Physical } from '../physics/physical'; +export class CirclePhysical extends Circle implements Physical { + public readonly canCollide = true; public velocity = vec2.create(); public lastNormal = vec2.fromValues(0, 1); - private _boundingBox: BoundingBox; - - protected commandExecutors: CommandExecutors = { - [ReactToCollisionCommand.type]: this.onCollision.bind(this), - }; + private readonly box = new BoundingBox(); constructor( - private _center: vec2, - private _radius: number, - public owner: GameObject, + center: vec2, + radius: number, + public readonly owner: Physical, private readonly container: PhysicalContainer, - // Public + readonly so CirclePhysical satisfies the shared PhysicsBody - // interface the movement simulation operates on. public readonly restitution = 0, ) { - super(); - this._boundingBox = new BoundingBox(); - this.syncBoundingBox(); + super(center, radius); } - public get boundingBox(): BoundingBoxBase { - return this._boundingBox; - } - - public get center(): vec2 { - return this._center; - } - - public set center(value: vec2) { - this._center = value; - this.syncBoundingBox(); - } - - public onCollision(c: ReactToCollisionCommand) { - this.owner.handleCommand(c); + public get boundingBox(): BoundingBox { + this.box.xMin = this.center[0] - this.radius; + this.box.xMax = this.center[0] + this.radius; + this.box.yMin = this.center[1] - this.radius; + this.box.yMax = this.center[1] + this.radius; + return this.box; } public get gameObject(): GameObject { - return this.owner; + return this.owner.gameObject; } - public get radius(): number { - return this._radius; - } - - public set radius(value: number) { - this._radius = value; - this.syncBoundingBox(); - } - - public distance(target: vec2): number { - return vec2.distance(target, this.center) - this.radius; - } - - public syncBoundingBox() { - this._boundingBox.xMin = this.center.x - this._radius; - this._boundingBox.xMax = this.center.x + this._radius; - this._boundingBox.yMin = this.center.y - this._radius; - this._boundingBox.yMax = this.center.y + this._radius; + public onCollision(other: GameObject) { + this.owner.onCollision?.(other); } public applyForce(force: vec2, timeInSeconds: number) { applyForce(this, force, timeInSeconds); } - // Delegates to the shared resolveCircleMovement so the server integrates with - // the exact same geometry the client predictor runs — no parallel copy to - // keep in sync. `possibleIntersectors` lets a caller that already broadphased - // avoid a second container query; otherwise it is self-gathered. public stepManually( deltaTimeInSeconds: number, possibleIntersectors?: Array, - ): { - hitObject: GameObject | undefined; - velocity: vec2; - } { + ): GameObject | undefined { const intersecting = ( possibleIntersectors ?? this.sweptBroadphase(deltaTimeInSeconds) ).filter((b) => b.gameObject !== this.gameObject && b.canCollide); - const { hitObject, velocity } = resolveCircleMovement( + const hit = resolveCircleMovement( this, deltaTimeInSeconds, intersecting, (intersected) => { const physical = intersected as Physical; - physical.handleCommand(new ReactToCollisionCommand(this.gameObject)); - this.handleCommand(new ReactToCollisionCommand(physical.gameObject)); + physical.onCollision?.(this.gameObject); + this.onCollision(physical.gameObject); }, - ); + ) as Physical | undefined; - this.syncBoundingBox(); - - return { hitObject: (hitObject as Physical | undefined)?.gameObject, velocity }; + return hit?.gameObject; } private sweptBroadphase(deltaTimeInSeconds: number): Array { - const sweep = vec2.length( - vec2.scale(vec2.create(), this.velocity, deltaTimeInSeconds), - ); - // Grow the query box, not this.radius: mutating the radius would re-register - // the body's extent mid-tick via the setter. + const sweep = vec2.length(this.velocity) * deltaTimeInSeconds; return this.container.findIntersecting( - getBoundingBoxOfCircle(new Circle(this.center, this.radius + sweep)), + BoundingBox.ofCircle(this.center, this.radius + sweep), ); } - - public toArray(): Array { - const { center, radius } = this; - return [center, radius]; - } } diff --git a/backend/src/objects/lamp-physical.ts b/backend/src/objects/lamp-physical.ts index 7438f7d..07d04ef 100644 --- a/backend/src/objects/lamp-physical.ts +++ b/backend/src/objects/lamp-physical.ts @@ -1,29 +1,15 @@ import { vec2, vec3 } from 'gl-matrix'; -import { Circle, LampBase, settings, id, serializesTo } from 'shared'; +import { LampBase, settings, id } from 'shared'; +import { BoundingBox } from '../physics/bounding-box'; +import { Physical } from '../physics/physical'; -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) -export class LampPhysical extends LampBase implements StaticPhysical { +export class LampPhysical extends LampBase implements Physical { public readonly canCollide = false; - public readonly canMove = false; + public readonly boundingBox: BoundingBox; constructor(center: vec2, color: vec3, lightness: number) { super(id(), center, color, lightness); - } - - private _boundingBox?: ImmutableBoundingBox; - - public get boundingBox(): ImmutableBoundingBox { - if (!this._boundingBox) { - this._boundingBox = getBoundingBoxOfCircle( - new Circle(this.center, settings.lightCutoffDistance), - ); - } - - return this._boundingBox; + this.boundingBox = BoundingBox.ofCircle(center, settings.lightCutoffDistance); } public get gameObject(): this { diff --git a/backend/src/objects/planet-physical.ts b/backend/src/objects/planet-physical.ts index db54c33..950a548 100644 --- a/backend/src/objects/planet-physical.ts +++ b/backend/src/objects/planet-physical.ts @@ -1,145 +1,90 @@ import { vec2 } from 'gl-matrix'; - import { - Circle, clamp01, id, mix, Random, - serializesTo, settings, PlanetBase, CharacterTeam, PropertyUpdatesForObject, UpdatePropertyCommand, - planetDistance, - planetGravity, - CommandExecutors, - CommandReceiver, } from 'shared'; -import { - GeneratePointsCommand, - AnnounceCommand, - StepCommand, -} from '../commands/commands'; - -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 { GameEvents } from '../game-events'; +import { BoundingBox } from '../physics/bounding-box'; +import { Physical } from '../physics/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. - public readonly isGround = true; +export const planetsIn = (objects: Array): Array => + objects.filter((o): o is PlanetPhysical => o instanceof PlanetPhysical); +export class PlanetPhysical extends PlanetBase implements Physical { + public readonly boundingBox: BoundingBox; public readonly sizePointMultiplier: number; - // Authoritative spin angle, streamed to the client so the rendered outline - // and this collision polygon turn as one. cos/sin are memoised: distance() - // is called many times per tick by the raymarcher and the angle rarely - // changes between those calls. - private rotation = 0; - private readonly rotationSpeed: number; - private cachedRotation = Number.NaN; - private cosRotation = 1; - private sinRotation = 0; - - private _boundingBox?: ImmutableBoundingBox; - private readonly lamps: Array = []; - 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(). private presentCharacters: Array = []; private isContested = false; + private timeSinceLastPointGeneration = 0; - protected commandExecutors: CommandExecutors = { - [StepCommand.type]: this.step.bind(this), - }; - - public addLamp(lamp: LampPhysical) { - this.lamps.push(lamp); - } - - constructor(vertices: Array, isKeystone = false) { + constructor( + vertices: Array, + isKeystone: boolean, + private readonly game: GameEvents, + ) { super(id(), vertices, 0.5, isKeystone); const sizeClass = clamp01( (this.radius - settings.planetMinReferenceRadius) / (settings.planetMaxReferenceRadius - settings.planetMinReferenceRadius), ); - this.sizePointMultiplier = mix(1, settings.planetSizePointMultiplierMax, sizeClass); - - this.rotationSpeed = + this.angularVelocity = (0.05 + Random.getRandom() * 0.07) * (Random.getRandom() < 0.5 ? -1 : 1); + this.boundingBox = BoundingBox.ofCircle( + this.center, + Math.max(...vertices.map((v) => vec2.distance(this.center, v))), + ); + } + + public get gameObject(): this { + return this; + } + + public addLamp(lamp: LampPhysical) { + this.lamps.push(lamp); } public registerPresence(character: CharacterPhysical) { this.presentCharacters.push(character); } - public distance(target: vec2): number { - // The shared planet outline — the same function the client predictor - // collides against, which is what lets prediction reconcile. - this.syncRotationTrigonometry(); - return planetDistance( - target, - this.vertices, - this.center, - this.cosRotation, - this.sinRotation, - ); - } - - private syncRotationTrigonometry() { - if (this.rotation !== this.cachedRotation) { - this.cachedRotation = this.rotation; - this.cosRotation = Math.cos(this.rotation); - this.sinRotation = Math.sin(this.rotation); - } - } - - // Signed angular velocity in rad/s, exposed so a character can ride the - // planet's spin (see carryWithRotatingPlanet in shared). - public get angularVelocity(): number { - return this.rotationSpeed; - } - - private timeSinceLastPointGeneration = 0; - private getPoints(game: CommandReceiver) { - if (this.timeSinceLastPointGeneration > settings.planetPointGenerationInterval) { - this.timeSinceLastPointGeneration = 0; - - const value = Math.round( - settings.planetPointGenerationValue * this.sizePointMultiplier, - ); - game.handleCommand( - new GeneratePointsCommand( - this.team === CharacterTeam.blue ? value : 0, - this.team === CharacterTeam.red ? value : 0, - ), - ); - } - } - - private step({ deltaTimeInSeconds, game }: StepCommand) { - this.rotation += deltaTimeInSeconds * this.rotationSpeed; + public step(deltaTimeInSeconds: number) { + this.advanceRotation(deltaTimeInSeconds); this.timeSinceLastPointGeneration += deltaTimeInSeconds; - // In reverse order, so that teams can achieve a 100% control. - this.getPoints(game); + this.generatePoints(); this.resolveCapture(deltaTimeInSeconds); - this.detectFlip(game); + this.detectFlip(); - this.presentCharacters = []; + this.presentCharacters.length = 0; + } + + private generatePoints() { + if (this.timeSinceLastPointGeneration > settings.planetPointGenerationInterval) { + this.timeSinceLastPointGeneration = 0; + this.awardToOwner(settings.planetPointGenerationValue); + } + } + + private awardToOwner(base: number) { + const value = Math.round(base * this.sizePointMultiplier); + this.game.addPoints( + this.team === CharacterTeam.blue ? value : 0, + this.team === CharacterTeam.red ? value : 0, + ); } private resolveCapture(deltaTime: number) { @@ -175,7 +120,7 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical { } } - private detectFlip(game: CommandReceiver) { + private detectFlip() { const currentTeam = this.team; if (currentTeam === this.lastTeam) { return; @@ -183,22 +128,15 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical { this.lastTeam = currentTeam; if (currentTeam !== CharacterTeam.neutral) { - const reward = Math.round( - settings.captureFlipPointReward * this.sizePointMultiplier, - ); - this.remoteCall('generatedPoints', reward); - game.handleCommand( - new GeneratePointsCommand( - currentTeam === CharacterTeam.blue ? reward : 0, - currentTeam === CharacterTeam.red ? reward : 0, - ), + this.remoteCall( + 'generatedPoints', + Math.round(settings.captureFlipPointReward * this.sizePointMultiplier), ); + this.awardToOwner(settings.captureFlipPointReward); if (this.isKeystone) { - game.handleCommand( - new AnnounceCommand( - `Team ${currentTeam} captured the Heart`, - ), + this.game.announce( + `Team ${currentTeam} captured the Heart`, ); } } @@ -206,22 +144,12 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical { const control = Math.abs(this.ownership - 0.5) / 0.5; const lightness = mix(settings.lampMinLightness, settings.lampMaxLightness, control); const color = settings.palette[settings.colorIndices[currentTeam]]; - this.lamps.forEach((lamp) => lamp.queueSetLight(color, lightness)); this.remoteCall('onFlipped', currentTeam); } - public getPropertyUpdates(): PropertyUpdatesForObject { - return new PropertyUpdatesForObject(this.id, [ - new UpdatePropertyCommand('ownership', this.ownership, 0), - // 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), - ]); - } - - public takeControl(team: CharacterTeam, deltaTime: number) { + private takeControl(team: CharacterTeam, deltaTime: number) { if (team === CharacterTeam.blue) { this.ownership -= (0.5 / settings.takeControlTimeInSeconds) * deltaTime; } else if (team === CharacterTeam.red) { @@ -232,10 +160,7 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical { -Math.sign(this.ownership - 0.5) * (0.5 / settings.loseControlTimeInSeconds) * deltaTime; - if ( - (previous < 0.5 && this.ownership > 0.5) || - (previous > 0.5 && this.ownership < 0.5) - ) { + if ((previous - 0.5) * (this.ownership - 0.5) < 0) { this.ownership = 0.5; } } @@ -243,30 +168,10 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical { this.ownership = clamp01(this.ownership); } - public get boundingBox(): ImmutableBoundingBox { - if (!this._boundingBox) { - // The polygon spins about its centre (see distance), so this static box - // has to cover every orientation, not just the spawn-time one: take the - // circumscribed circle around the rotation centre. - const maxVertexDistance = this.vertices.reduce( - (max, vertex) => Math.max(max, vec2.distance(this.center, vertex)), - 0, - ); - - this._boundingBox = getBoundingBoxOfCircle( - new Circle(this.center, maxVertexDistance), - ); - } - - return this._boundingBox; - } - - // GroundSurface gravity, shared with the client predictor. - public gravityAt(position: vec2): vec2 { - return planetGravity(this.center, this.radius, position); - } - - public get gameObject(): this { - return this; + public getPropertyUpdates(): PropertyUpdatesForObject { + return new PropertyUpdatesForObject(this.id, [ + new UpdatePropertyCommand('ownership', this.ownership, 0), + new UpdatePropertyCommand('rotation', this.rotation, this.angularVelocity), + ]); } } diff --git a/backend/src/objects/projectile-physical.ts b/backend/src/objects/projectile-physical.ts index 2437d0c..fd8496c 100644 --- a/backend/src/objects/projectile-physical.ts +++ b/backend/src/objects/projectile-physical.ts @@ -2,55 +2,42 @@ import { vec2 } from 'gl-matrix'; import { id, settings, - serializesTo, ProjectileBase, CharacterTeam, PropertyUpdatesForObject, UpdatePropertyCommand, - CommandExecutors, - Circle, - marchCircle, + GameObject, + evaluateSdf, + sumGravity, } from 'shared'; -import { BoundingBoxBase } from '../physics/bounding-boxes/bounding-box-base'; +import { BoundingBox } from '../physics/bounding-box'; import { CirclePhysical } from './circle-physical'; -import { DynamicPhysical } from '../physics/physicals/dynamic-physical'; -import { PhysicalContainer } from '../physics/containers/physical-container'; +import { Physical } from '../physics/physical'; +import { PhysicalContainer } from '../physics/physical-container'; import { CharacterPhysical } from './character-physical'; -import { forceAtPosition } from '../physics/functions/force-at-position'; -import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle'; -import { StepCommand, ReactToCollisionCommand } from '../commands/commands'; +import { planetsIn } from './planet-physical'; -@serializesTo(ProjectileBase) -export class ProjectilePhysical extends ProjectileBase implements DynamicPhysical { +export class ProjectilePhysical extends ProjectileBase implements Physical { public readonly canCollide = true; - public readonly canMove = true; + public readonly object: CirclePhysical; private isDestroyed = false; private bounceCount = 0; - public object: CirclePhysical; - - protected commandExecutors: CommandExecutors = { - [StepCommand.type]: this.handleStep.bind(this), - [ReactToCollisionCommand.type]: this.onCollision.bind(this), - }; - constructor( center: vec2, radius: number, public strength: number, team: CharacterTeam, - private velocity: vec2, + velocity: vec2, public readonly originator: CharacterPhysical, - readonly container: PhysicalContainer, - // Normalised charge (0..1) of the shot that fired this; the victim scales - // hit/kill feedback and the death fling by it. + private readonly container: PhysicalContainer, public readonly charge: number = 0, ) { super(id(), center, radius, team, strength); - this.object = new CirclePhysical(center, radius, this, container, 0.9); - - this.moveOutsideOfObject(); + this.object = new CirclePhysical(this.center, radius, this, container, 0.9); + vec2.copy(this.object.velocity, velocity); + this.moveOutsideFriendlyBodies(); } public get isAlive(): boolean { @@ -58,35 +45,13 @@ export class ProjectilePhysical extends ProjectileBase implements DynamicPhysica } public get direction(): vec2 { - const direction = vec2.clone(this.velocity); + const direction = vec2.clone(this.object.velocity); return vec2.length(direction) > 0 ? vec2.normalize(direction, direction) : vec2.fromValues(0, -1); } - private moveOutsideOfObject() { - let wasCollision = true; - const delta = vec2.scale( - vec2.create(), - vec2.normalize(vec2.create(), this.velocity), - 10, - ); - // Bounded: each pass advances by `delta`, but a degenerate direction would - // otherwise spin here forever inside the physics tick. - let passes = 0; - while (wasCollision && passes++ < 32) { - const intersecting = this.container - .findIntersecting(this.boundingBox) - .filter((g) => g instanceof CharacterPhysical && g.team === this.team); - const { hitSurface } = marchCircle(this.object, delta, intersecting, true); - wasCollision = hitSurface; - this.object.syncBoundingBox(); - } - } - - // Live view of the circle's box, not a snapshot — the circle keeps it in sync - // as it moves. - public get boundingBox(): BoundingBoxBase { + public get boundingBox(): BoundingBox { return this.object.boundingBox; } @@ -98,6 +63,19 @@ export class ProjectilePhysical extends ProjectileBase implements DynamicPhysica return this.object.distance(target); } + private moveOutsideFriendlyBodies() { + const step = vec2.scale(vec2.create(), this.direction, 10); + for (let pass = 0; pass < 32; pass++) { + vec2.add(this.center, this.center, step); + const friendly = this.container + .findIntersecting(this.boundingBox) + .filter((o) => o instanceof CharacterPhysical && o.team === this.team); + if (evaluateSdf(this.center, friendly) >= this.radius) { + return; + } + } + } + public destroy() { if (!this.isDestroyed) { this.isDestroyed = true; @@ -105,7 +83,7 @@ export class ProjectilePhysical extends ProjectileBase implements DynamicPhysica } } - public onCollision({ other }: ReactToCollisionCommand) { + public onCollision(other: GameObject) { if ( !(other instanceof CharacterPhysical && other.team === this.team) && this.bounceCount++ === settings.projectileMaxBounceCount @@ -116,15 +94,11 @@ export class ProjectilePhysical extends ProjectileBase implements DynamicPhysica public getPropertyUpdates(): PropertyUpdatesForObject { return new PropertyUpdatesForObject(this.id, [ - new UpdatePropertyCommand('center', this.center, this.velocity), + new UpdatePropertyCommand('center', this.center, this.object.velocity), ]); } - private handleStep({ deltaTimeInSeconds }: StepCommand) { - this.advance(deltaTimeInSeconds); - } - - private advance(deltaTimeInSeconds: number) { + public step(deltaTimeInSeconds: number) { super.step(deltaTimeInSeconds); if (this.strength <= 0) { @@ -132,24 +106,17 @@ export class ProjectilePhysical extends ProjectileBase implements DynamicPhysica return; } - // 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 = this.container.findIntersecting( - getBoundingBoxOfCircle( - new Circle(this.center, this.object.radius + settings.maxGravityDistance), - ), + BoundingBox.ofCircle(this.center, this.radius + settings.maxGravityDistance), ); vec2.scaleAndAdd( - this.velocity, - this.velocity, - forceAtPosition(this.center, intersecting), + this.object.velocity, + this.object.velocity, + sumGravity(planetsIn(intersecting), this.center), settings.projectileGravityScale * deltaTimeInSeconds, ); - vec2.copy(this.object.velocity, this.velocity); - const { velocity } = this.object.stepManually(deltaTimeInSeconds, intersecting); - vec2.copy(this.velocity, velocity); + this.object.stepManually(deltaTimeInSeconds, intersecting); } } diff --git a/backend/src/physics/bounding-box.ts b/backend/src/physics/bounding-box.ts index 1a00fb3..0cc6477 100644 --- a/backend/src/physics/bounding-box.ts +++ b/backend/src/physics/bounding-box.ts @@ -1,54 +1,28 @@ import { vec2 } from 'gl-matrix'; -import { BoundingBoxBase } from './bounding-box-base'; -export class BoundingBox extends BoundingBoxBase { - public get xMin(): number { - return this._xMin; +export class BoundingBox { + constructor( + public xMin = 0, + public xMax = 0, + public yMin = 0, + public yMax = 0, + ) {} + + public static ofCircle(center: vec2, radius: number): BoundingBox { + return new BoundingBox( + center[0] - radius, + center[0] + radius, + center[1] - radius, + center[1] + radius, + ); } - public set xMin(value: number) { - this._xMin = value; - } - - public set xMax(value: number) { - this._xMax = value; - } - - public get xMax(): number { - return this._xMax; - } - - public set yMin(value: number) { - this._yMin = value; - } - - public get yMin(): number { - return this._yMin; - } - - public set yMax(value: number) { - this._yMax = value; - } - - public get yMax(): number { - return this._yMax; - } - - public get topLeft(): vec2 { - return vec2.fromValues(this._xMin, this._yMax); - } - - public set topLeft(value: vec2) { - this._xMin = value.x; - this._yMax = value.y; - } - - public set size(value: vec2) { - this._xMax = this.xMin + value.x; - this._yMin = this.yMax - value.y; - } - - public get size(): vec2 { - return vec2.fromValues(this._xMax - this._xMin, this._yMax - this._yMin); + public intersects(other: BoundingBox): boolean { + return ( + this.xMin < other.xMax && + this.xMax > other.xMin && + this.yMin < other.yMax && + this.yMax > other.yMin + ); } } diff --git a/backend/src/physics/physical-container.ts b/backend/src/physics/physical-container.ts index 604672e..a3ac6c2 100644 --- a/backend/src/physics/physical-container.ts +++ b/backend/src/physics/physical-container.ts @@ -1,54 +1,29 @@ -import { BoundingBoxBase } from '../bounding-boxes/bounding-box-base'; -import { BoundingBoxList } from './bounding-box-list'; -import { BoundingBoxTree } from './bounding-box-tree'; -import { Physical } from '../physicals/physical'; -import { StaticPhysical } from '../physicals/static-physical'; -import { DynamicPhysical } from '../physicals/dynamic-physical'; -import { Command, CommandReceiver } from 'shared'; +import { GameEvents } from '../game-events'; +import { BoundingBox } from './bounding-box'; +import { Physical } from './physical'; -export class PhysicalContainer extends CommandReceiver { - private isTreeInitialized = false; - private staticBoundingBoxesWaitList: Array = []; - private staticBoundingBoxes = new BoundingBoxTree(); - private dynamicBoundingBoxes = new BoundingBoxList(); +export class PhysicalContainer { private objects: Array = []; - protected defaultCommandExecutor(c: Command) { - this.objects.forEach((o) => o.handleCommand(c)); - } - - public initialize() { - this.staticBoundingBoxes.build(this.staticBoundingBoxesWaitList); - this.isTreeInitialized = true; - } + constructor(public readonly game: GameEvents) {} public addObject(physical: Physical) { this.objects.push(physical); - - if (physical.canMove) { - this.dynamicBoundingBoxes.insert(physical); - } else { - if (!this.isTreeInitialized) { - this.staticBoundingBoxesWaitList.push(physical); - } else { - this.staticBoundingBoxes.insert(physical); - } - } } - public removeObject(object: DynamicPhysical) { - this.objects = this.objects.filter((p) => p !== object); - this.dynamicBoundingBoxes.remove(object); + public removeObject(physical: Physical) { + this.objects = this.objects.filter((o) => o !== physical); + } + + public step(deltaTimeInSeconds: number) { + this.objects.forEach((o) => o.step?.(deltaTimeInSeconds)); } public resetRemoteCalls() { this.objects.forEach((o) => o.gameObject.resetRemoteCalls()); } - public findIntersecting(box: BoundingBoxBase): Array { - return [ - ...this.staticBoundingBoxes.findIntersecting(box), - ...this.dynamicBoundingBoxes.findIntersecting(box), - ]; + public findIntersecting(box: BoundingBox): Array { + return this.objects.filter((o) => o.boundingBox.intersects(box)); } } diff --git a/backend/src/physics/physical.ts b/backend/src/physics/physical.ts index 2e3189b..4e3fc1a 100644 --- a/backend/src/physics/physical.ts +++ b/backend/src/physics/physical.ts @@ -1,4 +1,13 @@ -import { DynamicPhysical } from './dynamic-physical'; -import { StaticPhysical } from './static-physical'; +import { vec2 } from 'gl-matrix'; +import { GameObject } from 'shared'; +import { BoundingBox } from './bounding-box'; -export type Physical = StaticPhysical | DynamicPhysical; +export interface Physical { + readonly canCollide: boolean; + readonly boundingBox: BoundingBox; + readonly gameObject: GameObject; + + distance(target: vec2): number; + step?(deltaTimeInSeconds: number): void; + onCollision?(other: GameObject): void; +} diff --git a/backend/src/players/npc.ts b/backend/src/players/npc.ts index 6881fc3..3fa8bef 100644 --- a/backend/src/players/npc.ts +++ b/backend/src/players/npc.ts @@ -1,23 +1,15 @@ import { vec2 } from 'gl-matrix'; -import { - PlayerInformation, - settings, - Circle, - Random, - MoveActionCommand, - CharacterTeam, - Id, -} from 'shared'; -import { PhysicalContainer } from '../physics/containers/physical-container'; +import { PlayerInformation, settings, Random, CharacterTeam, Id } from 'shared'; +import { PhysicalContainer } from '../physics/physical-container'; +import { BoundingBox } from '../physics/bounding-box'; import { PlayerContainer } from './player-container'; import { PlayerBase } from './player-base'; -import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle'; import { CharacterPhysical } from '../objects/character-physical'; -import { PlanetPhysical } from '../objects/planet-physical'; +import { PlanetPhysical, planetsIn } from '../objects/planet-physical'; import { ProjectilePhysical } from '../objects/projectile-physical'; -import { Physical } from '../physics/physicals/physical'; +import { Physical } from '../physics/physical'; -const npcTuning = { +const tuning = { planIntervalSeconds: 1, shootIntervalSeconds: 1.5, reactionIntervalSeconds: 1 / 10, @@ -40,7 +32,6 @@ const npcTuning = { dodgeThreatRange: 450, dodgeApproachDot: 0.6, - dodgeBaseChance: 0.25, dodgeAggressionChance: 0.35, dodgeCommitSeconds: 0.35, @@ -64,27 +55,33 @@ const npcTuning = { leapChancePerSecond: 0.35, }; +interface Enemy { + character: CharacterPhysical; + distance: number; +} + export class NPC extends PlayerBase { private direction = vec2.fromValues(Random.getRandom() - 0.5, Random.getRandom() - 0.5); - private timeSinceLastPlan = 10000; - private timeSinceLastShoot = 10000; - private isWandering = false; + private movement = vec2.create(); + private timeSinceLastPlan = Infinity; + private timeSinceLastShoot = Infinity; + private timeSinceObserve = Infinity; private timeSinceLastWanderingConsideration = 0; + private isWandering = false; private isComingBack = false; private readonly aggression = Random.getRandomInRange( - npcTuning.aggressionMin, - npcTuning.aggressionMax, + tuning.aggressionMin, + tuning.aggressionMax, ); - private aimTargetId: Id = null; + private aimTargetId: Id | null = null; private readonly aimTargetLastPosition = vec2.create(); private readonly dodgeDirection = vec2.create(); private dodgeCommitRemaining = 0; private dodgeCooldownRemaining = 0; - private timeSinceObserve = npcTuning.reactionIntervalSeconds; private nearObjects: Array = []; constructor( @@ -95,7 +92,6 @@ export class NPC extends PlayerBase { ) { super(playerInfo, playerContainer, objectContainer, team); this.createCharacter(); - this.step(0); } public step(deltaTimeInSeconds: number) { @@ -106,44 +102,40 @@ export class NPC extends PlayerBase { if ( (this.timeSinceLastWanderingConsideration += deltaTimeInSeconds) > - npcTuning.wanderReconsiderSeconds + tuning.wanderReconsiderSeconds ) { this.timeSinceLastWanderingConsideration = 0; - this.isWandering = Random.getRandom() < npcTuning.wanderProbability; + this.isWandering = Random.getRandom() < tuning.wanderProbability; } - if ((this.timeSinceLastPlan += deltaTimeInSeconds) > npcTuning.planIntervalSeconds) { + if ((this.timeSinceLastPlan += deltaTimeInSeconds) > tuning.planIntervalSeconds) { this.timeSinceLastPlan = 0; this.plan(); } - if ( - (this.timeSinceObserve += deltaTimeInSeconds) > npcTuning.reactionIntervalSeconds - ) { - this.timeSinceObserve = 0; - this.nearObjects = this.observe(npcTuning.reactionObserveRadius); - } - this.dodgeCommitRemaining -= deltaTimeInSeconds; this.dodgeCooldownRemaining -= deltaTimeInSeconds; - const movement = this.decideMovement(this.nearObjects); - character.handleMovementAction(new MoveActionCommand(movement)); + + if ((this.timeSinceObserve += deltaTimeInSeconds) > tuning.reactionIntervalSeconds) { + this.timeSinceObserve = 0; + this.nearObjects = this.observe(tuning.reactionObserveRadius); + this.movement = this.decideMovement(character, this.nearObjects); + character.setMoveDirection(this.movement); + } if ( !this.isComingBack && character.groundPlanet && - vec2.length(movement) > 0 && + vec2.length(this.movement) > 0 && Random.getRandom() < - npcTuning.leapChancePerSecond * this.aggression * deltaTimeInSeconds + tuning.leapChancePerSecond * this.aggression * deltaTimeInSeconds ) { character.leap(); } - if ( - (this.timeSinceLastShoot += deltaTimeInSeconds) > npcTuning.shootIntervalSeconds - ) { + if ((this.timeSinceLastShoot += deltaTimeInSeconds) > tuning.shootIntervalSeconds) { this.timeSinceLastShoot = 0; - this.tryShoot(this.nearObjects); + this.tryShoot(character, this.nearObjects); } } @@ -159,20 +151,18 @@ export class NPC extends PlayerBase { } this.isComingBack = false; - const nearObjects = this.observe(npcTuning.planScanRadius); - const enemies = this.enemiesByDistance(nearObjects); - - const nearest = enemies[0]; + const nearObjects = this.observe(tuning.planScanRadius); + const nearest = this.enemiesByDistance(nearObjects)[0]; if (nearest) { const fleeRange = - npcTuning.fleeBaseRange * (npcTuning.fleeAggressionFalloff - this.aggression); + tuning.fleeBaseRange * (tuning.fleeAggressionFalloff - this.aggression); if (nearest.distance < fleeRange) { vec2.subtract(this.direction, this.center, nearest.character.center); return; } const chaseRange = - npcTuning.chaseBaseRange + npcTuning.chaseAggressionRange * this.aggression; + tuning.chaseBaseRange + tuning.chaseAggressionRange * this.aggression; if (nearest.distance < chaseRange) { vec2.subtract(this.direction, nearest.character.center, this.center); return; @@ -182,7 +172,7 @@ export class NPC extends PlayerBase { if (!this.isWandering) { const planet = this.capturablePlanetsByDistance(nearObjects)[0]; if (planet) { - vec2.subtract(this.direction, planet.planet.center, this.center); + vec2.subtract(this.direction, planet.center, this.center); return; } } @@ -191,11 +181,14 @@ export class NPC extends PlayerBase { this.direction, this.direction, vec2.create(), - Random.getRandomInRange(-npcTuning.wanderTurn, npcTuning.wanderTurn), + Random.getRandomInRange(-tuning.wanderTurn, tuning.wanderTurn), ); } - private decideMovement(nearObjects: Array): vec2 { + private decideMovement( + character: CharacterPhysical, + nearObjects: Array, + ): vec2 { if (this.dodgeCommitRemaining > 0) { return vec2.clone(this.dodgeDirection); } @@ -204,23 +197,20 @@ export class NPC extends PlayerBase { if (dodge) { if ( Random.getRandom() < - npcTuning.dodgeBaseChance + npcTuning.dodgeAggressionChance * this.aggression + tuning.dodgeBaseChance + tuning.dodgeAggressionChance * this.aggression ) { vec2.copy(this.dodgeDirection, dodge); - this.dodgeCommitRemaining = npcTuning.dodgeCommitSeconds; + this.dodgeCommitRemaining = tuning.dodgeCommitSeconds; return vec2.clone(this.dodgeDirection); } - this.dodgeCooldownRemaining = npcTuning.dodgeCooldownSeconds; + this.dodgeCooldownRemaining = tuning.dodgeCooldownSeconds; } } - const planet = this.character!.groundPlanet; + const planet = character.groundPlanet; if (planet && planet.team !== this.team) { const enemies = this.enemiesByDistance(nearObjects); - if ( - enemies.length === 0 || - enemies[0].distance > npcTuning.captureHoldEnemyDistance - ) { + if (enemies.length === 0 || enemies[0].distance > tuning.captureHoldEnemyDistance) { return vec2.create(); } } @@ -239,12 +229,12 @@ export class NPC extends PlayerBase { } const toMe = vec2.subtract(vec2.create(), this.center, p.center); const distance = vec2.length(toMe); - if (distance > npcTuning.dodgeThreatRange || distance === 0) { + if (distance > tuning.dodgeThreatRange || distance === 0) { continue; } vec2.normalize(toMe, toMe); if ( - vec2.dot(p.direction, toMe) > npcTuning.dodgeApproachDot && + vec2.dot(p.direction, toMe) > tuning.dodgeApproachDot && distance < threatDistance ) { threatDistance = distance; @@ -261,14 +251,11 @@ export class NPC extends PlayerBase { if (vec2.dot(perpendicular, toMe) < 0) { vec2.negate(perpendicular, perpendicular); } - vec2.normalize(perpendicular, perpendicular); - - return perpendicular; + return vec2.normalize(perpendicular, perpendicular); } - private tryShoot(nearObjects: Array) { - const enemies = this.enemiesByDistance(nearObjects); - const visible = enemies.find((e) => + private tryShoot(character: CharacterPhysical, nearObjects: Array) { + const visible = this.enemiesByDistance(nearObjects).find((e) => this.hasLineOfSightTo(e.character.center, nearObjects), ); if (!visible) { @@ -276,71 +263,69 @@ export class NPC extends PlayerBase { return; } - const target = visible.character; - const distance = visible.distance; + const { character: target, distance } = visible; const velocity = vec2.create(); if (this.aimTargetId === target.id) { vec2.subtract(velocity, target.center, this.aimTargetLastPosition); - vec2.scale(velocity, velocity, 1 / npcTuning.shootIntervalSeconds); + vec2.scale(velocity, velocity, 1 / tuning.shootIntervalSeconds); } this.aimTargetId = target.id; vec2.copy(this.aimTargetLastPosition, target.center); if ( Random.getRandom() > - npcTuning.fireBaseChance + npcTuning.fireAggressionChance * this.aggression + tuning.fireBaseChance + tuning.fireAggressionChance * this.aggression ) { return; } const charge = - distance > npcTuning.chargeRangeThreshold && + distance > tuning.chargeRangeThreshold && Random.getRandom() < - npcTuning.chargeBaseChance + npcTuning.chargeAggressionChance * this.aggression - ? Random.getRandomInRange(npcTuning.chargeMin, 1) + tuning.chargeBaseChance + tuning.chargeAggressionChance * this.aggression + ? Random.getRandomInRange(tuning.chargeMin, 1) : 0; const projectileSpeed = charge > 0 ? settings.chargeShotSpeedMax : settings.chargeShotSpeedMin; - const leadTime = distance / projectileSpeed; - const aim = vec2.scaleAndAdd(vec2.create(), target.center, velocity, leadTime); + const aim = vec2.scaleAndAdd( + vec2.create(), + target.center, + velocity, + distance / projectileSpeed, + ); const spread = - (npcTuning.spreadBase + distance * npcTuning.spreadPerDistance) * - (npcTuning.spreadAggressionFalloff - this.aggression); - aim.x += Random.getRandomInRange(-spread, spread); - aim.y += Random.getRandomInRange(-spread, spread); + (tuning.spreadBase + distance * tuning.spreadPerDistance) * + (tuning.spreadAggressionFalloff - this.aggression); + aim[0] += Random.getRandomInRange(-spread, spread); + aim[1] += Random.getRandomInRange(-spread, spread); - this.character!.shootTowards(aim, charge); + character.shootTowards(aim, charge); } private hasLineOfSightTo(target: vec2, nearObjects: Array): boolean { - const planets: Array = []; - for (const o of nearObjects) { - if (o.gameObject instanceof PlanetPhysical) { - planets.push(o.gameObject); - } - } + const planets = planetsIn(nearObjects); if (planets.length === 0) { return true; } const direction = vec2.subtract(vec2.create(), target, this.center); const totalDistance = vec2.length(direction); - if (totalDistance <= npcTuning.lineOfSightStartOffset) { + if (totalDistance <= tuning.lineOfSightStartOffset) { return true; } vec2.normalize(direction, direction); - let traveled = npcTuning.lineOfSightStartOffset; + let traveled = tuning.lineOfSightStartOffset; const position = vec2.scaleAndAdd(vec2.create(), this.center, direction, traveled); while (traveled < totalDistance) { let sdf = Infinity; for (const planet of planets) { sdf = Math.min(sdf, planet.distance(position)); } - if (sdf < npcTuning.lineOfSightClearance) { + if (sdf < tuning.lineOfSightClearance) { return false; } traveled += sdf; @@ -351,45 +336,34 @@ export class NPC extends PlayerBase { private observe(radius: number): Array { return this.objectContainer.findIntersecting( - getBoundingBoxOfCircle(new Circle(this.center, radius)), + BoundingBox.ofCircle(this.center, radius), ); } - private enemiesByDistance( - nearObjects: Array, - ): Array<{ character: CharacterPhysical; distance: number }> { - const seen = new Set(); - const enemies: Array<{ character: CharacterPhysical; distance: number }> = []; + private enemiesByDistance(nearObjects: Array): Array { + const enemies: Array = []; for (const o of nearObjects) { const c = o.gameObject; if ( c instanceof CharacterPhysical && c !== this.character && c.isAlive && - c.team !== this.team && - !seen.has(c) + c.team !== this.team ) { - seen.add(c); enemies.push({ character: c, distance: vec2.distance(this.center, c.center) }); } } - enemies.sort((a, b) => a.distance - b.distance); - return enemies; + return enemies.sort((a, b) => a.distance - b.distance); } private capturablePlanetsByDistance( nearObjects: Array, - ): Array<{ planet: PlanetPhysical; distance: number }> { - const planets = nearObjects - .filter( - (o): o is Physical => - o.gameObject instanceof PlanetPhysical && o.gameObject.team !== this.team, - ) - .map((o) => ({ - planet: o.gameObject as PlanetPhysical, - distance: vec2.distance(this.center, (o.gameObject as PlanetPhysical).center), - })); - planets.sort((a, b) => a.distance - b.distance); - return planets; + ): Array { + return planetsIn(nearObjects) + .filter((p) => p.team !== this.team) + .sort( + (a, b) => + vec2.distance(this.center, a.center) - vec2.distance(this.center, b.center), + ); } } diff --git a/backend/src/players/player-base.ts b/backend/src/players/player-base.ts index 5ce7552..25a6751 100644 --- a/backend/src/players/player-base.ts +++ b/backend/src/players/player-base.ts @@ -1,7 +1,6 @@ import { vec2 } from 'gl-matrix'; import { CommandReceiver, - Circle, PlayerInformation, CharacterTeam, Random, @@ -10,53 +9,64 @@ import { boundRadius, evaluateSdf, } from 'shared'; -import { PhysicalContainer } from '../physics/containers/physical-container'; -import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle'; +import { PhysicalContainer } from '../physics/physical-container'; +import { BoundingBox } from '../physics/bounding-box'; import { CharacterPhysical } from '../objects/character-physical'; -import { PlanetPhysical } from '../objects/planet-physical'; +import { PlanetPhysical, planetsIn } from '../objects/planet-physical'; import { PlayerContainer } from './player-container'; const maximumNameLength = 40; +export interface Score { + kills: number; + deaths: number; +} + export abstract class PlayerBase extends CommandReceiver { - public character?: CharacterPhysical | null; + public character: CharacterPhysical | null = null; public center: vec2 = vec2.create(); - protected sumKills = 0; - protected sumDeaths = 0; + protected lastCharacter?: CharacterPhysical; protected timeUntilRespawn = 0; + private kills: number; + private deaths: number; constructor( protected readonly playerInfo: PlayerInformation, protected readonly playerContainer: PlayerContainer, protected readonly objectContainer: PhysicalContainer, public readonly team: CharacterTeam, + score: Score = { kills: 0, deaths: 0 }, ) { super(); + this.kills = score.kills; + this.deaths = score.deaths; } - protected createCharacter() { + public get score(): Score { + return { + kills: (this.character ?? this.lastCharacter)?.killCount ?? this.kills, + deaths: this.deaths, + }; + } + + protected createCharacter(): CharacterPhysical { + this.kills = this.lastCharacter?.killCount ?? this.kills; this.character = new CharacterPhysical( - // Coerce, don't just truncate: input is untrusted (from deserialize), and - // arrays have `.slice()` too — a non-string name would throw inside the - // reviver and kill the recipient's whole message batch. sanitizeName(this.playerInfo.name, maximumNameLength), - this.sumKills, - this.sumDeaths, + this.kills, + this.deaths, this.team, this.objectContainer, this.findEmptyPositionForPlayer(this.findSpawnCenter()), ); - this.objectContainer.addObject(this.character); + this.center = this.character.center; + return this.character; } public abstract step(deltaTimeInSeconds: number): void; - /** - * Shared death/respawn cycle. Returns the living character to act with this - * tick (or undefined), so a subclass can use it directly without re-narrowing. - */ protected stepLifecycle(deltaTimeInSeconds: number): CharacterPhysical | undefined { if (this.character) { this.center = this.character.center; @@ -65,33 +75,25 @@ export abstract class PlayerBase extends CommandReceiver { return this.character; } - this.sumDeaths++; - this.sumKills = this.character.killCount; - this.onCharacterDied(this.character); + this.deaths++; + this.lastCharacter = 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; } - // Override hooks (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( - getBoundingBoxOfCircle(new Circle(vec2.create(), settings.worldRadius * 2)), - ) - .filter((o): o is PlanetPhysical => o instanceof PlanetPhysical); + const planets = planetsIn( + this.objectContainer.findIntersecting( + BoundingBox.ofCircle(vec2.create(), settings.worldRadius * 2), + ), + ); const friendly = planets.filter((p) => p.team === this.team); const neutral = planets.filter((p) => p.team === CharacterTeam.neutral); @@ -109,42 +111,32 @@ export abstract class PlayerBase extends CommandReceiver { ); const safe = candidates.filter((p) => !isContested(p)); - // candidates is non-empty here, so choose() always returns a planet. return vec2.clone(Random.choose(safe.length ? safe : candidates)!.center); } - public restoreScore(kills: number, deaths: number) { - this.sumKills = kills; - this.sumDeaths = deaths; - } - - protected findEmptyPositionForPlayer(preferredCenter: vec2): vec2 { + private findEmptyPositionForPlayer(preferredCenter: vec2): vec2 { let rotation = 0; let radius = 0; - // preferredCenter is a planet centre, the single worst place to give up on: - // depenetrateCircle only runs four passes, and a body left that deep inside - // the rock registers a zero-distance hit every march and never moves again. - // So the roomiest point the spiral saw is kept as the fallback instead. let roomiestPosition = vec2.clone(preferredCenter); let roomiestClearance = -Infinity; for (let attempt = 0; attempt < 512; attempt++) { - const playerPosition = vec2.fromValues( - radius * Math.cos(rotation) + preferredCenter.x, - radius * Math.sin(rotation) + preferredCenter.y, + const position = vec2.fromValues( + radius * Math.cos(rotation) + preferredCenter[0], + radius * Math.sin(rotation) + preferredCenter[1], ); - const playerBoundingCircle = new Circle(playerPosition, boundRadius); - - const playerBoundingBox = getBoundingBoxOfCircle(playerBoundingCircle); - const possibleIntersectors = - this.objectContainer.findIntersecting(playerBoundingBox); - const clearance = evaluateSdf(playerBoundingCircle.center, possibleIntersectors); - if (clearance >= playerBoundingCircle.radius) { - return playerPosition; + const clearance = evaluateSdf( + position, + this.objectContainer.findIntersecting( + BoundingBox.ofCircle(position, boundRadius), + ), + ); + if (clearance >= boundRadius) { + return position; } if (clearance > roomiestClearance) { roomiestClearance = clearance; - roomiestPosition = playerPosition; + roomiestPosition = position; } rotation += Math.PI / 8; diff --git a/backend/src/players/player-container.ts b/backend/src/players/player-container.ts index a430220..8fc8b2f 100644 --- a/backend/src/players/player-container.ts +++ b/backend/src/players/player-container.ts @@ -1,22 +1,25 @@ -import { CharacterTeam, PlayerInformation, Random, settings, Command } from 'shared'; +import { + CharacterTeam, + PlayerInformation, + Random, + settings, + Command, + GameObject, + PropertyUpdatesForObject, +} from 'shared'; import { Socket } from 'socket.io'; -import { PhysicalContainer } from '../physics/containers/physical-container'; +import { PhysicalContainer } from '../physics/physical-container'; import { NPC } from './npc'; import { Player } from './player'; -import { PlayerBase } from './player-base'; -import { ServerFullError } from './server-full-error'; +import { PlayerBase, Score } from './player-base'; -// Team and score carried over from the connection a reconnecting client is -// replacing, so it resumes the match instead of starting from zero. -export interface CarriedScore { +export interface CarriedScore extends Score { team: CharacterTeam; - kills: number; - deaths: number; } export class PlayerContainer { private _players: Array = []; - private _npcs: Array = []; + private npcs: Array = []; constructor( private readonly objects: PhysicalContainer, @@ -26,14 +29,14 @@ export class PlayerContainer { this.createNPCs(); } - public createNPCs() { + private createNPCs() { const newNpcCount = Math.min( - this.playerMaxCount - this._players.length - this._npcs.length, - this.npcMaxCount - this._npcs.length, + this.playerMaxCount - this._players.length - this.npcs.length, + this.npcMaxCount - this.npcs.length, ); for (let i = 0; i < newNpcCount; i++) { const name = `🤖 ${Random.choose(settings.npcNames)}`; - this._npcs.push( + this.npcs.push( new NPC({ name }, this, this.objects, this.getTeamOfNextPlayer(true)), ); } @@ -44,31 +47,20 @@ export class PlayerContainer { socket: Socket, carried?: CarriedScore, ): Player { - if (this._players.length >= this.playerMaxCount) { - throw new ServerFullError(); - } - const team = carried ? carried.team : this.getTeamOfNextPlayer(); - // Retire the bot before the player spawns: its body would otherwise push - // the spawn search away from the position it picked. const npcToReplace = - this._npcs.find((n) => n.team === team) ?? this._npcs.find((n) => n.team !== team); + this.npcs.find((n) => n.team === team) ?? this.npcs.find((n) => n.team !== team); npcToReplace?.destroy(); - this._npcs = this._npcs.filter((n) => n !== npcToReplace); + this.npcs = this.npcs.filter((n) => n !== npcToReplace); - const player = new Player(playerInfo, this, this.objects, team, socket); + const player = new Player(playerInfo, this, this.objects, team, socket, carried); this._players.push(player); - - if (carried) { - player.restoreScore(carried.kills, carried.deaths); - } - return player; } public get players(): Array { - return [...this._players, ...this._npcs]; + return [...this._players, ...this.npcs]; } public get count(): number { @@ -79,20 +71,22 @@ export class PlayerContainer { return this._players.length >= this.playerMaxCount; } - // Real connected players only — NPCs have no socket and are excluded. public get connectedPlayerRttsMs(): Array { return this._players.map((p) => p.rttMs); } public step(deltaTimeInSeconds: number) { - // Iterate the backing arrays directly: this runs per 200 Hz substep and the - // `players` getter allocates a new array every call. this._players.forEach((p) => p.step(deltaTimeInSeconds)); - this._npcs.forEach((p) => p.step(deltaTimeInSeconds)); + this.npcs.forEach((p) => p.step(deltaTimeInSeconds)); } - public stepCommunication(deltaTimeInSeconds: number) { - this._players.forEach((p) => p.stepCommunications(deltaTimeInSeconds)); + public stepCommunication( + deltaTimeInSeconds: number, + propertyUpdatesOf: (object: GameObject) => PropertyUpdatesForObject | undefined, + ) { + this._players.forEach((p) => + p.stepCommunications(deltaTimeInSeconds, propertyUpdatesOf), + ); } public endGame(winner: CharacterTeam) { @@ -120,12 +114,8 @@ export class PlayerContainer { } public deletePlayer(player: Player) { - const had = this._players.includes(player); - this._players = this._players.filter((p) => p !== player); - // Only refill bots if this player was actually ours: a stale socket from a - // previous round reports its disconnect against the new container, and - // topping up on that would over-fill the roster. - if (had) { + if (this._players.includes(player)) { + this._players = this._players.filter((p) => p !== player); this.createNPCs(); } } diff --git a/backend/src/players/player.ts b/backend/src/players/player.ts index 8787332..c4678c7 100644 --- a/backend/src/players/player.ts +++ b/backend/src/players/player.ts @@ -31,44 +31,42 @@ import { isFiniteNumber, } from 'shared'; import { Socket } from 'socket.io'; -import { BoundingBox } from '../physics/bounding-boxes/bounding-box'; -import { PhysicalContainer } from '../physics/containers/physical-container'; +import { BoundingBox } from '../physics/bounding-box'; +import { PhysicalContainer } from '../physics/physical-container'; import { PlayerContainer } from './player-container'; -import { PlayerBase } from './player-base'; +import { PlayerBase, Score } from './player-base'; import { CharacterPhysical } from '../objects/character-physical'; const pingIntervalSeconds = 1; - const minimumAspectRatio = 0.2; const maximumAspectRatio = 8; -const maximumBufferedBytes = settings.maxBufferedBytesPerClient; +const sheddableWhenBackedUp: ReadonlyArray = [ + PropertyUpdatesForObjects.type, + UpdateMinimap.type, + InputAcknowledgement.type, +]; export class Player extends PlayerBase { - private aspectRatio: number = 16 / 9; + public reconnectToken = ''; + public rttMs = 0; + + private aspectRatio = 16 / 9; private timeSinceLastMessage = 0; - // A Set: the per-snapshot diff is membership-heavy, and insertion-order - // iteration keeps the streamed object order unchanged. private objectsInViewArea = new Set(); + private commandsToBeSent: Array = []; + private winnerTeam?: CharacterTeam; + private lastInputClientTimeMs = 0; private lastInputReceiptMs = 0; private lastLeapClientTimeMs = 0; - public reconnectToken = ''; - - // Server-side latency stat, reported in GameServer's periodic log. - public rttMs = 0; private timeSinceLastPing = 0; private lastPingSentMs = 0; - // Nonce of the ping awaiting a reply: prevents a client forging Pongs or - // answering a stale ping to corrupt the measured RTT. private pendingPingNonce = 0; private nextPingNonce = 1; - // Input below is untrusted: every command arrives from `deserialize` - // (a bare JSON.parse, which accepts Infinity as `1e999`). A single - // non-finite value reaching the simulation is unrecoverable — it spreads - // through the shared SDF and leaves a character that never dies or respawns. + // Input is untrusted: one non-finite value reaching the simulation leaves a character that never dies. protected commandExecutors: CommandExecutors = { [SetAspectRatioActionCommand.type]: (v: SetAspectRatioActionCommand) => { this.aspectRatio = finiteInRange( @@ -84,7 +82,7 @@ export class Player extends PlayerBase { return; } this.observeClientTime(c.clientTimeMs); - this.character?.handleMovementAction(new MoveActionCommand(direction, 0)); + this.character?.setMoveDirection(direction); }, [PrimaryActionCommand.type]: (c: PrimaryActionCommand) => { const position = finiteVec2(c.position, settings.maxClientPositionMagnitude); @@ -98,48 +96,34 @@ export class Player extends PlayerBase { if (!isFiniteNumber(c.clientTimeMs)) { return; } - // Record receipt whether or not leap() accepts it: the predictor must - // stop replaying this leap either way. this.lastLeapClientTimeMs = c.clientTimeMs; this.observeClientTime(c.clientTimeMs); this.character?.leap(); }, - // Advances the acknowledgement at the client's send rate regardless of - // whether input changed — otherwise a held key freezes the predictor's - // replay window. [ClientHeartbeatCommand.type]: (c: ClientHeartbeatCommand) => this.observeClientTime(c.clientTimeMs), }; - // Tracks both the newest applied client-clock instant and the server-clock - // instant it arrived. The predictor needs both: which inputs are already in - // the snapshot, and how stale that answer was (see InputAcknowledgement). - private observeClientTime(clientTimeMs: number) { - if (isFiniteNumber(clientTimeMs) && clientTimeMs > this.lastInputClientTimeMs) { - this.lastInputClientTimeMs = clientTimeMs; - this.lastInputReceiptMs = performance.now(); - } - } - constructor( playerInfo: PlayerInformation, playerContainer: PlayerContainer, objectContainer: PhysicalContainer, team: CharacterTeam, private readonly socket: Socket, + score?: Score, ) { - super(playerInfo, playerContainer, objectContainer, team); + super(playerInfo, playerContainer, objectContainer, team, score); this.createCharacter(); - this.step(0); - - // Only one ping is ever in flight, so RTT is simply now − send-time. this.socket.on(TransportEvents.Pong, this.onPong); } - // Only the outstanding ping's nonce counts, once: a client cannot forge a - // Pong or answer a stale ping to skew the stat. Sitting on the live ping to - // inflate the measurement is bounded by the clamp, and the next ping retires - // the nonce, so any stall costs at most one ping interval. + private observeClientTime(clientTimeMs: number) { + if (isFiniteNumber(clientTimeMs) && clientTimeMs > this.lastInputClientTimeMs) { + this.lastInputClientTimeMs = clientTimeMs; + this.lastInputReceiptMs = performance.now(); + } + } + private readonly onPong = (nonce: unknown) => { if (this.pendingPingNonce === 0 || nonce !== this.pendingPingNonce) { return; @@ -151,86 +135,93 @@ export class Player extends PlayerBase { ); }; - // The Pong listener outlives the round: the socket survives a restart, so a - // retired Player would stay reachable through it unless removed explicitly. public detachFromSocket() { this.socket.off(TransportEvents.Pong, this.onPong); } - protected createCharacter() { - super.createCharacter(); - - this.objectsInViewArea.add(this.character!); - this.queueCommandSend(new CreatePlayerCommand(this.character!)); + protected createCharacter(): CharacterPhysical { + const character = super.createCharacter(); + this.objectsInViewArea.add(character); + this.queueCommandSend(new CreatePlayerCommand(character)); + return character; } - private winnerTeam?: CharacterTeam; public onGameEnded(winnerTeam: CharacterTeam) { this.winnerTeam = winnerTeam; } - // Retained so an in-flight shot can still credit a kill to this corpse: - // killCount is re-read at respawn, not snapshotted when the body died. - private dyingCharacter?: CharacterPhysical | null; - public step(deltaTimeInSeconds: number) { this.stepLifecycle(deltaTimeInSeconds); } - protected onCharacterDied(character: CharacterPhysical) { - this.dyingCharacter = character; + public queueCommandSend(command: Command) { + this.commandsToBeSent.push(command); } - protected onBeforeRespawn() { - if (this.dyingCharacter) { - this.sumKills = Math.max(this.sumKills, this.dyingCharacter.killCount); - this.dyingCharacter = null; + public stepCommunications( + deltaTime: number, + propertyUpdatesOf: (object: GameObject) => PropertyUpdatesForObject | undefined, + ) { + const remoteCalls: Array = []; + 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) { + this.queueCommandSend(new RemoteCallsForObjects(remoteCalls)); + } + + if ((this.timeSinceLastPing += deltaTime) > pingIntervalSeconds) { + this.timeSinceLastPing = 0; + this.lastPingSentMs = performance.now(); + this.pendingPingNonce = this.nextPingNonce++; + this.socket.emit(TransportEvents.Ping, this.pendingPingNonce); + } + + if ((this.timeSinceLastMessage += deltaTime) > settings.updateMessageInterval) { + this.timeSinceLastMessage = 0; + this.queueAnnouncement(); + this.queueSnapshot(propertyUpdatesOf); + this.sendQueuedCommandsToClient(); } } - private handleViewAreaUpdate() { - const viewArea = calculateViewArea(this.center, this.aspectRatio, 1.2); - const bb = new BoundingBox(); - bb.topLeft = viewArea.topLeft; - bb.size = viewArea.size; - - const inViewArea = new Set( - this.objectContainer.findIntersecting(bb).map((o) => o.gameObject), + private queueSnapshot( + propertyUpdatesOf: (object: GameObject) => PropertyUpdatesForObject | undefined, + ) { + const { topLeft, size } = calculateViewArea(this.center, this.aspectRatio, 1.2); + const viewBox = new BoundingBox( + topLeft[0], + topLeft[0] + size[0], + topLeft[1] - size[1], + topLeft[1], ); - // The owning character must always be in its own snapshot so the client - // predictor never loses its authoritative anchor. + const inViewArea = new Set( + this.objectContainer.findIntersecting(viewBox).map((o) => o.gameObject), + ); if (this.character) { inViewArea.add(this.character); } - // Set membership rather than Array.includes: this diff runs per player per - // snapshot over everything on screen and was quadratic in that. - const newlyIntersecting = [...inViewArea].filter( - (o) => !this.objectsInViewArea.has(o), - ); - - const noLongerIntersecting = [...this.objectsInViewArea].filter( - (o) => !inViewArea.has(o), - ); - + const created = [...inViewArea].filter((o) => !this.objectsInViewArea.has(o)); + const deleted = [...this.objectsInViewArea].filter((o) => !inViewArea.has(o)); this.objectsInViewArea = inViewArea; - if (noLongerIntersecting.length > 0) { - this.queueCommandSend( - new DeleteObjectsCommand(noLongerIntersecting.map((g) => g.id)), - ); + if (deleted.length > 0) { + this.queueCommandSend(new DeleteObjectsCommand(deleted.map((g) => g.id))); } - - if (newlyIntersecting.length > 0) { - this.queueCommandSend(new CreateObjectsCommand(newlyIntersecting)); + if (created.length > 0) { + this.queueCommandSend(new CreateObjectsCommand(created)); } this.queueCommandSend(new UpdateMinimap(this.getMinimapPlayers())); const propertyUpdates: Array = []; for (const object of this.objectsInViewArea) { - const update = object.getPropertyUpdatesForFrame(); + const update = propertyUpdatesOf(object); if (update) { propertyUpdates.push(update); } @@ -239,8 +230,6 @@ export class Player extends PlayerBase { new PropertyUpdatesForObjects(propertyUpdates, performance.now() / 1000), ); - // Tells the client how much of its own input is reflected in the snapshot - // so its predictor can replay the rest. Only while alive. if (this.character) { this.queueCommandSend( new InputAcknowledgement( @@ -255,8 +244,6 @@ export class Player extends PlayerBase { } } - // Every living player except this one, by absolute world position for the - // minimap. private getMinimapPlayers(): Array { return this.playerContainer.players .filter((p) => p !== this && p.character?.isAlive) @@ -266,42 +253,6 @@ export class Player extends PlayerBase { ); } - private commandsToBeSent: Array = []; - public queueCommandSend(command: Command) { - this.commandsToBeSent.push(command); - } - - public stepCommunications(deltaTime: number) { - // 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) { - this.queueCommandSend(new RemoteCallsForObjects(remoteCalls)); - } - - if ((this.timeSinceLastPing += deltaTime) > pingIntervalSeconds) { - this.timeSinceLastPing = 0; - this.lastPingSentMs = performance.now(); - this.pendingPingNonce = this.nextPingNonce++; - this.socket.emit(TransportEvents.Ping, this.pendingPingNonce); - } - - if ((this.timeSinceLastMessage += deltaTime) > settings.updateMessageInterval) { - this.handleAnnouncements(); - this.handleViewAreaUpdate(); - this.sendQueuedCommandsToClient(); - this.timeSinceLastMessage = 0; - } - } - - // engine.io's Socket exposes no bufferedAmount — it lives on the underlying - // ws socket, and only the websocket transport has one. private get bufferedBytes(): number { const conn = this.socket.conn as | { transport?: { socket?: { bufferedAmount?: unknown } } } @@ -311,53 +262,31 @@ export class Player extends PlayerBase { } public sendQueuedCommandsToClient() { + // Only state the next snapshot regenerates may be dropped; create/delete are one-shot. + if (this.bufferedBytes > settings.maxBufferedBytesPerClient) { + this.commandsToBeSent = this.commandsToBeSent.filter( + (c) => !sheddableWhenBackedUp.includes(c.type), + ); + } if (this.commandsToBeSent.length === 0) { return; } - // Only state next tick regenerates in full may be dropped. Create/delete - // are one-shot: handleViewAreaUpdate has ALREADY advanced - // objectsPreviouslyInViewArea, so dropping them leaves this client - // permanently missing objects and holding ghosts. They go out even backed up. - if (this.bufferedBytes > maximumBufferedBytes) { - // Matched by command type, not `instanceof`: a class identity from a - // bundled shared module is not something to depend on. - const sheddable: ReadonlyArray = [ - PropertyUpdatesForObjects.type, - UpdateMinimap.type, - // Regenerated in full next tick — shed with the pose, never advance the - // replay anchor past a snapshot the client never received. - InputAcknowledgement.type, - ]; - this.commandsToBeSent = this.commandsToBeSent.filter( - (c) => !sheddable.includes(c.type), - ); - if (this.commandsToBeSent.length === 0) { - return; - } - } - this.socket.emit(TransportEvents.ServerToPlayer, serialize(this.commandsToBeSent)); this.commandsToBeSent = []; } - public get scoreSnapshot(): { kills: number; deaths: number } { - return { - kills: Math.max(this.sumKills, this.character?.killCount ?? 0), - deaths: this.sumDeaths, - }; - } - - private handleAnnouncements() { - let announcement = ''; + private queueAnnouncement() { if (this.winnerTeam) { - announcement = `Team ${this.winnerTeam} won 🎉`; + this.queueCommandSend( + new ServerAnnouncement( + `Team ${this.winnerTeam} won 🎉`, + ), + ); } else if (!this.character) { - announcement = `Reviving in ${Math.round(this.timeUntilRespawn)}…`; - } - - if (announcement) { - this.queueCommandSend(new ServerAnnouncement(announcement)); + this.queueCommandSend( + new ServerAnnouncement(`Reviving in ${Math.round(this.timeUntilRespawn)}…`), + ); } } } diff --git a/backend/webpack.config.js b/backend/webpack.config.js index 87a462a..0d1a0e3 100644 --- a/backend/webpack.config.js +++ b/backend/webpack.config.js @@ -33,8 +33,7 @@ module.exports = (env, argv) => ({ minimizer: [ new TerserJSPlugin({ exclude: /node_modules/, - // The custom serialization protocol keys on class names, so they must - // survive minification (see shared/src/serialization). + // The serialization protocol keys on class names. terserOptions: { keep_classnames: true, }, diff --git a/eslint.config.js b/eslint.config.js index 84a6892..20da74d 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,4 +1,3 @@ -// Flat config (ESLint 9+/10). Ported from the former .eslintrc.json. const tseslint = require('typescript-eslint'); const prettierRecommended = require('eslint-plugin-prettier/recommended'); const unusedImports = require('eslint-plugin-unused-imports'); @@ -25,7 +24,6 @@ module.exports = tseslint.config( '@typescript-eslint/explicit-module-boundary-types': 'off', '@typescript-eslint/no-non-null-assertion': 'off', '@typescript-eslint/no-empty-function': 'off', - // Renamed from the former no-var-requires; keep require() allowed. '@typescript-eslint/no-require-imports': 'off', }, }, diff --git a/frontend/src/index.ts b/frontend/src/index.ts index 99d95f2..ea8ff1d 100644 --- a/frontend/src/index.ts +++ b/frontend/src/index.ts @@ -1,6 +1,5 @@ import { glMatrix } from 'gl-matrix'; import { - applyArrayPlugins, LampBase, overrideDeserialization, PlanetBase, @@ -14,20 +13,12 @@ import '../static/favicons/apple-touch-icon.png'; import '../static/favicons/favicon-16x16.png'; import '../static/favicons/favicon-32x32.png'; import '../static/favicons/favicon.ico'; +import ResizeObserver from 'resize-observer-polyfill'; import { LandingPageBackground } from './scripts/landing-page-background'; import { JoinFormHandler } from './scripts/join-form-handler'; import { handleFullScreen } from './scripts/helper/handle-full-screen'; import { Game } from './scripts/game'; -import ResizeObserver from 'resize-observer-polyfill'; -import { OptionsHandler } from './scripts/options-handler'; -const hide = (e: HTMLElement, d?: boolean) => { - if (d) e.style.display = 'none'; - else e.style.visibility = 'hidden'; -}; -const show = (e: HTMLElement, d?: boolean, v?: string) => { - if (d) e.style.display = v!; - else e.style.visibility = 'inherit'; -}; +import { initializeOptions } from './scripts/options-handler'; import { SoundHandler, Sounds } from './scripts/sound-handler'; import { VibrationHandler } from './scripts/vibration-handler'; import { CharacterView } from './scripts/objects/types/character-view'; @@ -36,36 +27,43 @@ import { PlanetView } from './scripts/objects/types/planet-view'; import { ProjectileView } from './scripts/objects/types/projectile-view'; glMatrix.setMatrixArrayType(Array); -applyArrayPlugins(); overrideDeserialization(CharacterBase, CharacterView); overrideDeserialization(PlanetBase, PlanetView); overrideDeserialization(LampBase, LampView); overrideDeserialization(ProjectileBase, ProjectileView); -const landingUI = document.querySelector('#landing-ui') as HTMLElement; -const nameInput = document.querySelector('#name') as HTMLInputElement; -const joinGameForm = document.querySelector('#join-game-form') as HTMLFormElement; -const serverContainer = document.querySelector('#server-container') as HTMLElement; -const canvas = document.querySelector('canvas') as HTMLCanvasElement; -const overlay = document.querySelector('#overlay') as HTMLElement; -const settings = document.querySelector('#settings') as HTMLElement; -const toggleSettingsButton = document.querySelector( - '#toggle-settings-container', -) as HTMLElement; -const minimize = document.querySelector('#minimize') as HTMLElement; -const maximize = document.querySelector('#maximize') as HTMLElement; -const logoutButton = document.querySelector('#logout') as HTMLElement; -const enableSounds = document.querySelector('#enable-sounds') as HTMLInputElement; -const enableMusic = document.querySelector('#enable-music') as HTMLInputElement; -const enableVibration = document.querySelector('#enable-vibration') as HTMLInputElement; -const spinner = document.querySelector('#spinner-container') as HTMLElement; +const query = (selector: string) => + document.querySelector(selector) as T; + +const landingUI = query('#landing-ui'); +const nameInput = query('#name'); +const joinGameForm = query('#join-game-form'); +const serverContainer = query('#server-container'); +const canvas = query('canvas'); +const overlay = query('#overlay'); +const settings = query('#settings'); +const toggleSettingsButton = query('#toggle-settings-container'); +const minimize = query('#minimize'); +const maximize = query('#maximize'); +const logoutButton = query('#logout'); +const enableSounds = query('#enable-sounds'); +const enableMusic = query('#enable-music'); +const enableVibration = query('#enable-vibration'); +const spinner = query('#spinner-container'); const joinNotice = document.createElement('p'); joinNotice.className = 'join-notice'; joinNotice.style.display = 'none'; joinGameForm.prepend(joinNotice); +const setVisible = (e: HTMLElement, visible: boolean) => { + e.style.visibility = visible ? 'inherit' : 'hidden'; +}; +const setDisplayed = (e: HTMLElement, display: string | null) => { + e.style.display = display ?? 'none'; +}; + const toggleSettings = () => { settings.className = settings.className === 'open' ? '' : 'open'; SoundHandler.play(Sounds.click); @@ -76,21 +74,19 @@ const applyServerContainerShadows = () => { const bottomShadow = 'inset 0 8px 8px -8px rgba(0, 0, 0, 0.4)'; const { scrollHeight, clientHeight, scrollTop } = serverContainer; - if (scrollHeight > clientHeight) { - if (scrollTop <= 0) { - serverContainer.style.boxShadow = topShadow; - } else if (scrollTop + clientHeight >= scrollHeight) { - serverContainer.style.boxShadow = bottomShadow; - } else { - serverContainer.style.boxShadow = topShadow + ',' + bottomShadow; - } - } else { + if (scrollHeight <= clientHeight) { serverContainer.style.boxShadow = ''; + } else if (scrollTop <= 0) { + serverContainer.style.boxShadow = topShadow; + } else if (scrollTop + clientHeight >= scrollHeight) { + serverContainer.style.boxShadow = bottomShadow; + } else { + serverContainer.style.boxShadow = topShadow + ',' + bottomShadow; } }; const main = async () => { - let game: Game; + let game: Game | undefined; const storedUserName = localStorage.getItem('userName'); if (storedUserName) { @@ -113,7 +109,7 @@ const main = async () => { document.addEventListener('click', firstClickListener); if (!VibrationHandler.isVibrationEnabledHeuristics) { - hide(document.querySelector("label[for='enable-vibration']") as HTMLElement, true); + setDisplayed(query("label[for='enable-vibration']"), null); } handleFullScreen(minimize, maximize); @@ -122,28 +118,28 @@ const main = async () => { new ResizeObserver(applyServerContainerShadows).observe(serverContainer); serverContainer.addEventListener('scroll', applyServerContainerShadows); - OptionsHandler.initialize({ + initializeOptions({ soundsEnabled: enableSounds, vibrationEnabled: enableVibration, musicEnabled: enableMusic, }); logoutButton.addEventListener('click', () => { - game.destroy(); + game?.destroy(); toggleSettings(); }); - window.onpopstate = () => game.destroy(); + window.onpopstate = () => game?.destroy(); for (;;) { - show(spinner); - hide(logoutButton, true); - show(landingUI, true, 'flex'); + setVisible(spinner, true); + setDisplayed(logoutButton, null); + setDisplayed(landingUI, 'flex'); const background = new LandingPageBackground(canvas); const joinHandler = new JoinFormHandler(joinGameForm, serverContainer); await background.renderer; - hide(spinner); + setVisible(spinner, false); const playerDecision = await joinHandler.getPlayerDecision(); @@ -153,29 +149,25 @@ const main = async () => { history.pushState(true, ''); } - hide(landingUI, true); - show(spinner); + setDisplayed(landingUI, null); + setVisible(spinner, true); background.destroy(); game = new Game(playerDecision, canvas, overlay); const gameOver = game.start(); await game.started; - hide(spinner); - show(logoutButton, true, 'block'); + setVisible(spinner, false); + setDisplayed(logoutButton, 'block'); await gameOver; - const reason = game.lastRejectionReason; - if (reason) { - joinNotice.innerText = Game.rejectionText(reason); - joinNotice.style.display = 'block'; - } else { - joinNotice.style.display = 'none'; - } + const reason = game.rejectionReason; + joinNotice.innerText = reason ? Game.rejectionText(reason) : ''; + setDisplayed(joinNotice, reason ? 'block' : null); } }; main().catch((error) => { console.error(error); - hide(spinner); + setVisible(spinner, false); joinNotice.innerText = 'Something went wrong. Please reload the page.'; - joinNotice.style.display = 'block'; + setDisplayed(joinNotice, 'block'); }); diff --git a/frontend/src/scripts/charge-indicator.ts b/frontend/src/scripts/charge-indicator.ts index 1a3d01d..1bad137 100644 --- a/frontend/src/scripts/charge-indicator.ts +++ b/frontend/src/scripts/charge-indicator.ts @@ -1,4 +1,4 @@ -import { holdDurationToCharge } from 'shared'; +import { chargeHeldSince } from 'shared'; import { pointer } from './helper/pointer'; let element: HTMLElement | undefined; @@ -24,7 +24,7 @@ function beginChargeIndicator(x: number, y: number, followPointer = false) { function endChargeIndicator() { if (element) { cancelAnimationFrame(raf); - element.parentElement?.removeChild(element); + element.remove(); element = undefined; } } @@ -34,7 +34,7 @@ function updateChargeIndicator() { return; } - const charge = holdDurationToCharge((performance.now() - heldSince) / 1000); + const charge = chargeHeldSince(heldSince); element.style.opacity = charge < 0.12 ? '0' : '1'; element.style.background = `conic-gradient(rgba(255, 255, 255, 0.85) ${ charge * 360 @@ -44,15 +44,14 @@ function updateChargeIndicator() { if (isFollowingPointer) { const cursor = pointer.displayPosition; if (cursor) { - element.style.left = `${cursor.x}px`; - element.style.top = `${cursor.y}px`; + element.style.left = `${cursor[0]}px`; + element.style.top = `${cursor[1]}px`; } } raf = requestAnimationFrame(updateChargeIndicator); } -// The one public surface; the functions above stay module-private. export const ChargeIndicator = { begin: beginChargeIndicator, end: endChargeIndicator, diff --git a/frontend/src/scripts/commands/command-socket.ts b/frontend/src/scripts/commands/command-socket.ts index da06121..b68484d 100644 --- a/frontend/src/scripts/commands/command-socket.ts +++ b/frontend/src/scripts/commands/command-socket.ts @@ -1,49 +1,35 @@ import { ClientHeartbeatCommand, Command, - CommandReceiver, serialize, settings, TransportEvents, } from 'shared'; import { Socket } from 'socket.io-client'; -import { predictorNowMs } from '../helper/prediction/local-character-predictor'; +import { clientTimeMs } from '../helper/prediction/local-character-predictor'; const heartbeatIntervalMs = settings.clientSendInterval * 1000; - -// A frame that lands a hair short of the deadline still counts as due. At an -// exact ratio of frame rate to interval — 60 Hz against 30 Hz — whether the gap -// compares as reached is decided by floating-point noise, and the heartbeat -// slips a whole frame every third beat, drifting to ~21 Hz instead of 30. +// At an exact frame-rate/interval ratio (60 Hz vs 30 Hz) float noise would otherwise drop every third beat. const dueToleranceMs = 1; -export class CommandSocket extends CommandReceiver { - constructor(private readonly socket: Socket) { - super(); - } - +export class CommandSocket { private commandQueue: Array = []; private lastSendMs = -Infinity; - protected defaultCommandExecutor(command: Command) { + constructor(private readonly socket: Socket) {} + + public queue(command: Command) { this.commandQueue.push(command); } public sendQueuedCommands() { - // Nothing goes out while the transport is down. socket.io-client would - // otherwise buffer every emit in an uncapped sendBuffer and flush it on - // reconnect BEFORE the `connect` handler re-sends PlayerJoining — the - // server has no PlayerToServer listener attached yet and drops the lot. - // The local queue is dropped with it: these are timestamped inputs, and - // replaying a minute-old shot after the gap would be worse than losing it. + // socket.io-client would buffer emits made while disconnected and flush them before the re-join. if (!this.socket.connected) { this.commandQueue = []; return; } - const nowMs = predictorNowMs(); - - // Real input goes out on the frame it happens — that is the latency budget the player feels. An idle client still keeps the server's input acknowledgement moving (a held key generates no fresh command), paced by the clock not the frame rate; sending every rendered frame would exceed settings.maxInboundMessagesPerSecond, and since movement commands are edge-triggered and never re-sent, a discarded batch would drop the direction change for good. + const nowMs = clientTimeMs(); if ( this.commandQueue.length === 0 && nowMs - this.lastSendMs < heartbeatIntervalMs - dueToleranceMs @@ -52,7 +38,7 @@ export class CommandSocket extends CommandReceiver { } this.lastSendMs = nowMs; - this.commandQueue.push(new ClientHeartbeatCommand(Math.round(nowMs))); + this.commandQueue.push(new ClientHeartbeatCommand(nowMs)); this.socket.emit(TransportEvents.PlayerToServer, serialize(this.commandQueue)); this.commandQueue = []; } diff --git a/frontend/src/scripts/commands/input-generator.ts b/frontend/src/scripts/commands/input-generator.ts new file mode 100644 index 0000000..6c8ef50 --- /dev/null +++ b/frontend/src/scripts/commands/input-generator.ts @@ -0,0 +1,29 @@ +import { vec2 } from 'gl-matrix'; +import { + Command, + LeapActionCommand, + MoveActionCommand, + PrimaryActionCommand, +} from 'shared'; +import { + clientTimeMs, + localCharacterPredictor, +} from '../helper/prediction/local-character-predictor'; + +export abstract class InputGenerator { + constructor(protected readonly onCommand: (command: Command) => void) {} + + protected sendMove(direction: vec2) { + this.onCommand( + new MoveActionCommand(direction, localCharacterPredictor.recordInput(direction)), + ); + } + + protected sendLeap() { + this.onCommand(new LeapActionCommand(localCharacterPredictor.recordLeap())); + } + + protected sendPrimary(position: vec2, charge: number) { + this.onCommand(new PrimaryActionCommand(position, charge, clientTimeMs())); + } +} diff --git a/frontend/src/scripts/commands/keyboard-listener.ts b/frontend/src/scripts/commands/keyboard-listener.ts index 971a0c9..476396e 100644 --- a/frontend/src/scripts/commands/keyboard-listener.ts +++ b/frontend/src/scripts/commands/keyboard-listener.ts @@ -1,13 +1,11 @@ import { vec2 } from 'gl-matrix'; -import { CommandGenerator, LeapActionCommand, MoveActionCommand } from 'shared'; -import { localCharacterPredictor } from '../helper/prediction/local-character-predictor'; +import { InputGenerator } from './input-generator'; -export class KeyboardListener extends CommandGenerator { +export class KeyboardListener extends InputGenerator { private keysDown: Set = new Set(); - constructor() { - super(); - + constructor(onCommand: ConstructorParameters[0]) { + super(onCommand); addEventListener('keydown', this.keyDownListener); addEventListener('keyup', this.keyUpListener); addEventListener('blur', this.blurListener); @@ -15,45 +13,35 @@ export class KeyboardListener extends CommandGenerator { private keyDownListener = (event: KeyboardEvent) => { const key = event.code; - if ( - (key === 'Space' || key === 'ShiftLeft' || key === 'ShiftRight') && - !this.keysDown.has(key) - ) { - const clientTimeMs = localCharacterPredictor.recordLeap(); - this.sendCommandToSubscribers(new LeapActionCommand(clientTimeMs)); - } - if (this.keysDown.has(key)) { return; } this.keysDown.add(key); - this.generateCommands(); + if (key === 'Space' || key === 'ShiftLeft' || key === 'ShiftRight') { + this.sendLeap(); + } + this.sendMovement(); }; private keyUpListener = (event: KeyboardEvent) => { if (this.keysDown.delete(event.code)) { - this.generateCommands(); + this.sendMovement(); } }; private blurListener = () => { this.keysDown.clear(); - this.generateCommands(); + this.sendMovement(); }; - private generateCommands() { - const up = ~~(this.keysDown.has('KeyW') || this.keysDown.has('ArrowUp')); - const down = ~~(this.keysDown.has('KeyS') || this.keysDown.has('ArrowDown')); - const left = ~~(this.keysDown.has('KeyA') || this.keysDown.has('ArrowLeft')); - const right = ~~(this.keysDown.has('KeyD') || this.keysDown.has('ArrowRight')); - - const movement = vec2.fromValues(right - left, up - down); - if (vec2.squaredLength(movement) > 0) { - vec2.normalize(movement, movement); - } - - const clientTimeMs = localCharacterPredictor.recordInput(movement); - this.sendCommandToSubscribers(new MoveActionCommand(movement, clientTimeMs)); + private sendMovement() { + const down = (...codes: Array) => + codes.some((c) => this.keysDown.has(c)) ? 1 : 0; + const movement = vec2.fromValues( + down('KeyD', 'ArrowRight') - down('KeyA', 'ArrowLeft'), + down('KeyW', 'ArrowUp') - down('KeyS', 'ArrowDown'), + ); + this.sendMove(vec2.normalize(movement, movement)); } public destroy() { diff --git a/frontend/src/scripts/commands/mouse-listener.ts b/frontend/src/scripts/commands/mouse-listener.ts index fdd06e7..5747a14 100644 --- a/frontend/src/scripts/commands/mouse-listener.ts +++ b/frontend/src/scripts/commands/mouse-listener.ts @@ -1,28 +1,26 @@ import { vec2 } from 'gl-matrix'; -import { CommandGenerator, PrimaryActionCommand, holdDurationToCharge } from 'shared'; +import { chargeHeldSince, Command } from 'shared'; import { Game } from '../game'; import { ChargeIndicator } from '../charge-indicator'; -import { predictorNowMs } from '../helper/prediction/local-character-predictor'; import { pointer } from '../helper/pointer'; +import { InputGenerator } from './input-generator'; -export class MouseListener extends CommandGenerator { +export class MouseListener extends InputGenerator { private primaryDownAt: number | null = null; constructor( - private target: HTMLElement, + private readonly target: HTMLElement, private readonly game: Game, + onCommand: (command: Command) => void, ) { - super(); - + super(onCommand); target.addEventListener('mousedown', this.mouseDownListener); target.addEventListener('mousemove', this.mouseMoveListener); target.addEventListener('contextmenu', this.contextMenuListener); - // Release watched on the window, not the canvas — a release over UI or outside the window must still fire. window.addEventListener('mouseup', this.mouseUpListener); window.addEventListener('blur', this.cancelPrimary); } - // Store screen position; reproject to world each frame so gaze stays correct while the camera pans. private mouseMoveListener = (event: MouseEvent) => { pointer.displayPosition = vec2.fromValues(event.clientX, event.clientY); }; @@ -38,36 +36,24 @@ export class MouseListener extends CommandGenerator { if (event.button !== 0 || this.primaryDownAt === null) { return; } - ChargeIndicator.end(); - const charge = holdDurationToCharge((performance.now() - this.primaryDownAt) / 1000); + const charge = chargeHeldSince(this.primaryDownAt); this.primaryDownAt = null; - this.sendCommandToSubscribers( - new PrimaryActionCommand( - this.positionFromEvent(event), - charge, - Math.round(predictorNowMs()), - ), + this.sendPrimary( + this.game.displayToWorldCoordinates(vec2.fromValues(event.clientX, event.clientY)), + charge, ); }; private cancelPrimary = () => { - if (this.primaryDownAt !== null) { - this.primaryDownAt = null; - ChargeIndicator.end(); - } + this.primaryDownAt = null; + ChargeIndicator.end(); }; private contextMenuListener = (event: MouseEvent) => { event.preventDefault(); }; - private positionFromEvent(event: MouseEvent): vec2 { - return this.game.displayToWorldCoordinates( - vec2.fromValues(event.clientX, event.clientY), - ); - } - public destroy() { ChargeIndicator.end(); this.target.removeEventListener('mousedown', this.mouseDownListener); diff --git a/frontend/src/scripts/commands/touch-listener.ts b/frontend/src/scripts/commands/touch-listener.ts index a73bf5d..5b933ab 100644 --- a/frontend/src/scripts/commands/touch-listener.ts +++ b/frontend/src/scripts/commands/touch-listener.ts @@ -1,72 +1,53 @@ import { vec2 } from 'gl-matrix'; -import { - CommandGenerator, - MoveActionCommand, - PrimaryActionCommand, - LeapActionCommand, - holdDurationToCharge, - settings, -} from 'shared'; +import { chargeHeldSince, Command, settings } from 'shared'; import { Game } from '../game'; import { ChargeIndicator } from '../charge-indicator'; -import { - localCharacterPredictor, - predictorNowMs, -} from '../helper/prediction/local-character-predictor'; +import { InputGenerator } from './input-generator'; -export class TouchListener extends CommandGenerator { - private static readonly deadZone = 8; - private static readonly deltaScaling = 0.4; - private static readonly aimDeadZone = 18; +const deadZone = 8; +const deltaScaling = 0.4; +const aimDeadZone = 18; +const joystickMaxLength = 20; + +export class TouchListener extends InputGenerator { + private readonly joystick = document.createElement('div'); + private readonly joystickButton = document.createElement('div'); + private readonly fireButton = document.createElement('div'); + private readonly fireStrengthRing = document.createElement('div'); + private readonly fireAimLine = document.createElement('div'); + private readonly leapButton = document.createElement('div'); - private joystick: HTMLElement; - private joystickButton: HTMLElement; private isJoystickActive = false; - private touchStartPosition!: vec2; + private touchStartPosition = vec2.create(); private primaryDownAt: number | null = null; private gestureTouchId: number | null = null; - private fireButton: HTMLElement; - private fireStrengthRing: HTMLElement; - private fireAimLine!: HTMLElement; - private leapButton: HTMLElement; - private fireDownAt: number | null = null; - private fireButtonCenter: vec2 | null = null; - private fireAimScreen: vec2 | null = null; + private fireButtonCenter = vec2.create(); + private fireAim: vec2 | null = null; constructor( - private target: HTMLElement, - private overlay: HTMLElement, + private readonly target: HTMLElement, + private readonly overlay: HTMLElement, private readonly game: Game, + onCommand: (command: Command) => void, ) { - super(); + super(onCommand); - this.joystick = document.createElement('div'); this.joystick.className = 'joystick'; - this.joystickButton = document.createElement('div'); this.joystick.appendChild(this.joystickButton); - this.fireButton = document.createElement('div'); this.fireButton.className = 'touch-button fire'; - this.fireStrengthRing = document.createElement('div'); this.fireStrengthRing.className = 'strength-ring'; - this.fireButton.appendChild(this.fireStrengthRing); - this.fireAimLine = document.createElement('div'); this.fireAimLine.className = 'aim-line'; - this.fireButton.appendChild(this.fireAimLine); - + this.fireButton.append(this.fireStrengthRing, this.fireAimLine); this.fireButton.addEventListener('touchstart', this.fireButtonDownListener); this.fireButton.addEventListener('touchmove', this.fireButtonMoveListener); 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); target.addEventListener('touchend', this.touchEndListener); @@ -74,9 +55,6 @@ export class TouchListener extends CommandGenerator { } private findGestureTouch(list: TouchList): Touch | undefined { - if (this.gestureTouchId === null) { - return undefined; - } for (let i = 0; i < list.length; i++) { if (list[i].identifier === this.gestureTouchId) { return list[i]; @@ -87,27 +65,22 @@ export class TouchListener extends CommandGenerator { private touchStartListener = (event: TouchEvent) => { event.preventDefault(); + const touch = event.changedTouches[event.changedTouches.length - 1]; + if (!touch) { + return; + } if (this.isJoystickActive) { - const center = vec2.fromValues( - event.changedTouches[event.changedTouches.length - 1].clientX, - event.changedTouches[event.changedTouches.length - 1].clientY, - ); - this.sendCommandToSubscribers( - new PrimaryActionCommand( - this.game.displayToWorldCoordinates(center), - 0, - Math.round(predictorNowMs()), + this.sendPrimary( + this.game.displayToWorldCoordinates( + vec2.fromValues(touch.clientX, touch.clientY), ), + 0, ); } else { - const touch = event.changedTouches[0]; - if (!touch) { - return; - } this.gestureTouchId = touch.identifier; - this.touchStartPosition = vec2.fromValues(touch.clientX, touch.clientY); + vec2.set(this.touchStartPosition, touch.clientX, touch.clientY); this.primaryDownAt = performance.now(); - ChargeIndicator.begin(this.touchStartPosition.x, this.touchStartPosition.y); + ChargeIndicator.begin(touch.clientX, touch.clientY); } }; @@ -115,43 +88,36 @@ export class TouchListener extends CommandGenerator { event.preventDefault(); const touch = this.findGestureTouch(event.touches); - if (!touch || !this.touchStartPosition) { + if (!touch) { return; } - const touchPosition = vec2.fromValues(touch.clientX, touch.clientY); - - const delta = vec2.subtract(vec2.create(), touchPosition, this.touchStartPosition); - vec2.scale(delta, delta, TouchListener.deltaScaling); + const delta = vec2.fromValues( + touch.clientX - this.touchStartPosition[0], + touch.clientY - this.touchStartPosition[1], + ); + vec2.scale(delta, delta, deltaScaling); const deltaLength = vec2.length(delta); - if (!this.isJoystickActive && deltaLength > TouchListener.deadZone) { + if (!this.isJoystickActive && deltaLength > deadZone) { this.isJoystickActive = true; this.primaryDownAt = null; ChargeIndicator.end(); this.overlay.appendChild(this.joystick); - this.joystickButton.style.transform = `translateX(-50%) translateY(-50%)`; - this.joystick.style.transform = `translateX(${this.touchStartPosition.x}px) translateY(${this.touchStartPosition.y}px) translateX(-50%) translateY(-50%)`; + this.joystick.style.transform = `translateX(${this.touchStartPosition[0]}px) translateY(${this.touchStartPosition[1]}px) translateX(-50%) translateY(-50%)`; } - const maxLength = 20; - vec2.scale(delta, delta, Math.min(1, maxLength / deltaLength)); - this.joystickButton.style.transform = `translateX(${delta.x}px) translateY(${delta.y}px) translateX(-50%) translateY(-50%)`; + vec2.scale(delta, delta, Math.min(1, joystickMaxLength / deltaLength)); + this.joystickButton.style.transform = `translateX(${delta[0]}px) translateY(${delta[1]}px) translateX(-50%) translateY(-50%)`; - vec2.set(delta, delta.x, -delta.y); - if (deltaLength > TouchListener.deadZone) { - const direction = vec2.normalize(delta, delta); - this.sendMove(direction); + if (deltaLength > deadZone) { + vec2.set(delta, delta[0], -delta[1]); + this.sendMove(vec2.normalize(delta, delta)); } else { 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(); @@ -161,27 +127,20 @@ export class TouchListener extends CommandGenerator { } this.gestureTouchId = null; - if (!this.isJoystickActive) { - ChargeIndicator.end(); - const charge = - this.primaryDownAt === null - ? 0 - : holdDurationToCharge((performance.now() - this.primaryDownAt) / 1000); - this.primaryDownAt = null; - const center = vec2.fromValues(touch.clientX, touch.clientY); - this.sendCommandToSubscribers( - new PrimaryActionCommand( - this.game.displayToWorldCoordinates(center), - charge, - Math.round(predictorNowMs()), - ), - ); - } else { + if (this.isJoystickActive) { this.releaseJoystick(); + return; } + + ChargeIndicator.end(); + const charge = this.primaryDownAt === null ? 0 : chargeHeldSince(this.primaryDownAt); + this.primaryDownAt = null; + this.sendPrimary( + this.game.displayToWorldCoordinates(vec2.fromValues(touch.clientX, touch.clientY)), + charge, + ); }; - // Also reached from touchcancel — otherwise a system gesture leaves the stick latched. private touchCancelListener = (event: TouchEvent) => { if (!this.findGestureTouch(event.changedTouches)) { return; @@ -196,53 +155,59 @@ export class TouchListener extends CommandGenerator { private releaseJoystick() { this.isJoystickActive = false; - this.joystick.parentElement?.removeChild(this.joystick); + this.joystick.remove(); this.sendMove(vec2.create()); } - private swallowTouch = (event: TouchEvent) => { + private swallowTouch(event: TouchEvent) { event.preventDefault(); event.stopPropagation(); - }; + } private leapButtonListener = (event: TouchEvent) => { this.swallowTouch(event); - const clientTimeMs = localCharacterPredictor.recordLeap(); - this.sendCommandToSubscribers(new LeapActionCommand(clientTimeMs)); + this.sendLeap(); }; private fireButtonDownListener = (event: TouchEvent) => { this.swallowTouch(event); this.fireDownAt = performance.now(); const rect = this.fireButton.getBoundingClientRect(); - this.fireButtonCenter = vec2.fromValues( + vec2.set( + this.fireButtonCenter, rect.left + rect.width / 2, rect.top + rect.height / 2, ); - this.fireAimScreen = null; + this.fireAim = null; ChargeIndicator.begin(this.fireButtonCenter[0], this.fireButtonCenter[1]); }; private fireButtonMoveListener = (event: TouchEvent) => { this.swallowTouch(event); - if (this.fireDownAt === null || !this.fireButtonCenter) { - return; - } const touch = event.targetTouches[0] ?? event.changedTouches[0]; - if (!touch) { + if (this.fireDownAt === null || !touch) { return; } - this.fireAimScreen = vec2.fromValues(touch.clientX, touch.clientY); - const dx = this.fireAimScreen[0] - this.fireButtonCenter[0]; - const dy = this.fireAimScreen[1] - this.fireButtonCenter[1]; - if (dx * dx + dy * dy > TouchListener.aimDeadZone * TouchListener.aimDeadZone) { + const aim = this.aimFromTouch(touch); + this.fireAim = aim; + if (aim) { this.fireAimLine.style.opacity = '1'; - this.fireAimLine.style.transform = `translateY(-50%) rotate(${Math.atan2(dy, dx)}rad)`; + this.fireAimLine.style.transform = `translateY(-50%) rotate(${Math.atan2(-aim[1], aim[0])}rad)`; } else { this.fireAimLine.style.opacity = '0'; } }; + private aimFromTouch(touch: Touch): vec2 | null { + const dx = touch.clientX - this.fireButtonCenter[0]; + const dy = touch.clientY - this.fireButtonCenter[1]; + if (dx * dx + dy * dy <= aimDeadZone * aimDeadZone) { + return null; + } + const aim = vec2.fromValues(dx, -dy); + return vec2.normalize(aim, aim); + } + private fireButtonUpListener = (event: TouchEvent) => { this.swallowTouch(event); ChargeIndicator.end(); @@ -251,48 +216,33 @@ export class TouchListener extends CommandGenerator { return; } - const charge = holdDurationToCharge((performance.now() - this.fireDownAt) / 1000); + const charge = chargeHeldSince(this.fireDownAt); this.fireDownAt = null; - const character = this.game.gameObjects.player; + const character = this.game.gameObjects.localPlayer; if (!character) { - this.fireButtonCenter = null; - this.fireAimScreen = null; return; } - // Screen drag → world aim direction (flip Y: screen +y is down). - let direction = character.facingDirection; - if (this.fireButtonCenter && this.fireAimScreen) { - const dx = this.fireAimScreen[0] - this.fireButtonCenter[0]; - const dy = this.fireAimScreen[1] - this.fireButtonCenter[1]; - if (dx * dx + dy * dy > TouchListener.aimDeadZone * TouchListener.aimDeadZone) { - direction = vec2.normalize(vec2.create(), vec2.fromValues(dx, -dy)); - } - } - this.fireButtonCenter = null; - this.fireAimScreen = null; - - const aim = vec2.scaleAndAdd( - vec2.create(), - character.bodyCenter, - direction, - settings.touchAimRange, - ); - this.sendCommandToSubscribers( - new PrimaryActionCommand(aim, charge, Math.round(predictorNowMs())), + const direction = this.fireAim ?? character.facingDirection; + this.fireAim = null; + this.sendPrimary( + vec2.scaleAndAdd( + vec2.create(), + character.bodyCenter, + direction, + settings.touchAimRange, + ), + charge, ); }; public update() { if (!this.fireButton.parentElement) { - this.overlay.appendChild(this.fireButton); - } - if (!this.leapButton.parentElement) { - this.overlay.appendChild(this.leapButton); + this.overlay.append(this.fireButton, this.leapButton); } - const character = this.game.gameObjects.player; + const character = this.game.gameObjects.localPlayer; if (character) { this.fireStrengthRing.style.background = `conic-gradient(rgba(255, 255, 255, 0.75) ${ character.strengthFraction * 360 @@ -312,7 +262,7 @@ export class TouchListener extends CommandGenerator { this.fireButton.removeEventListener('touchend', this.fireButtonUpListener); this.leapButton.removeEventListener('touchstart', this.leapButtonListener); - this.fireButton.parentElement?.removeChild(this.fireButton); - this.leapButton.parentElement?.removeChild(this.leapButton); + this.fireButton.remove(); + this.leapButton.remove(); } } diff --git a/frontend/src/scripts/configuration.ts b/frontend/src/scripts/configuration.ts index f41d8f9..d8d2dd0 100644 --- a/frontend/src/scripts/configuration.ts +++ b/frontend/src/scripts/configuration.ts @@ -1,13 +1,5 @@ -/** - * Game server origins. The join screen polls each and only shows those that - * respond, so listing an offline origin here is harmless. - */ const productionServers: Array = ['https://declared.schmelczer.dev']; -/** - * On localhost (webpack-dev-server) also offer the local backend on its default - * port; in production the page is served from its own hostname, so this never leaks. - */ const isDevelopment = typeof location !== 'undefined' && (location.hostname === 'localhost' || location.hostname === '127.0.0.1'); diff --git a/frontend/src/scripts/feedback-hud.ts b/frontend/src/scripts/feedback-hud.ts index ccc4636..ef7b251 100644 --- a/frontend/src/scripts/feedback-hud.ts +++ b/frontend/src/scripts/feedback-hud.ts @@ -22,7 +22,7 @@ function ensureRoot(): { root: HTMLElement; killfeed: HTMLElement } { function focusPoint(): { x: number; y: number } { const cursor = pointer.displayPosition; if (cursor) { - return { x: cursor.x, y: cursor.y }; + return { x: cursor[0], y: cursor[1] }; } return { x: window.innerWidth / 2, y: window.innerHeight / 2 }; } @@ -30,7 +30,7 @@ function focusPoint(): { x: number; y: number } { function addTransient(el: HTMLElement, lifetimeMs: number) { const { root: r } = ensureRoot(); r.appendChild(el); - setTimeout(() => el.parentElement?.removeChild(el), lifetimeMs); + setTimeout(() => el.remove(), lifetimeMs); } function hitMarker(charge = 0) { @@ -55,7 +55,7 @@ function killConfirmed(victimName?: string, streak = 1, charge = 0) { entry.className = 'kill-entry'; entry.innerHTML = `Eliminated ${escapeHtml(victimName ?? 'enemy')}`; kf.insertBefore(entry, kf.firstChild); - setTimeout(() => entry.parentElement?.removeChild(entry), 4500); + setTimeout(() => entry.remove(), 4500); const { x, y } = focusPoint(); const popup = document.createElement('div'); @@ -89,13 +89,13 @@ function showElimination(): void { } function hideElimination(): void { - elimination?.parentElement?.removeChild(elimination); + elimination?.remove(); elimination = undefined; } function resetFeedbackHud(): void { hideElimination(); - root?.parentElement?.removeChild(root); + root?.remove(); root = undefined; killfeed = undefined; } @@ -119,7 +119,6 @@ function escapeHtml(text: string): string { return div.innerHTML; } -// The one public surface; the functions above stay module-private. export const FeedbackHud = { hitMarker, killConfirmed, diff --git a/frontend/src/scripts/game.ts b/frontend/src/scripts/game.ts index 29ca72e..834757d 100644 --- a/frontend/src/scripts/game.ts +++ b/frontend/src/scripts/game.ts @@ -33,8 +33,6 @@ import { GameObjectContainer } from './objects/game-object-container'; import parser from 'socket.io-msgpack-parser'; import { CharacterShape } from './shapes/character-shape'; 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, @@ -46,32 +44,57 @@ import { Minimap } from './minimap'; import { ScreenShake } from './screen-shake'; import { FeedbackHud } from './feedback-hud'; -// Ten attempts at the configured backoff is on the order of a minute of -// retrying before the client gives up and returns to the landing page. const maximumReconnectionAttempts = 10; export class Game extends CommandReceiver { public gameObjects = new GameObjectContainer(this); public renderer?: Renderer; + public rejectionReason?: JoinRejectionReason; + public readonly started: Promise; + private socket!: Socket; - private isBetweenGames = false; - - public started: Promise; - private resolveStarted!: () => unknown; - - private keyboardListener: KeyboardListener; - private mouseListener: MouseListener; - private touchListener: TouchListener; - - private scoreboard = new Scoreboard(); - private minimap = new Minimap(); - private announcementText = document.createElement('h2'); - private keystoneArrow?: HTMLElement; private socketReceiver!: CommandSocket; private tutorial!: Tutorial; - private reconnectToken?: string; + private resolveStarted!: () => void; + private isBetweenGames = false; + private isActive = true; + private isEnding = false; + private timeScaling = 1; + + private readonly keyboardListener: KeyboardListener; + private readonly mouseListener: MouseListener; + private readonly touchListener: TouchListener; + + private readonly scoreboard = new Scoreboard(); + private readonly minimap = new Minimap(); + private readonly announcementText = document.createElement('h2'); + private keystoneArrow?: HTMLElement; private connectionBanner?: HTMLElement; - private rejectionReason?: JoinRejectionReason; + private reconnectToken?: string; + private lastAspectRatio?: number; + private lastGameState?: UpdateGameState; + private lastMinimap?: UpdateMinimap; + private lastAnnouncementText = ''; + private timeSinceLastAnnouncement = 0; + private framesSinceLastLayoutUpdate = 0; + + protected commandExecutors: CommandExecutors = { + [ServerAnnouncement.type]: (c: ServerAnnouncement) => { + this.lastAnnouncementText = c.text; + this.timeSinceLastAnnouncement = 0; + }, + [UpdateGameState.type]: (c: UpdateGameState) => (this.lastGameState = c), + [InputAcknowledgement.type]: (c: InputAcknowledgement) => + localCharacterPredictor.acknowledge( + c.clientTimeMs, + c.movement, + c.lastLeapClientTimeMs, + c.ackAgeMs, + ), + [GameEndCommand.type]: () => (this.isEnding = true), + [UpdateMinimap.type]: (c: UpdateMinimap) => (this.lastMinimap = c), + [GameStartCommand.type]: () => this.initialize(), + }; constructor( private readonly playerDecision: PlayerDecision, @@ -82,9 +105,17 @@ export class Game extends CommandReceiver { this.started = new Promise((r) => (this.resolveStarted = r)); this.announcementText.className = 'announcement'; - this.keyboardListener = new KeyboardListener(); - this.mouseListener = new MouseListener(this.canvas, this); - this.touchListener = new TouchListener(this.canvas, this.overlay, this); + const onInput = (c: Command) => { + this.socketReceiver.queue(c); + this.tutorial.handleCommand(c); + }; + this.keyboardListener = new KeyboardListener(onInput); + this.mouseListener = new MouseListener(this.canvas, this, onInput); + this.touchListener = new TouchListener(this.canvas, this.overlay, this, onInput); + } + + protected defaultCommandExecutor(c: Command) { + this.gameObjects.handleCommand(c); } private initialize() { @@ -94,6 +125,7 @@ export class Game extends CommandReceiver { serverTimeline.reset(); localCharacterPredictor.reset(); ScreenShake.reset(); + this.gameObjects.reset(); this.gameObjects = new GameObjectContainer(this); this.overlay.innerHTML = ''; this.keystoneArrow = undefined; @@ -101,58 +133,50 @@ export class Game extends CommandReceiver { this.lastMinimap = undefined; this.isEnding = false; this.lastAnnouncementText = ''; - this.overlay.appendChild(this.scoreboard.element); - this.overlay.appendChild(this.minimap.element); this.announcementText.innerText = ''; this.timeScaling = 1; - this.overlay.appendChild(this.announcementText); + this.overlay.append( + this.scoreboard.element, + this.minimap.element, + this.announcementText, + ); this.tutorial = new Tutorial(this.overlay); this.socket = io(this.playerDecision.server, { reconnectionDelayMax: 10000, - // Must be finite. The default is Infinity, and with it the manager never - // gives up, so `reconnect_failed` below never fires and a client whose - // server is gone sits on a frozen world behind the banner forever. + // Must be finite, otherwise `reconnect_failed` never fires. reconnectionAttempts: maximumReconnectionAttempts, transports: ['websocket'], forceNew: true, parser, } as any); + this.socketReceiver = new CommandSocket(this.socket); - // In socket.io-client v4 reconnection events are emitted by the Manager (`socket.io`), not the Socket itself. this.socket.io.on('reconnect_attempt', () => { this.socket.io.opts.transports = ['polling', 'websocket']; }); - // A transport drop is not the end of the match — tearing the game down would cancel the reconnection the client is configured for. this.socket.on('disconnect', () => { - if (this.isBetweenGames) { - return; + if (!this.isBetweenGames) { + this.showConnectionBanner('Reconnecting…'); } - this.showConnectionBanner('Reconnecting…'); }); + // Fires for every (re)connection: a reconnect is a brand-new server-side player. this.socket.on('connect', () => { if (this.isBetweenGames) { return; } - // A reconnect is a brand-new server-side connection that has never seen a join, so the join must be re-sent or the client sits connected and invisible forever. this.hideConnectionBanner(); serverTimeline.reset(); localCharacterPredictor.reset(); - // A brand-new server-side player whose view-area bookkeeping starts empty: the dropped session's objects will never be retracted, so reset here. this.gameObjects.reset(); this.socket.emit(TransportEvents.PlayerJoining, { ...this.playerDecision, reconnectToken: this.reconnectToken, }); - // A reconnect gets a brand-new server-side Player, back on the default - // aspect ratio; the camera only reports a ratio when it changes, so - // without this re-send it would stay wrong for the rest of the match. if (this.lastAspectRatio !== undefined) { - this.socketReceiver.handleCommand( - new SetAspectRatioActionCommand(this.lastAspectRatio), - ); + this.socketReceiver.queue(new SetAspectRatioActionCommand(this.lastAspectRatio)); } }); @@ -172,13 +196,11 @@ export class Game extends CommandReceiver { this.destroy(); }); - // Echo the nonce: the server only accepts a reply matching the ping still outstanding, so a stale or duplicated Pong cannot move its RTT. this.socket.on(TransportEvents.Ping, (nonce: unknown) => { this.socket.emit(TransportEvents.Pong, nonce); }); this.socket.on(TransportEvents.ServerToPlayer, (serializedCommands: string) => { - // deserialize revives classes by name from the payload, so a hostile or corrupt field can throw inside JSON.parse's reviver — one malformed object must not take down the message pump or every later batch is lost with it. try { const commands: Array = deserialize(serializedCommands); commands.forEach((c) => { @@ -193,54 +215,12 @@ export class Game extends CommandReceiver { } }); - this.socketReceiver = new CommandSocket(this.socket); - this.keyboardListener.clearSubscribers(); - this.keyboardListener.subscribe(this.socketReceiver); - this.keyboardListener.subscribe(this.tutorial); - this.mouseListener.clearSubscribers(); - this.mouseListener.subscribe(this.socketReceiver); - this.mouseListener.subscribe(this.tutorial); - this.touchListener.clearSubscribers(); - this.touchListener.subscribe(this.socketReceiver); - this.touchListener.subscribe(this.tutorial); - - // Join is emitted from the `connect` handler above, which fires for the first connection and every reconnection alike — so one code path covers both and a reconnect can never be left unjoined. this.isBetweenGames = false; } - protected defaultCommandExecutor(c: Command) { - this.gameObjects.handleCommand(c); - } - - private lastGameState?: UpdateGameState; - private isEnding = false; - private timeScaling = 1; - - private lastAnnouncementText = ''; - protected commandExecutors: CommandExecutors = { - [ServerAnnouncement.type]: (c: ServerAnnouncement) => { - this.lastAnnouncementText = c.text; - this.timeSinceLastAnnouncement = 0; - }, - [UpdateGameState.type]: (c: UpdateGameState) => (this.lastGameState = c), - [InputAcknowledgement.type]: (c: InputAcknowledgement) => - localCharacterPredictor.acknowledge( - c.clientTimeMs, - c.movement, - c.lastLeapClientTimeMs, - c.ackAgeMs, - ), - [GameEndCommand.type]: () => (this.isEnding = true), - [UpdateMinimap.type]: (c: UpdateMinimap) => (this.lastMinimap = c), - [GameStartCommand.type]: this.initialize.bind(this), - }; - - private lastMinimap?: UpdateMinimap; - public async start(): Promise { - const noiseTexture = await renderNoise([256, 256], 2, 1); - this.initialize(); + const noiseTexture = await renderNoise([256, 256], 2, 1); await runAnimation( this.canvas, @@ -272,20 +252,17 @@ export class Game extends CommandReceiver { }, }, ); + this.socket.close(); + this.gameObjects.reset(); this.overlay.innerHTML = ''; this.hideConnectionBanner(); - // The HUD root lives on document.body, not the overlay, so it must be torn down explicitly or it stays painted over the landing page. FeedbackHud.reset(); this.keyboardListener.destroy(); this.mouseListener.destroy(); this.touchListener.destroy(); } - public get lastRejectionReason(): JoinRejectionReason | undefined { - return this.rejectionReason; - } - public static rejectionText(reason: JoinRejectionReason): string { switch (reason) { case JoinRejectionReason.ServerFull: @@ -319,38 +296,32 @@ export class Game extends CommandReceiver { return this.renderer?.displayToWorldCoordinates(p) ?? vec2.create(); } - private lastAspectRatio?: number; public aspectRatioChanged(aspectRatio: number) { this.lastAspectRatio = aspectRatio; - this.socketReceiver.handleCommand(new SetAspectRatioActionCommand(aspectRatio)); + this.socketReceiver.queue(new SetAspectRatioActionCommand(aspectRatio)); } - private isActive = true; public destroy() { this.isActive = false; } - private timeSinceLastAnnouncement = 0; - private framesSinceLastLayoutUpdate = 0; private gameLoop( renderer: Renderer, currentTime: DOMHighResTimeStamp, deltaTime: DOMHighResTimeStamp, ): boolean { + this.renderer = renderer; this.resolveStarted(); - // Must use the frame's timestamp, not performance.now(): the dispatch delay would land in the prediction replay window and jitter the predicted body. + // The frame timestamp, not performance.now(): dispatch jitter would land in the replay window. setFrameTimeMs(currentTime); deltaTime /= 1000; - // Camera impact decays on raw wall-clock time, before the end-game slow-motion scaling below — view-only, never the simulation, so it stays decoupled from prediction and netcode. + // Both run on wall-clock time: the end-game slow motion is already baked into the snapshots. ScreenShake.step(deltaTime); - - // Stepped before the end-game time scaling on purpose: the slow motion is already baked into the server's snapshots, so the playback cursor must keep running on wall-clock time. serverTimeline.step(deltaTime); - let shouldChangeLayout = false; - if (++this.framesSinceLastLayoutUpdate > 1) { - shouldChangeLayout = true; + const shouldChangeLayout = ++this.framesSinceLastLayoutUpdate > 1; + if (shouldChangeLayout) { this.framesSinceLastLayoutUpdate = 0; this.draw(); } @@ -366,17 +337,10 @@ export class Game extends CommandReceiver { deltaTime /= this.timeScaling; } - this.renderer = renderer; - - this.gameObjects.handleCommand(new StepCommand(deltaTime)); - this.gameObjects.handleCommand( - new RenderCommand(this.renderer, this.overlay, shouldChangeLayout), - ); - + this.gameObjects.step(deltaTime); + this.gameObjects.render(renderer, this.overlay, shouldChangeLayout); this.touchListener.update(); - this.tutorial.step(this.gameObjects); - this.socketReceiver.sendQueuedCommands(); return this.isActive; @@ -384,11 +348,11 @@ export class Game extends CommandReceiver { private draw() { if (this.lastGameState) { - this.scoreboard.update(this.lastGameState, this.gameObjects.player?.team); + this.scoreboard.update(this.lastGameState, this.gameObjects.localPlayer?.team); } this.minimap.update( - this.gameObjects.localPlayerPosition, + this.gameObjects.localPlayer?.position, this.lastMinimap?.players ?? [], ); @@ -400,11 +364,8 @@ export class Game extends CommandReceiver { } private handleKeystoneArrow() { - if (!this.renderer) { - return; - } const keystone = this.gameObjects.planets.find((p) => p.isKeystone); - if (!keystone) { + if (!this.renderer || !keystone) { if (this.keystoneArrow) { this.keystoneArrow.style.display = 'none'; } @@ -416,40 +377,34 @@ export class Game extends CommandReceiver { this.overlay.appendChild(this.keystoneArrow); } - const width = this.renderer.canvasSize.x; - const height = this.renderer.canvasSize.y; + const [width, height] = this.renderer.canvasSize; 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; + display[0] >= margin && + display[0] <= width - margin && + display[1] >= margin && + display[1] <= height - margin; this.keystoneArrow.className = 'keystone-arrow ' + keystone.team; - + this.keystoneArrow.style.display = onScreen ? 'none' : 'block'; 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 dx = display[0] - width / 2; + const dy = display[1] - height / 2; + const angle = Math.atan2(dy, dx); - 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; + if (width / height < Math.abs(dx / dy)) { + deltaX = (width / 2 - margin) * Math.sign(dx); + deltaY = (deltaX * dy) / dx; } else { - deltaY = (height / 2 - margin) * Math.sign(dir.y); - deltaX = deltaY * directionRatio; + deltaY = (height / 2 - margin) * Math.sign(dy); + deltaX = (deltaY * dx) / dy; } - 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)`; + this.keystoneArrow.style.transform = `translateX(${width / 2 + deltaX}px) translateY(${height / 2 + deltaY}px) translateX(-50%) translateY(-50%) rotate(${angle + Math.PI / 2}rad)`; } } diff --git a/frontend/src/scripts/helper/interpolators/linear-interpolator.ts b/frontend/src/scripts/helper/interpolators/linear-interpolator.ts index fdd8a4b..133a4bf 100644 --- a/frontend/src/scripts/helper/interpolators/linear-interpolator.ts +++ b/frontend/src/scripts/helper/interpolators/linear-interpolator.ts @@ -13,11 +13,6 @@ const blendSeconds = 0.12; const maxFrames = 32; -/** - * Replays a server-streamed scalar by interpolating between timestamped - * snapshots at the render time; coasts briefly on the streamed rate of change - * when the buffer runs dry. - */ export class LinearInterpolator { private frames: Array = []; private blendOffset = 0; diff --git a/frontend/src/scripts/helper/interpolators/vec2-interpolator.ts b/frontend/src/scripts/helper/interpolators/vec2-interpolator.ts index 5a4544e..ef67ad8 100644 --- a/frontend/src/scripts/helper/interpolators/vec2-interpolator.ts +++ b/frontend/src/scripts/helper/interpolators/vec2-interpolator.ts @@ -1,7 +1,6 @@ import { vec2 } from 'gl-matrix'; import { LinearInterpolator } from './linear-interpolator'; -// Indexed access, not .x/.y, so it never depends on the vec2 prototype plugin. export class Vec2Interpolator { private x: LinearInterpolator; private y: LinearInterpolator; diff --git a/frontend/src/scripts/helper/prediction/client-character-world.ts b/frontend/src/scripts/helper/prediction/client-character-world.ts index f3b5df1..4668cbd 100644 --- a/frontend/src/scripts/helper/prediction/client-character-world.ts +++ b/frontend/src/scripts/helper/prediction/client-character-world.ts @@ -4,119 +4,43 @@ import { GroundSurface, Id, PhysicsBody, - planetDistance, - planetGravity, + PlanetBase, resolveCircleMovement, } from 'shared'; +import { PlanetView } from '../../objects/types/planet-view'; -// What the predictor needs to know about a planet to collide and be pulled by it. -export interface PredictablePlanet { - id: Id; - vertices: Array; - center: vec2; - radius: number; - rotation: number; - rotationSpeed: number; -} - -// A planet surface whose rotation advances during replay, in lockstep with the body — 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); - } -} - -// Planets-only collision world for the predictor. Persistent surfaces keyed by id (currentPlanet stays valid across frames); never dispatches collision reactions — those are server-authoritative. +// Planets only, re-seeded from the newest snapshot every frame; never dispatches collisions. export class ClientCharacterWorld implements CharacterWorld { - private surfaces = new Map(); - private ordered: Array = []; + private surfaces: Array = []; - // 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]); + public sync(planets: Array) { + this.surfaces = planets + .map((planet) => { + const surface = new PlanetBase(planet.id, planet.vertices); + surface.rotation = planet.snapshotRotation; + surface.angularVelocity = planet.snapshotRotationSpeed; + return surface; + }) + .sort((a, b) => a.id - b.id); } - // Advance every planet's collision frame by one replay substep, so surfaces and the carried body rotate together. 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); - } + this.surfaces.forEach((s) => s.advanceRotation(deltaTimeInSeconds)); } - public surfaceById(id: Id | undefined): GroundSurface | undefined { - return id == null ? undefined : this.surfaces.get(id); + public surfaceById(id: Id | null): GroundSurface | undefined { + return this.surfaces.find((s) => s.id === id); } - public groundsNear(): Array { - return this.ordered; + public groundsNear(_center: vec2, _radius: number): Array { + return this.surfaces; } 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; + const hit = resolveCircleMovement(body, deltaTimeInSeconds, this.surfaces); + return hit instanceof PlanetBase ? hit : undefined; } } diff --git a/frontend/src/scripts/helper/prediction/input-history.ts b/frontend/src/scripts/helper/prediction/input-history.ts index be372fb..fab8f7a 100644 --- a/frontend/src/scripts/helper/prediction/input-history.ts +++ b/frontend/src/scripts/helper/prediction/input-history.ts @@ -7,7 +7,6 @@ interface InputSample { const retainMs = 1500; -// Timeline of local movement directions; the predictor replays exactly the input the server receives. export class InputHistory { private samples: Array = []; diff --git a/frontend/src/scripts/helper/prediction/local-character-predictor.ts b/frontend/src/scripts/helper/prediction/local-character-predictor.ts index e7b3407..f437042 100644 --- a/frontend/src/scripts/helper/prediction/local-character-predictor.ts +++ b/frontend/src/scripts/helper/prediction/local-character-predictor.ts @@ -5,25 +5,26 @@ import { CharacterMovementState, PhysicsBody, settings, + clamp, + easeVec2, stepCharacterMovement, applyLeapImpulse, tickPlanetDetachment, feetRadius, headRadius, } from 'shared'; -import { ClientCharacterWorld, PredictablePlanet } from './client-character-world'; +import { ClientCharacterWorld } from './client-character-world'; +import { PlanetView } from '../../objects/types/planet-view'; import { InputHistory } from './input-history'; -// Must match the server's tick source — the replay integrates over this dt. const stepSeconds = settings.targetPhysicsDeltaTimeInSeconds; const stepMs = stepSeconds * 1000; -// Frame timestamp, not performance.now() — dispatch jitter would land in the replay window. let frameTimeMs = 0; export const setFrameTimeMs = (timeMs: number): void => { frameTimeMs = timeMs; }; -export const predictorNowMs = (): number => frameTimeMs; +export const clientTimeMs = (): number => Math.round(frameTimeMs); const maxReplayMs = 400; @@ -33,7 +34,6 @@ const snapDistance = 250; const upwards = vec2.fromValues(0, 1); -// Velocity starts at zero — the sim rebuilds it each tick, so it is not carried state. const makeBody = (center: vec2, radius: number, normal: vec2): PhysicsBody => ({ center: vec2.clone(center), radius, @@ -47,15 +47,12 @@ export class LocalCharacterPredictor { private readonly world = new ClientCharacterWorld(); private authoritative?: { head: Circle; leftFoot: Circle; rightFoot: Circle }; - // Replay anchor: built from both halves of the ack (input time + ackAge), advances forward only. private replayAnchorMs?: number; private movement?: CharacterMovementSnapshot; private leapHistory: Array = []; - // Last leap the server folded into streamed momentum; newer ones only, so a leap is never applied twice. private lastLeapAckMs = -Infinity; private currentStrength = settings.playerMaxStrength; - // Dead players don't predict — the server ignores their input, so moving would desync. private alive = true; private renderHead = new Circle(vec2.create(), headRadius); @@ -74,7 +71,7 @@ export class LocalCharacterPredictor { } public recordInput(direction: vec2): number { - const timeMs = Math.round(frameTimeMs); + const timeMs = clientTimeMs(); this.inputHistory.record(direction, timeMs); return timeMs; } @@ -85,7 +82,6 @@ export class LocalCharacterPredictor { lastLeapClientTimeMs: number, ackAgeMs = 0, ): void { - // The anchor only advances forward; movement state and leap boundary always adopt the latest authoritative values. const anchor = clientTimeMs + Math.max(0, ackAgeMs); if (this.replayAnchorMs === undefined || anchor > this.replayAnchorMs) { this.replayAnchorMs = anchor; @@ -98,9 +94,8 @@ export class LocalCharacterPredictor { this.authoritative = { head, leftFoot, rightFoot }; } - // Recorded regardless of acceptance — a rejected leap self-corrects via streamed momentum. public recordLeap(): number { - const timeMs = Math.round(frameTimeMs); + const timeMs = clientTimeMs(); this.leapHistory.push(timeMs); const cutoff = timeMs - 1500; while (this.leapHistory.length > 0 && this.leapHistory[0] <= cutoff) { @@ -137,7 +132,6 @@ export class LocalCharacterPredictor { ); } - // While spawn-in/death the server freezes walking (head radius < nominal) — don't predict, let interpolation show it. private get isAnimatingInOrOut(): boolean { return ( this.authoritative !== undefined && @@ -145,7 +139,7 @@ export class LocalCharacterPredictor { ); } - public update(planets: Array, frameSeconds: number): boolean { + public update(planets: Array, frameSeconds: number): boolean { if (!this.alive || !this.canPredict || this.isAnimatingInOrOut) { this.hasRender = false; return false; @@ -166,14 +160,12 @@ export class LocalCharacterPredictor { private simulate(): CharacterMovementState { const auth = this.authoritative!; const movement = this.movement!; - const now = Math.round(frameTimeMs); - // Replay unconfirmed input from anchor to now, clamped so a stalled tab can't grind hundreds of steps. - const startMs = Math.min(now, Math.max(this.replayAnchorMs!, now - maxReplayMs)); + const now = clientTimeMs(); + const startMs = clamp(this.replayAnchorMs!, now - maxReplayMs, now); const windowMs = Math.max(0, now - startMs); const steps = Math.floor(windowMs / stepMs); const remainderSeconds = (windowMs - steps * stepMs) / 1000; - // Full authoritative state — replay is a pure function of (snapshot, input, window) and cannot drift. const state: CharacterMovementState = { head: makeBody(auth.head.center, auth.head.radius, upwards), leftFoot: makeBody( @@ -189,14 +181,10 @@ export class LocalCharacterPredictor { direction: movement.direction, currentPlanet: this.world.surfaceById(movement.groundPlanetId), secondsSinceOnSurface: movement.secondsSinceOnSurface, - // Leaps before the window are baked in; inside it they're re-applied — neither double-counted. bodyVelocity: vec2.fromValues(movement.bodyVelocity[0], movement.bodyVelocity[1]), }; - // Planet frames are synced to the newest snapshot's rotation, then advanced forward in lockstep with the body — no rewind needed. - const cooldownMs = settings.leapCooldownSeconds * 1000; - // Mirror the server: each accepted leap spends strength, so a burst is gated by running strength. let availableStrength = this.currentStrength; let lastLeapMs = -Infinity; @@ -211,7 +199,6 @@ export class LocalCharacterPredictor { if ( leapMs >= t && leapMs < t + stepMs && - // Only leaps the server hasn't seeded — never both seeded and replayed. leapMs > this.lastLeapAckMs && state.currentPlanet && leapMs - lastLeapMs >= cooldownMs && @@ -226,7 +213,6 @@ export class LocalCharacterPredictor { t += stepMs; } - // Sub-tick the remainder so the pose is continuous in the window length, not quantized to stepMs. if (remainderSeconds > 0) { tickPlanetDetachment(state, remainderSeconds); stepCharacterMovement( @@ -254,18 +240,13 @@ export class LocalCharacterPredictor { } 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); + this.easePart(this.renderHead, state.head, frameSeconds); + this.easePart(this.renderLeftFoot, state.leftFoot, frameSeconds); + this.easePart(this.renderRightFoot, state.rightFoot, frameSeconds); } - 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); - } + private easePart(render: Circle, target: PhysicsBody, frameSeconds: number): void { + easeVec2(render.center, target.center, frameSeconds, smoothSeconds, snapDistance); render.radius = target.radius; } } diff --git a/frontend/src/scripts/helper/server-timeline.ts b/frontend/src/scripts/helper/server-timeline.ts index 7121da1..9ac449f 100644 --- a/frontend/src/scripts/helper/server-timeline.ts +++ b/frontend/src/scripts/helper/server-timeline.ts @@ -6,35 +6,24 @@ const maxRateAdjustment = 0.25; const resyncSeconds = 0.3; -/** - * Playback clock for streamed server state — renders interpolationDelaySeconds - * behind the server clock so a newer snapshot is usually available; the cursor - * adjusts its rate gently and only snaps after a long divergence. - */ class ServerTimeline { private cursor?: number; private newestSnapshotTime?: number; private sinceNewestSnapshot = 0; - private _snapshotTime = 0; - - public get snapshotTime(): number { - return this._snapshotTime; - } + public snapshotTime = 0; public get renderTime(): number { return this.cursor ?? 0; } public onSnapshot(timestamp: number) { - this._snapshotTime = timestamp; + this.snapshotTime = timestamp; if (this.newestSnapshotTime === undefined || timestamp > this.newestSnapshotTime) { this.newestSnapshotTime = timestamp; this.sinceNewestSnapshot = 0; } } - // Must be called with unscaled wall-clock time, even during the end-game - // slow motion: the slowdown is already baked into the snapshots. public step(deltaTimeInSeconds: number) { if (this.newestSnapshotTime === undefined) { return; @@ -63,7 +52,7 @@ class ServerTimeline { this.cursor = undefined; this.newestSnapshotTime = undefined; this.sinceNewestSnapshot = 0; - this._snapshotTime = 0; + this.snapshotTime = 0; } } diff --git a/frontend/src/scripts/join-form-handler.ts b/frontend/src/scripts/join-form-handler.ts index ebc4d6b..c5592cb 100644 --- a/frontend/src/scripts/join-form-handler.ts +++ b/frontend/src/scripts/join-form-handler.ts @@ -10,52 +10,43 @@ export type PlayerDecision = { }; const pollInterval = 8000; + export class JoinFormHandler { - private joinButton: HTMLButtonElement; - private waitingForDecision: Promise; - private resolvePlayerDecision!: (d: PlayerDecision) => void; - private pollServersTimer: any; + private readonly joinButton: HTMLButtonElement; + private readonly decision: Promise; + private readonly pollServersTimer: ReturnType; + private servers: Array = []; + private keyUpListener = (e: KeyboardEvent) => { - // requestSubmit() (not submit()) fires onsubmit and runs HTML5 validation, so Enter matches clicking Join. if (e.key === 'Enter' && !this.joinButton.disabled) { this.form.requestSubmit(); } }; constructor( - private form: HTMLFormElement, + private readonly form: HTMLFormElement, private readonly container: HTMLElement, ) { this.joinButton = form.querySelector('button[type="submit"]') as HTMLButtonElement; this.joinButton.disabled = true; - this.waitingForDecision = new Promise((r) => (this.resolvePlayerDecision = r)); - new FormData(form); + this.decision = new Promise((resolve) => { + form.onsubmit = (e) => { + e.preventDefault(); + SoundHandler.play(Sounds.click); + const data = new FormData(form); + resolve({ name: String(data.get('name')), server: String(data.get('server')) }); + }; + }); + this.decision.then(() => this.destroy()); addEventListener('keyup', this.keyUpListener); - - form.onsubmit = (e) => { - SoundHandler.play(Sounds.click); - const result: PlayerDecision = ( - Array.from((new FormData(form) as any).entries()) as Array<[string, any]> - ).reduce((result, [name, value]) => { - (result as any)[name] = value; - return result; - }, {}) as any; - - this.resolvePlayerDecision(result); - - e.preventDefault(); - }; - - this.pollServersTimer = setInterval(this.loadServers.bind(this), pollInterval); + this.pollServersTimer = setInterval(() => this.loadServers(), pollInterval); this.loadServers(); - this.waitForFinish(); } - private async waitForFinish() { - await this.waitingForDecision; - this.destroy(); + public getPlayerDecision(): Promise { + return this.decision; } private destroy() { @@ -64,77 +55,86 @@ export class JoinFormHandler { this.servers.forEach((s) => s.destroy()); } - private servers: Array = []; - private async loadServers() { - const serverList = servers.filter((u) => !this.servers.find((s) => s.url === u)); + private loadServers() { + servers + .filter((url) => !this.servers.some((s) => s.url === url)) + .forEach(async (url) => { + const controller = new AbortController(); + setTimeout(() => controller.abort(), pollInterval * 0.8); - serverList.map(async (url) => { - const controller = new AbortController(); - const signal = controller.signal; - setTimeout(() => controller.abort(), pollInterval * 0.8); + let content: ServerInformation; + try { + const response = await fetch(url + serverInformationEndpoint, { + signal: controller.signal, + }); + if (!response.ok) { + return; + } + content = await response.json(); + } catch { + return; + } - let response: Response | undefined; - try { - response = await fetch(url + serverInformationEndpoint, { signal }); - } catch {} - - if (response?.ok) { - const content: ServerInformation = await response.json(); - if (!this.servers.find((s) => s.url === url)) { + if (!this.servers.some((s) => s.url === url)) { const server = new ServerChooserOption( content, url, - this.removeServer.bind(this), + (s) => this.removeServer(s), this.servers.length === 0, ); this.servers.push(server); this.joinButton.disabled = false; this.container.appendChild(server.element); } - } - }); + }); } private removeServer(server: ServerChooserOption) { this.servers = this.servers.filter((s) => s !== server); - if (!this.servers.length) { - this.joinButton.disabled = true; - } - } - - public async getPlayerDecision(): Promise { - return this.waitingForDecision; + this.joinButton.disabled = this.servers.length === 0; } } -class ServerChooserOption { - private divElement = document.createElement('div'); - private inputElement = document.createElement('input'); - private labelElement = document.createElement('label'); - private serverNameElement = document.createElement('span'); - private completionElement = document.createElement('span'); +const roundCompletionTexts = [ + 'Just started', + 'Just started', + 'Ongoing', + 'Halfway through', + 'Nearly over', + 'About to finish', + 'Game is over', +]; - private socket: Socket; +class ServerChooserOption { + public readonly element = document.createElement('div'); + private readonly serverNameElement = document.createElement('span'); + private readonly completionElement = document.createElement('span'); + private readonly socket: Socket; constructor( - private content: ServerInformation, + private readonly content: ServerInformation, public readonly url: string, - private onDestroy: (v: ServerChooserOption) => unknown, + private readonly onDestroy: (v: ServerChooserOption) => unknown, isFirst: boolean, ) { - this.inputElement.required = true; - this.inputElement.type = 'radio'; - this.inputElement.id = this.inputElement.value = url; - this.inputElement.name = 'server'; - this.inputElement.checked = isFirst; - this.labelElement.htmlFor = url; - this.labelElement.onclick = () => SoundHandler.play(Sounds.click); - this.divElement.appendChild(this.inputElement); - this.divElement.appendChild(this.labelElement); - this.labelElement.appendChild(this.serverNameElement); - this.labelElement.appendChild(document.createElement('br')); - this.labelElement.appendChild(this.completionElement); + const input = document.createElement('input'); + input.required = true; + input.type = 'radio'; + input.id = input.value = url; + input.name = 'server'; + input.checked = isFirst; + + const label = document.createElement('label'); + label.htmlFor = url; + label.onclick = () => SoundHandler.play(Sounds.click); + label.append( + this.serverNameElement, + document.createElement('br'), + this.completionElement, + ); this.completionElement.className = 'completion'; + + this.element.append(input, label); this.setServerInfoLabelText(); this.socket = io(url, { @@ -144,8 +144,7 @@ class ServerChooserOption { parser, } as any); - this.socket.io.on('reconnect_failed', this.destroy.bind(this)); - + this.socket.io.on('reconnect_failed', () => this.destroy()); this.socket.on('connect', () => this.socket.emit(TransportEvents.SubscribeForServerInfoUpdates), ); @@ -161,32 +160,16 @@ class ServerChooserOption { public destroy() { this.socket.close(); - this.divElement.parentElement?.removeChild(this.divElement); + this.element.remove(); this.onDestroy(this); } - private getRoundCompletionText(percent: number): string { - const texts = [ - 'Just started', - 'Just started', - 'Ongoing', - 'Halfway through', - 'Nearly over', - 'About to finish', - 'Game is over', - ]; - - return texts[Math.floor((percent / 100) * (texts.length - 1))]; - } - private setServerInfoLabelText() { - this.serverNameElement.innerText = `${this.content.serverName} - ${this.content.playerCount}/${this.content.playerLimit} players`; - this.completionElement.innerText = this.getRoundCompletionText( - this.content.gameStatePercent, - ); - } - - public get element(): HTMLElement { - return this.divElement; + const { serverName, playerCount, playerLimit, gameStatePercent } = this.content; + this.serverNameElement.innerText = `${serverName} - ${playerCount}/${playerLimit} players`; + this.completionElement.innerText = + roundCompletionTexts[ + Math.floor((gameStatePercent / 100) * (roundCompletionTexts.length - 1)) + ]; } } diff --git a/frontend/src/scripts/landing-page-background.ts b/frontend/src/scripts/landing-page-background.ts index f6565f0..ca1863b 100644 --- a/frontend/src/scripts/landing-page-background.ts +++ b/frontend/src/scripts/landing-page-background.ts @@ -1,27 +1,34 @@ -import { vec2, vec3 } from 'gl-matrix'; +import { ReadonlyVec2, vec2, vec3 } from 'gl-matrix'; import { CircleLight, FilteringOptions, - hsl, Renderer, renderNoise, runAnimation, WrapOptions, } from 'sdf-2d'; -import { settings, PlanetBase, Random } from 'shared'; +import { settings, PlanetBase, Random, hsl } from 'shared'; import { PlanetShape } from './shapes/planet-shape'; const bluePlanet = 0; const redPlanet = 0.85; export class LandingPageBackground { + public readonly renderer: Promise; + private isActive = true; - public renderer: Promise; private resolveRenderer!: (r: Renderer) => unknown; + private scene?: { + canvasSize: vec2; + topPlanet: PlanetShape; + bottomPlanet: PlanetShape; + planetAngle: number; + planetDistance: number; + }; constructor(canvas: HTMLCanvasElement) { - this.start(canvas); this.renderer = new Promise((r) => (this.resolveRenderer = r)); + this.start(canvas); } private async start(canvas: HTMLCanvasElement): Promise { @@ -30,14 +37,8 @@ export class LandingPageBackground { runAnimation( canvas, [ - { - ...PlanetShape.descriptor, - shaderCombinationSteps: [0, 1, 2], - }, - { - ...CircleLight.descriptor, - shaderCombinationSteps: [0, 2], - }, + { ...PlanetShape.descriptor, shaderCombinationSteps: [0, 1, 2] }, + { ...CircleLight.descriptor, shaderCombinationSteps: [0, 2] }, ], this.gameLoop.bind(this), { @@ -59,79 +60,77 @@ export class LandingPageBackground { ); } - private gameLoop(renderer: Renderer, time: DOMHighResTimeStamp, _: number): boolean { + private buildScene(canvasSize: ReadonlyVec2) { Random.seed = 42; - this.resolveRenderer(renderer); - - renderer.setViewArea(vec2.fromValues(0, renderer.canvasSize.y), renderer.canvasSize); - - const topPlanetPosition = vec2.fromValues( - 0.7 * renderer.canvasSize.x, - 0.7 * renderer.canvasSize.y, - ); - + const topPosition = vec2.scale(vec2.create(), canvasSize, 0.7); const topPlanet = new PlanetShape( PlanetBase.createPlanetVertices( - topPlanetPosition, + topPosition, Random.getRandomInRange(150, 400), Random.getRandomInRange(150, 400), Random.getRandomInRange(10, 20), ), redPlanet, ); - topPlanet.randomOffset = Random.getRandom(); - topPlanet.rotation = (time / 1000) * 0.09; - - const bottomPlanetPosition = vec2.fromValues( - 0.3 * renderer.canvasSize.x, - 0.3 * renderer.canvasSize.y, - ); + const bottomPosition = vec2.scale(vec2.create(), canvasSize, 0.3); const bottomPlanet = new PlanetShape( PlanetBase.createPlanetVertices( - bottomPlanetPosition, + bottomPosition, Random.getRandomInRange(150, 800), Random.getRandomInRange(150, 400), Random.getRandomInRange(10, 40), ), bluePlanet, ); - bottomPlanet.randomOffset = Random.getRandom(); - bottomPlanet.rotation = (time / 1000) * -0.06; - const planetDistance = vec2.subtract( - vec2.create(), - topPlanetPosition, - bottomPlanetPosition, - ); - const planetDistanceLength = vec2.length(planetDistance); - const planetDirection = vec2.normalize(planetDistance, planetDistance); - const planetAngle = Math.atan2(planetDirection.y, planetDirection.x); + const between = vec2.subtract(vec2.create(), topPosition, bottomPosition); + this.scene = { + canvasSize: vec2.clone(canvasSize), + topPlanet, + bottomPlanet, + planetAngle: Math.atan2(between[1], between[0]), + planetDistance: vec2.length(between), + }; + return this.scene; + } - renderer.addDrawable(topPlanet); - renderer.addDrawable(bottomPlanet); + private gameLoop(renderer: Renderer, time: DOMHighResTimeStamp): boolean { + this.resolveRenderer(renderer); + renderer.setViewArea(vec2.fromValues(0, renderer.canvasSize[1]), renderer.canvasSize); + + const scene = + this.scene && vec2.exactEquals(this.scene.canvasSize, renderer.canvasSize) + ? this.scene + : this.buildScene(renderer.canvasSize); + + scene.topPlanet.rotation = (time / 1000) * 0.09; + scene.bottomPlanet.rotation = (time / 1000) * -0.06; + renderer.addDrawable(scene.topPlanet); + renderer.addDrawable(scene.bottomPlanet); + + const lightDistance = scene.planetDistance * 1.2; renderer.addDrawable( new CircleLight( this.calculateLightPosition( renderer, - planetAngle, - planetDistanceLength * 1.2, + scene.planetAngle, + lightDistance, -time / 3000, ), hsl(25, 75, 60), 0.75, ), ); - renderer.addDrawable( new CircleLight( this.calculateLightPosition( renderer, - planetAngle, - planetDistanceLength * 1.2, + scene.planetAngle, + lightDistance, time / 2000 + Math.PI, ), hsl(249, 79, 70), @@ -152,12 +151,9 @@ export class LandingPageBackground { length * Math.sin(t), length * Math.sin(t) * Math.cos(t), ); - const canvasCenter = vec2.scale(vec2.create(), renderer.canvasSize, 0.5); - vec2.add(lightPosition, lightPosition, canvasCenter); - vec2.rotate(lightPosition, lightPosition, canvasCenter, angle); - return lightPosition; + return vec2.rotate(lightPosition, lightPosition, canvasCenter, angle); } public destroy() { diff --git a/frontend/src/scripts/minimap.ts b/frontend/src/scripts/minimap.ts index 92fc0c2..7074738 100644 --- a/frontend/src/scripts/minimap.ts +++ b/frontend/src/scripts/minimap.ts @@ -1,18 +1,11 @@ import { vec2 } from 'gl-matrix'; -import { CharacterTeam, Id, settings } from 'shared'; - -export interface MinimapBlip { - id: Id; - position: vec2; - team: CharacterTeam; -} +import { CharacterTeam, Id, MinimapPlayer, settings } from 'shared'; export class Minimap { public readonly element = document.createElement('canvas'); private readonly ctx: CanvasRenderingContext2D; private readonly colors: Record; - // Smoothed per player so the 25 Hz snapshots glide rather than step between frames. private readonly smoothed = new Map(); private bufferSize = 0; @@ -30,7 +23,7 @@ export class Minimap { }; } - public update(localPosition: vec2 | undefined, players: Array) { + public update(localPosition: vec2 | undefined, players: Array) { const size = this.element.clientWidth; if (size === 0) { return; @@ -45,8 +38,8 @@ export class Minimap { const scale = innerRadius / settings.worldRadius; const toMap = (world: vec2): { x: number; y: number } => { - let dx = world.x * scale; - let dy = -world.y * scale; // world Y points up, canvas Y points down + let dx = world[0] * scale; + let dy = -world[1] * scale; const distance = Math.hypot(dx, dy); if (distance > innerRadius) { dx = (dx / distance) * innerRadius; @@ -109,8 +102,6 @@ export class Minimap { this.element.width = target; this.element.height = target; } - // Setting the buffer size resets the transform, so (re)establish it every - // frame and draw in CSS pixels regardless of the device pixel ratio. this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); } } diff --git a/frontend/src/scripts/objects/game-object-container.ts b/frontend/src/scripts/objects/game-object-container.ts index 82dc34f..69f03db 100644 --- a/frontend/src/scripts/objects/game-object-container.ts +++ b/frontend/src/scripts/objects/game-object-container.ts @@ -1,38 +1,35 @@ -import { vec2 } from 'gl-matrix'; +import { Renderer } from 'sdf-2d'; import { Circle, - Command, CommandExecutors, CommandReceiver, CreateObjectsCommand, CreatePlayerCommand, DeleteObjectsCommand, - GameObject, Id, PropertyUpdatesForObjects, RemoteCallsForObjects, settings, UpdatePropertyCommand, } from 'shared'; -import { BeforeDestroyCommand } from '../commands/types/before-destroy'; -import { RenderCommand } from '../commands/types/render'; -import { StepCommand } from '../commands/types/step'; import { FeedbackHud } from '../feedback-hud'; 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'; +import { View } from './view'; export class GameObjectContainer extends CommandReceiver { - protected objects: Map = new Map(); - public player!: CharacterView; - public camera: Camera = new Camera(this.game); + public player?: CharacterView; + public readonly camera: Camera; + public planets: Array = []; + + private objects: Map = new Map(); private wasLocalPlayerAlive = false; - // Create/delete are admitted/retired on the render timeline (not the wire) so interpolated objects don't pop at the muzzle. + // Creates and deletes take effect on the render timeline, not on arrival. private visibleFrom = new Map(); private deleteAt = new Map(); private awaitingCreateStamp: Array = []; @@ -50,42 +47,10 @@ export class GameObjectContainer extends CommandReceiver { [CreateObjectsCommand.type]: (c: CreateObjectsCommand) => c.objects.forEach((o) => { - this.addObject(o as GameObject); - this.awaitingCreateStamp.push((o as GameObject).id); + this.addObject(o as View); + this.awaitingCreateStamp.push(o.id); }), - [StepCommand.type]: (c: StepCommand) => { - // Fall back to newest snapshot if a batch had no property updates, so nothing is stranded. - this.stampPending(serverTimeline.snapshotTime); - this.retireDueObjects(); - this.defaultCommandExecutor(c); - - // Alive = object exists AND health > 0 (player pointer outlives the body). - const bodyPresent = !!this.player && this.objects.has(this.player.id); - const alive = bodyPresent && this.player.health > 0; - - if (this.wasLocalPlayerAlive && !alive) { - FeedbackHud.showElimination(); - } - this.wasLocalPlayerAlive = alive; - - if (bodyPresent) { - // Override the local player's interpolated pose with the predicted one; suppressed while dead (server ignores dead input). - localCharacterPredictor.setAlive(alive); - 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); - } - }, - [RemoteCallsForObjects.type]: (c: RemoteCallsForObjects) => c.callsForObjects.forEach((c) => this.objects.get(c.id)?.processRemoteCalls(c.calls), @@ -93,11 +58,11 @@ export class GameObjectContainer extends CommandReceiver { [PropertyUpdatesForObjects.type]: (c: PropertyUpdatesForObjects) => { serverTimeline.onSnapshot(c.timestamp); - // Create/delete precede property updates in a batch — timestamp is known here. this.stampPending(c.timestamp); c.updates.forEach((u) => { - u.updates.forEach((au) => this.objects.get(u.id)?.handleCommand(au)); - if (this.player && u.id === this.player.id) { + const object = this.objects.get(u.id); + u.updates.forEach((au) => object?.updateProperty?.(au)); + if (object && object === this.player) { this.feedPredictor(u.updates); } }); @@ -107,19 +72,15 @@ export class GameObjectContainer extends CommandReceiver { c.ids.forEach((id: Id) => this.awaitingDeleteStamp.push(id)), }; - constructor(private game: Game) { + constructor(game: Game) { super(); + this.camera = new Camera(game); } - /** - * A reconnect is a new server-side player that never sends deletes for the - * old session's objects — clear them or they stay as frozen ghosts. `player` - * is left stale: reads are gated on `objects`, and the next CreatePlayerCommand - * replaces it. - */ public reset() { - this.objects.forEach((o) => o.handleCommand(new BeforeDestroyCommand())); + this.objects.forEach((o) => o.beforeDestroy?.()); this.objects.clear(); + this.planets = []; this.visibleFrom.clear(); this.deleteAt.clear(); this.awaitingCreateStamp = []; @@ -127,32 +88,43 @@ export class GameObjectContainer extends CommandReceiver { this.wasLocalPlayerAlive = false; } - public get localPlayerPosition(): vec2 | undefined { - return this.player && this.objects.has(this.player.id) - ? this.player.position - : undefined; + public get localPlayer(): CharacterView | undefined { + return this.player && this.objects.has(this.player.id) ? this.player : undefined; } - public get planets(): Array { - const planets: Array = []; - this.objects.forEach((o) => { - if (o instanceof PlanetView) { - planets.push(o); + public step(deltaTimeInSeconds: number) { + this.stampPending(serverTimeline.snapshotTime); + this.retireDueObjects(); + this.objects.forEach((o) => o.step(deltaTimeInSeconds)); + + const player = this.localPlayer; + const alive = !!player && player.health > 0; + if (this.wasLocalPlayerAlive && !alive) { + FeedbackHud.showElimination(); + } + this.wasLocalPlayerAlive = alive; + + if (player) { + localCharacterPredictor.setAlive(alive); + localCharacterPredictor.setStrength( + player.strengthFraction * settings.playerMaxStrength, + ); + if (localCharacterPredictor.update(this.planets, deltaTimeInSeconds)) { + player.head = localCharacterPredictor.head; + player.leftFoot = localCharacterPredictor.leftFoot; + player.rightFoot = localCharacterPredictor.rightFoot; } - }); - return planets; + this.camera.follow(player.position, deltaTimeInSeconds); + } } - protected defaultCommandExecutor(c: Command) { - const isRender = c.type === RenderCommand.type; + public render(renderer: Renderer, overlay: HTMLElement, shouldChangeLayout: boolean) { this.objects.forEach((o) => { - // Stepping runs even for not-yet-visible objects so they enter the scene already moving. - if (isRender && !this.isVisible(o.id)) { - return; + if (this.isVisible(o.id)) { + o.render(renderer, overlay, shouldChangeLayout); } - o.handleCommand(c); }); - this.camera.handleCommand(c); + this.camera.draw(renderer); } private isVisible(id: Id): boolean { @@ -173,52 +145,41 @@ export class GameObjectContainer extends CommandReceiver { } private retireDueObjects() { - if (this.deleteAt.size === 0) { - return; - } const renderTime = serverTimeline.renderTime; for (const [id, at] of [...this.deleteAt]) { if (renderTime >= at) { - this.deleteAt.delete(id); this.deleteObject(id); } } } - // Feed the predictor the raw (not interpolated) authoritative pose. private feedPredictor(updates: Array) { - let head: Circle | undefined; - let leftFoot: Circle | undefined; - let rightFoot: Circle | undefined; + const pose: Partial> = {}; 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 ( + u.propertyKey === 'head' || + u.propertyKey === 'leftFoot' || + u.propertyKey === 'rightFoot' + ) { + pose[u.propertyKey] = u.propertyValue; + } } - if (head && leftFoot && rightFoot) { - localCharacterPredictor.setAuthoritative(head, leftFoot, rightFoot); + if (pose.head && pose.leftFoot && pose.rightFoot) { + localCharacterPredictor.setAuthoritative(pose.head, pose.leftFoot, pose.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) { + private addObject(object: View) { this.objects.set(object.id, object); + if (object instanceof PlanetView) { + this.planets.push(object); + } } private deleteObject(id: Id) { - const object = this.objects.get(id); - object?.handleCommand(new BeforeDestroyCommand()); + this.objects.get(id)?.beforeDestroy?.(); this.objects.delete(id); + this.planets = this.planets.filter((p) => p.id !== id); this.visibleFrom.delete(id); this.deleteAt.delete(id); } diff --git a/frontend/src/scripts/objects/types/camera.ts b/frontend/src/scripts/objects/types/camera.ts index 3b9b004..b39e10e 100644 --- a/frontend/src/scripts/objects/types/camera.ts +++ b/frontend/src/scripts/objects/types/camera.ts @@ -1,56 +1,29 @@ import { vec2 } from 'gl-matrix'; -import { - calculateViewArea, - CommandExecutors, - CommandReceiver, - mixRgb, - settings, -} from 'shared'; -import { RenderCommand } from '../../commands/types/render'; +import { Renderer } from 'sdf-2d'; +import { calculateViewArea, easeVec2, mixRgb, settings } from 'shared'; import { Game } from '../../game'; import { ScreenShake } from '../../screen-shake'; -export class Camera extends CommandReceiver { - public center: vec2 = vec2.create(); +const followSeconds = 0.08; +const snapDistance = 1500; + +export class Camera { + public readonly center: vec2 = vec2.create(); private aspectRatio?: number; - // A short exponential lag masks any residual stepping in the followed - // position without the camera noticeably trailing during normal movement. - private static readonly followSeconds = 0.08; - - // Swooshing across half the map after a respawn would be disorienting; - // beyond this distance the camera cuts instead. - private static readonly snapDistance = 1500; - - constructor(private game: Game) { - super(); - } + constructor(private readonly game: Game) {} public follow(target: vec2, deltaTimeInSeconds: number) { - if (vec2.distance(target, this.center) > Camera.snapDistance) { - vec2.copy(this.center, target); - return; - } - - const q = 1 - Math.exp(-deltaTimeInSeconds / Camera.followSeconds); - vec2.lerp(this.center, this.center, target, q); + easeVec2(this.center, target, deltaTimeInSeconds, followSeconds, snapDistance); } - protected commandExecutors: CommandExecutors = { - [RenderCommand.type]: this.draw.bind(this), - }; - - private draw({ renderer }: RenderCommand) { - const canvasAspectRatio = renderer.canvasSize.x / renderer.canvasSize.y; + public draw(renderer: Renderer) { + const canvasAspectRatio = renderer.canvasSize[0] / renderer.canvasSize[1]; if (canvasAspectRatio !== this.aspectRatio) { this.aspectRatio = canvasAspectRatio; this.game.aspectRatioChanged(canvasAspectRatio); } - // Shake displaces only the rendered view centre and the zoom-punch shrinks - // only the rendered view area — neither touches the followed position, so - // impacts jolt the frame without nudging the camera off the player. Passing - // the zoom as oversizeRatio scales the area about the (shaken) centre. const shakenCenter = vec2.fromValues( this.center[0] + ScreenShake.offsetX, this.center[1] + ScreenShake.offsetY, diff --git a/frontend/src/scripts/objects/types/character-view.ts b/frontend/src/scripts/objects/types/character-view.ts index 6fe951b..2efa0ca 100644 --- a/frontend/src/scripts/objects/types/character-view.ts +++ b/frontend/src/scripts/objects/types/character-view.ts @@ -1,6 +1,5 @@ import { vec2, vec3 } from 'gl-matrix'; import { CircleLight, Renderer } from 'sdf-2d'; - import { characterCenter, Circle, @@ -12,12 +11,8 @@ import { clamp01, mix, strengthToCharge, - CommandExecutors, UpdatePropertyCommand, } from 'shared'; -import { BeforeDestroyCommand } from '../../commands/types/before-destroy'; -import { RenderCommand } from '../../commands/types/render'; -import { StepCommand } from '../../commands/types/step'; import { CircleInterpolator } from '../../helper/interpolators/circle-interpolator'; import { LinearInterpolator } from '../../helper/interpolators/linear-interpolator'; import { pointer } from '../../helper/pointer'; @@ -26,11 +21,10 @@ import { SoundHandler, Sounds } from '../../sound-handler'; import { VibrationHandler } from '../../vibration-handler'; import { FeedbackHud } from '../../feedback-hud'; import { ScreenShake } from '../../screen-shake'; +import { View } from '../view'; const muzzleFlashDecaySeconds = 0.12; const hitFlashDecaySeconds = 0.15; - -// No radius knob on CircleLight, so the death burst is sold by a bright (HDR) colour with a fast-decaying intensity. const deathBurstDecaySeconds = 0.42; const deathBurstMaxIntensity = 1.7; const deathBurstColor = vec3.fromValues(2.5, 2.3, 2.1); @@ -42,34 +36,29 @@ const deathIcon = ''; -export class CharacterView extends CharacterBase { - private shape: CharacterShape; - private muzzleFlash: CircleLight; - private muzzleFlashIntensity = 0; - private hitFlashIntensity = 0; - private deathBurst: CircleLight; - private deathBurstIntensity = 0; - private strength = settings.playerMaxStrength; - private strengthInterpolator = new LinearInterpolator(settings.playerMaxStrength); - private nameElement: HTMLElement = document.createElement('div'); - private statsElement: HTMLElement = document.createElement('div'); - private killCountElement: HTMLElement = document.createElement('span'); - private deathCountElement: HTMLElement = document.createElement('span'); - private healthElement: HTMLElement = document.createElement('div'); - private chargeElement: HTMLElement = document.createElement('div'); - +export class CharacterView extends CharacterBase implements View { public isMainCharacter = false; - private headInterpolator: CircleInterpolator; - private leftFootInterpolator: CircleInterpolator; - private rightFootInterpolator: CircleInterpolator; + private readonly shape: CharacterShape; + private readonly muzzleFlash: CircleLight; + private readonly deathBurst: CircleLight; + private muzzleFlashIntensity = 0; + private hitFlashIntensity = 0; + private deathBurstIntensity = 0; + private strength = settings.playerMaxStrength; - protected commandExecutors: CommandExecutors = { - [RenderCommand.type]: this.draw.bind(this), - [StepCommand.type]: this.step.bind(this), - [BeforeDestroyCommand.type]: this.beforeDestroy.bind(this), - [UpdatePropertyCommand.type]: this.updateProperty.bind(this), - }; + private readonly headInterpolator: CircleInterpolator; + private readonly leftFootInterpolator: CircleInterpolator; + private readonly rightFootInterpolator: CircleInterpolator; + private readonly strengthInterpolator = new LinearInterpolator( + settings.playerMaxStrength, + ); + + private readonly nameElement = document.createElement('div'); + private readonly healthElement = document.createElement('div'); + private readonly chargeElement = document.createElement('div'); + private readonly killCountElement = document.createElement('span'); + private readonly deathCountElement = document.createElement('span'); constructor( id: Id, @@ -78,29 +67,30 @@ export class CharacterView extends CharacterBase { deathCount: number, team: CharacterTeam, health: number, - head?: Circle, - leftFoot?: Circle, - rightFoot?: Circle, + head: Circle, + leftFoot: Circle, + rightFoot: Circle, ) { super(id, name, killCount, deathCount, team, health, head, leftFoot, rightFoot); this.shape = new CharacterShape(settings.colorIndices[team]); this.muzzleFlash = new CircleLight( - vec2.clone(this.head!.center), + vec2.clone(head.center), settings.paletteDim[settings.colorIndices[team]], 0, ); - this.deathBurst = new CircleLight(vec2.clone(this.head!.center), deathBurstColor, 0); + this.deathBurst = new CircleLight(vec2.clone(head.center), deathBurstColor, 0); - this.headInterpolator = new CircleInterpolator(this.head!); - this.leftFootInterpolator = new CircleInterpolator(this.leftFoot!); - this.rightFootInterpolator = new CircleInterpolator(this.rightFoot!); + this.headInterpolator = new CircleInterpolator(head); + this.leftFootInterpolator = new CircleInterpolator(leftFoot); + this.rightFootInterpolator = new CircleInterpolator(rightFoot); this.nameElement.className = 'player-tag ' + this.team; this.nameElement.innerText = this.name; this.healthElement.className = 'health'; this.chargeElement.className = 'charge'; - this.statsElement.className = 'stats'; + const stats = document.createElement('div'); + stats.className = 'stats'; this.killCountElement.className = 'value'; this.deathCountElement.className = 'value'; const killStat = document.createElement('span'); @@ -111,29 +101,27 @@ export class CharacterView extends CharacterBase { deathStat.className = 'stat deaths'; deathStat.innerHTML = deathIcon; deathStat.appendChild(this.deathCountElement); - this.statsElement.append(killStat, deathStat); + stats.append(killStat, deathStat); - this.nameElement.appendChild(this.healthElement); - this.nameElement.appendChild(this.chargeElement); - this.nameElement.appendChild(this.statsElement); + this.nameElement.append(this.healthElement, this.chargeElement, stats); } public get position(): vec2 { - return this.head!.center; + return this.head.center; } public get bodyCenter(): vec2 { - return characterCenter(this.head!, this.leftFoot!, this.rightFoot!); + return characterCenter(this.head, this.leftFoot, this.rightFoot); } public get facingDirection(): vec2 { const footAverage = vec2.add( vec2.create(), - this.leftFoot!.center, - this.rightFoot!.center, + this.leftFoot.center, + this.rightFoot.center, ); vec2.scale(footAverage, footAverage, 0.5); - const forward = vec2.subtract(footAverage, this.head!.center, footAverage); + const forward = vec2.subtract(footAverage, this.head.center, footAverage); return vec2.length(forward) > 0 ? vec2.normalize(forward, forward) : vec2.fromValues(0, 1); @@ -143,21 +131,18 @@ export class CharacterView extends CharacterBase { return clamp01(this.strength / settings.playerMaxStrength); } - private updateProperty({ + public updateProperty({ propertyKey, propertyValue, rateOfChange, }: UpdatePropertyCommand) { if (propertyKey === 'head') { this.headInterpolator.addFrame(propertyValue, rateOfChange); - } - if (propertyKey === 'leftFoot') { + } else if (propertyKey === 'leftFoot') { this.leftFootInterpolator.addFrame(propertyValue, rateOfChange); - } - if (propertyKey === 'rightFoot') { + } else if (propertyKey === 'rightFoot') { this.rightFootInterpolator.addFrame(propertyValue, rateOfChange); - } - if (propertyKey === 'strength') { + } else if (propertyKey === 'strength') { this.strengthInterpolator.addFrame(propertyValue, rateOfChange); } } @@ -184,7 +169,6 @@ export class CharacterView extends CharacterBase { if (this.isMainCharacter) { VibrationHandler.vibrate(150); } - // Visible to everyone who can see the body: a white-hot flash plus a full-body whiteout. this.deathBurstIntensity = 1; this.hitFlashIntensity = 1; } @@ -193,7 +177,6 @@ export class CharacterView extends CharacterBase { if (!this.isMainCharacter) { return; } - // Layer a meaty thud under the crisp confirmation tick; a charged hit lands lower and harder. SoundHandler.play(Sounds.hit, mix(0.35, 0.7, charge), mix(1.3, 0.95, charge)); SoundHandler.play(Sounds.click, mix(0.4, 0.75, charge), mix(1.7, 1.1, charge)); ScreenShake.add(mix(0.22, 0.5, charge)); @@ -205,7 +188,6 @@ export class CharacterView extends CharacterBase { if (!this.isMainCharacter) { return; } - // A heavy low thud for the kill with a brighter confirmation over the top, a hard frame jolt, a zoom-punch, and a double-thump rumble. SoundHandler.play(Sounds.hit, 1, mix(0.62, 0.5, charge)); SoundHandler.play(Sounds.click, 0.9, mix(0.6, 0.45, charge)); ScreenShake.add(mix(0.75, 1, charge)); @@ -215,16 +197,21 @@ export class CharacterView extends CharacterBase { } public onLeap() { - if (!this.isMainCharacter) { - return; + if (this.isMainCharacter) { + SoundHandler.play(Sounds.shoot, 0.3, 1.5); } - SoundHandler.play(Sounds.shoot, 0.3, 1.5); } - private step({ deltaTimeInSeconds }: StepCommand): void { - this.head! = this.headInterpolator.getValue(deltaTimeInSeconds); - this.leftFoot! = this.leftFootInterpolator.getValue(deltaTimeInSeconds); - this.rightFoot! = this.rightFootInterpolator.getValue(deltaTimeInSeconds); + public onShoot(strength: number) { + 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); + } + + public step(deltaTimeInSeconds: number) { + this.head = this.headInterpolator.getValue(deltaTimeInSeconds); + this.leftFoot = this.leftFootInterpolator.getValue(deltaTimeInSeconds); + this.rightFoot = this.rightFootInterpolator.getValue(deltaTimeInSeconds); this.strength = clamp( this.strengthInterpolator.getValue(deltaTimeInSeconds), @@ -237,7 +224,7 @@ export class CharacterView extends CharacterBase { 0, this.muzzleFlashIntensity - deltaTimeInSeconds / muzzleFlashDecaySeconds, ); - this.muzzleFlash.center = this.head!.center; + this.muzzleFlash.center = this.head.center; this.muzzleFlash.intensity = this.muzzleFlashIntensity; } @@ -254,35 +241,30 @@ export class CharacterView extends CharacterBase { this.deathBurstIntensity - deltaTimeInSeconds / deathBurstDecaySeconds, ); this.deathBurst.center = this.bodyCenter; - // Square the envelope so the flash blooms then drops off sharply rather - // than fading out in a flat ramp. this.deathBurst.intensity = deathBurstMaxIntensity * this.deathBurstIntensity * this.deathBurstIntensity; } } - public onShoot(strength: number) { - 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); + public beforeDestroy() { + this.nameElement.remove(); } - private beforeDestroy(): void { - this.nameElement.parentElement?.removeChild(this.nameElement); - } - - private draw({ renderer, overlay, shouldChangeLayout }: RenderCommand): void { + public render(renderer: Renderer, overlay: HTMLElement, shouldChangeLayout: boolean) { if (shouldChangeLayout) { if (!this.nameElement.parentElement) { overlay.appendChild(this.nameElement); } const screenPosition = renderer.worldToDisplayCoordinates( - this.calculateTextPosition(), + vec2.scaleAndAdd( + vec2.create(), + this.head.center, + this.facingDirection, + this.head.radius + 80, + ), ); - - this.nameElement.style.transform = `translateX(${screenPosition.x}px) translateY(${screenPosition.y}px) translateX(-50%) translateY(-50%) rotate(-15deg)`; - + this.nameElement.style.transform = `translateX(${screenPosition[0]}px) translateY(${screenPosition[1]}px) translateX(-50%) translateY(-50%) rotate(-15deg)`; this.healthElement.style.width = (50 * this.health) / settings.playerMaxHealth + 'px'; this.chargeElement.style.width = @@ -293,13 +275,12 @@ export class CharacterView extends CharacterBase { this.shape.hitFlash = this.hitFlashIntensity; this.shape.gazeTarget = this.calculateGazeTarget(renderer); - this.shape.setCircles([this.head!, this.leftFoot!, this.rightFoot!]); + this.shape.setCircles([this.head, this.leftFoot, this.rightFoot]); renderer.addDrawable(this.shape); if (this.muzzleFlashIntensity > 0) { renderer.addDrawable(this.muzzleFlash); } - if (this.deathBurstIntensity > 0) { renderer.addDrawable(this.deathBurst); } @@ -310,21 +291,11 @@ export class CharacterView extends CharacterBase { if (this.isMainCharacter && cursor) { return renderer.displayToWorldCoordinates(cursor); } - return vec2.scaleAndAdd( vec2.create(), - this.head!.center, + this.head.center, this.facingDirection, - this.head!.radius * 8, - ); - } - - private calculateTextPosition(): vec2 { - return vec2.scaleAndAdd( - vec2.create(), - this.head!.center, - this.facingDirection, - this.head!.radius + 80, + this.head.radius * 8, ); } } diff --git a/frontend/src/scripts/objects/types/lamp-view.ts b/frontend/src/scripts/objects/types/lamp-view.ts index e4db4f6..e4f32ce 100644 --- a/frontend/src/scripts/objects/types/lamp-view.ts +++ b/frontend/src/scripts/objects/types/lamp-view.ts @@ -1,20 +1,13 @@ import { vec2, vec3 } from 'gl-matrix'; -import { CircleLight } from 'sdf-2d'; -import { CommandExecutors, Id, LampBase, mixRgb, settings } from 'shared'; -import { RenderCommand } from '../../commands/types/render'; -import { StepCommand } from '../../commands/types/step'; - -export class LampView extends LampBase { - private light: CircleLight; +import { CircleLight, Renderer } from 'sdf-2d'; +import { Id, LampBase, mix, mixRgb, settings, smoothing } from 'shared'; +import { View } from '../view'; +export class LampView extends LampBase implements View { + private readonly light: CircleLight; private targetColor: vec3; private targetLightness: number; - protected commandExecutors: CommandExecutors = { - [RenderCommand.type]: this.draw.bind(this), - [StepCommand.type]: this.step.bind(this), - }; - constructor(id: Id, center: vec2, color: vec3, lightness: number) { super(id, center, color, lightness); this.light = new CircleLight(vec2.clone(center), vec3.clone(color), lightness); @@ -27,13 +20,13 @@ export class LampView extends LampBase { this.targetLightness = lightness; } - private step({ deltaTimeInSeconds }: StepCommand): void { - const t = 1 - Math.exp(-deltaTimeInSeconds / settings.lampLerpSeconds); + public step(deltaTimeInSeconds: number) { + const t = smoothing(deltaTimeInSeconds, settings.lampLerpSeconds); this.light.color = mixRgb(this.light.color, this.targetColor, t); - this.light.intensity += (this.targetLightness - this.light.intensity) * t; + this.light.intensity = mix(this.light.intensity, this.targetLightness, t); } - private draw({ renderer }: RenderCommand): void { + public render(renderer: Renderer) { renderer.addDrawable(this.light); } } diff --git a/frontend/src/scripts/objects/types/planet-view.ts b/frontend/src/scripts/objects/types/planet-view.ts index 7c4df7b..28e0d47 100644 --- a/frontend/src/scripts/objects/types/planet-view.ts +++ b/frontend/src/scripts/objects/types/planet-view.ts @@ -1,63 +1,40 @@ import { vec2, vec3 } from 'gl-matrix'; -import { CircleLight } from 'sdf-2d'; +import { CircleLight, Renderer } from 'sdf-2d'; import { Id, Random, PlanetBase, UpdatePropertyCommand, - CommandExecutors, CharacterTeam, settings, } from 'shared'; -import { BeforeDestroyCommand } from '../../commands/types/before-destroy'; -import { RenderCommand } from '../../commands/types/render'; -import { StepCommand } from '../../commands/types/step'; import { LinearInterpolator } from '../../helper/interpolators/linear-interpolator'; import { PlanetShape } from '../../shapes/planet-shape'; +import { View } from '../view'; const fallingPointLifetimeMs = 2000; -// Global budget for simultaneously-lit capture flares, so a clustered wave can't -// white out the SDF exposure; excess flips still pulse the ring/toast but skip the light. -abstract class FlareBudget { - private static active = 0; +let activeFlares = 0; - public static tryAcquire(): boolean { - if (FlareBudget.active >= settings.maxConcurrentFlipFlares) { - return false; - } - FlareBudget.active++; - return true; - } +export class PlanetView extends PlanetBase implements View { + // Rotation as of the newest snapshot: the predictor collides at this phase, not the drawn one. + public snapshotRotation = 0; + public snapshotRotationSpeed = 0; - public static release(): void { - FlareBudget.active = Math.max(0, FlareBudget.active - 1); - } -} - -export class PlanetView extends PlanetBase { - private shape: PlanetShape; - private ownershipProgress: HTMLElement; - // Rotation is owned by the backend and streamed in; the interpolator replays it on the snapshot timeline, in sync with characters on the surface. + private readonly shape: PlanetShape; + private readonly ownershipProgress = document.createElement('div'); private readonly rotationInterpolator = new LinearInterpolator(0); private flareLight?: CircleLight; private flareIntensity = 0; private holdsFlareSlot = false; - - protected commandExecutors: CommandExecutors = { - [RenderCommand.type]: this.draw.bind(this), - [StepCommand.type]: this.step.bind(this), - [BeforeDestroyCommand.type]: this.beforeDestroy.bind(this), - [UpdatePropertyCommand.type]: this.updateProperty.bind(this), - }; + private lastGeneratedPoint?: number; + private lastGradient?: string; 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' + (isKeystone ? ' keystone' : ''); } @@ -65,52 +42,11 @@ export class PlanetView extends PlanetBase { this.ownershipProgress.classList.toggle('contested', contested); } - private rotationSpeed = 0; - // Newest streamed rotation (server-current at the latest snapshot), NOT the lagged - // rendered one. The predictor seeds the body from the same snapshot's pose, so it - // must collide at THIS phase — using the drawn (lagged) angle biases the body off the surface. - 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.shape.colorMixQ = this.ownership; - - if (this.flareIntensity > 0) { - this.flareIntensity = Math.max( - 0, - this.flareIntensity - deltaTimeInSeconds / settings.lampFlareDecaySeconds, - ); - - if (this.flareLight) { - this.flareLight.intensity = - settings.lampFlareIntensity * this.flareIntensity * this.flareIntensity; - } - - if (this.flareIntensity === 0) { - this.releaseFlareSlot(); - } - } - } - - private releaseFlareSlot(): void { - if (this.holdsFlareSlot) { - this.holdsFlareSlot = false; - FlareBudget.release(); - } - } - - private lastGeneratedPoint?: number; public generatedPoints(value: number) { this.lastGeneratedPoint = value; } - public onFlipped(team: CharacterTeam): void { + public onFlipped(team: CharacterTeam) { const color = settings.palette[settings.colorIndices[team]]; if (!this.flareLight) { @@ -120,44 +56,70 @@ export class PlanetView extends PlanetBase { } if (!this.holdsFlareSlot) { - if (!FlareBudget.tryAcquire()) { + if (activeFlares >= settings.maxConcurrentFlipFlares) { return; } + activeFlares++; this.holdsFlareSlot = true; } this.flareIntensity = 1; } - private beforeDestroy(): void { - this.ownershipProgress.parentElement?.removeChild(this.ownershipProgress); - this.releaseFlareSlot(); - } - - private updateProperty({ + public updateProperty({ propertyKey, propertyValue, rateOfChange, - }: UpdatePropertyCommand): void { + }: UpdatePropertyCommand) { if (propertyKey === 'rotation') { this.rotationInterpolator.addFrame(propertyValue, rateOfChange); - this.latestRotation = propertyValue; - this.rotationSpeed = rateOfChange; + this.snapshotRotation = propertyValue; + this.snapshotRotationSpeed = rateOfChange; } else { this.ownership = propertyValue; } } - private draw({ renderer, overlay, shouldChangeLayout }: RenderCommand): void { + public step(deltaTimeInSeconds: number) { + this.shape.rotation = this.rotationInterpolator.getValue(deltaTimeInSeconds); + this.shape.colorMixQ = this.ownership; + + if (this.flareIntensity > 0) { + this.flareIntensity = Math.max( + 0, + this.flareIntensity - deltaTimeInSeconds / settings.lampFlareDecaySeconds, + ); + if (this.flareLight) { + this.flareLight.intensity = + settings.lampFlareIntensity * this.flareIntensity * this.flareIntensity; + } + if (this.flareIntensity === 0) { + this.releaseFlareSlot(); + } + } + } + + public beforeDestroy() { + this.ownershipProgress.remove(); + this.releaseFlareSlot(); + } + + private releaseFlareSlot() { + if (this.holdsFlareSlot) { + this.holdsFlareSlot = false; + activeFlares--; + } + } + + public render(renderer: Renderer, overlay: HTMLElement, shouldChangeLayout: boolean) { if (shouldChangeLayout) { if (!this.ownershipProgress.parentElement) { overlay.appendChild(this.ownershipProgress); } const screenPosition = renderer.worldToDisplayCoordinates(this.center); + this.ownershipProgress.style.transform = `translateX(${screenPosition[0]}px) translateY(${screenPosition[1]}px) translateX(-50%) translateY(-50%)`; - this.ownershipProgress.style.transform = `translateX(${screenPosition.x}px) translateY(${screenPosition.y}px) translateX(-50%) translateY(-50%)`; - // Re-parsing a conic-gradient per planet per layout frame is not free; ownership moves slower than the ring shows. const gradient = this.getGradient(); if (gradient !== this.lastGradient) { this.lastGradient = gradient; @@ -168,14 +130,10 @@ export class PlanetView extends PlanetBase { const element = document.createElement('div'); element.className = 'falling-point ' + (this.ownership < 0.5 ? 'blue' : 'red'); element.innerText = '+' + this.lastGeneratedPoint; - element.style.left = `${screenPosition.x}px`; - element.style.top = `${screenPosition.y}px`; + element.style.left = `${screenPosition[0]}px`; + element.style.top = `${screenPosition[1]}px`; overlay.appendChild(element); - setTimeout( - () => element.parentElement?.removeChild(element), - fallingPointLifetimeMs, - ); - + setTimeout(() => element.remove(), fallingPointLifetimeMs); this.lastGeneratedPoint = undefined; } } @@ -187,16 +145,11 @@ 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 - // (settings.planetControlThreshold), so "the ring fills" and "this planet - // pays my team" happen together rather than disagreeing. const control = Math.abs(this.ownership - 0.5); const t = settings.planetControlThreshold; const sidePercent = control <= t ? 0 : ((control - t) / (0.5 - t)) * 100; - return sideBlue + return this.ownership < 0.5 ? `conic-gradient( var(--bright-blue) ${sidePercent}%, var(--bright-blue) ${sidePercent}%, diff --git a/frontend/src/scripts/objects/types/projectile-view.ts b/frontend/src/scripts/objects/types/projectile-view.ts index 5843e59..be6d710 100644 --- a/frontend/src/scripts/objects/types/projectile-view.ts +++ b/frontend/src/scripts/objects/types/projectile-view.ts @@ -1,27 +1,18 @@ import { vec2 } from 'gl-matrix'; -import { CircleLight } from 'sdf-2d'; +import { CircleLight, Renderer } from 'sdf-2d'; import { CharacterTeam, - CommandExecutors, Id, ProjectileBase, settings, UpdatePropertyCommand, } from 'shared'; -import { RenderCommand } from '../../commands/types/render'; -import { StepCommand } from '../../commands/types/step'; import { Vec2Interpolator } from '../../helper/interpolators/vec2-interpolator'; +import { View } from '../view'; -export class ProjectileView extends ProjectileBase { - private light: CircleLight; - - private centerInterpolator: Vec2Interpolator; - - protected commandExecutors: CommandExecutors = { - [RenderCommand.type]: this.draw.bind(this), - [StepCommand.type]: this.handleStep.bind(this), - [UpdatePropertyCommand.type]: this.updateProperty.bind(this), - }; +export class ProjectileView extends ProjectileBase implements View { + private readonly light: CircleLight; + private readonly centerInterpolator: Vec2Interpolator; constructor( id: Id, @@ -39,12 +30,12 @@ export class ProjectileView extends ProjectileBase { this.centerInterpolator = new Vec2Interpolator(center); } - private updateProperty({ propertyValue, rateOfChange }: UpdatePropertyCommand): void { + public updateProperty({ propertyValue, rateOfChange }: UpdatePropertyCommand) { this.centerInterpolator.addFrame(propertyValue, rateOfChange); } - private handleStep({ deltaTimeInSeconds }: StepCommand): void { - this.step(deltaTimeInSeconds); + public step(deltaTimeInSeconds: number) { + super.step(deltaTimeInSeconds); this.center = this.centerInterpolator.getValue(deltaTimeInSeconds); this.light.center = this.center; @@ -54,7 +45,7 @@ export class ProjectileView extends ProjectileBase { ); } - private draw({ renderer }: RenderCommand): void { + public render(renderer: Renderer) { renderer.addDrawable(this.light); } } diff --git a/frontend/src/scripts/objects/view.ts b/frontend/src/scripts/objects/view.ts new file mode 100644 index 0000000..af4be1c --- /dev/null +++ b/frontend/src/scripts/objects/view.ts @@ -0,0 +1,9 @@ +import { Renderer } from 'sdf-2d'; +import { GameObject, UpdatePropertyCommand } from 'shared'; + +export interface View extends GameObject { + step(deltaTimeInSeconds: number): void; + render(renderer: Renderer, overlay: HTMLElement, shouldChangeLayout: boolean): void; + beforeDestroy?(): void; + updateProperty?(update: UpdatePropertyCommand): void; +} diff --git a/frontend/src/scripts/options-handler.ts b/frontend/src/scripts/options-handler.ts index 8479fb4..c5f41bf 100644 --- a/frontend/src/scripts/options-handler.ts +++ b/frontend/src/scripts/options-handler.ts @@ -1,77 +1,57 @@ import { SoundHandler, Sounds } from './sound-handler'; import { VibrationHandler } from './vibration-handler'; -interface Options { +export interface Options { vibrationEnabled: boolean; soundsEnabled: boolean; musicEnabled: boolean; } -export abstract class OptionsHandler { - private static initialized = false; - private static _options: Options = { - vibrationEnabled: true, - soundsEnabled: true, - musicEnabled: true, - }; +export const options: Options = { + vibrationEnabled: true, + soundsEnabled: true, + musicEnabled: true, +}; - public static initialize(inputElements: { - [k in Extract]: HTMLInputElement; - }) { - if (localStorage.getItem('options')) { - const stored: Partial | null = JSON.parse( - localStorage.getItem('options')!, - ); - this._options = { - ...this._options, - ...stored, - }; - } +const save = () => localStorage.setItem('options', JSON.stringify(options)); - if (this._options.musicEnabled) { - SoundHandler.playAmbient(); - } +export const initializeOptions = ( + inputElements: Record, +) => { + const stored = localStorage.getItem('options'); + if (stored) { + Object.assign(options, JSON.parse(stored)); + } - for (const k in inputElements) { - const element = inputElements[k as keyof Options]; - element.checked = OptionsHandler._options[k as keyof Options]; - element.addEventListener('change', function () { - OptionsHandler._options[k as keyof Options] = this.checked; - if (!this.checked && k === 'soundsEnabled') { - OptionsHandler._options.musicEnabled = false; - inputElements.musicEnabled.checked = false; + if (options.musicEnabled) { + SoundHandler.playAmbient(); + } + + for (const key of Object.keys(inputElements) as Array) { + const element = inputElements[key]; + element.checked = options[key]; + element.addEventListener('change', () => { + options[key] = element.checked; + if (!element.checked && key === 'soundsEnabled') { + options.musicEnabled = false; + inputElements.musicEnabled.checked = false; + SoundHandler.stopAmbient(); + } + + if (key === 'musicEnabled') { + if (element.checked) { + SoundHandler.playAmbient(); + } else { SoundHandler.stopAmbient(); } + } - if (k === 'musicEnabled') { - if (this.checked) { - SoundHandler.playAmbient(); - } else { - SoundHandler.stopAmbient(); - } - } + if (element.checked && key === 'vibrationEnabled') { + VibrationHandler.vibrate(100); + } - if (this.checked && k === 'vibrationEnabled') { - VibrationHandler.vibrate(100); - } - - SoundHandler.play(Sounds.click); - OptionsHandler.save(); - }); - } - - this.initialized = true; + SoundHandler.play(Sounds.click); + save(); + }); } - - private static save() { - localStorage.setItem('options', JSON.stringify(this._options)); - } - - public static get options(): Options { - if (!this.initialized) { - throw new Error('Uninitialized'); - } - - return this._options; - } -} +}; diff --git a/frontend/src/scripts/screen-shake.ts b/frontend/src/scripts/screen-shake.ts index 98b84e6..9b606da 100644 --- a/frontend/src/scripts/screen-shake.ts +++ b/frontend/src/scripts/screen-shake.ts @@ -46,8 +46,6 @@ function resetScreenShake(): void { offsetYValue = 0; } -// The one public surface. The functions above stay module-private so each -// operation has exactly one name. export const ScreenShake = { add: addTrauma, addPunch, diff --git a/frontend/src/scripts/shapes/character-shape.ts b/frontend/src/scripts/shapes/character-shape.ts index 0fe1840..f8422a9 100644 --- a/frontend/src/scripts/shapes/character-shape.ts +++ b/frontend/src/scripts/shapes/character-shape.ts @@ -19,7 +19,6 @@ export class CharacterShape extends Drawable { return distance(target, circleCenter) - radius; } - // Distance to the segment a->b; subtract a radius to get a capsule (a rounded thick line). float segmentDistance(vec2 a, vec2 b, vec2 target) { vec2 pa = target - a; vec2 ba = b - a; @@ -37,7 +36,6 @@ export class CharacterShape extends Drawable { float headRadius = characterHeadRadii[i]; float footRadius = characterFootRadii[i]; - // The server rotates the whole posture toward travel, so the head leads: forward is the facing direction, perp its 90deg rotation, so the face sits on the leading side at any rotation. vec2 footAverage = (leftFoot + rightFoot) * 0.5; vec2 toHead = head - footAverage; float toHeadLength = length(toHead); @@ -66,7 +64,6 @@ export class CharacterShape extends Drawable { float scleraRadius = renderRadius * 0.26; float pupilRadius = renderRadius * 0.11; - // The normalize is guarded so a cursor resting exactly on an eye can't produce NaNs. vec2 gazeTarget = characterGazeTargets[i]; float pupilReach = (scleraRadius - pupilRadius) * 0.6; vec2 toLeftGaze = gazeTarget - leftEyeCenter; @@ -83,7 +80,7 @@ export class CharacterShape extends Drawable { circleDistance(rightEyeCenter + rightGaze * pupilReach, pupilRadius, target) ); - // Computed here in uniform control flow — NOT inside the body branch below — so the screen-space derivatives fwidth needs stay defined. WebGL1 fallback keeps the plain world-space band (no fwidth in GLSL ES 1.00). + // Uniform control flow on purpose: fwidth needs defined derivatives. float eyeAaBase = renderRadius * 0.025; #ifdef WEBGL2_IS_AVAILABLE float scleraAa = max(eyeAaBase, fwidth(sclera)); @@ -101,7 +98,6 @@ export class CharacterShape extends Drawable { clamp(characterFlash[i], 0.0, 1.0) ); - // The sclera albedo is HDR (>> 1) so the shading pass — which multiplies by dim scene light before clamping — still renders a clean white eye instead of dimming to a dark socket. Needs sdf-2d's float colour buffer; on 8-bit fallback it clamps to plain white. color = mix(color, vec4(10.0, 10.0, 10.0, 1.0), 1.0 - smoothstep(-scleraAa, scleraAa, sclera)); color = mix(color, vec4(0.04, 0.04, 0.07, 1.0), 1.0 - smoothstep(-pupilAa, pupilAa, pupil)); } diff --git a/frontend/src/scripts/shapes/planet-shape.ts b/frontend/src/scripts/shapes/planet-shape.ts index 7d8ac96..492b122 100644 --- a/frontend/src/scripts/shapes/planet-shape.ts +++ b/frontend/src/scripts/shapes/planet-shape.ts @@ -2,7 +2,7 @@ import { mat2d, vec2, vec3, vec4 } from 'gl-matrix'; import { PolygonFactory, DrawableDescriptor, Drawable } from 'sdf-2d'; import { settings } from 'shared'; -export const colorToString = (v: vec3 | vec4): string => +const colorToString = (v: vec3 | vec4): string => `vec4(${v[0]}, ${v[1]}, ${v[2]}, ${v.length > 3 ? v[3] : 1})`; export class PlanetShape extends PolygonFactory(settings.planetEdgeCount, 0) { @@ -58,7 +58,6 @@ export class PlanetShape extends PolygonFactory(settings.planetEdgeCount, 0) { float cr = cos(rotation); float sr = sin(rotation); - // Evaluate the SDF in the planet's own rotating frame so the outline turns with its terrain. vec2 targetCenterDelta = target - center; float targetDistance = length(targetCenterDelta); vec2 localTarget = center + vec2( @@ -127,7 +126,6 @@ export class PlanetShape extends PolygonFactory(settings.planetEdgeCount, 0) { public randomOffset = 0; public rotation = 0; - // Circle about the vertex centroid — the point the shader spins around (planetMinDistance above). Vertices are immutable after construction, so it is cached once. private readonly cullCenter: vec2; private readonly cullRadius: number; diff --git a/frontend/src/scripts/sound-handler.ts b/frontend/src/scripts/sound-handler.ts index c36445f..77180d0 100644 --- a/frontend/src/scripts/sound-handler.ts +++ b/frontend/src/scripts/sound-handler.ts @@ -1,15 +1,16 @@ +import { clamp01 } from 'shared'; import hitSound from '../../static/hit.mp3'; import shootSound from '../../static/shoot.mp3'; import clickSound from '../../static/click.mp3'; import ambientAudio from '../../static/ambient.mp3'; -import { OptionsHandler } from './options-handler'; +import { options } from './options-handler'; export const Sounds = { hit: 'hit', shoot: 'shoot', click: 'click' } as const; export type Sound = (typeof Sounds)[keyof typeof Sounds]; let sounds: Record; let isAmbientPlaying = false; -const ambientSnd = new Audio(ambientAudio); +const ambient = new Audio(ambientAudio); let initialized = false; async function initializeSound(src: string): Promise { @@ -26,29 +27,28 @@ async function initialize( onPlayKeypress: () => unknown = () => null, onPauseKeypress: () => unknown = () => null, ) { - sounds = { - hit: await initializeSound(hitSound), - shoot: await initializeSound(shootSound), - click: await initializeSound(clickSound), - }; + const [hit, shoot, click] = await Promise.all( + [hitSound, shootSound, clickSound].map(initializeSound), + ); + sounds = { hit, shoot, click }; - await ambientSnd.play(); - ambientSnd.muted = true; + await ambient.play(); + ambient.muted = true; initialized = true; - ambientSnd.onpause = onPauseKeypress; - ambientSnd.onplay = onPlayKeypress; + ambient.onpause = onPauseKeypress; + ambient.onplay = onPlayKeypress; - ambientSnd.muted = false; - ambientSnd.volume = 0.5; - ambientSnd.loop = true; + ambient.muted = false; + ambient.volume = 0.5; + ambient.loop = true; if (!isAmbientPlaying) { - ambientSnd.pause(); + ambient.pause(); } } function play(snd: Sound, volume = 1, playbackRate = 1) { - if (!initialized || !OptionsHandler.options.soundsEnabled) { + if (!initialized || !options.soundsEnabled) { return; } @@ -58,7 +58,7 @@ function play(snd: Sound, volume = 1, playbackRate = 1) { if (!isBusy) { audio.currentTime = 0; } - audio.volume = Math.max(0, Math.min(1, volume)); + audio.volume = clamp01(volume); audio.playbackRate = playbackRate; void audio.play().catch(() => undefined); } @@ -66,14 +66,14 @@ function play(snd: Sound, volume = 1, playbackRate = 1) { function playAmbient() { isAmbientPlaying = true; if (initialized) { - ambientSnd.play(); + ambient.play(); } } function stopAmbient() { isAmbientPlaying = false; if (initialized) { - ambientSnd.pause(); + ambient.pause(); } } diff --git a/frontend/src/scripts/tutorial.ts b/frontend/src/scripts/tutorial.ts index c0d1155..ba047ef 100644 --- a/frontend/src/scripts/tutorial.ts +++ b/frontend/src/scripts/tutorial.ts @@ -71,7 +71,7 @@ export class Tutorial extends CommandReceiver { return; } - const player = gameObjects.player; + const player = gameObjects.localPlayer; if (!player) { return; } diff --git a/frontend/src/scripts/vibration-handler.ts b/frontend/src/scripts/vibration-handler.ts index 0ca5e76..7d56f59 100644 --- a/frontend/src/scripts/vibration-handler.ts +++ b/frontend/src/scripts/vibration-handler.ts @@ -1,14 +1,11 @@ -import { OptionsHandler } from './options-handler'; +import { options } from './options-handler'; export const VibrationHandler = { vibrate(pattern: number | number[]) { - if (OptionsHandler.options.vibrationEnabled && 'vibrate' in navigator) { + if (options.vibrationEnabled && 'vibrate' in navigator) { navigator.vibrate(pattern); } }, - get isVibrationEnabled() { - return 'vibrate' in navigator; - }, get isVibrationEnabledHeuristics() { return 'vibrate' in navigator && 'ontouchstart' in window; }, diff --git a/frontend/webpack.config.js b/frontend/webpack.config.js index f729fa2..bbbca6b 100644 --- a/frontend/webpack.config.js +++ b/frontend/webpack.config.js @@ -22,8 +22,6 @@ module.exports = (env, argv) => { host: '0.0.0.0', allowedHosts: 'all', }, - // webpack-dev-server 5 no longer accepts `watchOptions` under `devServer`; - // polling is configured on the compiler instead (needed for some FS mounts). watchOptions: { poll: true, }, @@ -33,23 +31,16 @@ module.exports = (env, argv) => { new HtmlWebpackPlugin({ template: './src/index.html', }), - // The SVG UI icons used to be inlined into the HTML by the (abandoned, - // webpack-4-only) html-webpack-inline-svg-plugin. They are now emitted as - // static files and referenced via `static/.svg` from index.html. new CopyWebpackPlugin({ patterns: [{ from: 'static/*.svg', to: 'static/[name][ext]' }], }), - // Inline the extracted CSS into the HTML for production builds (replaces - // the abandoned html-webpack-inline-source-plugin). In development the CSS - // stays a separate, linked file for faster rebuilds. ...(isProduction ? [new HTMLInlineCSSWebpackPlugin()] : []), ], optimization: { minimizer: [ new TerserJSPlugin({ exclude: /node_modules/, - // The custom serialization protocol keys on class names, so they must - // survive minification (see shared/src/serialization). + // The serialization protocol keys on class names. terserOptions: { keep_classnames: true, }, @@ -84,23 +75,12 @@ module.exports = (env, argv) => { ], }, { - // SVGs referenced from CSS (`mask-image`/`background-image`) and JS - // must be inlined as real `data:` URIs. svg-url-loader emits a CJS - // module (`module.exports = "data:..."`) which webpack 5 + css-loader 7 - // write out verbatim as a `.svg` asset file, so the browser fetches JS - // instead of an image and the mask/background silently fails. webpack 5's - // built-in `asset/inline` produces a proper data URI instead. - // (HTML icons are unaffected: they are copied by CopyWebpackPlugin - // and referenced by literal `static/*.svg` paths, not through this rule.) + // svg-url-loader emits a CJS module that css-loader 7 writes out verbatim. test: /\.svg$/, type: 'asset/inline', }, { - // Use oneOf so each asset matches exactly one rule (og-image.png would - // otherwise match both the generic png rule and its own rule). - // The directory is encoded in `name` (not `outputPath`) and has no - // leading slash: with the default `publicPath: 'auto'` a leading slash - // produces a doubled slash in the emitted URL (e.g. `//static/x.mp3`). + // No leading slash in `name`: with publicPath 'auto' it doubles the slash. oneOf: [ { test: /og-image\.png$/, diff --git a/shared/src/commands/command.ts b/shared/src/commands/command.ts index 1142b7a..7670416 100644 --- a/shared/src/commands/command.ts +++ b/shared/src/commands/command.ts @@ -1,9 +1,9 @@ export abstract class Command { public static get type(): string { - return (this as any).name; + return this.name; } public get type(): string { - return (this as any).constructor.name; + return this.constructor.name; } } diff --git a/shared/src/commands/types/actions/client-heartbeat.ts b/shared/src/commands/types/actions/client-heartbeat.ts index 013485d..e2e0644 100644 --- a/shared/src/commands/types/actions/client-heartbeat.ts +++ b/shared/src/commands/types/actions/client-heartbeat.ts @@ -1,10 +1,6 @@ import { serializable } from '../../../serialization/serializable'; import { Command } from '../../command'; -// Appended to every client -> server batch. Movement is only sent on change, -// so without this the server's "newest input applied" marker freezes while a -// key is held, pinning the predictor's replay window. This carries the client's -// wall-clock so the marker advances at the send rate regardless of input change. @serializable export class ClientHeartbeatCommand extends Command { public constructor(public readonly clientTimeMs: number = 0) { diff --git a/shared/src/commands/types/actions/leap-action.ts b/shared/src/commands/types/actions/leap-action.ts index a7ca5c8..3681de2 100644 --- a/shared/src/commands/types/actions/leap-action.ts +++ b/shared/src/commands/types/actions/leap-action.ts @@ -1,9 +1,6 @@ import { serializable } from '../../../serialization/serializable'; import { Command } from '../../command'; -// clientTimeMs is 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) { diff --git a/shared/src/commands/types/actions/move-action.ts b/shared/src/commands/types/actions/move-action.ts index 2cecb1c..7f2a455 100644 --- a/shared/src/commands/types/actions/move-action.ts +++ b/shared/src/commands/types/actions/move-action.ts @@ -4,10 +4,6 @@ import { Command } from '../../command'; @serializable export class MoveActionCommand extends Command { - // clientTimeMs (integer ms, survives the serializer's toFixed(3)) is echoed - // back via InputAcknowledgement so the client knows how much of its input - // timeline is baked into a snapshot and can replay the rest. Defaults to 0 - // for inputs the server itself synthesises. public constructor( public readonly direction: vec2, public readonly clientTimeMs: number = 0, diff --git a/shared/src/commands/types/actions/primary-action.ts b/shared/src/commands/types/actions/primary-action.ts index 762feeb..ea60b20 100644 --- a/shared/src/commands/types/actions/primary-action.ts +++ b/shared/src/commands/types/actions/primary-action.ts @@ -7,9 +7,6 @@ export class PrimaryActionCommand extends Command { public constructor( public readonly position: vec2, public readonly charge: number = 0, - // Client wall-clock at shot release, echoed back via InputAcknowledgement - // like the other actions' — it advances the predictor's replay anchor past - // this shot. Appended last so the existing wire order is unchanged. public readonly clientTimeMs: number = 0, ) { super(); diff --git a/shared/src/commands/types/game-start.ts b/shared/src/commands/types/game-start.ts index 15f14c8..4e56570 100644 --- a/shared/src/commands/types/game-start.ts +++ b/shared/src/commands/types/game-start.ts @@ -3,10 +3,6 @@ import { Command } from '../command'; @serializable export class GameStartCommand extends Command { - constructor() { - super(); - } - public toArray(): Array { return []; } diff --git a/shared/src/commands/types/input-acknowledgement.ts b/shared/src/commands/types/input-acknowledgement.ts index 2359288..79f6a83 100644 --- a/shared/src/commands/types/input-acknowledgement.ts +++ b/shared/src/commands/types/input-acknowledgement.ts @@ -3,16 +3,7 @@ import { Id } from '../../communication/communication'; import { serializable } from '../../serialization/serializable'; import { Command } from '../command'; -/** - * Movement state carried tick-to-tick that is NOT in the pose. Together with - * the pose (which travels as ordinary property updates) this is the complete - * state `stepCharacterMovement` needs, so the predictor resumes the server's - * simulation rather than approximating it. - * - * `direction` is not recoverable from the pose: the body is sprung *towards* - * the posture that direction implies and lags it while walking, so deriving the - * angle back out is off by tens of degrees. - */ +// `direction` is not recoverable from the pose: the body lags the posture it is sprung towards. @serializable export class CharacterMovementSnapshot { public constructor( @@ -20,7 +11,7 @@ export class CharacterMovementSnapshot { public readonly bodyVelocity: vec2, public readonly leftFootNormal: vec2, public readonly rightFootNormal: vec2, - public readonly groundPlanetId: Id, + public readonly groundPlanetId: Id | null, public readonly secondsSinceOnSurface: number, ) {} @@ -36,21 +27,12 @@ export class CharacterMovementSnapshot { } } -// Sent server -> owning client only. Carries the clientTimeMs of the most -// recent input the server had received when the snapshot was taken (so the -// predictor replays only the inputs the server hasn't seen) and the movement -// state the replay resumes from. See LocalCharacterPredictor. @serializable export class InputAcknowledgement extends Command { public constructor( public readonly clientTimeMs: number, public readonly movement: CharacterMovementSnapshot, - // clientTimeMs of the most recent leap the server has received: any leap at - // or before it is already in movement.bodyVelocity and must not be replayed. public readonly lastLeapClientTimeMs: number, - // Age of the acknowledged input at snapshot time. Adding it back places the - // replay anchor at the snapshot instant instead of whichever input landed - // last, keeping the predicted pose free of the send-cadence beat. public readonly ackAgeMs: number = 0, ) { super(); diff --git a/shared/src/commands/types/property-updates-for-objects.ts b/shared/src/commands/types/property-updates-for-objects.ts index 1ed4b1e..aa4ec34 100644 --- a/shared/src/commands/types/property-updates-for-objects.ts +++ b/shared/src/commands/types/property-updates-for-objects.ts @@ -6,7 +6,6 @@ import { Command } from '../command'; export class PropertyUpdatesForObjects extends Command { constructor( public readonly updates: Array, - // Only differences between timestamps are meaningful. public readonly timestamp: number, ) { super(); diff --git a/shared/src/commands/types/remote-calls-for-objects.ts b/shared/src/commands/types/remote-calls-for-objects.ts index 3a99962..f1d14fc 100644 --- a/shared/src/commands/types/remote-calls-for-objects.ts +++ b/shared/src/commands/types/remote-calls-for-objects.ts @@ -1,4 +1,5 @@ -import { Id, RemoteCall } from '../../main'; +import { Id } from '../../communication/communication'; +import { RemoteCall } from '../../objects/game-object'; import { serializable } from '../../serialization/serializable'; import { Command } from '../command'; diff --git a/shared/src/communication/communication.ts b/shared/src/communication/communication.ts index c23f7c4..51a9cd6 100644 --- a/shared/src/communication/communication.ts +++ b/shared/src/communication/communication.ts @@ -1,9 +1,7 @@ -export type Id = number | null; +export type Id = number; let currentId = 0; -export const id = (): number => { - return currentId++; -}; +export const id = (): Id => currentId++; export interface PlayerInformation { name: string; diff --git a/shared/src/helper/calculate-view-area.ts b/shared/src/helper/calculate-view-area.ts index e23f886..5c36b56 100644 --- a/shared/src/helper/calculate-view-area.ts +++ b/shared/src/helper/calculate-view-area.ts @@ -1,5 +1,5 @@ import { vec2 } from 'gl-matrix'; -import { settings } from '../main'; +import { settings } from '../settings'; export const calculateViewArea = ( center: vec2, diff --git a/shared/src/helper/charge.ts b/shared/src/helper/charge.ts index be2a67a..80cd5c8 100644 --- a/shared/src/helper/charge.ts +++ b/shared/src/helper/charge.ts @@ -4,8 +4,9 @@ import { settings } from '../settings'; export const holdDurationToCharge = (heldSeconds: number): number => clamp01(heldSeconds / settings.chargeShotFullHoldSeconds); -// Inverse of the server's projectile mix(); both sides need this number, so it -// lives here rather than being derived twice. +export const chargeHeldSince = (heldSinceMs: number): number => + holdDurationToCharge((performance.now() - heldSinceMs) / 1000); + export const strengthToCharge = (strength: number): number => clamp01( (strength - settings.chargeShotStrengthMin) / diff --git a/shared/src/helper/ease.ts b/shared/src/helper/ease.ts new file mode 100644 index 0000000..13f9f78 --- /dev/null +++ b/shared/src/helper/ease.ts @@ -0,0 +1,15 @@ +import { vec2 } from 'gl-matrix'; + +export const smoothing = (deltaTimeInSeconds: number, seconds: number): number => + 1 - Math.exp(-deltaTimeInSeconds / seconds); + +export const easeVec2 = ( + out: vec2, + target: vec2, + deltaTimeInSeconds: number, + seconds: number, + snapDistance: number, +): vec2 => + vec2.distance(out, target) > snapDistance + ? vec2.copy(out, target) + : vec2.lerp(out, out, target, smoothing(deltaTimeInSeconds, seconds)); diff --git a/shared/src/helper/finite.ts b/shared/src/helper/finite.ts index ddfb6c4..e659aaf 100644 --- a/shared/src/helper/finite.ts +++ b/shared/src/helper/finite.ts @@ -1,9 +1,9 @@ import { vec2 } from 'gl-matrix'; +import { clamp } from './clamp'; export const isFiniteNumber = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value); -/** A finite number within [min, max], or `fallback` if the input is unusable. */ export const finiteInRange = ( value: unknown, min: number, @@ -13,15 +13,9 @@ export const finiteInRange = ( if (!isFiniteNumber(value)) { return fallback; } - return Math.min(max, Math.max(min, value)); + return clamp(value, min, max); }; -/** - * A finite 2-vector whose magnitude is within `maxMagnitude`, or `undefined` if - * the input is not a usable pair of finite numbers. Callers should treat - * `undefined` as "drop this command" rather than substituting a default, so a - * malformed action is ignored instead of being silently reinterpreted. - */ export const finiteVec2 = (value: unknown, maxMagnitude: number): vec2 | undefined => { if (!value || typeof value !== 'object') { return undefined; @@ -47,10 +41,6 @@ export const finiteVec2 = (value: unknown, maxMagnitude: number): vec2 | undefin return result; }; -/** - * Coerces non-strings: a non-string survives `.slice()` and round-trips through - * the serializer revived as a class on every peer, throwing in the reviver. - */ export const sanitizeName = (value: unknown, maxLength: number): string => { const text = typeof value === 'string' ? value : String(value ?? ''); return text.slice(0, maxLength); diff --git a/shared/src/helper/mix-rgb.ts b/shared/src/helper/mix-rgb.ts index b3959b4..17195af 100644 --- a/shared/src/helper/mix-rgb.ts +++ b/shared/src/helper/mix-rgb.ts @@ -2,8 +2,6 @@ import { vec3 } from 'gl-matrix'; import { clamp01 } from './clamp'; import { mix } from './mix'; -// Component-wise lerp of two colours. Kept as a helper because the alternative -// is the same clamp-plus-three-mixes expansion repeated at every call site. export const mixRgb = (a: vec3, b: vec3, q: number): vec3 => { const clampedQ = clamp01(q); return vec3.fromValues( diff --git a/shared/src/helper/random.ts b/shared/src/helper/random.ts index 7e49865..d200cf0 100644 --- a/shared/src/helper/random.ts +++ b/shared/src/helper/random.ts @@ -8,9 +8,6 @@ function getRandom(): number { } export const Random = { - get seed() { - return seed; - }, set seed(value: number) { seed = value; }, diff --git a/shared/src/main.ts b/shared/src/main.ts index 8501417..befadfc 100644 --- a/shared/src/main.ts +++ b/shared/src/main.ts @@ -10,13 +10,11 @@ export * from './commands/types/game-start'; export * from './commands/types/update-minimap'; export * from './commands/types/update-game-state'; export * from './commands/command-receiver'; -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/client-heartbeat'; export * from './commands/types/actions/set-aspect-ratio-action'; -export * from './helper/array'; export * from './helper/hsl'; export * from './helper/rgb255'; export * from './helper/clamp'; @@ -25,13 +23,13 @@ export * from './helper/charge'; export * from './helper/calculate-view-area'; export * from './helper/circle'; export * from './helper/mix'; +export * from './helper/ease'; export * from './helper/mix-rgb'; export * from './helper/random'; export * from './communication/communication'; export * from './objects/game-object'; export * from './serialization/deserialize'; export * from './serialization/serialize'; -export * from './serialization/serializes-to'; export * from './serialization/serializable'; export * from './serialization/override-deserialization'; export * from './objects/types/character-base'; diff --git a/shared/src/objects/game-object.ts b/shared/src/objects/game-object.ts index fbcd26f..54afd7e 100644 --- a/shared/src/objects/game-object.ts +++ b/shared/src/objects/game-object.ts @@ -1,5 +1,4 @@ import { Command } from '../commands/command'; -import { CommandReceiver } from '../commands/command-receiver'; import { Id } from '../communication/communication'; import { serializable } from '../serialization/serializable'; @@ -15,8 +14,6 @@ export class RemoteCall { } } -// Wire property keys — compiler-checked at both the producing (server) and -// consuming (client) ends; wire strings are unchanged. export type SyncPropertyKey = | 'head' | 'leftFoot' @@ -53,67 +50,51 @@ export class PropertyUpdatesForObject { } } -let currentUpdateGeneration = 0; -export const beginPropertyUpdateGeneration = (): void => { - currentUpdateGeneration++; -}; +// Security: remote calls are dispatched by a raw string off the network. +const allowedRemoteCalls: ReadonlySet = new Set([ + 'onShoot', + 'onLeap', + 'onDie', + 'onHitConfirmed', + 'onKillConfirmed', + 'setHealth', + 'setKillCount', + 'onFlipped', + 'setContested', + 'generatedPoints', + 'setLight', +]); -export abstract class GameObject extends CommandReceiver { +export abstract class GameObject { private remoteCalls: Array = []; - private updateGeneration = -1; - private cachedPropertyUpdates?: PropertyUpdatesForObject; - // Security: processRemoteCalls dispatches by a raw string off the network. - // Keep this allow-list in sync with the remoteCall() emitters on *-physical. - private static readonly allowedRemoteCalls: ReadonlySet = new Set([ - 'onShoot', - 'onLeap', - 'onDie', - 'onHitConfirmed', - 'onKillConfirmed', - 'setHealth', - 'setKillCount', - 'onFlipped', - 'setContested', - 'generatedPoints', - 'setLight', - ]); - - constructor(public readonly id: Id) { - super(); - } + constructor(public readonly id: Id) {} public processRemoteCalls(remoteCalls: Array) { - remoteCalls.forEach((r) => { - if (!GameObject.allowedRemoteCalls.has(r.functionName)) { - console.warn(`Dropped disallowed remote call: ${r.functionName}`); - return; + for (const { functionName, args } of remoteCalls) { + if (!allowedRemoteCalls.has(functionName)) { + console.warn(`Dropped disallowed remote call: ${functionName}`); + continue; } - const fn = this[r.functionName as keyof this]; + const fn = this[functionName as keyof this]; if (typeof fn === 'function') { - (fn as (...args: Array) => unknown).apply(this, r.args); + (fn as (...args: Array) => unknown).apply(this, args); } - }); + } } public getPropertyUpdates(): PropertyUpdatesForObject | undefined { return undefined; } - public getPropertyUpdatesForFrame(): PropertyUpdatesForObject | undefined { - if (this.updateGeneration !== currentUpdateGeneration) { - this.updateGeneration = currentUpdateGeneration; - this.cachedPropertyUpdates = this.getPropertyUpdates(); - } - return this.cachedPropertyUpdates; - } - public getRemoteCalls(): Array { return this.remoteCalls; } public resetRemoteCalls() { - this.remoteCalls = []; + if (this.remoteCalls.length > 0) { + this.remoteCalls = []; + } } protected remoteCall(name: string & keyof this, ...args: Array) { diff --git a/shared/src/objects/types/character-base.ts b/shared/src/objects/types/character-base.ts index 631d91e..d860730 100644 --- a/shared/src/objects/types/character-base.ts +++ b/shared/src/objects/types/character-base.ts @@ -1,7 +1,6 @@ import { Id } from '../../communication/communication'; import { Circle } from '../../helper/circle'; import { serializable } from '../../serialization/serializable'; -import { toArrayFromFields } from '../../serialization/serialized-fields'; import { GameObject } from '../game-object'; export enum CharacterTeam { @@ -12,18 +11,6 @@ export enum CharacterTeam { @serializable export class CharacterBase extends GameObject { - private static readonly serializedFields = [ - 'id', - 'name', - 'killCount', - 'deathCount', - 'team', - 'health', - 'head', - 'leftFoot', - 'rightFoot', - ] as const; - constructor( id: Id, public name: string, @@ -31,23 +18,20 @@ export class CharacterBase extends GameObject { public deathCount: number, public team: CharacterTeam, public health: number, - public head?: Circle, - public leftFoot?: Circle, - public rightFoot?: Circle, + public head: Circle, + public leftFoot: Circle, + public rightFoot: Circle, ) { super(id); } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - public onShoot(strength: number) {} + public onShoot(_strength: number) {} public onLeap() {} - // eslint-disable-next-line @typescript-eslint/no-unused-vars - public onHitConfirmed(charge?: number) {} + public onHitConfirmed(_charge?: number) {} - // eslint-disable-next-line @typescript-eslint/no-unused-vars - public onKillConfirmed(victimName?: string, streak?: number, charge?: number) {} + public onKillConfirmed(_victimName?: string, _streak?: number, _charge?: number) {} public setHealth(health: number) { this.health = health; @@ -60,6 +44,16 @@ export class CharacterBase extends GameObject { } public toArray(): Array { - return toArrayFromFields(this, CharacterBase.serializedFields); + return [ + this.id, + this.name, + this.killCount, + this.deathCount, + this.team, + this.health, + this.head, + this.leftFoot, + this.rightFoot, + ]; } } diff --git a/shared/src/objects/types/lamp-base.ts b/shared/src/objects/types/lamp-base.ts index 00c20bb..aba7d83 100644 --- a/shared/src/objects/types/lamp-base.ts +++ b/shared/src/objects/types/lamp-base.ts @@ -1,17 +1,10 @@ import { vec2, vec3 } from 'gl-matrix'; -import { Id, serializable } from '../../main'; -import { toArrayFromFields } from '../../serialization/serialized-fields'; +import { Id } from '../../communication/communication'; +import { serializable } from '../../serialization/serializable'; import { GameObject } from '../game-object'; @serializable export class LampBase extends GameObject { - private static readonly serializedFields = [ - 'id', - 'center', - 'color', - 'lightness', - ] as const; - constructor( id: Id, public center: vec2, @@ -21,10 +14,9 @@ export class LampBase extends GameObject { super(id); } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - public setLight(color: vec3, lightness: number) {} + public setLight(_color: vec3, _lightness: number) {} public toArray(): Array { - return toArrayFromFields(this, LampBase.serializedFields); + return [this.id, this.center, this.color, this.lightness]; } } diff --git a/shared/src/objects/types/planet-base.ts b/shared/src/objects/types/planet-base.ts index 84a8e6d..b193100 100644 --- a/shared/src/objects/types/planet-base.ts +++ b/shared/src/objects/types/planet-base.ts @@ -2,23 +2,23 @@ import { vec2 } from 'gl-matrix'; import { Random } from '../../helper/random'; import { settings } from '../../settings'; import { serializable } from '../../serialization/serializable'; -import { toArrayFromFields } from '../../serialization/serialized-fields'; import { GameObject } from '../game-object'; import { Id } from '../../communication/communication'; import { CharacterTeam } from './character-base'; +import { GroundSurface } from '../../physics/sdf'; +import { planetDistance, planetGravity } from '../../physics/planet-sdf'; @serializable -export class PlanetBase extends GameObject { - // centre/radius are derived from vertices, so not serialized. - private static readonly serializedFields = [ - 'id', - 'vertices', - 'ownership', - 'isKeystone', - ] as const; - +export class PlanetBase extends GameObject implements GroundSurface { + public readonly canCollide = true; + public readonly isGround = true; public readonly center: vec2; public readonly radius: number; + public angularVelocity = 0; + + private _rotation = 0; + private cosRotation = 1; + private sinRotation = 0; constructor( id: Id, @@ -31,11 +31,9 @@ export class PlanetBase extends GameObject { vec2.scale(this.center, this.center, 1 / vertices.length); this.radius = vertices.reduce((sum, v) => sum + vec2.distance(this.center, v), 0) / - this.vertices.length; + vertices.length; } - // The one capture rule: server flips and scoring, client tints and the - // keystone arrow all read this. public get team(): CharacterTeam { return Math.abs(this.ownership - 0.5) < settings.planetControlThreshold ? CharacterTeam.neutral @@ -44,12 +42,37 @@ export class PlanetBase extends GameObject { : CharacterTeam.red; } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - 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 get rotation(): number { + return this._rotation; + } + + public set rotation(value: number) { + this._rotation = value; + this.cosRotation = Math.cos(value); + this.sinRotation = Math.sin(value); + } + + public advanceRotation(deltaTimeInSeconds: number) { + this.rotation += this.angularVelocity * deltaTimeInSeconds; + } + + public distance(target: vec2): number { + return planetDistance( + target, + this.vertices, + this.center, + this.cosRotation, + this.sinRotation, + ); + } + + public gravityAt(position: vec2): vec2 { + return planetGravity(this.center, this.radius, position); + } + + public generatedPoints(_value: number) {} + public onFlipped(_team: CharacterTeam) {} + public setContested(_contested: boolean) {} public static createPlanetVertices( center: vec2, @@ -76,6 +99,6 @@ export class PlanetBase extends GameObject { } public toArray(): Array { - return toArrayFromFields(this, PlanetBase.serializedFields); + return [this.id, this.vertices, this.ownership, this.isKeystone]; } } diff --git a/shared/src/objects/types/projectile-base.ts b/shared/src/objects/types/projectile-base.ts index 0d68e4d..2450398 100644 --- a/shared/src/objects/types/projectile-base.ts +++ b/shared/src/objects/types/projectile-base.ts @@ -1,21 +1,12 @@ import { vec2 } from 'gl-matrix'; import { settings } from '../../settings'; import { serializable } from '../../serialization/serializable'; -import { toArrayFromFields } from '../../serialization/serialized-fields'; import { GameObject } from '../game-object'; import { Id } from '../../communication/communication'; import { CharacterTeam } from './character-base'; @serializable export class ProjectileBase extends GameObject { - private static readonly serializedFields = [ - 'id', - 'center', - 'radius', - 'team', - 'strength', - ] as const; - constructor( id: Id, public center: vec2, @@ -32,6 +23,6 @@ export class ProjectileBase extends GameObject { } public toArray(): Array { - return toArrayFromFields(this, ProjectileBase.serializedFields); + return [this.id, this.center, this.radius, this.team, this.strength]; } } diff --git a/shared/src/physics/character-movement.ts b/shared/src/physics/character-movement.ts index 49cac9a..1098478 100644 --- a/shared/src/physics/character-movement.ts +++ b/shared/src/physics/character-movement.ts @@ -3,8 +3,6 @@ import { settings } from '../settings'; import { GroundSurface, PhysicsBody } from './sdf'; import { interpolateAngles } from './interpolate-angles'; -// Body layout — must match CharacterPhysical verbatim so the predicted body -// equals the authoritative one to the bit. export const headRadius = 50; export const feetRadius = 20; @@ -33,7 +31,6 @@ export const rightFootOffset = vec2.subtract( ); export const boundRadius = (headRadius + feetRadius * 2) * 2; -// Shared by backend CharacterPhysical and the client predictor. export interface CharacterMovementState { readonly head: PhysicsBody; readonly leftFoot: PhysicsBody; @@ -41,21 +38,14 @@ export interface CharacterMovementState { direction: number; currentPlanet: GroundSurface | undefined; secondsSinceOnSurface: number; - // Persistent launch momentum; walking zeroes per-part velocity each tick, so - // anything that must carry lives here. The client predictor leaves it zero and - // snaps to the server's impulses; the server uses it for full movement. bodyVelocity: vec2; } -// The server backs this with its spatial container (dispatching collision -// reactions); the client backs it with planets only, dispatching nothing. export interface CharacterWorld { groundsNear(center: vec2, radius: number): Array; stepBody(body: PhysicsBody, deltaTimeInSeconds: number): GroundSurface | undefined; } -// Exported so the backend's CirclePhysical accumulates forces through the exact -// same expression, f32 intermediate included. export const applyForce = ( body: PhysicsBody, force: vec2, @@ -68,8 +58,7 @@ export const applyForce = ( ); }; -// ((head + leftFoot) + rightFoot) / 3 — do not reassociate; every reader of the -// character centre uses this exact association. +// ((head + leftFoot) + rightFoot) / 3 — do not reassociate. export const characterCenter = ( head: { center: vec2 }, leftFoot: { center: vec2 }, @@ -98,7 +87,6 @@ const springMove = ( const desiredPosition = vec2.add(vec2.create(), center, offset); vec2.rotate(desiredPosition, desiredPosition, center, state.direction); const positionDelta = vec2.subtract(vec2.create(), desiredPosition, body.center); - // dt arrives later at integration, so per-tick displacement is positionDelta * stiffness * dt. vec2.scaleAndAdd(body.velocity, body.velocity, positionDelta, stiffness); }; @@ -121,8 +109,6 @@ const keepPosture = (state: CharacterMovementState) => { springMove(state, state.head, center, headOffset, settings.postureHeadStiffness); }; -// Ride a planet's spin: rotate the body about the planet centre by the same -// per-tick angle the collision SDF turns by (negative, matching R(-rotation)). const carryWithRotatingPlanet = ( state: CharacterMovementState, deltaTimeInSeconds: number, @@ -148,8 +134,6 @@ const carryWithRotatingPlanet = ( ); }; -// Shared so the server's leap() and the client's prediction apply the exact -// same impulse. Caller does gating; no-op when not on a surface. export const applyLeapImpulse = (state: CharacterMovementState, moveDirection: vec2) => { const planet = state.currentPlanet; if (!planet) { @@ -179,7 +163,6 @@ export const applyLeapImpulse = (state: CharacterMovementState, moveDirection: v vec2.normalize(launch, launch); vec2.scaleAndAdd(state.bodyVelocity, state.bodyVelocity, launch, settings.leapSpeed); - // Slingshot: tangential velocity of the spinning surface (same motion carryWithRotatingPlanet imparts). const center = characterCenter(state.head, state.leftFoot, state.rightFoot); const omega = planet.angularVelocity; const surfaceVelocity = vec2.fromValues( @@ -197,8 +180,6 @@ export const applyLeapImpulse = (state: CharacterMovementState, moveDirection: v state.secondsSinceOnSurface = settings.planetDetachmentSeconds; }; -// Kept as its own step: 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, @@ -210,8 +191,6 @@ export const tickPlanetDetachment = ( } }; -// Inject body momentum onto every part right before they step, so the whole -// body translates rigidly without disturbing the posture springs. No-op while walking. const applyBodyMomentum = (state: CharacterMovementState) => { if (vec2.squaredLength(state.bodyVelocity) === 0) { return; @@ -221,8 +200,6 @@ const applyBodyMomentum = (state: CharacterMovementState) => { vec2.add(state.head.velocity, state.head.velocity, state.bodyVelocity); }; -// Shared so the living body, the client predictor, and the ragdoll corpse all -// brake identically. export const decayMomentum = ( bodyVelocity: vec2, onGround: boolean, @@ -251,7 +228,7 @@ const decayBodyMomentum = (state: CharacterMovementState, deltaTimeInSeconds: nu decayMomentum(state.bodyVelocity, !!state.currentPlanet, deltaTimeInSeconds); }; -const sumGravity = (grounds: Array, position: vec2): vec2 => +export const sumGravity = (grounds: Array, position: vec2): vec2 => grounds.reduce( (sum, ground) => vec2.add(sum, sum, ground.gravityAt(position)), vec2.create(), @@ -267,15 +244,16 @@ const latchGround = ( } }; -// The exact movement block of CharacterPhysical.step (gravity → movement force -// → on/off-planet branch → posture → step the parts); server-only concerns are -// left to the caller. `inputDirection` is the already-normalized movement direction. export const stepCharacterMovement = ( state: CharacterMovementState, world: CharacterWorld, inputDirection: vec2, deltaTimeInSeconds: number, ) => { + vec2.zero(state.head.velocity); + vec2.zero(state.leftFoot.velocity); + vec2.zero(state.rightFoot.velocity); + const movementForce = vec2.scale( inputDirection, inputDirection, @@ -285,8 +263,6 @@ export const stepCharacterMovement = ( applyForce(state.rightFoot, movementForce, deltaTimeInSeconds); if (!state.currentPlanet) { - // Widest query the character makes — only run while airborne; grounded is - // the common case. const center = characterCenter(state.head, state.leftFoot, state.rightFoot); const grounds = world.groundsNear(center, boundRadius + settings.maxGravityDistance); const leftFootGravity = sumGravity(grounds, state.leftFoot.center); @@ -341,10 +317,6 @@ export const stepCharacterMovement = ( applyBodyMomentum(state); - // Only the feet latch. The grounded branch above projects gravity onto the - // foot contact normals and leap() launches off them, so latching on a head - // contact would ground the body against normals no part of it is touching — - // a head-first tumble would read as standing. latchGround(state, world.stepBody(state.leftFoot, deltaTimeInSeconds)); latchGround(state, world.stepBody(state.rightFoot, deltaTimeInSeconds)); world.stepBody(state.head, deltaTimeInSeconds); diff --git a/shared/src/physics/depenetrate-circle.ts b/shared/src/physics/depenetrate-circle.ts index e39572e..85bb04a 100644 --- a/shared/src/physics/depenetrate-circle.ts +++ b/shared/src/physics/depenetrate-circle.ts @@ -3,9 +3,6 @@ import { PhysicsBody, Sdf } from './sdf'; import { evaluateSdf } from './evaluate-sdf'; import { sdfNormal } from './sdf-normal'; -// marchCircle assumes an overlap-free start (inside, it registers a zero- -// distance hit and never moves), so any overlap from rotating surfaces must be -// resolved here before marching. export const depenetrateCircle = ( body: PhysicsBody, possibleIntersectors: Array, diff --git a/shared/src/physics/evaluate-sdf.ts b/shared/src/physics/evaluate-sdf.ts index 0f0cac7..e8f3cb5 100644 --- a/shared/src/physics/evaluate-sdf.ts +++ b/shared/src/physics/evaluate-sdf.ts @@ -1,8 +1,6 @@ import { vec2 } from 'gl-matrix'; import { Sdf } from './sdf'; -// Innermost leaf of the physics stack — plain loop, not filter().reduce(), -// to avoid the per-call intermediate array. export const evaluateSdf = (target: vec2, objects: Array): number => { let minimum = 1000000; for (let i = 0; i < objects.length; i++) { diff --git a/shared/src/physics/march-circle.ts b/shared/src/physics/march-circle.ts index 7d72ac1..4e17329 100644 --- a/shared/src/physics/march-circle.ts +++ b/shared/src/physics/march-circle.ts @@ -12,30 +12,20 @@ export interface MarchResult { const minimumStep = 0.5; const maximumSteps = 256; -// Raymarch a circle by `delta`, stopping at the first surface it would overlap. -// Shared so server and client resolve motion identically; the collision reaction -// is not dispatched here — on a real hit `onHit(intersecting)` is invoked by the -// caller. Advances by the free gap (distance minus radius), as sphere-tracing requires. export const marchCircle = ( body: PhysicsBody, delta: vec2, possibleIntersectors: Array, - ignoreCollision = false, onHit?: (intersecting: Sdf) => void, ): MarchResult => { const deltaLength = vec2.length(delta); - - // Zero-length move cannot resolve a contact by advancing; report no hit so a - // caller looping on `hitSurface` doesn't spin forever. if (!(deltaLength > 0)) { return { hitSurface: false }; } const direction = vec2.normalize(vec2.create(), delta); - const rayEnd = vec2.create(); let travelled = 0; - // Furthest point along the ray known to be overlap-free; a hit rewinds here. let lastFreeDistance = 0; for (let step = 0; step < maximumSteps; step++) { @@ -48,19 +38,6 @@ export const marchCircle = ( (i) => i.distance(rayEnd) <= body.radius, )!; - if (ignoreCollision) { - // Pass through, but still report the contact so repeated marches keep - // progressing. The normal comes along: `hitSurface: true` is the - // contract callers read it under, and one without it would be a - // TypeError in whichever caller first enables pass-through. - vec2.scaleAndAdd(body.center, body.center, direction, deltaLength); - return { - hitSurface: true, - normal: sdfNormal(rayEnd, [intersecting]), - hitObject: intersecting, - }; - } - onHit?.(intersecting); vec2.scaleAndAdd(rayEnd, body.center, direction, lastFreeDistance); @@ -85,11 +62,7 @@ export const marchCircle = ( ); } - // lastFreeDistance, not deltaLength: on a completed march they are equal, and - // if the step budget ran out it is the furthest point actually checked. vec2.scaleAndAdd(body.center, body.center, direction, lastFreeDistance); - return { - hitSurface: false, - }; + return { hitSurface: false }; }; diff --git a/shared/src/physics/planet-sdf.ts b/shared/src/physics/planet-sdf.ts index ea7e72f..d54183c 100644 --- a/shared/src/physics/planet-sdf.ts +++ b/shared/src/physics/planet-sdf.ts @@ -2,8 +2,6 @@ import { vec2 } from 'gl-matrix'; import { clamp, clamp01 } from '../helper/clamp'; import { settings } from '../settings'; -// R(-rotation) about the centre, matching the shader's localTarget = center + R(rotation)*(target-center). -// cos/sin passed in for memoisation; `out` written in place for the hot path. export const toPlanetLocalFrame = ( out: vec2, target: vec2, @@ -16,22 +14,14 @@ export const toPlanetLocalFrame = ( return vec2.set(out, center[0] + cos * dx - sin * dy, center[1] + sin * dx + cos * dy); }; -// Innermost leaf — module-level scratch to avoid per-call allocation. Nothing -// re-enters planetDistance and none of these outlive the call. -// -// Plain array literals, NOT vec2.create(): imports are evaluated before either -// entry point reaches its glMatrix.setMatrixArrayType(Array) call, so -// vec2.create() here would hand back Float32Array and quietly run the one -// outline the server and the client must agree on at f32 while every other -// vector in the simulation is f64. +// Plain array literals, not vec2.create(): these are allocated before +// setMatrixArrayType(Array) runs and must be f64 like the rest of the simulation. const localPoint: vec2 = [0, 0]; const targetFromDelta: vec2 = [0, 0]; const toFromDelta: vec2 = [0, 0]; const closestOnEdge: vec2 = [0, 0]; const edgeSample: vec2 = [0, 0]; -// The one and only planet outline: the authoritative server and the client -// predictor both collide against it, which is what lets prediction reconcile. export const planetDistance = ( target: vec2, vertices: Array, diff --git a/shared/src/physics/resolve-circle-movement.ts b/shared/src/physics/resolve-circle-movement.ts index 9d728cf..a0cd612 100644 --- a/shared/src/physics/resolve-circle-movement.ts +++ b/shared/src/physics/resolve-circle-movement.ts @@ -3,19 +3,12 @@ import { PhysicsBody, Sdf } from './sdf'; import { depenetrateCircle } from './depenetrate-circle'; import { marchCircle } from './march-circle'; -// Position-resolution half of CirclePhysical.stepManually, extracted so server -// and client integrate a body identically. The caller owns the broadphase -// (gathering `possibleIntersectors`, incl. the swept-radius bump); everything -// from depenetration onward lives here. `onHit` is threaded to marchCircle so -// the backend dispatches collision reactions at the same points; the client -// passes none. Velocity is reset at the end — the character re-applies all -// forces from zero every tick, so retained velocity would double-integrate and diverge. export const resolveCircleMovement = ( body: PhysicsBody, deltaTimeInSeconds: number, possibleIntersectors: Array, onHit?: (intersecting: Sdf) => void, -): { hitObject?: Sdf; velocity: vec2 } => { +): Sdf | undefined => { let delta = vec2.scale(vec2.create(), body.velocity, deltaTimeInSeconds); depenetrateCircle(body, possibleIntersectors); @@ -24,7 +17,6 @@ export const resolveCircleMovement = ( body, delta, possibleIntersectors, - false, onHit, ); @@ -43,12 +35,9 @@ export const resolveCircleMovement = ( if (vec2.length(body.velocity) > 50) { delta = vec2.scale(vec2.create(), body.velocity, deltaTimeInSeconds); - marchCircle(body, delta, possibleIntersectors, false, onHit); + marchCircle(body, delta, possibleIntersectors, onHit); } } - const lastVelocity = vec2.clone(body.velocity); - vec2.zero(body.velocity); - - return { hitObject, velocity: lastVelocity }; + return hitObject; }; diff --git a/shared/src/physics/sdf-normal.ts b/shared/src/physics/sdf-normal.ts index 96ba9cc..cc5df2a 100644 --- a/shared/src/physics/sdf-normal.ts +++ b/shared/src/physics/sdf-normal.ts @@ -2,10 +2,7 @@ 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 -// raymarch hit. A plain array literal for the same reason as planet-sdf's -// scratch: it is allocated before setMatrixArrayType(Array) runs, and the -// gradient must be computed at f64 like the rest of the simulation. +// Plain array literal for the same reason as planet-sdf's scratch vectors. const probe: vec2 = [0, 0]; const sampleAt = (x: number, y: number, objects: Array): number => { @@ -13,9 +10,6 @@ const sampleAt = (x: number, y: number, objects: Array): number => { return evaluateSdf(probe, objects); }; -// Central-difference gradient. Can be zero where samples cancel (e.g. on the -// medial axis) — callers must handle that. Indexed access keeps it independent -// of the vec2 .x/.y prototype plugin. export const sdfNormal = (target: vec2, objects: Array): vec2 => { const dx = sampleAt(target[0] + 0.01, target[1], objects) - @@ -25,5 +19,5 @@ export const sdfNormal = (target: vec2, objects: Array): vec2 => { sampleAt(target[0], target[1] - 0.01, objects); const normal = vec2.fromValues(dx, dy); - return vec2.squaredLength(normal) > 0 ? vec2.normalize(normal, normal) : normal; + return vec2.normalize(normal, normal); }; diff --git a/shared/src/physics/sdf.ts b/shared/src/physics/sdf.ts index e33e81e..f2202e3 100644 --- a/shared/src/physics/sdf.ts +++ b/shared/src/physics/sdf.ts @@ -1,11 +1,7 @@ import { vec2 } from 'gl-matrix'; -// Minimal signed-distance collider. Backend Physicals and the client's planet -// surfaces both satisfy this. export interface Sdf { readonly canCollide: boolean; - // Planets set this so a body landing on one can latch it as ground; replaces - // the server's `instanceof PlanetPhysical` with a structural flag both share. readonly isGround?: boolean; distance(target: vec2): number; } diff --git a/shared/src/serialization/deserialize.ts b/shared/src/serialization/deserialize.ts index d5d2418..d0513b6 100644 --- a/shared/src/serialization/deserialize.ts +++ b/shared/src/serialization/deserialize.ts @@ -1,14 +1,12 @@ import { serializableMapping } from './serializable-mapping'; -export const deserialize = (json: string): any => { - return JSON.parse(json, (_, v) => { - if (v !== null && Object.prototype.hasOwnProperty.call(v, '0')) { - const Ctor = serializableMapping.get(v[0]); +export const deserialize = (json: string): any => + JSON.parse(json, (_, value) => { + if (Array.isArray(value) && typeof value[0] === 'string') { + const Ctor = serializableMapping.get(value[0]); if (Ctor) { - return new Ctor(...v.slice(1)); + return new Ctor(...value.slice(1)); } - return v; } - return v; + return value; }); -}; diff --git a/shared/src/serialization/serializable-mapping.ts b/shared/src/serialization/serializable-mapping.ts index 2de8ad3..d741b31 100644 --- a/shared/src/serialization/serializable-mapping.ts +++ b/shared/src/serialization/serializable-mapping.ts @@ -1,9 +1,5 @@ export type SerializableClass = { new (...args: Array): unknown; name: string }; -// Wire format: the property the decorators stamp on and serialize() looks for -// to recognise a decorated class. Defined once on purpose — per-file copies -// would have to agree byte-for-byte, and a typo in one of them compiles and -// type-checks cleanly while every networked object degrades to a raw array. export const MANGLE = '__serializable_type'; export const serializableMapping = new Map(); diff --git a/shared/src/serialization/serializable.ts b/shared/src/serialization/serializable.ts index 79852f3..71952d3 100644 --- a/shared/src/serialization/serializable.ts +++ b/shared/src/serialization/serializable.ts @@ -1,12 +1,7 @@ import { MANGLE, SerializableClass, serializableMapping } from './serializable-mapping'; export const serializable = (type: SerializableClass): any => { - if (!serializableMapping.has(type.name)) { - serializableMapping.set(type.name, type); - } - - Object.defineProperty(type, MANGLE, { value: type.name }); + serializableMapping.set(type.name, type); Object.defineProperty(type.prototype, MANGLE, { value: type.name }); - return type; }; diff --git a/shared/src/serialization/serialize.ts b/shared/src/serialization/serialize.ts index 2a91848..6772ba5 100644 --- a/shared/src/serialization/serialize.ts +++ b/shared/src/serialization/serialize.ts @@ -1,12 +1,9 @@ import { MANGLE } from './serializable-mapping'; -export const serialize = (object: any): string => { - return JSON.stringify(object, (_, value) => { +export const serialize = (object: any): string => + JSON.stringify(object, (_, value) => { if (value && value[MANGLE]) { - const props = value.toArray() as Array; - props.unshift(value[MANGLE]); - return props; + return [value[MANGLE], ...value.toArray()]; } - return value?.toFixed ? Number(value.toFixed(3)) : value; + return typeof value === 'number' ? Math.round(value * 1000) / 1000 : value; }); -}; diff --git a/shared/src/settings.ts b/shared/src/settings.ts index 67f4aeb..1a6a0fb 100644 --- a/shared/src/settings.ts +++ b/shared/src/settings.ts @@ -7,8 +7,6 @@ const redColor = rgb255(209, 86, 82); const q = 2.5; const blueColorDim = rgb255(64 * q, 105 * q, 165 * q); const redColorDim = rgb255(209 * q, 86 * q, 82 * q); -const bluePlanetColor = blueColorDim; -const redPlanetColor = redColorDim; export const settings = { lightCutoffDistance: 600, @@ -30,8 +28,6 @@ export const settings = { maxGravityDistance: 800, minGravityDistance: 1, maxGravityQ: 5000, - // Neutral dead-band around 50% ownership. The single capture rule: flips, - // scoring and every client-side tint all read PlanetBase.team. planetControlThreshold: 0.12, playerMaxHealth: 100, maxGravityStrength: 50000, @@ -67,7 +63,7 @@ export const settings = { chargeShotSpeedMax: 3400, backgroundGradient: [rgb255(90, 38, 43), rgb255(43, 39, 73)], blueColor, - bluePlanetColor, + bluePlanetColor: blueColorDim, npcNames: [ 'Adam', 'Andrew', @@ -119,7 +115,7 @@ export const settings = { 'Wyatt', ], redColor, - redPlanetColor, + redPlanetColor: redColorDim, colorIndices: { [CharacterTeam.blue]: 0, [CharacterTeam.neutral]: 1, @@ -168,20 +164,14 @@ export const settings = { chargeShotRecoilMax: 650, - // DoS guard: parsing is synchronous with the physics loop, so one oversized - // message stalls the tick for everyone. maxInboundMessageBytes: 16 * 1024, - // Must NOT be the frame rate: per-render-frame sends exceed the inbound - // allowance on high-Hz displays, and dropped batches lose edge-triggered - // movement commands that are never re-sent. + // Must not be the frame rate: dropped batches lose edge-triggered movement commands. clientSendInterval: 1 / 30, maxInboundMessagesPerSecond: 240, maxInboundMessageBurst: 480, - // The nonce check makes the Pong reply unforgeable/single-use; this bounds - // what stalling one can buy. maxMeasuredRttMs: 1000, maxBufferedBytesPerClient: 256 * 1024, diff --git a/test/backend-regressions.test.ts b/test/backend-regressions.test.ts index e3b9640..9b3aadb 100644 --- a/test/backend-regressions.test.ts +++ b/test/backend-regressions.test.ts @@ -1,185 +1,150 @@ -// Regression net for backend defects — drives the real classes. import { describe, it, expect, beforeEach } from 'vitest'; import { createRequire } from 'node:module'; -import { PhysicalContainer } from '../backend/src/physics/containers/physical-container'; -import { BoundingBox } from '../backend/src/physics/bounding-boxes/bounding-box'; +import { PhysicalContainer } from '../backend/src/physics/physical-container'; +import { BoundingBox } from '../backend/src/physics/bounding-box'; import { ProjectilePhysical } from '../backend/src/objects/projectile-physical'; import { CharacterPhysical } from '../backend/src/objects/character-physical'; -import { StepCommand, GeneratePointsCommand } from '../backend/src/commands/commands'; const require = createRequire(import.meta.url); const shared = require('../shared/lib/main.js'); const { vec2 } = require('../shared/node_modules/gl-matrix'); -const { applyArrayPlugins, Random, settings, CharacterTeam } = shared; - -applyArrayPlugins(); +const { Random, settings, CharacterTeam } = shared; const step = settings.targetPhysicsDeltaTimeInSeconds; class PointsSpy { public blue = 0; public red = 0; - public handleCommand(command: unknown) { - if (command instanceof GeneratePointsCommand) { - this.blue += command.blue; - this.red += command.red; - } + public addPoints(blue: number, red: number) { + this.blue += blue; + this.red += red; } + public announce() {} } -const boxAround = (center: number[], radius: number) => { - const box = new BoundingBox(); - box.xMin = center[0] - radius; - box.xMax = center[0] + radius; - box.yMin = center[1] - radius; - box.yMax = center[1] + radius; - return box; +const makeWorld = () => { + const game = new PointsSpy(); + return { game, container: new PhysicalContainer(game) }; }; +const spawnProjectile = (container: PhysicalContainer) => + new ProjectilePhysical( + vec2.fromValues(0, 0), + 20, + 40, + CharacterTeam.blue, + vec2.fromValues(3000, 0), + { team: CharacterTeam.blue } as never, + container, + 0, + ); + +const spawnCharacter = (container: PhysicalContainer) => { + const character = new CharacterPhysical( + 'tester', + 0, + 0, + CharacterTeam.blue, + container, + vec2.fromValues(0, 0), + ); + container.addObject(character); + return character; +}; + +const projectilesIn = (container: PhysicalContainer) => + container + .findIntersecting(BoundingBox.ofCircle([0, 0], 5000)) + .filter((o) => o.gameObject instanceof ProjectilePhysical) + .map((o) => o.gameObject as ProjectilePhysical); + beforeEach(() => { Random.seed = 1; }); describe('projectile broadphase registration', () => { it('follows the projectile instead of staying at the muzzle', () => { - const container = new PhysicalContainer(); - container.initialize(); - - const projectile = new ProjectilePhysical( - vec2.fromValues(0, 0), - 20, - 40, - CharacterTeam.blue, - vec2.fromValues(3000, 0), - { team: CharacterTeam.blue } as never, - container, - 0, - ); + const { container } = makeWorld(); + const projectile = spawnProjectile(container); container.addObject(projectile); for (let i = 0; i < 40; i++) { - container.handleCommand(new StepCommand(step, new PointsSpy() as never)); + container.step(step); } expect(projectile.center[0]).toBeGreaterThan(400); - expect(container.findIntersecting(boxAround(projectile.center, 100))).toContain( + expect( + container.findIntersecting(BoundingBox.ofCircle(projectile.center, 100)), + ).toContain(projectile); + expect(container.findIntersecting(BoundingBox.ofCircle([0, 0], 100))).not.toContain( projectile, ); - expect(container.findIntersecting(boxAround([0, 0], 100))).not.toContain(projectile); }); it('spawns a shot just clear of the shooter, not a jump downrange', () => { - const container = new PhysicalContainer(); - container.initialize(); - const projectile = new ProjectilePhysical( - vec2.fromValues(0, 0), - 20, - 40, - CharacterTeam.blue, - vec2.fromValues(3000, 0), - { team: CharacterTeam.blue } as never, - container, - 0, - ); + const { container } = makeWorld(); + const projectile = spawnProjectile(container); expect(projectile.center[0]).toBeLessThanOrEqual(15); }); }); describe('scoring', () => { - const spawnCharacter = (container: PhysicalContainer) => { - const character = new CharacterPhysical( - 'tester', - 0, - 0, - CharacterTeam.blue, - container, - vec2.fromValues(0, 0), - ); - container.addObject(character); - return character; - }; - it('awards nothing for an administrative death', () => { - const container = new PhysicalContainer(); - container.initialize(); + const { game, container } = makeWorld(); const character = spawnCharacter(container); - const game = new PointsSpy(); - character.onDie(); // what PlayerBase.destroy() does on a socket drop - container.handleCommand(new StepCommand(step, game as never)); + character.onDie(); + container.step(step); expect(game.blue).toBe(0); expect(game.red).toBe(0); }); it('still awards a kill for a combat death', () => { - const container = new PhysicalContainer(); - container.initialize(); + const { game, container } = makeWorld(); const character = spawnCharacter(container); - const game = new PointsSpy(); character.onDie(true); - container.handleCommand(new StepCommand(step, game as never)); + container.step(step); expect(game.red).toBe(settings.playerKillPoint); expect(game.blue).toBe(0); }); + + it('awards a combat death only once', () => { + const { game, container } = makeWorld(); + const character = spawnCharacter(container); + + character.onDie(true); + character.onDie(true); + + expect(game.red).toBe(settings.playerKillPoint); + }); }); describe('charged shots', () => { it('scales the shot by the strength actually paid, not the requested charge', () => { - const container = new PhysicalContainer(); - container.initialize(); - const character = new CharacterPhysical( - 'tester', - 0, - 0, - CharacterTeam.blue, - container, - vec2.fromValues(0, 0), - ); - container.addObject(character); - + const { container } = makeWorld(); + const character = spawnCharacter(container); (character as unknown as { projectileStrength: number }).projectileStrength = settings.chargeShotStrengthMin; - const before = container.findIntersecting(boxAround([0, 0], 5000)).length; character.shootTowards(vec2.fromValues(1000, 0), 1); - const spawned = container - .findIntersecting(boxAround([0, 0], 5000)) - .filter((o) => o.gameObject instanceof ProjectilePhysical) - .map((o) => o.gameObject as ProjectilePhysical); + const [spawned] = projectilesIn(container); - expect(container.findIntersecting(boxAround([0, 0], 5000)).length).toBeGreaterThan( - before, - ); - expect(spawned[0].charge).toBeCloseTo(0, 5); - expect(spawned[0].radius).toBeCloseTo(settings.chargeShotRadiusMin, 5); + expect(spawned).toBeDefined(); + expect(spawned.charge).toBeCloseTo(0, 5); + expect(spawned.radius).toBeCloseTo(settings.chargeShotRadiusMin, 5); }); it('ignores a shot aimed at the shooter itself', () => { - const container = new PhysicalContainer(); - container.initialize(); - const character = new CharacterPhysical( - 'tester', - 0, - 0, - CharacterTeam.blue, - container, - vec2.fromValues(0, 0), - ); - container.addObject(character); + const { container } = makeWorld(); + const character = spawnCharacter(container); - const projectilesBefore = container - .findIntersecting(boxAround([0, 0], 5000)) - .filter((o) => o.gameObject instanceof ProjectilePhysical).length; character.shootTowards(vec2.clone(character.center), 1); - const projectilesAfter = container - .findIntersecting(boxAround([0, 0], 5000)) - .filter((o) => o.gameObject instanceof ProjectilePhysical).length; - expect(projectilesAfter).toBe(projectilesBefore); + expect(projectilesIn(container)).toHaveLength(0); }); }); diff --git a/test/backpressure.test.ts b/test/backpressure.test.ts index b3cccac..921bf88 100644 --- a/test/backpressure.test.ts +++ b/test/backpressure.test.ts @@ -1,15 +1,13 @@ -// Slow-client backpressure: what a Player sheds when its socket stops draining. import { describe, it, expect, beforeEach } from 'vitest'; import { createRequire } from 'node:module'; -import { PhysicalContainer } from '../backend/src/physics/containers/physical-container'; +import { PhysicalContainer } from '../backend/src/physics/physical-container'; import { PlayerContainer } from '../backend/src/players/player-container'; import { createWorld } from '../backend/src/create-world'; const require = createRequire(import.meta.url); const shared = require('../shared/lib/main.js'); const { - applyArrayPlugins, Random, settings, TransportEvents, @@ -21,9 +19,6 @@ const { ServerAnnouncement, } = shared; -applyArrayPlugins(); - -// Shaped like engine.io: the byte count lives on the ws socket under the transport. class FakeSocket { public readonly sent: Array<{ event: string; payload: unknown }> = []; public readonly conn = { transport: { socket: { bufferedAmount: 0 } } }; @@ -48,9 +43,8 @@ class FakeSocket { const makePlayer = () => { Random.seed = 3; - const objects = new PhysicalContainer(); + const objects = new PhysicalContainer({ addPoints() {}, announce() {} }); createWorld(objects); - objects.initialize(); const players = new PlayerContainer(objects, 4, 0); const socket = new FakeSocket(); const player = players.createPlayer({ name: 'player' }, socket as never); diff --git a/test/client-send-cadence.test.ts b/test/client-send-cadence.test.ts index 4093ff2..b94ffce 100644 --- a/test/client-send-cadence.test.ts +++ b/test/client-send-cadence.test.ts @@ -1,4 +1,3 @@ -// Client outbound send cadence vs. server inbound allowance. import { describe, it, expect } from 'vitest'; import { createRequire } from 'node:module'; @@ -8,9 +7,7 @@ import { setFrameTimeMs } from '../frontend/src/scripts/helper/prediction/local- const require = createRequire(import.meta.url); const shared = require('../shared/lib/main.js'); const { vec2 } = require('../shared/node_modules/gl-matrix'); -const { applyArrayPlugins, settings, MoveActionCommand, ClientHeartbeatCommand } = shared; - -applyArrayPlugins(); +const { settings, MoveActionCommand, ClientHeartbeatCommand } = shared; const drive = ( frameRate: number, @@ -57,7 +54,7 @@ describe('client send cadence', () => { let sentOn = -1; const batches = drive(144, 0.1, (commandSocket) => { if (sentOn === -1) { - commandSocket.handleCommand(new MoveActionCommand(vec2.fromValues(1, 0), 0)); + commandSocket.queue(new MoveActionCommand(vec2.fromValues(1, 0), 0)); sentOn = 0; } }); @@ -73,15 +70,12 @@ describe('client send cadence', () => { expect(batches.every((b) => b.includes(ClientHeartbeatCommand.name))).toBe(true); }); - // socket.io-client buffers emits made while disconnected in an uncapped - // sendBuffer and flushes them on reconnect, ahead of the re-join — so every - // one of them is both a leak and wasted bandwidth. it('sends nothing at all while the transport is down', () => { const batches = drive( 60, 30, (commandSocket) => - commandSocket.handleCommand(new MoveActionCommand(vec2.fromValues(1, 0), 0)), + commandSocket.queue(new MoveActionCommand(vec2.fromValues(1, 0), 0)), false, ); diff --git a/test/connection-lifecycle.test.ts b/test/connection-lifecycle.test.ts index 06cb90d..5ec9dbc 100644 --- a/test/connection-lifecycle.test.ts +++ b/test/connection-lifecycle.test.ts @@ -1,4 +1,3 @@ -// Connection lifecycle: join guard, refusal reasons, reconnect — driven through a fake socket.io. import { describe, it, expect, beforeEach, vi } from 'vitest'; import { createRequire } from 'node:module'; @@ -7,10 +6,7 @@ import { defaultOptions } from '../backend/src/options'; const require = createRequire(import.meta.url); const shared = require('../shared/lib/main.js'); -const { applyArrayPlugins, Random, TransportEvents, JoinRejectionReason, settings } = - shared; - -applyArrayPlugins(); +const { Random, TransportEvents, JoinRejectionReason, settings } = shared; class FakeSocket { public readonly sent: Array<{ event: string; payload: unknown }> = []; @@ -40,7 +36,6 @@ class FakeSocket { this.disconnected = true; } - /** Number of listeners the server has attached for an event. */ public listenerCount(event: string): number { return (this.handlers.get(event) ?? []).length; } @@ -142,9 +137,6 @@ describe('hostile payloads', () => { expect(server.serverInfo.playerCount).toBe(4); }); - // socket.io dispatches listeners with no try/catch of its own, so anything - // thrown out of a connection-scoped handler is an uncaught exception: the - // process exits and every player in the match is dropped with it. it.each([ ['no payload at all', undefined], ['null', null], @@ -190,7 +182,6 @@ describe('reconnect', () => { expect(server.serverInfo.playerCount).toBe(1); expect(second.disconnected).toBe(false); expect(second.lastPayloadFor(TransportEvents.JoinRejected)).toBeUndefined(); - // Fresh token: the old one cannot be replayed. expect(second.lastPayloadFor(TransportEvents.PlayerJoined)).not.toBe(token); }); @@ -251,9 +242,6 @@ describe('inbound rate limiting', () => { }); describe('pre-join rate limiting', () => { - // These listeners exist before any join, so the limiter inside - // onPlayerToServer never sees them: unmetered, each one is a reply or an - // allocation an unauthenticated client can ask for at line rate. it('clamps a flood of joins on an already-joined connection', () => { const { attach } = startServer({ playerLimit: 4, npcCount: 0 }); const socket = attach(); @@ -312,7 +300,6 @@ describe('reconnect tokens', () => { }); describe('reconnect while the dropped socket is still live', () => { - // engine.io needs ~45 s to notice a transport drop; the client is back in ~1 s. it('retires the ghost so the returning player keeps its slot, team and score', () => { const { server, attach } = startServer({ playerLimit: 1, npcCount: 0 }); @@ -321,7 +308,6 @@ describe('reconnect while the dropped socket is still live', () => { const token = first.lastPayloadFor(TransportEvents.PlayerJoined) as string; expect(server.serverInfo.playerCount).toBe(1); - // No 'disconnect' fired: the server still holds the dead socket's player. const second = attach(); second.fire(TransportEvents.PlayerJoining, { name: 'flaky', reconnectToken: token }); diff --git a/test/physics-determinism.test.mjs b/test/physics-determinism.test.mjs index a4409c9..bbb36f6 100644 --- a/test/physics-determinism.test.mjs +++ b/test/physics-determinism.test.mjs @@ -1,10 +1,7 @@ -// Determinism guard: client predictor and server run the same stepCharacterMovement. import { describe, it, expect } from 'vitest'; import { createRequire } from 'node:module'; const require = createRequire(import.meta.url); -// Numerics match production because test/setup.mjs has already applied -// glMatrix.setMatrixArrayType(Array) — without it this would pin f32. const shared = require('../shared/lib/main.js'); const { vec2 } = require('../shared/node_modules/gl-matrix'); @@ -12,8 +9,6 @@ const { stepCharacterMovement, tickPlanetDetachment, resolveCircleMovement, - planetDistance, - planetGravity, PlanetBase, Random, headRadius, @@ -23,6 +18,8 @@ const { rightFootOffset, } = shared; +const stepSeconds = 1 / 200; + const makeBody = (center, radius) => ({ center: vec2.clone(center), radius, @@ -31,113 +28,73 @@ const makeBody = (center, radius) => ({ restitution: 0, }); -// Free-space world (no planets): exercises the deterministic core without planet SDF geometry. -const emptyWorld = { - groundsNear: () => [], +const makeState = (start) => ({ + 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(), +}); + +const makeWorld = (grounds) => ({ + groundsNear: () => grounds, stepBody: (body, dt) => { - resolveCircleMovement(body, dt, []); - return undefined; + const hit = resolveCircleMovement(body, dt, grounds); + return hit && hit.isGround ? hit : undefined; }, -}; +}); -const stepSeconds = 1 / 200; - -// A spinning planet, built the way create-world builds one, so the SDF/gravity/on-surface branches have a pin too. -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; - }, - }, - }; -}; - -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), - }); -}; +const round = (v) => Math.round(v * 1000) / 1000; +const roundVec = (v) => [round(v[0]), round(v[1])]; const runSimulation = () => { - const start = vec2.fromValues(100, 100); - 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(), - }; + const state = makeState(vec2.fromValues(100, 100)); + const world = makeWorld([]); for (let i = 0; i < 300; i++) { const angle = i * 0.1; - const input = vec2.fromValues(Math.cos(angle), Math.sin(angle)); - stepCharacterMovement(state, emptyWorld, input, stepSeconds); + stepCharacterMovement( + state, + world, + vec2.fromValues(Math.cos(angle), Math.sin(angle)), + 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])], - bodyVelocity: [round(state.bodyVelocity[0]), round(state.bodyVelocity[1])], + head: roundVec(state.head.center), + leftFoot: roundVec(state.leftFoot.center), + rightFoot: roundVec(state.rightFoot.center), + bodyVelocity: roundVec(state.bodyVelocity), + }); +}; + +const runPlanetSimulation = () => { + Random.seed = 20; + const planet = new PlanetBase( + 1, + PlanetBase.createPlanetVertices(vec2.fromValues(0, -900), 1700, 1900, 70), + ); + planet.angularVelocity = 0.09; + const world = makeWorld([planet]); + const state = makeState(vec2.fromValues(0, 250)); + + for (let i = 0; i < 900; i++) { + const input = i < 200 ? vec2.create() : vec2.fromValues(i < 600 ? 1 : -1, 0); + tickPlanetDetachment(state, stepSeconds); + stepCharacterMovement(state, world, input, stepSeconds); + planet.advanceRotation(stepSeconds); + } + + return JSON.stringify({ + head: roundVec(state.head.center), + leftFoot: roundVec(state.leftFoot.center), + rightFoot: roundVec(state.rightFoot.center), + direction: round(state.direction), + onSurface: !!state.currentPlanet, + secondsSinceOnSurface: round(state.secondsSinceOnSurface), }); }; @@ -147,7 +104,6 @@ describe('shared character simulation determinism', () => { }); it('matches the pinned reference pose (changes only with intentional physics edits)', () => { - // Regression pin — update deliberately when physics behaviour changes. expect(runSimulation()).toMatchSnapshot(); }); diff --git a/test/prediction-reconciliation.test.ts b/test/prediction-reconciliation.test.ts index efd883a..8ed2ba9 100644 --- a/test/prediction-reconciliation.test.ts +++ b/test/prediction-reconciliation.test.ts @@ -1,4 +1,3 @@ -// Reconciliation guard for the client predictor: acknowledged pose, deterministic replay, replay-window behaviour. import { describe, it, expect } from 'vitest'; import { LocalCharacterPredictor, @@ -95,7 +94,6 @@ describe('local prediction reconciliation', () => { predictor.setAlive(false); setFrameTimeMs(5200); - // Regression: a dead body must not be predicted/moved. expect(predictor.update([], 1 / 60)).toBe(false); }); @@ -157,7 +155,6 @@ describe('local prediction reconciliation', () => { }); it('replays the streamed facing direction, not one guessed from the pose', () => { - // Head trails 35 units behind: atan2 gives ~0.57 rad, server direction is 0. const walkingPose = () => ({ head: { center: [-35, 37], radius: HEAD_RADIUS }, leftFoot: { center: [-33, -18], radius: FEET_RADIUS }, diff --git a/test/serialization.test.mjs b/test/serialization.test.mjs index 1260fdb..5f85482 100644 --- a/test/serialization.test.mjs +++ b/test/serialization.test.mjs @@ -1,4 +1,3 @@ -// Round-trip tests for the wire serializer, exercising the BUILT shared bundle. import { describe, it, expect } from 'vitest'; import { createRequire } from 'node:module'; @@ -13,12 +12,10 @@ const { MoveActionCommand, UpdatePropertyCommand, PropertyUpdatesForObject, - // networked entity bases — their toArray() must mirror their constructor order CharacterBase, PlanetBase, ProjectileBase, LampBase, - // geometry — single source of truth shared by server & client prediction headRadius, feetRadius, boundRadius, @@ -37,7 +34,6 @@ describe('serialization round-trip (built shared lib)', () => { it('round-trips a Circle and rounds floats to 3 decimals', () => { const out = deserialize(serialize(new Circle([12.34567, -7.1], 5.43219))); expect(out).toBeInstanceOf(Circle); - // serialize.ts rounds every float via toFixed(3) expect(out.center[0]).toBeCloseTo(12.346, 6); expect(out.center[1]).toBeCloseTo(-7.1, 6); expect(out.radius).toBeCloseTo(5.432, 6); @@ -64,7 +60,6 @@ describe('serialization round-trip (built shared lib)', () => { }); it('preserves per-item class identity across a mixed batch', () => { - // A clobbered name→constructor mapping surfaces as the wrong class. const batch = [ new ServerAnnouncement('a'), new MoveActionCommand([0, 1], 5), @@ -127,14 +122,25 @@ describe('networked entity round-trips (toArray ↔ constructor contract)', () = it('round-trips a PlanetBase (derived centre/radius recomputed from vertices)', () => { const out = deserialize( - serialize(new PlanetBase(10, [[0, 0], [100, 0], [50, 100]], 0.7, true)), + serialize( + new PlanetBase( + 10, + [ + [0, 0], + [100, 0], + [50, 100], + ], + 0.7, + true, + ), + ), ); expect(out).toBeInstanceOf(PlanetBase); expect(out.id).toBe(10); expect(out.vertices).toHaveLength(3); expect(out.ownership).toBeCloseTo(0.7, 5); expect(out.isKeystone).toBe(true); - expect(out.center[0]).toBeCloseTo(50, 5); // recomputed by the constructor + expect(out.center[0]).toBeCloseTo(50, 5); }); }); @@ -142,7 +148,7 @@ describe('shared character geometry — single source of truth', () => { it('matches the known body layout', () => { expect(headRadius).toBe(50); expect(feetRadius).toBe(20); - expect(boundRadius).toBe((headRadius + feetRadius * 2) * 2); // 180 + expect(boundRadius).toBe((headRadius + feetRadius * 2) * 2); }); it('posture offsets are measured from the centre of mass (they sum to ~0)', () => { diff --git a/test/setup.mjs b/test/setup.mjs index fb99774..d55aa06 100644 --- a/test/setup.mjs +++ b/test/setup.mjs @@ -1,12 +1,4 @@ -// Production calls glMatrix.setMatrixArrayType(Array) before anything else -// (backend/src/main.ts, frontend/src/index.ts), which makes every vector an f64 -// Array instead of the default Float32Array. Without the same call here the -// suite pins the numerics of a build nobody ships — including module-level -// scratch vectors, which are allocated the moment a module is imported. -// -// One call per installed gl-matrix: the prebuilt shared bundle keeps gl-matrix -// external and resolves shared/node_modules, while the backend and frontend -// sources the tests import directly resolve their own copies. +// Production makes every vector an f64 Array before any module-level vector is allocated. import { createRequire } from 'node:module'; const require = createRequire(import.meta.url); diff --git a/vitest.config.ts b/vitest.config.ts index 086f2a0..4bf89fe 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,8 +2,6 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { - // Runs before any test module is imported, which matters: module-level - // vectors are allocated at import time and cannot be converted afterwards. setupFiles: ['./test/setup.mjs'], }, });