diff --git a/.vscode/settings.json b/.vscode/settings.json index 57b1058..2561c01 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -3,7 +3,7 @@ "Deserializable", "Deserialization", "Respawn", - "doppler", + "decla", "overridable", "serializable" ] diff --git a/Dockerfile b/Dockerfile index 327d1f2..83f7922 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ # syntax=docker/dockerfile:1 -# doppler game server (the `doppler-server` package). +# decla.red game server (the `declared-server` package). # The frontend is a static site and is NOT built here — see .forgejo/workflows. # ---- Stage 1: build the shared lib, then bundle the server ------------------- diff --git a/README.md b/README.md index ce57285..c7f1f53 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ -# doppler +# [decla.red](https://decla.red) A 2-dimensional multiplayer game utilising ray tracing. -> **Available at [doppler.schmelczer.dev](https://doppler.schmelczer.dev).** +> **Available at [decla.red](https://decla.red).** ![screenshot of in-game fight](media/game-pc.png) ![three screenshots taken at iPhone SE screen size](media/collage-iphone.png) @@ -15,7 +15,7 @@ CI/CD runs on Forgejo Actions (`.forgejo/workflows/deploy.yml`). On a push to `main` it: - builds the static frontend and rsyncs `frontend/dist/` to the `/pages/declared` - mount on the runner host (the mount keeps its pre-rebrand name), and + mount on the runner host, and - builds the server image from the root `Dockerfile` and pushes it to the Forgejo container registry as `//-server`. @@ -30,6 +30,6 @@ edit it to add or remove game-server origins. Run the server image locally: ```sh -docker build -t doppler-server . -docker run -p 3000:3000 doppler-server --name "My server" --playerLimit 16 +docker build -t declared-server . +docker run -p 3000:3000 declared-server --name "My server" --playerLimit 16 ``` diff --git a/backend/deployment/README.md b/backend/deployment/README.md new file mode 100644 index 0000000..4e2c7d9 --- /dev/null +++ b/backend/deployment/README.md @@ -0,0 +1,14 @@ +# Setup a decla.red server on a fresh debian/ubuntu machine + +> The method was tested on Debian 10 DigitalOcean droplets. + +- Copy the files from this directory to the target machine. +- Execute the following commands. +- ```sh + chmod +x setup.sh + ./setup.sh serverX + ``` + + > Where `serverX` is the serverX.decla.red subdomain pointing to the server. + +- Optionally, change the arguments in [declared-servers.sh](declared-servers.sh). diff --git a/backend/deployment/declared-servers.service b/backend/deployment/declared-servers.service new file mode 100644 index 0000000..678942d --- /dev/null +++ b/backend/deployment/declared-servers.service @@ -0,0 +1,11 @@ +[Unit] +Description=declared-server + +Requires=basic.target network.target + +[Service] +Type=simple +ExecStart=/bin/sh /root/declared-servers.sh + +[Install] +WantedBy=multi-user.target diff --git a/backend/deployment/declared-servers.sh b/backend/deployment/declared-servers.sh new file mode 100644 index 0000000..166ef8b --- /dev/null +++ b/backend/deployment/declared-servers.sh @@ -0,0 +1,5 @@ +#!/bin/sh + +set -e + +NODE_ENV=production declared-server --port=3000 --name="Simonyi" --playerLimit=256 --npcCount=48 --scoreLimit=20000 diff --git a/backend/deployment/nginx.conf b/backend/deployment/nginx.conf new file mode 100644 index 0000000..1fbf3b5 --- /dev/null +++ b/backend/deployment/nginx.conf @@ -0,0 +1,49 @@ +user www-data; +worker_processes auto; +pid /run/nginx.pid; + +events { + worker_connections 768; +} + +http { + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + types_hash_max_size 2048; + server_tokens off; + + include /etc/nginx/mime.types; + default_type application/octet-stream; + + ssl_protocols TLSv1 TLSv1.1 TLSv1.2; + ssl_prefer_server_ciphers on; + + access_log /var/log/nginx/access.log; + error_log /var/log/nginx/error.log; + + map $http_upgrade $connection_upgrade { + default upgrade; + '' close; + } + + server { + listen 443 ssl; + + include /etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; + ssl_certificate /etc/letsencrypt/live/serverName.decla.red/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/serverName.decla.red/privkey.pem; + + server_name serverName.decla.red; + + location / { + proxy_pass http://127.0.0.1:3000/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host $host; + } + } +} \ No newline at end of file diff --git a/backend/deployment/setup.sh b/backend/deployment/setup.sh new file mode 100644 index 0000000..017e599 --- /dev/null +++ b/backend/deployment/setup.sh @@ -0,0 +1,28 @@ +#!/bin/sh + +set -e + +if [ -z "$1" ]; then + echo "Please specify a domain" 1>&2 + exit 1 +fi + +domain="$1" + +apt install -y curl +curl -sL https://deb.nodesource.com/setup_15.x | bash - +apt update +apt install -y nodejs nginx certbot python3-certbot-nginx +npm i -g declared-server + +certbot certonly --nginx -m schmelczerandras@gmail.com -d "$domain".decla.red --agree-tos --non-interactive + +mv nginx.conf /etc/nginx/nginx.conf +sed -i "s/serverName/$domain/g" /etc/nginx/nginx.conf +nginx -s reload + +chmod +x /root/declared-servers.sh +chown root:root /root/declared-servers.sh +cp declared-servers.service /etc/systemd/system/declared-servers.service +systemctl enable declared-servers.service +systemctl start declared-servers diff --git a/backend/package.json b/backend/package.json index 50cd443..9ccaec3 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,12 +1,12 @@ { - "name": "doppler-server", + "name": "declared-server", "version": "0.1.0", - "description": "Game server for doppler", + "description": "Game server for decla.red", "keywords": [], "author": "András Schmelczer (https://schmelczer.dev/)", "main": "dist/main.js", "bin": { - "doppler-server": "dist/main.js" + "declared-server": "dist/main.js" }, "scripts": { "build": "npx webpack --mode production", diff --git a/backend/src/commands/announce.ts b/backend/src/commands/announce.ts deleted file mode 100644 index 2e8aadf..0000000 --- a/backend/src/commands/announce.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Command } from 'shared'; - -// Internal request from a game object (e.g. a keystone planet flipping) asking -// the GameServer to broadcast a one-off announcement to every connected client. -export class AnnounceCommand extends Command { - public constructor(public readonly text: string) { - super(); - } -} diff --git a/backend/src/commands/generate-points.ts b/backend/src/commands/generate-points.ts index 3f1ac98..b639d4c 100644 --- a/backend/src/commands/generate-points.ts +++ b/backend/src/commands/generate-points.ts @@ -2,7 +2,7 @@ import { Command } from 'shared'; export class GeneratePointsCommand extends Command { public constructor( - public readonly blue: number, + public readonly decla: number, public readonly red: number, ) { super(); diff --git a/backend/src/create-world.ts b/backend/src/create-world.ts index a1039fc..b3941a0 100644 --- a/backend/src/create-world.ts +++ b/backend/src/create-world.ts @@ -1,8 +1,9 @@ import { vec2 } from 'gl-matrix'; -import { Random, PlanetBase, hsl, settings, evaluateSdf } from 'shared'; +import { Random, PlanetBase, hsl, settings } from 'shared'; import { LampPhysical } from './objects/lamp-physical'; import { PlanetPhysical } from './objects/planet-physical'; import { PhysicalContainer } from './physics/containers/physical-container'; +import { evaluateSdf } from './physics/functions/evaluate-sdf'; import { Physical } from './physics/physicals/physical'; export const createWorld = (objectContainer: PhysicalContainer) => { @@ -44,16 +45,13 @@ export const createWorld = (objectContainer: PhysicalContainer) => { ) { const planet = objects.length === 0 - ? // The first, central giant is the keystone "Heart": a named, - // always-contested focal objective the whole match orbits. - new PlanetPhysical( + ? new PlanetPhysical( PlanetBase.createPlanetVertices( position, Random.getRandomInRange(1600, 2400), Random.getRandomInRange(1600, 2400), Random.getRandomInRange(80, 300), ), - true, ) : new PlanetPhysical( PlanetBase.createPlanetVertices( diff --git a/backend/src/game-server.ts b/backend/src/game-server.ts index e321737..1b51a35 100644 --- a/backend/src/game-server.ts +++ b/backend/src/game-server.ts @@ -21,7 +21,6 @@ import { Options } from './options'; import { PlayerContainer } from './players/player-container'; import { StepCommand } from './commands/step'; import { GeneratePointsCommand } from './commands/generate-points'; -import { AnnounceCommand } from './commands/announce'; const gameStateSubscribedRoom = 'gameStateSubscribedRoom'; @@ -31,7 +30,7 @@ export class GameServer extends CommandReceiver { private deltaTimes!: Array; private deltaTimeCalculator!: DeltaTimeCalculator; - private bluePoints = 0; + private declaPoints = 0; private redPoints = 0; private matchPointAnnounced: Partial> = {}; @@ -53,7 +52,7 @@ export class GameServer extends CommandReceiver { ); this.deltaTimeCalculator = new DeltaTimeCalculator(); this.deltaTimes = []; - this.bluePoints = 0; + this.declaPoints = 0; this.redPoints = 0; this.matchPointAnnounced = {}; this.isInEndGame = false; @@ -64,8 +63,6 @@ export class GameServer extends CommandReceiver { protected commandExecutors: CommandExecutors = { [GeneratePointsCommand.type]: this.addPoints.bind(this), - [AnnounceCommand.type]: ({ text }: AnnounceCommand) => - this.players.queueCommandForEachClient(new ServerAnnouncement(text)), }; constructor( @@ -121,19 +118,19 @@ export class GameServer extends CommandReceiver { this.handlePhysics(); } - private addPoints({ blue, red }: GeneratePointsCommand) { + private addPoints({ decla, red }: GeneratePointsCommand) { if (this.isInEndGame) { return; } - this.bluePoints += blue; + this.declaPoints += decla; this.redPoints += red; - if (this.bluePoints >= this.options.scoreLimit) { - this.endGame(CharacterTeam.blue); + if (this.declaPoints >= this.options.scoreLimit) { + this.endGame(CharacterTeam.decla); } else if (this.redPoints >= this.options.scoreLimit) { this.endGame(CharacterTeam.red); } else { - this.announceMatchPointOnce(CharacterTeam.blue, this.bluePoints); + this.announceMatchPointOnce(CharacterTeam.decla, this.declaPoints); this.announceMatchPointOnce(CharacterTeam.red, this.redPoints); } } @@ -167,7 +164,6 @@ export class GameServer extends CommandReceiver { } private timeSinceLastPointUpdate = 0; - private physicsAccumulator = 0; private handlePhysics() { const delta = this.deltaTimeCalculator.getNextDeltaTimeInSeconds({ setAsBase: true }); @@ -183,32 +179,18 @@ export class GameServer extends CommandReceiver { if ((this.timeSinceLastPointUpdate += delta) > 0.5) { this.timeSinceLastPointUpdate = 0; this.players.queueCommandForEachClient( - new UpdateGameState(this.bluePoints, this.redPoints, this.options.scoreLimit), + new UpdateGameState(this.declaPoints, this.redPoints, this.options.scoreLimit), ); } - const fixedDelta = settings.targetPhysicsDeltaTimeInSeconds; - const maxSubstepsPerFrame = 5; - - this.physicsAccumulator += delta; - let substeps = Math.floor(this.physicsAccumulator / fixedDelta); - if (substeps > maxSubstepsPerFrame) { - substeps = maxSubstepsPerFrame; - this.physicsAccumulator = 0; - } else { - this.physicsAccumulator -= substeps * fixedDelta; - } - - for (let i = 0; i < substeps; i++) { - let scaledDelta = fixedDelta; - if (this.isInEndGame) { - this.timeScaling *= Math.pow(settings.endGameDeltaScaling, fixedDelta); - scaledDelta /= this.timeScaling; - } - this.objects.handleCommand(new StepCommand(scaledDelta, this)); - this.players.step(scaledDelta); + let scaledDelta = delta; + if (this.isInEndGame) { + this.timeScaling *= Math.pow(settings.endGameDeltaScaling, delta); + scaledDelta /= this.timeScaling; } + this.objects.handleCommand(new StepCommand(scaledDelta, this)); + this.players.step(scaledDelta); this.players.stepCommunication(delta); this.objects.resetRemoteCalls(); @@ -216,7 +198,7 @@ export class GameServer extends CommandReceiver { setTimeout( this.handlePhysics.bind(this), - Math.max(0, fixedDelta - physicsDelta) * 1000, + Math.max(0, settings.targetPhysicsDeltaTimeInSeconds - physicsDelta) * 1000, ); } @@ -242,7 +224,7 @@ export class GameServer extends CommandReceiver { } private get gameProgress(): number { - return (Math.max(this.bluePoints, this.redPoints) / this.options.scoreLimit) * 100; + return (Math.max(this.declaPoints, this.redPoints) / this.options.scoreLimit) * 100; } public get serverInfo(): ServerInformation { diff --git a/shared/src/physics/interpolate-angles.ts b/backend/src/helper/interpolate-angles.ts similarity index 100% rename from shared/src/physics/interpolate-angles.ts rename to backend/src/helper/interpolate-angles.ts diff --git a/backend/src/objects/character-physical.ts b/backend/src/objects/character-physical.ts index 2f60597..bd46282 100644 --- a/backend/src/objects/character-physical.ts +++ b/backend/src/objects/character-physical.ts @@ -14,18 +14,13 @@ import { CommandReceiver, mix, clamp01, - stepCharacterMovement, - applyLeapImpulse, - decayMomentum, - CharacterMovementState, - CharacterWorld, - GroundSurface, } from 'shared'; import { DynamicPhysical } from '../physics/physicals/dynamic-physical'; import { CirclePhysical } from './circle-physical'; import { PhysicalContainer } from '../physics/containers/physical-container'; import { BoundingBoxBase } from '../physics/bounding-boxes/bounding-box-base'; import { ProjectilePhysical } from './projectile-physical'; +import { interpolateAngles } from '../helper/interpolate-angles'; import { forceAtPosition } from '../physics/functions/force-at-position'; import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle'; import { PlanetPhysical } from './planet-physical'; @@ -97,16 +92,10 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical private currentPlanet?: PlanetPhysical; private secondsSinceOnSurface = settings.planetDetachmentSeconds; - private bodyVelocity = vec2.create(); - private timeSinceLastLeap = settings.leapCooldownSeconds; - public head: CirclePhysical; public leftFoot: CirclePhysical; public rightFoot: CirclePhysical; - private movementState!: CharacterMovementState; - private movementWorld!: CharacterWorld; - private movementActions: Array = []; private lastMovementAction: MoveActionCommand = new MoveActionCommand(vec2.create()); @@ -149,51 +138,6 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical container.addObject(this.head); container.addObject(this.leftFoot); container.addObject(this.rightFoot); - - this.initMovementBridge(); - } - - private initMovementBridge() { - const self = this; - this.movementState = { - head: this.head, - leftFoot: this.leftFoot, - rightFoot: this.rightFoot, - get direction() { - return self.direction; - }, - set direction(value: number) { - self.direction = value; - }, - get currentPlanet() { - return self.currentPlanet; - }, - set currentPlanet(value: GroundSurface | undefined) { - // On the server every ground is a PlanetPhysical (the world only ever - // hands back planets), so this narrowing is safe. - self.currentPlanet = value as PlanetPhysical | undefined; - }, - get secondsSinceOnSurface() { - return self.secondsSinceOnSurface; - }, - set secondsSinceOnSurface(value: number) { - self.secondsSinceOnSurface = value; - }, - bodyVelocity: this.bodyVelocity, - }; - - this.movementWorld = { - // Same set and order forceAtPosition used: planets in the force field, - // in container-traversal order (so the f64 gravity sum is unchanged). - groundsNear: (center, radius) => - self.container - .findIntersecting(getBoundingBoxOfCircle(new Circle(center, radius))) - .filter((o): o is PlanetPhysical => o instanceof PlanetPhysical), - stepBody: (body, deltaTimeInSeconds) => { - const { hitObject } = (body as CirclePhysical).stepManually(deltaTimeInSeconds); - return hitObject instanceof PlanetPhysical ? hitObject : undefined; - }, - }; } private get isSpawnProtected(): boolean { @@ -207,10 +151,10 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical private getPoints(game: CommandReceiver) { if (!this.isAlive && !this.hasGeneratedPoints) { this.hasGeneratedPoints = true; - const blue = this.team === CharacterTeam.blue ? 0 : settings.playerKillPoint; + const decla = this.team === CharacterTeam.decla ? 0 : settings.playerKillPoint; const red = this.team === CharacterTeam.red ? 0 : settings.playerKillPoint; - game.handleCommand(new GeneratePointsCommand(blue, red)); + game.handleCommand(new GeneratePointsCommand(decla, red)); } } @@ -226,13 +170,7 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical return this.currentPlanet; } - // Persistent launch momentum, streamed to the owning client so its predictor - // can reproduce a leap/slingshot/recoil flight instead of only snapping to it. - public get launchMomentum(): vec2 { - return this.bodyVelocity; - } - - public addKill(victimName: string, charge = 0) { + public addKill(victimName: string) { this.killCount++; this.killStreak++; this.remoteCall('setKillCount', this.killCount); @@ -242,11 +180,11 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical this.health + settings.playerKillHealthReward, ); this.syncHealth(); - this.remoteCall('onKillConfirmed', victimName, this.killStreak, charge); + this.remoteCall('onKillConfirmed', victimName, this.killStreak); } - public registerHit(charge = 0) { - this.remoteCall('onHitConfirmed', charge); + public registerHit() { + this.remoteCall('onHitConfirmed'); } private syncHealth() { @@ -259,9 +197,6 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical public onCollision({ other }: ReactToCollisionCommand) { if ( - // A corpse keeps its collidable circles for the despawn animation; the - // isAlive guard stops a flying corpse from eating shots aimed past it. - this.isAlive && other instanceof ProjectilePhysical && other.team !== this.team && other.isAlive @@ -278,17 +213,10 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical this.remoteCall('setHealth', this.health); if (this.health <= 0 && this.isAlive) { - // Throw the corpse along the killing shot, harder for charged hits. - vec2.scaleAndAdd( - this.bodyVelocity, - this.bodyVelocity, - other.direction, - mix(settings.deathImpulseMin, settings.deathImpulseMax, other.charge), - ); this.onDie(); - other.originator.addKill(this.name, other.charge); + other.originator.addKill(this.name); } else { - other.originator.registerHit(other.charge); + other.originator.registerHit(); } } } @@ -318,8 +246,6 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical const direction = vec2.subtract(vec2.create(), position, this.center); vec2.normalize(direction, direction); - // Keep the unit direction before vec2.scale repurposes it as the velocity. - const shotDirection = vec2.clone(direction); const velocity = vec2.scale(direction, direction, speed); const projectile = new ProjectilePhysical( vec2.clone(this.center), @@ -329,42 +255,12 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical velocity, this, this.container, - c, ); this.container.addObject(projectile); - if (c > 0) { - vec2.scaleAndAdd( - this.bodyVelocity, - this.bodyVelocity, - shotDirection, - -settings.chargeShotRecoilMax * c, - ); - } - this.remoteCall('onShoot', strength); } - public leap() { - if ( - !this.isAlive || - this.hasJustBorn || - !this.currentPlanet || - this.timeSinceLastLeap < settings.leapCooldownSeconds || - this.projectileStrength < settings.leapStrengthCost - ) { - return; - } - - this.timeSinceLastLeap = 0; - this.projectileStrength -= settings.leapStrengthCost; - - // Same impulse the client predicts with (shared), so a leap launches - // identically on both sides. - applyLeapImpulse(this.movementState, this.lastMovementAction.direction); - this.remoteCall('onLeap'); - } - public get boundingBox(): BoundingBoxBase { return getBoundingBoxOfCircle(new Circle(this.center, CharacterPhysical.boundRadius)); } @@ -467,7 +363,6 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical private step({ deltaTimeInSeconds, game }: StepCommand) { this.getPoints(game); this.timeAlive += deltaTimeInSeconds; - this.timeSinceLastLeap += deltaTimeInSeconds; const oldHead = new Circle(vec2.clone(this.head.center), this.head.radius); const oldLeftFoot = new Circle( vec2.clone(this.leftFoot.center), @@ -482,7 +377,6 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical if ((this.timeSinceDying += deltaTimeInSeconds) > settings.spawnDespawnTime) { this.destroy(); } else { - this.freeFallCorpse(deltaTimeInSeconds); this.animateScaling(1 - this.timeSinceDying / settings.spawnDespawnTime); } this.setPropertyUpdates(oldHead, oldLeftFoot, oldRightFoot, deltaTimeInSeconds); @@ -516,28 +410,9 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical this.regenerateHealth(deltaTimeInSeconds); - // The planet tallies who is standing on it and resolves capture itself, so - // a contested rock can freeze instead of two squads silently cancelling. - this.currentPlanet?.registerPresence(this); + this.currentPlanet?.takeControl(this.team, deltaTimeInSeconds); - // The whole walking model — gravity gather, movement force, on/off-planet - // branch, posture springs, body-momentum, and stepping the three parts — - // is the shared simulation the client predicts with, so the two can never - // drift. Server-only concerns (scoring, health, shooting, spawn/death, - // ownership) stay here around it. - const direction = this.averageAndResetMovementActions(); - stepCharacterMovement( - this.movementState, - this.movementWorld, - direction, - deltaTimeInSeconds, - ); - - this.setPropertyUpdates(oldHead, oldLeftFoot, oldRightFoot, deltaTimeInSeconds); - } - - private freeFallCorpse(deltaTime: number) { - const intersecting = this.container.findIntersecting( + const intersectingWithForceField = this.container.findIntersecting( getBoundingBoxOfCircle( new Circle( this.center, @@ -545,20 +420,117 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical ), ), ); - let grounded = false; - for (const part of [this.leftFoot, this.rightFoot, this.head]) { - part.applyForce(forceAtPosition(part.center, intersecting), deltaTime); - vec2.add(part.velocity, part.velocity, this.bodyVelocity); - const { hitObject } = part.stepManually(deltaTime); - if (hitObject instanceof PlanetPhysical) { - grounded = true; + + const direction = this.averageAndResetMovementActions(); + const movementForce = vec2.scale(direction, direction, settings.maxAcceleration); + this.leftFoot.applyForce(movementForce, deltaTimeInSeconds); + this.rightFoot.applyForce(movementForce, deltaTimeInSeconds); + + if (!this.currentPlanet) { + const leftFootGravity = forceAtPosition( + this.leftFoot.center, + intersectingWithForceField, + ); + const rightFootGravity = forceAtPosition( + this.rightFoot.center, + intersectingWithForceField, + ); + + this.leftFoot.applyForce(leftFootGravity, deltaTimeInSeconds); + this.rightFoot.applyForce(rightFootGravity, deltaTimeInSeconds); + + const sumForce = vec2.subtract(vec2.create(), leftFootGravity, movementForce); + + this.setDirection(vec2.length(sumForce) === 0 ? vec2.fromValues(0, -1) : sumForce); + } else { + const leftFootGravity = this.currentPlanet!.getForce(this.leftFoot.center); + const rightFootGravity = this.currentPlanet!.getForce(this.rightFoot.center); + + vec2.add(leftFootGravity, leftFootGravity, rightFootGravity); + const gravity = vec2.scale(leftFootGravity, leftFootGravity, 0.5); + + if ( + vec2.dot(movementForce, gravity) < + -vec2.length(movementForce) * settings.climbDotThreshold + ) { + vec2.scale(gravity, gravity, settings.climbGravityScale); } + + const scaledLeftFootGravity = vec2.scale( + vec2.create(), + this.leftFoot.lastNormal, + vec2.dot(this.leftFoot.lastNormal, gravity), + ); + this.leftFoot.applyForce(scaledLeftFootGravity, deltaTimeInSeconds); + + const scaledRightFootGravity = vec2.scale( + vec2.create(), + this.rightFoot.lastNormal, + vec2.dot(this.rightFoot.lastNormal, gravity), + ); + + this.rightFoot.applyForce(scaledRightFootGravity, deltaTimeInSeconds); + + if (vec2.length(gravity) <= settings.planetDetachmentForceThreshold) { + this.currentPlanet = undefined; + } + this.setDirection(gravity); } - // Brake with the exact shared model the living body uses: stiff on contact - // so the corpse skids to rest, gentle in the air, plus the constant stop and - // the speed cap — so a flung corpse comes to a definite stop instead of - // sliding forever. - decayMomentum(this.bodyVelocity, grounded, deltaTime); + + this.keepPosture(); + this.stepBodyPart(this.leftFoot, deltaTimeInSeconds); + this.stepBodyPart(this.rightFoot, deltaTimeInSeconds); + this.stepBodyPart(this.head, deltaTimeInSeconds); + + this.setPropertyUpdates(oldHead, oldLeftFoot, oldRightFoot, deltaTimeInSeconds); + } + + private setDirection(direction: vec2) { + this.direction = interpolateAngles( + this.direction, + Math.atan2(direction.y, direction.x) + Math.PI / 2, + 0.2, + ); + } + + private keepPosture() { + const center = this.center; + this.springMove( + this.leftFoot, + center, + CharacterPhysical.leftFootOffset, + settings.postureFeetStiffness, + ); + this.springMove( + this.rightFoot, + center, + CharacterPhysical.rightFootOffset, + settings.postureFeetStiffness, + ); + + this.springMove( + this.head, + center, + CharacterPhysical.headOffset, + settings.postureHeadStiffness, + ); + } + + private springMove( + object: CirclePhysical, + center: vec2, + offset: vec2, + stiffness: number, + ) { + const desiredPosition = vec2.add(vec2.create(), center, offset); + vec2.rotate(desiredPosition, desiredPosition, center, this.direction); + const positionDelta = vec2.subtract(vec2.create(), desiredPosition, object.center); + + // First-order velocity relaxation toward the desired posture position, + // added to (not replacing) the gravity/movement velocity accumulated + // earlier this tick. stepManually applies it over deltaTime, so the + // per-tick displacement is positionDelta * stiffness * deltaTime. + vec2.scaleAndAdd(object.velocity, object.velocity, positionDelta, stiffness); } private regenerateHealth(deltaTimeInSeconds: number) { @@ -575,6 +547,14 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical } } + private stepBodyPart(part: CirclePhysical, deltaTime: number) { + const { hitObject } = part.stepManually(deltaTime); + if (hitObject instanceof PlanetPhysical) { + this.secondsSinceOnSurface = 0; + this.currentPlanet = hitObject; + } + } + public onDie() { this.isDestroyed = true; this.remoteCall('onDie'); diff --git a/backend/src/objects/circle-physical.ts b/backend/src/objects/circle-physical.ts index e1a6abf..2a36215 100644 --- a/backend/src/objects/circle-physical.ts +++ b/backend/src/objects/circle-physical.ts @@ -4,14 +4,13 @@ import { CommandExecutors, CommandReceiver, GameObject, - resolveCircleMovement, serializesTo, } from 'shared'; import { BoundingBox } from '../physics/bounding-boxes/bounding-box'; import { BoundingBoxBase } from '../physics/bounding-boxes/bounding-box-base'; +import { moveCircle } from '../physics/functions/move-circle'; import { PhysicalContainer } from '../physics/containers/physical-container'; import { DynamicPhysical } from '../physics/physicals/dynamic-physical'; -import { Physical } from '../physics/physicals/physical'; import { ReactToCollisionCommand } from '../commands/react-to-collision'; @serializesTo(Circle) @@ -33,9 +32,7 @@ export class CirclePhysical extends CommandReceiver implements Circle, DynamicPh private _radius: number, public owner: GameObject, private readonly container: PhysicalContainer, - // Public + readonly so a CirclePhysical structurally satisfies the shared - // PhysicsBody interface the movement simulation operates on. - public readonly restitution = 0, + private restitution = 0, ) { super(); this._boundingBox = new BoundingBox(); @@ -91,49 +88,43 @@ export class CirclePhysical extends CommandReceiver implements Circle, DynamicPh ); } - // Position-resolution for one tick. Delegates to the shared - // resolveCircleMovement so the server integrates a body with the exact same - // geometry the client predictor runs (shared/physics) — no parallel copy to - // keep in sync. The onHit callback dispatches the collision reactions at the - // same points the old inline move-circle did (both the initial march and the - // post-bounce slide). `possibleIntersectors`, when supplied, lets a caller - // that already broadphased (e.g. a projectile's gravity query) avoid a second - // container query; otherwise it is self-gathered from the swept bounding box. - public stepManually( - deltaTimeInSeconds: number, - possibleIntersectors?: Array, - ): { + public stepManually(deltaTimeInSeconds: number): { hitObject: GameObject | undefined; velocity: vec2; } { - const intersecting = ( - possibleIntersectors ?? this.sweptBroadphase(deltaTimeInSeconds) - ).filter((b) => b.gameObject !== this.gameObject && b.canCollide); + let delta = vec2.scale(vec2.create(), this.velocity, deltaTimeInSeconds); - const { hitObject, velocity } = resolveCircleMovement( - this, - deltaTimeInSeconds, - intersecting, - (intersected) => { - const physical = intersected as Physical; - physical.handleCommand(new ReactToCollisionCommand(this.gameObject)); - this.handleCommand(new ReactToCollisionCommand(physical.gameObject)); - }, - ); + this.radius += vec2.length(delta); + const intersecting = this.container + .findIntersecting(this.boundingBox) + .filter((b) => b.gameObject !== this.gameObject && b.canCollide); + this.radius -= vec2.length(delta); - return { hitObject: (hitObject as Physical | undefined)?.gameObject, velocity }; - } + const { normal, hitSurface, hitObject } = moveCircle(this, delta, intersecting); - // Query the container with the bounding box grown by this tick's travel, so a - // fast-moving body still sees what it is about to sweep into. - private sweptBroadphase(deltaTimeInSeconds: number): Array { - const sweep = vec2.length( - vec2.scale(vec2.create(), this.velocity, deltaTimeInSeconds), - ); - this.radius += sweep; - const intersecting = this.container.findIntersecting(this.boundingBox); - this.radius -= sweep; - return intersecting; + if (hitSurface) { + vec2.copy(this.lastNormal, normal!); + + vec2.subtract( + this.velocity, + this.velocity, + vec2.scale( + normal!, + normal!, + (1 + this.restitution) * vec2.dot(normal!, this.velocity), + ), + ); + + if (vec2.length(this.velocity) > 50) { + delta = vec2.scale(vec2.create(), this.velocity, deltaTimeInSeconds); + moveCircle(this, delta, intersecting); + } + } + + const lastVelocity = vec2.clone(this.velocity); + vec2.zero(this.velocity); + + return { hitObject, velocity: lastVelocity }; } public toArray(): Array { diff --git a/backend/src/objects/planet-physical.ts b/backend/src/objects/planet-physical.ts index 4305b18..9e7b094 100644 --- a/backend/src/objects/planet-physical.ts +++ b/backend/src/objects/planet-physical.ts @@ -5,7 +5,6 @@ import { clamp01, id, mix, - Random, serializesTo, settings, PlanetBase, @@ -16,46 +15,25 @@ import { CommandReceiver, } from 'shared'; import { GeneratePointsCommand } from '../commands/generate-points'; -import { AnnounceCommand } from '../commands/announce'; import { StepCommand } from '../commands/step'; import { ImmutableBoundingBox } from '../physics/bounding-boxes/immutable-bounding-box'; import { StaticPhysical } from '../physics/physicals/static-physical'; import { LampPhysical } from './lamp-physical'; -import type { CharacterPhysical } from './character-physical'; @serializesTo(PlanetBase) export class PlanetPhysical extends PlanetBase implements StaticPhysical { public readonly canCollide = true; public readonly canMove = false; - // Marks this as standable ground for the shared movement simulation (a body - // landing on it latches it as currentPlanet). See shared GroundSurface. - public readonly isGround = true; public readonly sizePointMultiplier: number; - // Planets slowly spin. The angle is authoritative here and streamed to the - // client (see getPropertyUpdates), so the rendered outline and this collision - // polygon turn as one rigid body. cos/sin are memoised per angle because - // distance() is called many times per tick by the raymarcher and SDF sampling. - 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(). Drives the head-count tug-of-war. - private presentCharacters: Array = []; - private isContested = false; - protected commandExecutors: CommandExecutors = { [StepCommand.type]: this.step.bind(this), }; @@ -64,41 +42,28 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical { this.lamps.push(lamp); } - constructor(vertices: Array, isKeystone = false) { - super(id(), vertices, 0.5, isKeystone); + constructor(vertices: Array) { + super(id(), vertices); const sizeClass = clamp01( (this.radius - settings.planetMinReferenceRadius) / - (settings.planetMaxReferenceRadius - settings.planetMinReferenceRadius), + (settings.planetMaxReferenceRadius - settings.planetMinReferenceRadius), ); this.sizePointMultiplier = mix(1, settings.planetSizePointMultiplierMax, sizeClass); - - this.rotationSpeed = - (0.05 + Random.getRandom() * 0.07) * (Random.getRandom() < 0.5 ? -1 : 1); - } - - // A grounded character announces itself each tick so the planet can resolve - // contested capture from the net head-count. - public registerPresence(character: CharacterPhysical) { - this.presentCharacters.push(character); } public distance(target: vec2): number { - // Evaluate the SDF in the planet's own rotating frame so this collision - // outline turns in lockstep with the rendered planet (see planet-shape.ts). - const local = this.toLocalFrame(target); - const startEnd = this.vertices[0]; let vb = startEnd; - let d = vec2.dist(local, vb); + let d = vec2.squaredDistance(target, vb); let sign = 1; for (let i = 1; i <= this.vertices.length; i++) { const va = vb; vb = i === this.vertices.length ? startEnd : this.vertices[i]; - const targetFromDelta = vec2.subtract(vec2.create(), local, va); + const targetFromDelta = vec2.subtract(vec2.create(), target, va); const toFromDelta = vec2.subtract(vec2.create(), vb, va); const h = clamp01( vec2.dot(targetFromDelta, toFromDelta) / vec2.squaredLength(toFromDelta), @@ -110,8 +75,8 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical { ); if ( - (local.y >= va.y && local.y < vb.y && ds.y > 0) || - (local.y < va.y && local.y >= vb.y && ds.y <= 0) + (target.y >= va.y && target.y < vb.y && ds.y > 0) || + (target.y < va.y && target.y >= vb.y && ds.y <= 0) ) { sign *= -1; } @@ -122,35 +87,11 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical { return sign * d; } - // Rotate a world point by -rotation about the centre, matching the shader's - // `localTarget = center + R(rotation) * (target - center)` transform exactly. - private toLocalFrame(target: vec2): vec2 { - if (this.rotation !== this.cachedRotation) { - this.cachedRotation = this.rotation; - this.cosRotation = Math.cos(this.rotation); - this.sinRotation = Math.sin(this.rotation); - } - - const dx = target.x - this.center.x; - const dy = target.y - this.center.y; - - return vec2.fromValues( - this.center.x + this.cosRotation * dx - this.sinRotation * dy, - this.center.y + this.sinRotation * dx + this.cosRotation * dy, - ); - } - - // Signed angular velocity in rad/s, exposed so a character standing on the - // planet can ride its spin (see carryWithRotatingPlanet in shared). - public get angularVelocity(): number { - return this.rotationSpeed; - } - public get team(): CharacterTeam { - return Math.abs(this.ownership - 0.5) < settings.planetControlThreshold + return Math.abs(this.ownership - 0.5) < 0.1 ? CharacterTeam.neutral : this.ownership < 0.5 - ? CharacterTeam.blue + ? CharacterTeam.decla : CharacterTeam.red; } @@ -164,7 +105,7 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical { ); game.handleCommand( new GeneratePointsCommand( - this.team === CharacterTeam.blue ? value : 0, + this.team === CharacterTeam.decla ? value : 0, this.team === CharacterTeam.red ? value : 0, ), ); @@ -172,54 +113,12 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical { } private step({ deltaTimeInSeconds, game }: StepCommand) { - this.rotation += deltaTimeInSeconds * this.rotationSpeed; this.timeSinceLastPointGeneration += deltaTimeInSeconds; // In reverse order, so that teams can achieve a 100% control. this.getPoints(game); - this.resolveCapture(deltaTimeInSeconds); + this.takeControl(CharacterTeam.neutral, deltaTimeInSeconds); this.detectFlip(game); - - this.presentCharacters = []; - } - - // One capture step per tick driven by the net team head-count, so grouping up - // pays off and an equal standoff freezes the planet (contested) instead of - // both sides silently cancelling with no feedback. - private resolveCapture(deltaTime: number) { - let blue = 0; - let red = 0; - for (const c of this.presentCharacters) { - if (c.team === CharacterTeam.blue) { - blue++; - } else if (c.team === CharacterTeam.red) { - red++; - } - } - const net = red - blue; - const occupied = blue + red > 0; - - if (net !== 0) { - const lead = Math.min(Math.abs(net), settings.maxContestLeadMultiplier); - this.takeControl( - net > 0 ? CharacterTeam.red : CharacterTeam.blue, - deltaTime * lead, - ); - } else if (!occupied) { - // Empty planets drift back to neutral; the keystone drifts much slower so - // it lingers as a live flashpoint. - this.takeControl( - CharacterTeam.neutral, - this.isKeystone ? deltaTime / settings.keystoneLoseControlScale : deltaTime, - ); - } - // occupied tie -> frozen tug-of-war: no ownership change, ring pulses. - - const contested = occupied && net === 0; - if (contested !== this.isContested) { - this.isContested = contested; - this.remoteCall('setContested', contested); - } } private detectFlip(game: CommandReceiver) { @@ -236,18 +135,10 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical { this.remoteCall('generatedPoints', reward); game.handleCommand( new GeneratePointsCommand( - currentTeam === CharacterTeam.blue ? reward : 0, + currentTeam === CharacterTeam.decla ? reward : 0, currentTeam === CharacterTeam.red ? reward : 0, ), ); - - if (this.isKeystone) { - game.handleCommand( - new AnnounceCommand( - `Team ${currentTeam} captured the Heart`, - ), - ); - } } const control = Math.abs(this.ownership - 0.5) / 0.5; @@ -262,14 +153,11 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical { 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) { - if (team === CharacterTeam.blue) { + if (team === CharacterTeam.decla) { this.ownership -= (0.5 / settings.takeControlTimeInSeconds) * deltaTime; } else if (team === CharacterTeam.red) { this.ownership += (0.5 / settings.takeControlTimeInSeconds) * deltaTime; @@ -292,20 +180,22 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical { 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, + const { xMin, xMax, yMin, yMax } = this.vertices.reduce( + (extremities, vertex) => ({ + xMin: Math.min(extremities.xMin, vertex.x), + xMax: Math.max(extremities.xMax, vertex.x), + yMin: Math.min(extremities.yMin, vertex.y), + yMax: Math.max(extremities.yMax, vertex.y), + }), + { + xMin: Infinity, + xMax: -Infinity, + yMin: Infinity, + yMax: -Infinity, + }, ); - this._boundingBox = new ImmutableBoundingBox( - this.center.x - maxVertexDistance, - this.center.x + maxVertexDistance, - this.center.y - maxVertexDistance, - this.center.y + maxVertexDistance, - ); + this._boundingBox = new ImmutableBoundingBox(xMin, xMax, yMin, yMax); } return this._boundingBox; @@ -323,11 +213,6 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical { return vec2.scale(diff, diff, scale); } - // GroundSurface alias the shared movement simulation calls for gravity. - public gravityAt(position: vec2): vec2 { - return this.getForce(position); - } - public get gameObject(): this { return this; } diff --git a/backend/src/objects/projectile-physical.ts b/backend/src/objects/projectile-physical.ts index 8a967f1..51a7866 100644 --- a/backend/src/objects/projectile-physical.ts +++ b/backend/src/objects/projectile-physical.ts @@ -8,16 +8,13 @@ import { PropertyUpdatesForObject, UpdatePropertyCommand, CommandExecutors, - Circle, - marchCircle, } from 'shared'; import { ImmutableBoundingBox } from '../physics/bounding-boxes/immutable-bounding-box'; import { CirclePhysical } from './circle-physical'; import { DynamicPhysical } from '../physics/physicals/dynamic-physical'; import { PhysicalContainer } from '../physics/containers/physical-container'; import { CharacterPhysical } from './character-physical'; -import { forceAtPosition } from '../physics/functions/force-at-position'; -import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle'; +import { moveCircle } from '../physics/functions/move-circle'; import { StepCommand } from '../commands/step'; import { ReactToCollisionCommand } from '../commands/react-to-collision'; @@ -45,9 +42,6 @@ export class ProjectilePhysical extends ProjectileBase implements DynamicPhysica private velocity: vec2, public readonly originator: CharacterPhysical, readonly container: PhysicalContainer, - // Normalised charge (0..1) of the shot that fired this, used by the victim - // to scale hit/kill feedback and the death fling. - public readonly charge: number = 0, ) { super(id(), center, radius, team, strength); this.object = new CirclePhysical(center, radius, this, container, 0.9); @@ -77,7 +71,7 @@ export class ProjectilePhysical extends ProjectileBase implements DynamicPhysica const intersecting = this.container .findIntersecting(this.boundingBox) .filter((g) => g instanceof CharacterPhysical && g.team === this.team); - const { hitSurface } = marchCircle(this.object, delta, intersecting, true); + const { hitSurface } = moveCircle(this.object, delta, intersecting, true); wasCollision = hitSurface; } vec2.add(this.center, this.center, delta); @@ -130,31 +124,8 @@ export class ProjectilePhysical extends ProjectileBase implements DynamicPhysica return; } - // Curveball: the same planetary gravity that pulls on a free-falling - // character bends the shot, so slower (charged) shots arc and can be lobbed - // over a planet's horizon. Scale is tiny — near-surface gravity is huge. - // This single broadphase is reused for the step below: the gravity radius - // (maxGravityDistance) dwarfs one tick's travel, so the set is a superset of - // the swept-collision box and the step needn't query the container again. - const intersecting = settings.projectileGravityEnabled - ? this.container.findIntersecting( - getBoundingBoxOfCircle( - new Circle(this.center, this.object.radius + settings.maxGravityDistance), - ), - ) - : undefined; - - if (intersecting) { - vec2.scaleAndAdd( - this.velocity, - this.velocity, - forceAtPosition(this.center, intersecting), - settings.projectileGravityScale * deltaTimeInSeconds, - ); - } - vec2.copy(this.object.velocity, this.velocity); - const { velocity } = this.object.stepManually(deltaTimeInSeconds, intersecting); + const { velocity } = this.object.stepManually(deltaTimeInSeconds); vec2.copy(this.velocity, velocity); } } diff --git a/shared/src/physics/evaluate-sdf.ts b/backend/src/physics/functions/evaluate-sdf.ts similarity index 53% rename from shared/src/physics/evaluate-sdf.ts rename to backend/src/physics/functions/evaluate-sdf.ts index f3c60cf..16bc96c 100644 --- a/shared/src/physics/evaluate-sdf.ts +++ b/backend/src/physics/functions/evaluate-sdf.ts @@ -1,7 +1,7 @@ import { vec2 } from 'gl-matrix'; -import { Sdf } from './sdf'; +import { PhysicalBase } from '../physicals/physical-base'; -export const evaluateSdf = (target: vec2, objects: Array) => +export const evaluateSdf = (target: vec2, objects: Array) => objects .filter((i) => i.canCollide) .reduce((min, i) => (min = Math.min(min, i.distance(target))), 1000000); diff --git a/backend/src/physics/functions/is-circle-intersecting.ts b/backend/src/physics/functions/is-circle-intersecting.ts index 203eb1d..3f497fa 100644 --- a/backend/src/physics/functions/is-circle-intersecting.ts +++ b/backend/src/physics/functions/is-circle-intersecting.ts @@ -1,4 +1,5 @@ -import { Circle, evaluateSdf } from 'shared'; +import { Circle } from 'shared'; +import { evaluateSdf } from './evaluate-sdf'; import { PhysicalBase } from '../physicals/physical-base'; export const isCircleIntersecting = ( diff --git a/backend/src/physics/functions/move-circle.ts b/backend/src/physics/functions/move-circle.ts new file mode 100644 index 0000000..7c82fdc --- /dev/null +++ b/backend/src/physics/functions/move-circle.ts @@ -0,0 +1,89 @@ +import { vec2 } from 'gl-matrix'; +import { GameObject } from 'shared'; +import { CirclePhysical } from '../../objects/circle-physical'; +import { evaluateSdf } from './evaluate-sdf'; +import { Physical } from '../physicals/physical'; +import { ReactToCollisionCommand } from '../../commands/react-to-collision'; + +export const moveCircle = ( + circle: CirclePhysical, + delta: vec2, + possibleIntersectors: Array, + ignoreCollision = false, +): { + hitSurface: boolean; + normal?: vec2; + hitObject?: GameObject; +} => { + const direction = vec2.clone(delta); + + if (vec2.length(delta) > 0) { + vec2.normalize(direction, direction); + } + + const deltaLength = vec2.length(delta); + let travelled = 0; + const rayEnd = vec2.create(); + let prevMinDistance = 0; + while (travelled < deltaLength) { + travelled += prevMinDistance; + vec2.add( + rayEnd, + circle.center, + vec2.scale(vec2.create(), direction, Math.min(travelled, deltaLength)), + ); + + const minDistance = evaluateSdf(rayEnd, possibleIntersectors); + + if (minDistance < circle.radius) { + const intersecting = possibleIntersectors.find( + (i) => i.distance(rayEnd) <= circle.radius, + )!; + + if (ignoreCollision) { + circle.center = vec2.add(circle.center, circle.center, delta); + } else { + intersecting.handleCommand(new ReactToCollisionCommand(circle.gameObject)); + circle.handleCommand(new ReactToCollisionCommand(intersecting.gameObject)); + } + + vec2.add( + rayEnd, + circle.center, + vec2.scale(vec2.create(), direction, travelled - prevMinDistance), + ); + + vec2.copy(circle.center, rayEnd); + + const dx = + evaluateSdf(vec2.add(vec2.create(), rayEnd, vec2.fromValues(0.01, 0)), [ + intersecting, + ]) - + evaluateSdf(vec2.add(vec2.create(), rayEnd, vec2.fromValues(-0.01, 0)), [ + intersecting, + ]); + const dy = + evaluateSdf(vec2.add(vec2.create(), rayEnd, vec2.fromValues(0, 0.01)), [ + intersecting, + ]) - + evaluateSdf(vec2.add(vec2.create(), rayEnd, vec2.fromValues(0, -0.01)), [ + intersecting, + ]); + const normal = vec2.fromValues(dx, dy); + vec2.normalize(normal, normal); + return { + hitSurface: true, + normal, + hitObject: intersecting?.gameObject, + }; + } + + prevMinDistance = minDistance; + } + + vec2.add(circle.center, circle.center, delta); + + return { + hitSurface: false, + }; +}; diff --git a/backend/src/players/npc.ts b/backend/src/players/npc.ts index 23177b6..7bcb3f7 100644 --- a/backend/src/players/npc.ts +++ b/backend/src/players/npc.ts @@ -60,10 +60,6 @@ const npcTuning = { spreadBase: 60, spreadPerDistance: 0.08, spreadAggressionFalloff: 1.3, - - // Per-second chance to hop while grounded and on the move, so bots use the - // leap verb too instead of being grounded targets. - leapChancePerSecond: 0.35, }; export class NPC extends PlayerBase { @@ -145,16 +141,6 @@ export class NPC extends PlayerBase { const movement = this.decideMovement(this.nearObjects); this.character.handleMovementAction(new MoveActionCommand(movement)); - if ( - !this.isComingBack && - this.character.groundPlanet && - vec2.length(movement) > 0 && - Random.getRandom() < - npcTuning.leapChancePerSecond * this.aggression * deltaTimeInSeconds - ) { - this.character.leap(); - } - if ( (this.timeSinceLastShoot += deltaTimeInSeconds) > npcTuning.shootIntervalSeconds ) { diff --git a/backend/src/players/player-container.ts b/backend/src/players/player-container.ts index afc67bb..5faea71 100644 --- a/backend/src/players/player-container.ts +++ b/backend/src/players/player-container.ts @@ -79,11 +79,11 @@ export class PlayerContainer { private getTeamOfNextPlayer(isNpc = false): CharacterTeam { const players = isNpc ? this.players : this._players; - const blueCount = players.filter((p) => p.team === CharacterTeam.blue).length; + const declaCount = players.filter((p) => p.team === CharacterTeam.decla).length; const redCount = players.filter((p) => p.team === CharacterTeam.red).length; - if ((blueCount === redCount && Random.getRandom() >= 0.5) || blueCount < redCount) { - return CharacterTeam.blue; + if ((declaCount === redCount && Random.getRandom() >= 0.5) || declaCount < redCount) { + return CharacterTeam.decla; } else { return CharacterTeam.red; } diff --git a/backend/src/players/player.ts b/backend/src/players/player.ts index ca563fc..1478029 100644 --- a/backend/src/players/player.ts +++ b/backend/src/players/player.ts @@ -1,4 +1,3 @@ -import { performance } from 'perf_hooks'; import { vec2 } from 'gl-matrix'; import { CommandExecutors, @@ -23,8 +22,6 @@ import { PropertyUpdatesForObjects, PropertyUpdatesForObject, PrimaryActionCommand, - LeapActionCommand, - InputAcknowledgement, } from 'shared'; import { Socket } from 'socket.io'; import { BoundingBox } from '../physics/bounding-boxes/bounding-box'; @@ -39,27 +36,14 @@ export class Player extends PlayerBase { private timeUntilRespawn = 0; private timeSinceLastMessage = 0; private objectsPreviouslyInViewArea: Array = []; - private lastInputClientTimeMs = 0; - private lastLeapClientTimeMs = 0; protected commandExecutors: CommandExecutors = { [SetAspectRatioActionCommand.type]: (v: SetAspectRatioActionCommand) => (this.aspectRatio = v.aspectRatio), - [MoveActionCommand.type]: (c: MoveActionCommand) => { - // Remember how far into this client's input timeline we've consumed, to - // echo back for client-side prediction reconciliation. - this.lastInputClientTimeMs = c.clientTimeMs; - this.character?.handleMovementAction(c); - }, + [MoveActionCommand.type]: (c: MoveActionCommand) => + this.character?.handleMovementAction(c), [PrimaryActionCommand.type]: (c: PrimaryActionCommand) => this.character?.shootTowards(c.position, c.charge), - [LeapActionCommand.type]: (c: LeapActionCommand) => { - // Record receipt (whether or not leap() accepts it): either way its effect - // on bodyVelocity is now reflected in the streamed launch momentum, so the - // predictor must stop replaying this leap. - this.lastLeapClientTimeMs = c.clientTimeMs; - this.character?.leap(); - }, }; constructor( @@ -115,13 +99,6 @@ export class Player extends PlayerBase { new Set(this.objectContainer.findIntersecting(bb).map((o) => o.gameObject)), ); - // The owning character must always be in its own snapshot, regardless of the - // view-area query, so the client predictor never loses its authoritative - // anchor (the body can ride a fast spinner to the very edge of the box). - if (this.character && !objectsInViewArea.includes(this.character)) { - objectsInViewArea.push(this.character); - } - const newlyIntersecting = objectsInViewArea.filter( (o) => !this.objectsPreviouslyInViewArea.includes(o), ); @@ -149,22 +126,8 @@ export class Player extends PlayerBase { this.objectsPreviouslyInViewArea .map((o) => o.getPropertyUpdates()) .filter((u) => u) as Array, - performance.now() / 1000, ), ); - - // Tell the client how much of its own input is reflected in the snapshot it - // just received, so its predictor can replay the rest. Only while alive — - // a dead player isn't predicting. - if (this.character) { - this.queueCommandSend( - new InputAcknowledgement( - this.lastInputClientTimeMs, - this.character.launchMomentum, - this.lastLeapClientTimeMs, - ), - ); - } } private getOtherPlayers(): Array { diff --git a/frontend/.firebaserc b/frontend/.firebaserc new file mode 100644 index 0000000..2f95982 --- /dev/null +++ b/frontend/.firebaserc @@ -0,0 +1,5 @@ +{ + "projects": { + "default": "decla-red + } +} \ No newline at end of file diff --git a/frontend/firebase.json b/frontend/firebase.json new file mode 100644 index 0000000..2c33c29 --- /dev/null +++ b/frontend/firebase.json @@ -0,0 +1,16 @@ +{ + "hosting": { + "public": "dist", + "ignore": [ + "firebase.json", + "**/.*", + "**/node_modules/**" + ], + "rewrites": [ + { + "source": "**", + "destination": "/index.html" + } + ] + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index bfa9f49..9652017 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,11 +1,11 @@ { - "name": "doppler-frontend", + "name": "decla.red-frontend", "version": "0.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "doppler-frontend", + "name": "decla.red-frontend", "version": "0.0.0", "devDependencies": { "@plausible-analytics/tracker": "^0.4.5", diff --git a/frontend/package.json b/frontend/package.json index fbf1d72..6dc2f68 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,5 +1,5 @@ { - "name": "doppler-frontend", + "name": "decla.red-frontend", "version": "0.0.0", "description": "", "keywords": [], diff --git a/frontend/src/index.html b/frontend/src/index.html index 0d4d201..e04a9bc 100644 --- a/frontend/src/index.html +++ b/frontend/src/index.html @@ -9,13 +9,13 @@ /> - - + + - doppler + decla.red @@ -37,7 +37,7 @@
-

doppler

+

decla.red

Join a game diff --git a/frontend/src/main.scss b/frontend/src/main.scss index 5dcbb56..08e6889 100644 --- a/frontend/src/main.scss +++ b/frontend/src/main.scss @@ -41,18 +41,6 @@ h1 { font-size: 6rem; text-align: center; padding-bottom: $medium-padding; - width: fit-content; - margin-inline: auto; - background: linear-gradient(90deg, $bright-blue, $bright-red); - background-clip: text; - -webkit-background-clip: text; - color: transparent; - - &::selection { - color: white; - -webkit-text-fill-color: white; - background-color: $accent; - } @media (max-width: $height-breakpoint) { font-size: 4.5rem; @@ -63,6 +51,15 @@ h1 { } } +.red { + color: $accent; + + &::selection { + color: $accent; + background-color: white; + } +} + html, body, canvas { @@ -129,14 +126,14 @@ body { border-radius: 1000px; transition: transform 200ms; - &.blue { - color: $bright-blue; + &.decla { + color: $bright-decla; .health { - background-color: $bright-blue; + background-color: $bright-decla; &:before { - background-color: $bright-blue; + background-color: $bright-decla; opacity: 0.3; } } @@ -207,14 +204,6 @@ body { @include square(50px); border-radius: 1000px; mask-image: url('../static/mask.svg'); - - &.contested { - animation: contested-pulse 0.7s ease-in-out infinite; - } - - &.keystone { - @include square(72px); - } } .falling-point { @@ -222,8 +211,8 @@ body { font-weight: 700; animation: falling-point 2s ease-out forwards; - &.blue { - color: $bright-blue; + &.decla { + color: $bright-decla; } &.red { @@ -243,8 +232,8 @@ body { mask-image: url('../static/chevron.svg'); mask-size: contain; - &.blue { - background-color: $bright-blue; + &.decla { + background-color: $bright-decla; } &.red { @@ -252,34 +241,6 @@ body { } } - // Off-screen pointer to the keystone "Heart", tinted by its current owner. - .keystone-arrow { - transition: transform 150ms; - opacity: 0.9; - - @include square($large-icon); - - @media (max-width: $breakpoint) { - @include square($small-icon); - } - - mask-image: url('../static/chevron.svg'); - mask-size: contain; - background-color: $bright-neutral; - - &.blue { - background-color: $bright-blue; - } - - &.red { - background-color: $bright-red; - } - - &.neutral { - background-color: $bright-neutral; - } - } - .joystick { @include square($large-icon * 1.3); background-color: white; @@ -332,15 +293,6 @@ body { } } - &.leap { - right: $medium-padding; - bottom: $medium-padding + $large-icon * 1.6 + $small-padding; - - &::after { - content: '\25B2'; - } - } - .strength-ring { position: absolute; top: -3px; @@ -372,8 +324,8 @@ body { display: none; } - .blue { - color: $bright-blue; + .decla { + color: $bright-decla; } .red { @@ -422,10 +374,10 @@ body { transition: width $animation-time; } - .fill.blue { + .fill.decla { right: 50%; - background: $bright-blue; - color: $bright-blue; + background: $bright-decla; + color: $bright-decla; border-radius: 1000px 0 0 1000px; } @@ -473,7 +425,7 @@ body { font-size $animation-time; } - .score.blue { + .score.decla { right: calc(50% + #{$small-padding}); } @@ -486,10 +438,10 @@ body { font-size: 1.2rem; } - .score.blue.leading { + .score.decla.leading { text-shadow: 0 0 3px rgba(0, 0, 0, 0.95), - 0 0 8px $bright-blue; + 0 0 8px $bright-decla; } .score.red.leading { @@ -519,9 +471,9 @@ body { border-bottom: 5px solid currentColor; } - &.blue { + &.decla { right: calc(50% + #{$small-padding}); - color: $bright-blue; + color: $bright-decla; } &.red { @@ -617,16 +569,6 @@ body { } animation: hitmarker-pop 250ms ease-out forwards; - - &.charged { - - &::before, - &::after { - width: 18px; - height: 3px; - background-color: $accent; - } - } } .kill-popup { @@ -639,10 +581,6 @@ body { text-shadow: 0 0 6px rgba(0, 0, 0, 0.9); animation: kill-popup-rise 1200ms ease-out forwards; - &.charged { - color: $accent; - } - .heal { color: #6fcf6f; } @@ -685,18 +623,6 @@ body { } } -@keyframes contested-pulse { - - 0%, - 100% { - opacity: 1; - } - - 50% { - opacity: 0.35; - } -} - .charge-ring { position: fixed; @include square(56px); @@ -720,7 +646,6 @@ body { } @keyframes match-point-pulse { - 0%, 100% { box-shadow: 0 0 4px 0 currentColor; diff --git a/frontend/src/scripts/analytics.ts b/frontend/src/scripts/analytics.ts index e732cc4..474cb7a 100644 --- a/frontend/src/scripts/analytics.ts +++ b/frontend/src/scripts/analytics.ts @@ -1,7 +1,7 @@ import { init as plausibleInit } from '@plausible-analytics/tracker'; const ANALYTICS_AUTO_CAPTURE_PAGEVIEWS = true; -const ANALYTICS_DOMAIN = 'doppler.schmelczer.dev'; +const ANALYTICS_DOMAIN = 'decla.red'; const ANALYTICS_ENDPOINT = 'https://stats.schmelczer.dev/status'; const ANALYTICS_LOGGING = process.env.NODE_ENV !== 'production'; diff --git a/frontend/src/scripts/commands/keyboard-listener.ts b/frontend/src/scripts/commands/keyboard-listener.ts index 342d851..f16ce42 100644 --- a/frontend/src/scripts/commands/keyboard-listener.ts +++ b/frontend/src/scripts/commands/keyboard-listener.ts @@ -1,6 +1,5 @@ import { vec2 } from 'gl-matrix'; -import { CommandGenerator, LeapActionCommand, MoveActionCommand } from 'shared'; -import { localCharacterPredictor } from '../helper/prediction/local-character-predictor'; +import { CommandGenerator, MoveActionCommand } from 'shared'; export class KeyboardListener extends CommandGenerator { private keysDown: Set = new Set(); @@ -14,14 +13,7 @@ export class KeyboardListener extends CommandGenerator { } private keyDownListener = (event: KeyboardEvent) => { - const key = event.key.toLowerCase(); - // Space leaps (W / ArrowUp already cover walking up). Edge-triggered so a - // held key's auto-repeat doesn't spam leaps. - if ((key === ' ' || key === 'shift') && !this.keysDown.has(key)) { - const clientTimeMs = localCharacterPredictor.recordLeap(); - this.sendCommandToSubscribers(new LeapActionCommand(clientTimeMs)); - } - this.keysDown.add(key); + this.keysDown.add(event.key.toLowerCase()); this.generateCommands(); }; @@ -36,7 +28,11 @@ export class KeyboardListener extends CommandGenerator { }; private generateCommands() { - const up = ~~(this.keysDown.has('w') || this.keysDown.has('arrowup')); + const up = ~~( + this.keysDown.has('w') || + this.keysDown.has('arrowup') || + this.keysDown.has(' ') + ); const down = ~~(this.keysDown.has('s') || this.keysDown.has('arrowdown')); const left = ~~(this.keysDown.has('a') || this.keysDown.has('arrowleft')); const right = ~~(this.keysDown.has('d') || this.keysDown.has('arrowright')); @@ -46,8 +42,7 @@ export class KeyboardListener extends CommandGenerator { vec2.normalize(movement, movement); } - const clientTimeMs = localCharacterPredictor.recordInput(movement); - this.sendCommandToSubscribers(new MoveActionCommand(movement, clientTimeMs)); + this.sendCommandToSubscribers(new MoveActionCommand(movement)); } public destroy() { diff --git a/frontend/src/scripts/commands/touch-listener.ts b/frontend/src/scripts/commands/touch-listener.ts index 9dd2cfa..4ecb6bf 100644 --- a/frontend/src/scripts/commands/touch-listener.ts +++ b/frontend/src/scripts/commands/touch-listener.ts @@ -4,13 +4,11 @@ import { MoveActionCommand, last, PrimaryActionCommand, - LeapActionCommand, holdDurationToCharge, settings, } from 'shared'; import { Game } from '../game'; import { ChargeIndicator } from '../charge-indicator'; -import { localCharacterPredictor } from '../helper/prediction/local-character-predictor'; export class TouchListener extends CommandGenerator { private static readonly deadZone = 8; @@ -24,7 +22,6 @@ export class TouchListener extends CommandGenerator { private fireButton: HTMLElement; private fireStrengthRing: HTMLElement; - private leapButton: HTMLElement; private fireDownAt: number | null = null; @@ -49,12 +46,7 @@ export class TouchListener extends CommandGenerator { this.fireButton.addEventListener('touchstart', this.fireButtonDownListener); this.fireButton.addEventListener('touchend', this.fireButtonUpListener); - this.leapButton = document.createElement('div'); - this.leapButton.className = 'touch-button leap'; - this.leapButton.addEventListener('touchstart', this.leapButtonListener); - this.overlay.appendChild(this.fireButton); - this.overlay.appendChild(this.leapButton); target.addEventListener('touchstart', this.touchStartListener); target.addEventListener('touchmove', this.touchMoveListener); @@ -109,17 +101,12 @@ export class TouchListener extends CommandGenerator { vec2.set(delta, delta.x, -delta.y); if (deltaLength > TouchListener.deadZone) { const direction = vec2.normalize(delta, delta); - this.sendMove(direction); + this.sendCommandToSubscribers(new MoveActionCommand(direction)); } else { - this.sendMove(vec2.create()); + this.sendCommandToSubscribers(new MoveActionCommand(vec2.create())); } }; - private sendMove(direction: vec2) { - const clientTimeMs = localCharacterPredictor.recordInput(direction); - this.sendCommandToSubscribers(new MoveActionCommand(direction, clientTimeMs)); - } - private touchEndListener = (event: TouchEvent) => { event.preventDefault(); @@ -140,7 +127,7 @@ export class TouchListener extends CommandGenerator { } else if (event.touches.length === 0) { this.isJoystickActive = false; this.joystick.parentElement?.removeChild(this.joystick); - this.sendMove(vec2.create()); + this.sendCommandToSubscribers(new MoveActionCommand(vec2.create())); } }; @@ -149,12 +136,6 @@ export class TouchListener extends CommandGenerator { event.stopPropagation(); }; - private leapButtonListener = (event: TouchEvent) => { - this.swallowTouch(event); - const clientTimeMs = localCharacterPredictor.recordLeap(); - this.sendCommandToSubscribers(new LeapActionCommand(clientTimeMs)); - }; - private fireButtonDownListener = (event: TouchEvent) => { this.swallowTouch(event); this.fireDownAt = performance.now(); @@ -189,9 +170,6 @@ export class TouchListener extends CommandGenerator { if (!this.fireButton.parentElement) { this.overlay.appendChild(this.fireButton); } - if (!this.leapButton.parentElement) { - this.overlay.appendChild(this.leapButton); - } const character = this.game.gameObjects.player; if (character) { @@ -208,9 +186,7 @@ export class TouchListener extends CommandGenerator { this.fireButton.removeEventListener('touchstart', this.fireButtonDownListener); this.fireButton.removeEventListener('touchend', this.fireButtonUpListener); - this.leapButton.removeEventListener('touchstart', this.leapButtonListener); this.fireButton.parentElement?.removeChild(this.fireButton); - this.leapButton.parentElement?.removeChild(this.leapButton); } } diff --git a/frontend/src/scripts/configuration.ts b/frontend/src/scripts/configuration.ts index 9ab3fb6..d3b16fe 100644 --- a/frontend/src/scripts/configuration.ts +++ b/frontend/src/scripts/configuration.ts @@ -1,20 +1,18 @@ /** * Hardcoded list of game servers the landing page offers to players. * - * Each entry is the public origin of a dockerized `doppler-server` instance. + * Each entry is the public origin of a dockerized `declared-server` instance. * The join screen polls `/state` (see `serverInformationEndpoint`) and * only shows a server once it responds, so listing an offline origin here is * harmless. Add or remove origins as you deploy more server containers. */ -// This origin predates the rebrand; update it once the game server is -// redeployed under a doppler subdomain. const productionServers: Array = ['https://declared.schmelczer.dev']; /** * When the page is served from localhost (i.e. the webpack-dev-server), also * offer the local backend so a `npm start` server can be joined during * development. The backend's default port is 3000 (see backend/src/options.ts). - * In production the page is served from its own hostname, so this never leaks. + * In production the hostname is `declared.schmelczer.dev`, so this never leaks. */ const isDevelopment = typeof location !== 'undefined' && diff --git a/frontend/src/scripts/feedback-hud.ts b/frontend/src/scripts/feedback-hud.ts index 316aecf..801828b 100644 --- a/frontend/src/scripts/feedback-hud.ts +++ b/frontend/src/scripts/feedback-hud.ts @@ -33,19 +33,17 @@ export abstract class FeedbackHud { setTimeout(() => element.parentElement?.removeChild(element), lifetimeMs); } - public static hitMarker(charge = 0) { + public static hitMarker() { const { x, y } = this.focusPoint(); const marker = document.createElement('div'); - marker.className = - 'hitmarker' + (charge >= settings.chargedHitThreshold ? ' charged' : ''); + marker.className = 'hitmarker'; marker.style.left = `${x}px`; marker.style.top = `${y}px`; this.addTransient(marker, 250); } - public static killConfirmed(victimName?: string, streak = 1, charge = 0) { + public static killConfirmed(victimName?: string, streak = 1) { const { killfeed } = this.ensureRoot(); - const charged = charge >= settings.chargedHitThreshold; const entry = document.createElement('div'); entry.className = 'kill-entry'; @@ -55,13 +53,13 @@ export abstract class FeedbackHud { const { x, y } = this.focusPoint(); const popup = document.createElement('div'); - popup.className = 'kill-popup' + (charged ? ' charged' : ''); + popup.className = 'kill-popup'; popup.innerHTML = `+${settings.playerKillPoint} +${settings.playerKillHealthReward}❤`; popup.style.left = `${x}px`; popup.style.top = `${y}px`; this.addTransient(popup, 1200); - const callout = this.streakName(streak) ?? (charged ? 'Charged Kill!' : undefined); + const callout = this.streakName(streak); if (callout) { const el = document.createElement('div'); el.className = 'streak-callout'; diff --git a/frontend/src/scripts/game.ts b/frontend/src/scripts/game.ts index cad62cd..bce7784 100644 --- a/frontend/src/scripts/game.ts +++ b/frontend/src/scripts/game.ts @@ -21,7 +21,6 @@ import { CommandExecutors, Command, settings, - InputAcknowledgement, } from 'shared'; import { io, Socket } from 'socket.io-client'; import { KeyboardListener } from './commands/keyboard-listener'; @@ -35,8 +34,6 @@ 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 } from './helper/prediction/local-character-predictor'; import { Tutorial } from './tutorial'; import { Scoreboard } from './scoreboard'; @@ -56,7 +53,6 @@ export class Game extends CommandReceiver { private scoreboard = new Scoreboard(); private announcementText = document.createElement('h2'); private arrows: { [id: number]: HTMLElement } = {}; - private keystoneArrow?: HTMLElement; private socketReceiver!: CommandSocket; private tutorial!: Tutorial; @@ -78,12 +74,9 @@ export class Game extends CommandReceiver { this.isBetweenGames = true; this.socket?.close(); - serverTimeline.reset(); - localCharacterPredictor.reset(); this.gameObjects = new GameObjectContainer(this); this.overlay.innerHTML = ''; this.arrows = {}; - this.keystoneArrow = undefined; this.isEnding = false; this.lastAnnouncementText = ''; this.overlay.appendChild(this.scoreboard.element); @@ -153,12 +146,6 @@ export class Game extends CommandReceiver { this.timeSinceLastAnnouncement = 0; }, [UpdateGameState.type]: (c: UpdateGameState) => (this.lastGameState = c), - [InputAcknowledgement.type]: (c: InputAcknowledgement) => - localCharacterPredictor.acknowledge( - c.clientTimeMs, - c.bodyVelocity, - c.lastLeapClientTimeMs, - ), [GameEndCommand.type]: () => (this.isEnding = true), [UpdateOtherPlayerDirections.type]: (c: UpdateOtherPlayerDirections) => (this.lastOtherPlayerDirections = c), @@ -287,11 +274,6 @@ export class Game extends CommandReceiver { this.resolveStarted(); deltaTime /= 1000; - // Stepped before the end-game time scaling on purpose: the slow motion is - // already baked into the snapshots the server sends, so the playback - // cursor itself must keep running on wall-clock time. - serverTimeline.step(deltaTime); - let shouldChangeLayout = false; if (++this.framesSinceLastLayoutUpdate > 1) { shouldChangeLayout = true; @@ -336,71 +318,6 @@ export class Game extends CommandReceiver { this.handleOtherPlayerDirections(this.lastOtherPlayerDirections); } - this.handleKeystoneArrow(); - this.announcementText.innerHTML = this.lastAnnouncementText; } - - // Points an off-screen chevron toward the keystone "Heart" planet, tinted by - // who currently holds it, so the match's focal objective is always findable. - private handleKeystoneArrow() { - if (!this.renderer) { - return; - } - const keystone = this.gameObjects.planets.find((p) => p.isKeystone); - if (!keystone) { - if (this.keystoneArrow) { - this.keystoneArrow.style.display = 'none'; - } - return; - } - - if (!this.keystoneArrow) { - this.keystoneArrow = document.createElement('div'); - this.overlay.appendChild(this.keystoneArrow); - } - - const width = this.renderer.canvasSize.x; - const height = this.renderer.canvasSize.y; - const display = this.renderer.worldToDisplayCoordinates(keystone.center); - const margin = 48; - const onScreen = - display.x >= margin && - display.x <= width - margin && - display.y >= margin && - display.y <= height - margin; - - const control = Math.abs(keystone.ownership - 0.5); - const team = - control < settings.planetControlThreshold - ? 'neutral' - : keystone.ownership < 0.5 - ? 'blue' - : 'red'; - this.keystoneArrow.className = 'keystone-arrow ' + team; - - if (onScreen) { - this.keystoneArrow.style.display = 'none'; - return; - } - this.keystoneArrow.style.display = 'block'; - - const center = vec2.fromValues(width / 2, height / 2); - const dir = vec2.fromValues(display.x - center.x, display.y - center.y); - const angle = Math.atan2(dir.y, dir.x); - - const aspectRatio = width / height; - const directionRatio = dir.x / dir.y; - let deltaX: number, deltaY: number; - if (aspectRatio < Math.abs(directionRatio)) { - deltaX = (width / 2 - margin) * Math.sign(dir.x); - deltaY = deltaX / directionRatio; - } else { - deltaY = (height / 2 - margin) * Math.sign(dir.y); - deltaX = deltaY * directionRatio; - } - - const p = vec2.add(center, center, vec2.fromValues(deltaX, deltaY)); - this.keystoneArrow.style.transform = `translateX(${p.x}px) translateY(${p.y}px) translateX(-50%) translateY(-50%) rotate(${angle + Math.PI / 2}rad)`; - } } diff --git a/frontend/src/scripts/helper/interpolators/circle-interpolator.ts b/frontend/src/scripts/helper/extrapolators/circle-extrapolator.ts similarity index 53% rename from frontend/src/scripts/helper/interpolators/circle-interpolator.ts rename to frontend/src/scripts/helper/extrapolators/circle-extrapolator.ts index 62816e5..e5b4d45 100644 --- a/frontend/src/scripts/helper/interpolators/circle-interpolator.ts +++ b/frontend/src/scripts/helper/extrapolators/circle-extrapolator.ts @@ -1,14 +1,14 @@ import { Circle } from 'shared'; -import { LinearInterpolator } from './linear-interpolator'; -import { Vec2Interpolator } from './vec2-interpolator'; +import { LinearExtrapolator } from './linear-extrapolator'; +import { Vec2Extrapolator } from './vec2-extrapolator'; -export class CircleInterpolator { - private center: Vec2Interpolator; - private radius: LinearInterpolator; +export class CircleExtrapolator { + private center: Vec2Extrapolator; + private radius: LinearExtrapolator; constructor(currentValue: Circle) { - this.center = new Vec2Interpolator(currentValue.center); - this.radius = new LinearInterpolator(currentValue.radius); + this.center = new Vec2Extrapolator(currentValue.center); + this.radius = new LinearExtrapolator(currentValue.radius); } public addFrame(value: Circle, rateOfChange: Circle) { diff --git a/frontend/src/scripts/helper/extrapolators/linear-extrapolator.ts b/frontend/src/scripts/helper/extrapolators/linear-extrapolator.ts new file mode 100644 index 0000000..9154fc1 --- /dev/null +++ b/frontend/src/scripts/helper/extrapolators/linear-extrapolator.ts @@ -0,0 +1,36 @@ +export class LinearExtrapolator { + private velocity = 0; + private compensationVelocity = 0; + private timeSinceSet = 0; + + constructor(private currentValue: number) {} + + public addFrame(value: number, rateOfChange: number) { + this.timeSinceSet = 0; + const differenceFromCurrent = value - this.currentValue; + + if (Math.abs(differenceFromCurrent) > 200) { + this.currentValue = value; + this.compensationVelocity = 0; + } else { + this.compensationVelocity = differenceFromCurrent / (1 / 30); + } + + this.velocity = rateOfChange; + } + + public getValue(deltaTime: number): number { + this.currentValue += deltaTime * this.velocity; + + const compensationTimeLeft = 1 / 30 - this.timeSinceSet; + + if (compensationTimeLeft > 0) { + this.currentValue += + Math.min(compensationTimeLeft, deltaTime) * this.compensationVelocity; + } + + this.timeSinceSet += deltaTime; + + return this.currentValue; + } +} diff --git a/frontend/src/scripts/helper/interpolators/vec2-interpolator.ts b/frontend/src/scripts/helper/extrapolators/vec2-extrapolator.ts similarity index 57% rename from frontend/src/scripts/helper/interpolators/vec2-interpolator.ts rename to frontend/src/scripts/helper/extrapolators/vec2-extrapolator.ts index ae6fb52..72ae904 100644 --- a/frontend/src/scripts/helper/interpolators/vec2-interpolator.ts +++ b/frontend/src/scripts/helper/extrapolators/vec2-extrapolator.ts @@ -1,13 +1,13 @@ import { vec2 } from 'gl-matrix'; -import { LinearInterpolator } from './linear-interpolator'; +import { LinearExtrapolator } from './linear-extrapolator'; -export class Vec2Interpolator { - private x: LinearInterpolator; - private y: LinearInterpolator; +export class Vec2Extrapolator { + private x: LinearExtrapolator; + private y: LinearExtrapolator; constructor(currentValue: vec2) { - this.x = new LinearInterpolator(currentValue.x); - this.y = new LinearInterpolator(currentValue.y); + this.x = new LinearExtrapolator(currentValue.x); + this.y = new LinearExtrapolator(currentValue.y); } public addFrame(value: vec2, rateOfChange: vec2) { diff --git a/frontend/src/scripts/helper/interpolators/linear-interpolator.ts b/frontend/src/scripts/helper/interpolators/linear-interpolator.ts deleted file mode 100644 index bcc23b5..0000000 --- a/frontend/src/scripts/helper/interpolators/linear-interpolator.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { last, mix } from 'shared'; -import { serverTimeline } from '../server-timeline'; - -interface Frame { - time: number; - value: number; - velocity: number; -} - -// How far past the newest snapshot the value may coast on its last known -// velocity (network hiccup) before freezing in place. -const maxCoastSeconds = 0.06; - -// When fresh snapshots arrive after a coast, they usually disagree with where -// the coast ended up; the difference decays away with this time constant -// instead of being shown as a jump. -const blendSeconds = 0.12; - -// At 25 snapshots per second this spans over a second of state, far more than -// rendering ever needs; it only bounds memory while the tab is hidden and -// snapshots keep arriving without being consumed. -const maxFrames = 32; - -/** - * Replays a server-streamed scalar by interpolating between timestamped - * snapshots at the shared timeline's render time, which trails the newest - * snapshot. The streamed rate of change is only used to coast briefly when - * the buffer runs dry. - */ -export class LinearInterpolator { - private frames: Array = []; - private blendOffset = 0; - private lastValue: number; - private isCoasting = false; - - constructor(private readonly initialValue: number) { - this.lastValue = initialValue; - } - - public addFrame(value: number, rateOfChange: number) { - const time = serverTimeline.snapshotTime; - const newest = last(this.frames); - const wasCoasting = this.isCoasting; - - if (newest && time <= newest.time) { - this.frames[this.frames.length - 1] = { - time: newest.time, - value, - velocity: rateOfChange, - }; - } else { - this.frames.push({ time, value, velocity: rateOfChange }); - if (this.frames.length > maxFrames) { - this.frames.shift(); - } - } - - if (wasCoasting) { - // Continue from where the coast left off and let the offset decay, - // instead of jumping onto the freshly revealed trajectory. - this.blendOffset = this.lastValue - this.sample(serverTimeline.renderTime); - } - } - - public getValue(deltaTimeInSeconds: number): number { - this.blendOffset *= Math.exp(-deltaTimeInSeconds / blendSeconds); - return (this.lastValue = this.sample(serverTimeline.renderTime) + this.blendOffset); - } - - private sample(time: number): number { - if (this.frames.length === 0) { - return this.initialValue; - } - - while (this.frames.length >= 2 && this.frames[1].time <= time) { - this.frames.shift(); - } - - const [current, next] = this.frames; - this.isCoasting = false; - - if (time <= current.time) { - return current.value; - } - - if (next) { - return mix( - current.value, - next.value, - (time - current.time) / (next.time - current.time), - ); - } - - this.isCoasting = true; - return ( - current.value + current.velocity * Math.min(time - current.time, maxCoastSeconds) - ); - } -} diff --git a/frontend/src/scripts/helper/prediction/client-character-world.ts b/frontend/src/scripts/helper/prediction/client-character-world.ts deleted file mode 100644 index a08dbc5..0000000 --- a/frontend/src/scripts/helper/prediction/client-character-world.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { vec2 } from 'gl-matrix'; -import { - CharacterWorld, - GroundSurface, - Id, - PhysicsBody, - planetDistance, - planetGravity, - resolveCircleMovement, -} from 'shared'; - -// What the predictor needs to know about a planet to collide and be pulled by -// it. The client reads this off its PlanetViews. -export interface PredictablePlanet { - id: Id; - vertices: Array; - center: vec2; - radius: number; - rotation: number; - rotationSpeed: number; -} - -// A planet collision/gravity surface whose rotation can be advanced during -// replay, so its outline turns in lockstep with the body the carry term moves — -// exactly as the server steps the planet before the character each tick. -class PlanetSurface implements GroundSurface { - public readonly canCollide = true; - public readonly isGround = true; - public readonly id: Id; - public center: vec2; - public angularVelocity: number; - private vertices: Array; - private radius: number; - private rotation = 0; - private cos = 1; - private sin = 0; - - constructor(planet: PredictablePlanet) { - this.id = planet.id; - this.center = planet.center; - this.vertices = planet.vertices; - this.radius = planet.radius; - this.angularVelocity = planet.rotationSpeed; - this.setRotation(planet.rotation); - } - - public sync(planet: PredictablePlanet) { - this.center = planet.center; - this.vertices = planet.vertices; - this.radius = planet.radius; - this.angularVelocity = planet.rotationSpeed; - this.setRotation(planet.rotation); - } - - private setRotation(rotation: number) { - this.rotation = rotation; - this.cos = Math.cos(rotation); - this.sin = Math.sin(rotation); - } - - public advance(deltaTimeInSeconds: number) { - this.setRotation(this.rotation + this.angularVelocity * deltaTimeInSeconds); - } - - public distance(target: vec2): number { - return planetDistance(target, this.vertices, this.center, this.cos, this.sin); - } - - public gravityAt(target: vec2): vec2 { - return planetGravity(this.center, this.radius, target); - } -} - -// The planets-only collision world the local predictor runs against. It holds -// persistent surfaces keyed by planet id (so a `currentPlanet` reference stays -// valid across frames) and never dispatches collision reactions — damage, -// scoring and the like are server-authoritative. -export class ClientCharacterWorld implements CharacterWorld { - private surfaces = new Map(); - private ordered: Array = []; - - // Refresh from the current PlanetViews. Far planets contribute zero gravity - // (the falloff clamps to 0 past maxGravityDistance) and never collide, so the - // whole set can be handed to every query without a range filter. Ordered by - // id so the (rare) two-surface contact picks a stable surface. - public sync(planets: Array) { - const seen = new Set(); - for (const planet of planets) { - seen.add(planet.id); - const existing = this.surfaces.get(planet.id); - if (existing) { - existing.sync(planet); - } else { - this.surfaces.set(planet.id, new PlanetSurface(planet)); - } - } - for (const id of [...this.surfaces.keys()]) { - if (!seen.has(id)) { - this.surfaces.delete(id); - } - } - this.ordered = [...this.surfaces.entries()] - .sort((a, b) => Number(a[0]) - Number(b[0])) - .map((e) => e[1]); - } - - // Advance every planet's collision frame by one replay substep, so surfaces - // and the carried body rotate together. The surfaces are re-synced to the - // newest snapshot rotation each frame (see sync), so the replay only ever - // steps forward from there. - public advance(deltaTimeInSeconds: number) { - for (const surface of this.ordered) { - surface.advance(deltaTimeInSeconds); - } - } - - public surfaceById(id: Id | undefined): GroundSurface | undefined { - return id == null ? undefined : this.surfaces.get(id); - } - - public idOf(surface: GroundSurface | undefined): Id | undefined { - return surface instanceof PlanetSurface ? surface.id : undefined; - } - - public groundsNear(): Array { - return this.ordered; - } - - public stepBody( - body: PhysicsBody, - deltaTimeInSeconds: number, - ): GroundSurface | undefined { - const { hitObject } = resolveCircleMovement(body, deltaTimeInSeconds, this.ordered); - return hitObject && (hitObject as GroundSurface).isGround - ? (hitObject as GroundSurface) - : undefined; - } -} diff --git a/frontend/src/scripts/helper/prediction/input-history.ts b/frontend/src/scripts/helper/prediction/input-history.ts deleted file mode 100644 index 3ec2604..0000000 --- a/frontend/src/scripts/helper/prediction/input-history.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { vec2 } from 'gl-matrix'; - -interface InputSample { - timeMs: number; - direction: vec2; -} - -// Keep a little over a second of input — far more than any sane reconciliation -// window — so a brief stall (or a backgrounded tab catching up) can still be -// replayed, while old samples are pruned to bound memory. -const retainMs = 1500; - -// A timeline of the local player's movement directions in client-clock time. -// Each generated MoveActionCommand is stamped with the time this hands out, and -// the same sample is recorded here so the predictor replays exactly the input -// the server will eventually receive. Direction is piecewise-constant: the -// active direction at any instant is the most recent sample at or before it. -export class InputHistory { - private samples: Array = []; - - public record(direction: vec2, timeMs: number): void { - this.samples.push({ timeMs, direction: vec2.clone(direction) }); - - const cutoff = timeMs - retainMs; - while (this.samples.length > 1 && this.samples[1].timeMs <= cutoff) { - this.samples.shift(); - } - } - - // The held direction at `timeMs`: the latest sample at or before it, or zero - // before any input exists. - public directionAt(timeMs: number): vec2 { - let direction = vec2.create(); - for (const sample of this.samples) { - if (sample.timeMs <= timeMs) { - direction = sample.direction; - } else { - break; - } - } - return vec2.clone(direction); - } - - public reset(): void { - this.samples = []; - } -} diff --git a/frontend/src/scripts/helper/prediction/local-character-predictor.ts b/frontend/src/scripts/helper/prediction/local-character-predictor.ts deleted file mode 100644 index c147477..0000000 --- a/frontend/src/scripts/helper/prediction/local-character-predictor.ts +++ /dev/null @@ -1,328 +0,0 @@ -import { vec2 } from 'gl-matrix'; -import { - Circle, - CharacterMovementState, - Id, - PhysicsBody, - settings, - stepCharacterMovement, - applyLeapImpulse, - tickPlanetDetachment, - feetRadius, - headRadius, -} from 'shared'; -import { ClientCharacterWorld, PredictablePlanet } from './client-character-world'; -import { InputHistory } from './input-history'; - -const stepMs = 1000 / 200; // match the server's 200 Hz fixed tick -const stepSeconds = 1 / 200; - -// Don't replay more than this far back: if the last acknowledged input is older -// (a stall, or a backgrounded tab catching up) snap to the authoritative pose -// instead of grinding through hundreds of steps. -const maxReplayMs = 300; - -// Render-side easing of the correction the reconciliation produces each frame, -// so a snapshot that disagrees with the prediction is smoothed out instead of -// popping. Short, so the local player still feels immediate. -const smoothSeconds = 0.06; - -// A correction bigger than this isn't prediction error — it's a respawn, -// teleport, or a server-side impulse (leap / slingshot / recoil / death throw) -// the predictor doesn't model. Snap to it rather than gliding across the gap. -const snapDistance = 250; - -const makeBody = (center: vec2, radius: number): PhysicsBody => ({ - center: vec2.clone(center), - radius, - velocity: vec2.create(), - lastNormal: vec2.fromValues(0, 1), - restitution: 0, -}); - -// Predicts the local player's character so it responds to input immediately, -// instead of lagging ~100 ms + RTT behind like the interpolated remote objects. -// Each frame it resets to the latest authoritative snapshot and replays the -// player's own un-acknowledged input through the SAME movement simulation the -// server runs (shared/stepCharacterMovement), then eases the rendered pose -// toward the result. Discrete server-side impulses it can't model show up as a -// large correction and snap rather than rubber-band. -export class LocalCharacterPredictor { - private readonly inputHistory = new InputHistory(); - private readonly world = new ClientCharacterWorld(); - - private authoritative?: { head: Circle; leftFoot: Circle; rightFoot: Circle }; - private lastAckClientTimeMs?: number; - // Wall-clock (client) time the latest authoritative snapshot was received. The - // replay predicts forward from HERE by the snapshot's age, so the local pose - // advances smoothly with real time between the 25 Hz snapshots. Anchoring to - // the last *acked input* time instead breaks when input is sent only on change - // (a held key sends nothing): that time freezes, the window pins to the - // maxReplayMs clamp, the replay displacement goes constant, and the pose - // stair-steps at the snapshot rate. - private authReceiptMs = 0; - // Authoritative launch momentum at the last snapshot — seeds each replay so a - // leap/slingshot/recoil flight is reproduced and continuously corrected. - private authoritativeBodyVelocity = vec2.create(); - // Wall-clock times the player issued a leap, replayed (with the impulse - // applied locally) so the launch is felt immediately, not after a round trip. - private leapHistory: Array = []; - // clientTimeMs of the last leap the server has folded into the streamed - // momentum. Leaps at or before this are already in authoritativeBodyVelocity; - // only newer ones are replayed, so a leap is never applied twice. - private lastLeapAckMs = -Infinity; - // Latest streamed shooting-strength, to gate predicted leaps as the server does. - private currentStrength = settings.playerMaxStrength; - - // Continuous state carried between replays (the snapshot carries only poses). - // The facing direction is NOT carried — it is re-derived from the pose each - // frame (see directionFromPose / simulate); only the latched planet and the - // time-since-surface persist. - private carriedPlanetId?: Id; - private carriedSecondsSinceSurface = 1; - - // The eased, rendered pose handed to the view. - private renderHead = new Circle(vec2.create(), headRadius); - private renderLeftFoot = new Circle(vec2.create(), feetRadius); - private renderRightFoot = new Circle(vec2.create(), feetRadius); - private hasRender = false; - - public get head(): Circle { - return this.renderHead; - } - public get leftFoot(): Circle { - return this.renderLeftFoot; - } - public get rightFoot(): Circle { - return this.renderRightFoot; - } - - // Stamp a movement command and record it for replay. Returns the wall-clock - // time the command should carry so the server can echo it back. - public recordInput(direction: vec2): number { - const timeMs = Math.round(performance.now()); - this.inputHistory.record(direction, timeMs); - return timeMs; - } - - public acknowledge( - clientTimeMs: number, - bodyVelocity: vec2, - lastLeapClientTimeMs: number, - ): void { - // Inputs only advance the acknowledgement forward; the launch momentum and - // leap boundary always adopt the latest authoritative values. - if (this.lastAckClientTimeMs === undefined || clientTimeMs > this.lastAckClientTimeMs) { - this.lastAckClientTimeMs = clientTimeMs; - } - vec2.set(this.authoritativeBodyVelocity, bodyVelocity[0], bodyVelocity[1]); - this.lastLeapAckMs = lastLeapClientTimeMs; - } - - public setAuthoritative(head: Circle, leftFoot: Circle, rightFoot: Circle): void { - this.authoritative = { head, leftFoot, rightFoot }; - this.authReceiptMs = Math.round(performance.now()); - } - - // The player pressed leap; remember when, so the replay applies the same - // impulse the server will. Recorded regardless of whether the server accepts - // it — a rejected leap (no strength/cooldown) self-corrects via the streamed - // authoritative momentum. - public recordLeap(): number { - const timeMs = Math.round(performance.now()); - this.leapHistory.push(timeMs); - const cutoff = timeMs - 1500; - while (this.leapHistory.length > 0 && this.leapHistory[0] <= cutoff) { - this.leapHistory.shift(); - } - return timeMs; - } - - public setStrength(strength: number): void { - this.currentStrength = strength; - } - - public reset(): void { - this.inputHistory.reset(); - this.leapHistory = []; - this.lastLeapAckMs = -Infinity; - this.authoritative = undefined; - this.lastAckClientTimeMs = undefined; - this.authReceiptMs = 0; - vec2.zero(this.authoritativeBodyVelocity); - this.currentStrength = settings.playerMaxStrength; - this.carriedPlanetId = undefined; - this.carriedSecondsSinceSurface = 1; - this.hasRender = false; - } - - public get canPredict(): boolean { - return this.authoritative !== undefined && this.lastAckClientTimeMs !== undefined; - } - - // During spawn-in and death the server freezes walking and only scales the - // body (CharacterPhysical.step returns early), so its head radius is below - // nominal. Predicting then would walk the body off a position the server is - // holding still — let interpolation show the animation instead. - private get isAnimatingInOrOut(): boolean { - return ( - this.authoritative !== undefined && - this.authoritative.head.radius < headRadius - 0.5 - ); - } - - // Run one frame of prediction. Returns true if it produced a rendered pose the - // caller should use (otherwise fall back to interpolation). `planets` is the - // current collision world; `frameSeconds` is the render delta. - public update(planets: Array, frameSeconds: number): boolean { - if (!this.canPredict || this.isAnimatingInOrOut) { - // Resume from the authoritative pose with a snap rather than gliding from - // a stale rendered one. - this.hasRender = false; - return false; - } - - this.world.sync(planets); - const predicted = this.simulate(); - - if (!this.hasRender) { - this.snapRenderTo(predicted); - this.hasRender = true; - } else { - this.easeRenderTo(predicted, frameSeconds); - } - return true; - } - - // The body's facing angle is encoded in the pose: each part is sprung toward - // center + R(direction)*offset and the head's offset points +y, so - // direction = atan2(head - center) - PI/2. Re-deriving it from the snapshot - // each frame (rather than carrying the previous frame's evolved value onto - // this past pose, re-evolved by a variable substep count) keeps the posture - // seed a pure function of the snapshot — carrying it fed a frame-rate-dependent - // loop that wobbled the rendered limbs. - private directionFromPose(head: Circle, leftFoot: Circle, rightFoot: Circle): number { - const cx = (head.center[0] + leftFoot.center[0] + rightFoot.center[0]) / 3; - const cy = (head.center[1] + leftFoot.center[1] + rightFoot.center[1]) / 3; - return Math.atan2(head.center[1] - cy, head.center[0] - cx) - Math.PI / 2; - } - - private simulate(): CharacterMovementState { - const auth = this.authoritative!; - const now = Math.round(performance.now()); - // Predict forward from the latest snapshot by its age, clamped so a stall - // (or a backgrounded tab catching up) snaps instead of grinding hundreds of - // steps. This advances with wall-clock time even while a held key sends no - // fresh input, so the pose no longer pins to the 25 Hz snapshot cadence. - const startMs = Math.max(this.authReceiptMs, now - maxReplayMs); - const windowMs = Math.max(0, now - startMs); - const steps = Math.floor(windowMs / stepMs); - const remainderSeconds = (windowMs - steps * stepMs) / 1000; - - const state: CharacterMovementState = { - head: makeBody(auth.head.center, auth.head.radius), - leftFoot: makeBody(auth.leftFoot.center, auth.leftFoot.radius), - rightFoot: makeBody(auth.rightFoot.center, auth.rightFoot.radius), - direction: this.directionFromPose(auth.head, auth.leftFoot, auth.rightFoot), - currentPlanet: this.world.surfaceById(this.carriedPlanetId), - secondsSinceOnSurface: this.carriedSecondsSinceSurface, - // Leaps before the replay window are already baked into this; leaps inside - // the window are re-applied below, so neither is double-counted. - bodyVelocity: vec2.clone(this.authoritativeBodyVelocity), - }; - - // The planet collision frames were synced (in update(), just before this) - // to the NEWEST snapshot's rotation — the same instant the authoritative - // body pose is from — so the body and the surface start the replay at the - // same phase. The loop below advances the surfaces FORWARD in lockstep with - // the body's carry from that shared phase, so no rewind is needed (and the - // persistent surfaces are re-synced next frame, so this never accumulates). - - const cooldownMs = settings.leapCooldownSeconds * 1000; - // Mirror the server: each accepted leap spends leapStrengthCost, so a burst - // of leaps in one replay window is gated by the running strength rather than - // a single up-front affordability check. - let availableStrength = this.currentStrength; - let lastLeapMs = -Infinity; - - let t = startMs; - for (let i = 0; i < steps; i++) { - const input = this.inputHistory.directionAt(t); - tickPlanetDetachment(state, stepSeconds); - stepCharacterMovement(state, this.world, input, stepSeconds); - this.world.advance(stepSeconds); - - // A leap issued during this step launches now (the next step injects the - // momentum), gated like the server: on a surface, off cooldown, with - // strength. applyLeapImpulse is a no-op off-surface. - for (const leapMs of this.leapHistory) { - if ( - leapMs >= t && - leapMs < t + stepMs && - // Only leaps the server hasn't yet folded into the seed, so a leap - // is never both seeded and replayed. - leapMs > this.lastLeapAckMs && - state.currentPlanet && - leapMs - lastLeapMs >= cooldownMs && - availableStrength >= settings.leapStrengthCost - ) { - applyLeapImpulse(state, this.inputHistory.directionAt(leapMs)); - availableStrength -= settings.leapStrengthCost; - lastLeapMs = leapMs; - } - } - - t += stepMs; - } - - // Final sub-tick of the leftover (< stepMs) so the predicted pose is a - // continuous function of the window length rather than advancing in 5 ms - // quanta — the floor() above would otherwise surface that as a per-frame - // wobble on top of the ~16.7 ms render cadence. - if (remainderSeconds > 0) { - tickPlanetDetachment(state, remainderSeconds); - stepCharacterMovement( - state, - this.world, - this.inputHistory.directionAt(now), - remainderSeconds, - ); - this.world.advance(remainderSeconds); - } - - this.carriedPlanetId = this.world.idOf(state.currentPlanet); - this.carriedSecondsSinceSurface = state.secondsSinceOnSurface; - - return state; - } - - private snapRenderTo(state: CharacterMovementState): void { - this.renderHead = new Circle(vec2.clone(state.head.center), state.head.radius); - this.renderLeftFoot = new Circle( - vec2.clone(state.leftFoot.center), - state.leftFoot.radius, - ); - this.renderRightFoot = new Circle( - vec2.clone(state.rightFoot.center), - state.rightFoot.radius, - ); - } - - private easeRenderTo(state: CharacterMovementState, frameSeconds: number): void { - const q = 1 - Math.exp(-frameSeconds / smoothSeconds); - this.easePart(this.renderHead, state.head, q); - this.easePart(this.renderLeftFoot, state.leftFoot, q); - this.easePart(this.renderRightFoot, state.rightFoot, q); - } - - private easePart(render: Circle, target: PhysicsBody, q: number): void { - if (vec2.distance(render.center, target.center) > snapDistance) { - vec2.copy(render.center, target.center); - } else { - vec2.lerp(render.center, render.center, target.center, q); - } - render.radius = target.radius; - } -} - -export const localCharacterPredictor = new LocalCharacterPredictor(); diff --git a/frontend/src/scripts/helper/server-timeline.ts b/frontend/src/scripts/helper/server-timeline.ts deleted file mode 100644 index 28aab75..0000000 --- a/frontend/src/scripts/helper/server-timeline.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { clamp, settings } from 'shared'; - -// Convergence rate of the playback cursor towards its target; a deviation -// decays with a time constant of 1 / rateGain seconds. -const rateGain = 2; - -// Playback speed stays within [0.75, 1.25]× so corrections are invisible. -const maxRateAdjustment = 0.25; - -// Beyond this divergence (tab was in the background, server changed) chasing -// the target is pointless: jump straight to it. -const resyncSeconds = 0.3; - -/** - * The playback clock for state streamed from the server. - * - * Snapshot timestamps estimate the server's clock: the newest received - * timestamp plus the time elapsed since it arrived. Rendering happens - * settings.interpolationDelaySeconds behind that estimate, so there is - * normally a newer snapshot to interpolate towards and network jitter is - * absorbed by the buffer instead of being shown. - * - * The cursor never jumps under normal operation: it runs at a gently adjusted - * rate to stay on target and only snaps after a long divergence. - */ -class ServerTimeline { - private cursor?: number; - private newestSnapshotTime?: number; - private sinceNewestSnapshot = 0; - private _snapshotTime = 0; - - /** Timestamp of the update batch currently being applied. */ - public get snapshotTime(): number { - return this._snapshotTime; - } - - /** The point on the server's clock that should be rendered this frame. */ - public get renderTime(): number { - return this.cursor ?? 0; - } - - public onSnapshot(timestamp: number) { - 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; - } - - this.sinceNewestSnapshot += deltaTimeInSeconds; - const target = - this.newestSnapshotTime + - this.sinceNewestSnapshot - - settings.interpolationDelaySeconds; - - if (this.cursor === undefined || Math.abs(target - this.cursor) > resyncSeconds) { - this.cursor = target; - return; - } - - const rate = clamp( - 1 + (target - this.cursor) * rateGain, - 1 - maxRateAdjustment, - 1 + maxRateAdjustment, - ); - this.cursor += deltaTimeInSeconds * rate; - } - - public reset() { - this.cursor = undefined; - this.newestSnapshotTime = undefined; - this.sinceNewestSnapshot = 0; - this._snapshotTime = 0; - } -} - -export const serverTimeline = new ServerTimeline(); diff --git a/frontend/src/scripts/objects/game-object-container.ts b/frontend/src/scripts/objects/game-object-container.ts index 81c1e2e..eb6b3dc 100644 --- a/frontend/src/scripts/objects/game-object-container.ts +++ b/frontend/src/scripts/objects/game-object-container.ts @@ -1,5 +1,4 @@ import { - Circle, Command, CommandExecutors, CommandReceiver, @@ -10,15 +9,10 @@ import { Id, PropertyUpdatesForObjects, RemoteCallsForObjects, - settings, - UpdatePropertyCommand, } from 'shared'; import { BeforeDestroyCommand } from '../commands/types/before-destroy'; import { StepCommand } from '../commands/types/step'; import { Game } from '../game'; -import { serverTimeline } from '../helper/server-timeline'; -import { PredictablePlanet } from '../helper/prediction/client-character-world'; -import { localCharacterPredictor } from '../helper/prediction/local-character-predictor'; import { Camera } from './types/camera'; import { CharacterView } from './types/character-view'; import { PlanetView } from './types/planet-view'; @@ -33,9 +27,6 @@ export class GameObjectContainer extends CommandReceiver { this.player = c.character as CharacterView; this.player.isMainCharacter = true; this.addObject(this.player); - // Fresh character (first spawn or respawn at a far planet): drop any - // prediction state so it snaps to the new body instead of gliding across. - localCharacterPredictor.reset(); }, [CreateObjectsCommand.type]: (c: CreateObjectsCommand) => @@ -45,19 +36,7 @@ export class GameObjectContainer extends CommandReceiver { this.defaultCommandExecutor(c); if (this.player) { - // Override the interpolated pose of the local player with the predicted - // one so it responds to input immediately. Remote objects keep - // interpolating. A large correction (respawn / death) snaps inside the - // predictor rather than rubber-banding. - localCharacterPredictor.setStrength( - this.player.strengthFraction * settings.playerMaxStrength, - ); - if (localCharacterPredictor.update(this.predictablePlanets(), c.deltaTimeInSeconds)) { - this.player.head = localCharacterPredictor.head; - this.player.leftFoot = localCharacterPredictor.leftFoot; - this.player.rightFoot = localCharacterPredictor.rightFoot; - } - this.camera.follow(this.player.position, c.deltaTimeInSeconds); + this.camera.center = this.player.position; } }, @@ -66,15 +45,10 @@ export class GameObjectContainer extends CommandReceiver { this.objects.get(c.id)?.processRemoteCalls(c.calls), ), - [PropertyUpdatesForObjects.type]: (c: PropertyUpdatesForObjects) => { - serverTimeline.onSnapshot(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) { - this.feedPredictor(u.updates); - } - }); - }, + [PropertyUpdatesForObjects.type]: (c: PropertyUpdatesForObjects) => + c.updates.forEach((u) => + u.updates.forEach((au) => this.objects.get(u.id)?.handleCommand(au)), + ), [DeleteObjectsCommand.type]: (c: DeleteObjectsCommand) => c.ids.forEach((id: Id) => this.deleteObject(id)), @@ -99,34 +73,6 @@ export class GameObjectContainer extends CommandReceiver { this.camera.handleCommand(c); } - // Hand the local player's raw authoritative pose to the predictor (the - // interpolated pose would already be ~100 ms stale). The three body parts - // arrive together in one snapshot. - private feedPredictor(updates: Array) { - let head: Circle | undefined; - let leftFoot: Circle | undefined; - let rightFoot: Circle | undefined; - for (const u of updates) { - if (u.propertyKey === 'head') head = u.propertyValue as Circle; - else if (u.propertyKey === 'leftFoot') leftFoot = u.propertyValue as Circle; - else if (u.propertyKey === 'rightFoot') rightFoot = u.propertyValue as Circle; - } - if (head && leftFoot && rightFoot) { - localCharacterPredictor.setAuthoritative(head, leftFoot, rightFoot); - } - } - - private predictablePlanets(): Array { - return this.planets.map((p) => ({ - id: p.id, - vertices: p.vertices, - center: p.center, - radius: p.radius, - rotation: p.predictionRotation, - rotationSpeed: p.predictionRotationSpeed, - })); - } - private addObject(object: GameObject) { this.objects.set(object.id, object); } diff --git a/frontend/src/scripts/objects/types/camera.ts b/frontend/src/scripts/objects/types/camera.ts index f1cf670..2d07048 100644 --- a/frontend/src/scripts/objects/types/camera.ts +++ b/frontend/src/scripts/objects/types/camera.ts @@ -13,28 +13,10 @@ export class Camera extends CommandReceiver { public 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(); } - 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); - } - protected commandExecutors: CommandExecutors = { [RenderCommand.type]: this.draw.bind(this), }; diff --git a/frontend/src/scripts/objects/types/character-view.ts b/frontend/src/scripts/objects/types/character-view.ts index 1806993..034d97a 100644 --- a/frontend/src/scripts/objects/types/character-view.ts +++ b/frontend/src/scripts/objects/types/character-view.ts @@ -16,8 +16,8 @@ import { 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 { CircleExtrapolator } from '../../helper/extrapolators/circle-extrapolator'; +import { LinearExtrapolator } from '../../helper/extrapolators/linear-extrapolator'; import { Pointer } from '../../helper/pointer'; import { CharacterShape } from '../../shapes/character-shape'; import { SoundHandler, Sounds } from '../../sound-handler'; @@ -40,7 +40,7 @@ export class CharacterView extends CharacterBase { private muzzleFlashIntensity = 0; private hitFlashIntensity = 0; private strength = settings.playerMaxStrength; - private strengthInterpolator = new LinearInterpolator(settings.playerMaxStrength); + private strengthExtrapolator = new LinearExtrapolator(settings.playerMaxStrength); private nameElement: HTMLElement = document.createElement('div'); private statsElement: HTMLElement = document.createElement('div'); private killCountElement: HTMLElement = document.createElement('span'); @@ -50,9 +50,9 @@ export class CharacterView extends CharacterBase { public isMainCharacter = false; - private leftFootInterpolator: CircleInterpolator; - private rightFootInterpolator: CircleInterpolator; - private headInterpolator: CircleInterpolator; + private leftFootExtrapolator: CircleExtrapolator; + private rightFootExtrapolator: CircleExtrapolator; + private headExtrapolator: CircleExtrapolator; protected commandExecutors: CommandExecutors = { [RenderCommand.type]: this.draw.bind(this), @@ -80,9 +80,9 @@ export class CharacterView extends CharacterBase { 0, ); - this.leftFootInterpolator = new CircleInterpolator(this.leftFoot!); - this.rightFootInterpolator = new CircleInterpolator(this.rightFoot!); - this.headInterpolator = new CircleInterpolator(this.head!); + this.leftFootExtrapolator = new CircleExtrapolator(this.leftFoot!); + this.rightFootExtrapolator = new CircleExtrapolator(this.rightFoot!); + this.headExtrapolator = new CircleExtrapolator(this.head!); this.nameElement.className = 'player-tag ' + this.team; this.nameElement.innerText = this.name; @@ -140,16 +140,16 @@ export class CharacterView extends CharacterBase { rateOfChange, }: UpdatePropertyCommand) { if (propertyKey === 'head') { - this.headInterpolator.addFrame(propertyValue, rateOfChange); + this.headExtrapolator.addFrame(propertyValue, rateOfChange); } if (propertyKey === 'leftFoot') { - this.leftFootInterpolator.addFrame(propertyValue, rateOfChange); + this.leftFootExtrapolator.addFrame(propertyValue, rateOfChange); } if (propertyKey === 'rightFoot') { - this.rightFootInterpolator.addFrame(propertyValue, rateOfChange); + this.rightFootExtrapolator.addFrame(propertyValue, rateOfChange); } if (propertyKey === 'strength') { - this.strengthInterpolator.addFrame(propertyValue, rateOfChange); + this.strengthExtrapolator.addFrame(propertyValue, rateOfChange); } } @@ -176,41 +176,30 @@ export class CharacterView extends CharacterBase { } } - public onHitConfirmed(charge = 0) { + public onHitConfirmed() { if (!this.isMainCharacter) { return; } - // A charged hit lands lower and harder than a panic tap. - SoundHandler.play(Sounds.click, mix(0.4, 0.75, charge), mix(1.7, 1.05, charge)); - if (charge >= settings.chargedHitThreshold) { - VibrationHandler.vibrate(25); - } - FeedbackHud.hitMarker(charge); + SoundHandler.play(Sounds.click, 0.4, 1.7); + FeedbackHud.hitMarker(); } - public onKillConfirmed(victimName?: string, streak = 1, charge = 0) { + public onKillConfirmed(victimName?: string, streak = 1) { if (!this.isMainCharacter) { return; } - SoundHandler.play(Sounds.click, 1, mix(0.7, 0.5, charge)); - VibrationHandler.vibrate(mix(60, 110, charge)); - FeedbackHud.killConfirmed(victimName, streak, charge); - } - - public onLeap() { - if (!this.isMainCharacter) { - return; - } - SoundHandler.play(Sounds.shoot, 0.3, 1.5); + SoundHandler.play(Sounds.click, 1, 0.7); + VibrationHandler.vibrate(60); + FeedbackHud.killConfirmed(victimName, streak); } private step({ deltaTimeInSeconds }: StepCommand): void { - this.head! = this.headInterpolator.getValue(deltaTimeInSeconds); - this.leftFoot! = this.leftFootInterpolator.getValue(deltaTimeInSeconds); - this.rightFoot! = this.rightFootInterpolator.getValue(deltaTimeInSeconds); + this.head! = this.headExtrapolator.getValue(deltaTimeInSeconds); + this.leftFoot! = this.leftFootExtrapolator.getValue(deltaTimeInSeconds); + this.rightFoot! = this.rightFootExtrapolator.getValue(deltaTimeInSeconds); this.strength = clamp( - this.strengthInterpolator.getValue(deltaTimeInSeconds), + this.strengthExtrapolator.getValue(deltaTimeInSeconds), 0, settings.playerMaxStrength, ); @@ -235,7 +224,7 @@ export class CharacterView extends CharacterBase { public onShoot(strength: number) { const q = clamp01( (strength - settings.chargeShotStrengthMin) / - (settings.chargeShotStrengthMax - settings.chargeShotStrengthMin), + (settings.chargeShotStrengthMax - settings.chargeShotStrengthMin), ); SoundHandler.play(Sounds.shoot, mix(0.55, 1, q), mix(1.15, 0.8, q)); this.muzzleFlashIntensity = mix(0.35, 1, q); diff --git a/frontend/src/scripts/objects/types/planet-view.ts b/frontend/src/scripts/objects/types/planet-view.ts index 06e6880..9c38963 100644 --- a/frontend/src/scripts/objects/types/planet-view.ts +++ b/frontend/src/scripts/objects/types/planet-view.ts @@ -12,7 +12,6 @@ import { 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'; const fallingPointLifetimeMs = 2000; @@ -40,10 +39,7 @@ abstract class FlareBudget { export class PlanetView extends PlanetBase { private shape: PlanetShape; private ownershipProgress: HTMLElement; - // Rotation is owned by the backend (it drives the collision polygon too) and - // streamed in; the interpolator replays the angle on the shared snapshot - // timeline, in sync with the characters standing on the surface. - private readonly rotationInterpolator = new LinearInterpolator(0); + private readonly rotationSpeed: number; private flareLight?: CircleLight; private flareIntensity = 0; @@ -56,39 +52,19 @@ export class PlanetView extends PlanetBase { [UpdatePropertyCommand.type]: this.updateProperty.bind(this), }; - constructor(id: Id, vertices: Array, ownership = 0.5, isKeystone = false) { - super(id, vertices, ownership, isKeystone); + constructor(id: Id, vertices: Array, ownership: number) { + super(id, vertices); this.shape = new PlanetShape(vertices, ownership); this.shape.randomOffset = Random.getRandom(); + this.rotationSpeed = + (0.05 + Random.getRandom() * 0.07) * (Random.getRandom() < 0.5 ? -1 : 1); this.ownershipProgress = document.createElement('div'); - this.ownershipProgress.className = 'ownership' + (isKeystone ? ' keystone' : ''); - } - - public setContested(contested: boolean) { - this.ownershipProgress.classList.toggle('contested', contested); - } - - private renderedRotation = 0; - private rotationSpeed = 0; - // Newest streamed rotation VALUE — the server-current angle at the latest - // snapshot — as opposed to renderedRotation, which the interpolator holds - // ~interpolationDelaySeconds in the PAST for drawing. The predictor seeds the - // local body from the same snapshot's pose, so it must collide against the - // planet at THIS (newest) phase and advance forward from it; using the drawn - // lagged angle biases the body off the surface by omega*delay*radius and - // wobbles it whenever the spin or the interpolator's rate cursor varies. - private latestRotation = 0; - public get predictionRotation(): number { - return this.latestRotation; - } - public get predictionRotationSpeed(): number { - return this.rotationSpeed; + this.ownershipProgress.className = 'ownership'; } private step({ deltaTimeInSeconds }: StepCommand): void { - this.renderedRotation = this.rotationInterpolator.getValue(deltaTimeInSeconds); - this.shape.rotation = this.renderedRotation; + this.shape.rotation += deltaTimeInSeconds * this.rotationSpeed; this.shape.colorMixQ = this.ownership; if (this.flareIntensity > 0) { @@ -144,18 +120,8 @@ export class PlanetView extends PlanetBase { this.releaseFlareSlot(); } - private updateProperty({ - propertyKey, - propertyValue, - rateOfChange, - }: UpdatePropertyCommand): void { - if (propertyKey === 'rotation') { - this.rotationInterpolator.addFrame(propertyValue, rateOfChange); - this.latestRotation = propertyValue; - this.rotationSpeed = rateOfChange; - } else { - this.ownership = propertyValue; - } + private updateProperty({ propertyValue }: UpdatePropertyCommand): void { + this.ownership = propertyValue; } private draw({ renderer, overlay, shouldChangeLayout }: RenderCommand): void { @@ -171,7 +137,7 @@ export class PlanetView extends PlanetBase { if (this.lastGeneratedPoint !== undefined) { const element = document.createElement('div'); - element.className = 'falling-point ' + (this.ownership < 0.5 ? 'blue' : 'red'); + element.className = 'falling-point ' + (this.ownership < 0.5 ? 'decla' : 'red'); element.innerText = '+' + this.lastGeneratedPoint; element.style.left = `${screenPosition.x}px`; element.style.top = `${screenPosition.y}px`; @@ -193,17 +159,12 @@ export class PlanetView extends PlanetBase { } 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 + const sideDecla = this.ownership < 0.5; + const sidePercent = (Math.abs(this.ownership - 0.5) / 0.5) * 100; + return sideDecla ? `conic-gradient( - var(--bright-blue) ${sidePercent}%, - var(--bright-blue) ${sidePercent}%, + var(--bright-decla) ${sidePercent}%, + var(--bright-decla) ${sidePercent}%, rgba(0, 0, 0, 0) ${sidePercent}%, rgba(0, 0, 0, 0) 100% )` diff --git a/frontend/src/scripts/objects/types/projectile-view.ts b/frontend/src/scripts/objects/types/projectile-view.ts index 5843e59..7a246fe 100644 --- a/frontend/src/scripts/objects/types/projectile-view.ts +++ b/frontend/src/scripts/objects/types/projectile-view.ts @@ -10,12 +10,12 @@ import { } from 'shared'; import { RenderCommand } from '../../commands/types/render'; import { StepCommand } from '../../commands/types/step'; -import { Vec2Interpolator } from '../../helper/interpolators/vec2-interpolator'; +import { Vec2Extrapolator } from '../../helper/extrapolators/vec2-extrapolator'; export class ProjectileView extends ProjectileBase { private light: CircleLight; - private centerInterpolator: Vec2Interpolator; + private centerExtrapolator: Vec2Extrapolator; protected commandExecutors: CommandExecutors = { [RenderCommand.type]: this.draw.bind(this), @@ -36,17 +36,17 @@ export class ProjectileView extends ProjectileBase { settings.paletteDim[settings.colorIndices[team]], 0, ); - this.centerInterpolator = new Vec2Interpolator(center); + this.centerExtrapolator = new Vec2Extrapolator(center); } private updateProperty({ propertyValue, rateOfChange }: UpdatePropertyCommand): void { - this.centerInterpolator.addFrame(propertyValue, rateOfChange); + this.centerExtrapolator.addFrame(propertyValue, rateOfChange); } private handleStep({ deltaTimeInSeconds }: StepCommand): void { this.step(deltaTimeInSeconds); - this.center = this.centerInterpolator.getValue(deltaTimeInSeconds); + this.center = this.centerExtrapolator.getValue(deltaTimeInSeconds); this.light.center = this.center; this.light.intensity = Math.min( 0.1, diff --git a/frontend/src/scripts/scoreboard.ts b/frontend/src/scripts/scoreboard.ts index 41b02ba..38dff4e 100644 --- a/frontend/src/scripts/scoreboard.ts +++ b/frontend/src/scripts/scoreboard.ts @@ -8,35 +8,35 @@ import { CharacterTeam, UpdateGameState, clamp, settings } from 'shared'; export class Scoreboard { public readonly element = document.createElement('div'); - private readonly blueFill = document.createElement('div'); + private readonly declaFill = document.createElement('div'); private readonly redFill = document.createElement('div'); - private readonly blueScore = document.createElement('span'); + private readonly declaScore = document.createElement('span'); private readonly redScore = document.createElement('span'); private readonly youMarker = document.createElement('div'); constructor() { this.element.className = 'planet-progress'; - this.blueFill.className = 'fill blue'; + this.declaFill.className = 'fill decla'; this.redFill.className = 'fill red'; - this.blueScore.className = 'score blue'; + this.declaScore.className = 'score decla'; this.redScore.className = 'score red'; this.youMarker.className = 'you-marker'; this.youMarker.innerText = 'YOU'; this.element.append( - this.blueFill, + this.declaFill, this.redFill, - this.blueScore, + this.declaScore, this.redScore, this.youMarker, ); } public update( - { blueCount, redCount, limit }: UpdateGameState, + { declaCount, redCount, limit }: UpdateGameState, localTeam: CharacterTeam | undefined, ) { const { scoreboardHalfWidthPercent, scoreboardMinFillPercent } = settings; @@ -46,23 +46,23 @@ export class Scoreboard { clamp(count / limit, 0, 1) * scoreboardHalfWidthPercent, ); - this.blueFill.style.width = fraction(blueCount) + '%'; + this.declaFill.style.width = fraction(declaCount) + '%'; this.redFill.style.width = fraction(redCount) + '%'; const isMatchPoint = (count: number) => count / limit >= settings.matchPointScoreRatio; - this.blueFill.classList.toggle('match-point', isMatchPoint(blueCount)); + this.declaFill.classList.toggle('match-point', isMatchPoint(declaCount)); this.redFill.classList.toggle('match-point', isMatchPoint(redCount)); - this.blueScore.innerText = String(Math.round(blueCount)); + this.declaScore.innerText = String(Math.round(declaCount)); this.redScore.innerText = String(Math.round(redCount)); - this.blueScore.classList.toggle('leading', blueCount > redCount); - this.redScore.classList.toggle('leading', redCount > blueCount); + this.declaScore.classList.toggle('leading', declaCount > redCount); + this.redScore.classList.toggle('leading', redCount > declaCount); - if (localTeam === CharacterTeam.blue || localTeam === CharacterTeam.red) { + if (localTeam === CharacterTeam.decla || localTeam === CharacterTeam.red) { this.youMarker.style.display = ''; - this.youMarker.classList.toggle('blue', localTeam === CharacterTeam.blue); + this.youMarker.classList.toggle('decla', localTeam === CharacterTeam.decla); this.youMarker.classList.toggle('red', localTeam === CharacterTeam.red); } else { this.youMarker.style.display = 'none'; diff --git a/frontend/src/scripts/shapes/planet-shape.ts b/frontend/src/scripts/shapes/planet-shape.ts index 1d1ec9e..6fa7565 100644 --- a/frontend/src/scripts/shapes/planet-shape.ts +++ b/frontend/src/scripts/shapes/planet-shape.ts @@ -54,24 +54,19 @@ export class PlanetShape extends PolygonFactory(settings.planetEdgeCount, 0) { float l = planetLengths[j]; float randomOffset = planetRandoms[j]; float rotation = planetRotations[j]; + vec2 targetCenterDelta = target - center; + float targetDistance = length(targetCenterDelta); + vec2 targetTangent = targetCenterDelta / clamp(targetDistance, 0.01, 1000.0); float cr = cos(rotation); float sr = sin(rotation); - - // Spin the whole planet: evaluate the SDF in the planet's own - // rotating frame so the polygon outline turns together with its - // terrain, instead of the terrain sliding over a fixed outline. - vec2 targetCenterDelta = target - center; - float targetDistance = length(targetCenterDelta); - vec2 localTarget = center + vec2( - cr * targetCenterDelta.x - sr * targetCenterDelta.y, - sr * targetCenterDelta.x + cr * targetCenterDelta.y + vec2 rotatedTangent = vec2( + cr * targetTangent.x - sr * targetTangent.y, + sr * targetTangent.x + cr * targetTangent.y ); - vec2 targetTangent = (localTarget - center) / clamp(targetDistance, 0.01, 1000.0); - - vec2 noisyTarget = localTarget - ( + vec2 noisyTarget = target - ( targetTangent * planetTerrain(vec2( - l * abs(atan(targetTangent.y, targetTangent.x)), + l * abs(atan(rotatedTangent.y, rotatedTangent.x)), randomOffset )) / 12.0 ); @@ -102,7 +97,7 @@ export class PlanetShape extends PolygonFactory(settings.planetEdgeCount, 0) { if (dist < minDistance) { minDistance = dist; - color = mix(${colorToString(settings.bluePlanetColor)}, ${colorToString( + color = mix(${colorToString(settings.declaPlanetColor)}, ${colorToString( settings.redPlanetColor, )}, planetColorMixQ[j]); } @@ -129,32 +124,11 @@ export class PlanetShape extends PolygonFactory(settings.planetEdgeCount, 0) { public randomOffset = 0; public rotation = 0; - // Circle about the rotation centre (the vertex centroid, which is what the - // shader spins around — see planetMinDistance above). The vertices never - // change after construction, so cache it once. - private readonly cullCenter: vec2; - private readonly cullRadius: number; - constructor( public vertices: Array, public colorMixQ: number, ) { super(vertices); - - this.cullCenter = vertices.reduce( - (sum, v) => vec2.add(sum, sum, v), - vec2.create(), - ); - vec2.scale(this.cullCenter, this.cullCenter, 1 / vertices.length); - - this.cullRadius = vertices.reduce( - (max, v) => Math.max(max, vec2.distance(this.cullCenter, v)), - 0, - ); - } - - public minDistance(target: vec2): number { - return vec2.distance(target, this.cullCenter) - this.cullRadius; } protected getObjectToSerialize(transform2d: mat2d, _: number): any { diff --git a/frontend/src/scripts/tutorial.ts b/frontend/src/scripts/tutorial.ts index c0d1155..823ef40 100644 --- a/frontend/src/scripts/tutorial.ts +++ b/frontend/src/scripts/tutorial.ts @@ -4,19 +4,17 @@ import { CommandExecutors, CommandReceiver, Id, - LeapActionCommand, MoveActionCommand, PrimaryActionCommand, } from 'shared'; import { GameObjectContainer } from './objects/game-object-container'; import { PlanetView } from './objects/types/planet-view'; -type StageTrigger = 'move' | 'shoot' | 'leap' | 'capture'; +type StageTrigger = 'move' | 'shoot' | 'capture'; const stages: ReadonlyArray<{ hint: string; clearsOn: StageTrigger }> = [ { hint: 'WASD / drag to walk', clearsOn: 'move' }, { hint: 'Click / tap to shoot', clearsOn: 'shoot' }, - { hint: 'Space / leap button to launch off a planet', clearsOn: 'leap' }, { hint: 'Stand on a planet to capture it', clearsOn: 'capture' }, ]; @@ -44,11 +42,6 @@ export class Tutorial extends CommandReceiver { this.advance(); } }, - [LeapActionCommand.type]: () => { - if (this.clearsOn() === 'leap') { - this.advance(); - } - }, }; constructor(overlay: HTMLElement) { @@ -93,7 +86,7 @@ export class Tutorial extends CommandReceiver { } if (standing) { const drift = standing.ownership - this.standingBaseline; - const progress = player.team === CharacterTeam.blue ? -drift : drift; + const progress = player.team === CharacterTeam.decla ? -drift : drift; if (progress > captureProgressThreshold) { this.finish(); } diff --git a/frontend/src/styles/_vars.scss b/frontend/src/styles/_vars.scss index f19825b..377d9db 100644 --- a/frontend/src/styles/_vars.scss +++ b/frontend/src/styles/_vars.scss @@ -11,12 +11,12 @@ $breakpoint: 700px; $height-breakpoint: 500px; $large-icon: 48px; $small-icon: 32px; -$bright-blue: #4069a5; +$bright-decla: #4069a5; $bright-red: #d15652; $bright-neutral: #ccc; :root { - --bright-blue: #{$bright-blue}; + --bright-decla: #{$bright-decla}; --bright-red: #{$bright-red}; --bright-neutral: #{$bright-neutral}; } diff --git a/shared/src/commands/types/actions/leap-action.ts b/shared/src/commands/types/actions/leap-action.ts deleted file mode 100644 index 0115e73..0000000 --- a/shared/src/commands/types/actions/leap-action.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { serializable } from '../../../serialization/serializable'; -import { Command } from '../../command'; - -// Sent client -> server when the player triggers a leap (Space / leap button). -// The launch direction is derived server-side from the character's surface -// normal and current movement input. clientTimeMs is the client's wall-clock at -// the press, echoed back via InputAcknowledgement so the predictor knows which -// leaps the server has already folded into the streamed launch momentum and -// must not replay again. -@serializable -export class LeapActionCommand extends Command { - public constructor(public readonly clientTimeMs: number = 0) { - super(); - } - - public toArray(): Array { - return [this.clientTimeMs]; - } -} diff --git a/shared/src/commands/types/actions/move-action.ts b/shared/src/commands/types/actions/move-action.ts index 117d3e3..67674fe 100644 --- a/shared/src/commands/types/actions/move-action.ts +++ b/shared/src/commands/types/actions/move-action.ts @@ -4,19 +4,11 @@ import { Command } from '../../command'; @serializable export class MoveActionCommand extends Command { - // clientTimeMs is the client's wall-clock (integer ms, survives the - // serializer's toFixed(3)) when the input was generated. The server echoes - // the latest one back via InputAcknowledgement so the client knows how much - // of its input timeline is already baked into a snapshot and can replay the - // rest for prediction. Defaults to 0 for inputs the server itself synthesises. - public constructor( - public readonly direction: vec2, - public readonly clientTimeMs: number = 0, - ) { + public constructor(public readonly direction: vec2) { super(); } public toArray(): Array { - return [this.direction, this.clientTimeMs]; + return [this.direction]; } } diff --git a/shared/src/commands/types/input-acknowledgement.ts b/shared/src/commands/types/input-acknowledgement.ts deleted file mode 100644 index 3f488e7..0000000 --- a/shared/src/commands/types/input-acknowledgement.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { vec2 } from 'gl-matrix'; -import { serializable } from '../../serialization/serializable'; -import { Command } from '../command'; - -// Sent server -> owning client only, alongside that client's own character -// snapshot. Carries the clientTimeMs of the most recent movement input the -// server had received when the snapshot was taken (so the client's predictor -// can reset to the snapshot and replay just the inputs the server hasn't seen -// yet) and the authoritative launch momentum (so the predictor reproduces a -// leap/slingshot/recoil flight rather than only snapping to it). See -// LocalCharacterPredictor. -@serializable -export class InputAcknowledgement extends Command { - public constructor( - public readonly clientTimeMs: number, - public readonly bodyVelocity: vec2, - // clientTimeMs of the most recent leap the server has received: any leap at - // or before it is already reflected in bodyVelocity, so the predictor must - // not replay it. - public readonly lastLeapClientTimeMs: number, - ) { - super(); - } - - public toArray(): Array { - return [this.clientTimeMs, this.bodyVelocity, this.lastLeapClientTimeMs]; - } -} diff --git a/shared/src/commands/types/property-updates-for-objects.ts b/shared/src/commands/types/property-updates-for-objects.ts index bef2d6b..a9e4985 100644 --- a/shared/src/commands/types/property-updates-for-objects.ts +++ b/shared/src/commands/types/property-updates-for-objects.ts @@ -4,16 +4,11 @@ import { Command } from '../command'; @serializable export class PropertyUpdatesForObjects extends Command { - constructor( - public readonly updates: Array, - // Seconds on the server's monotonic clock at the moment this state was - // captured. Only differences between timestamps are meaningful. - public readonly timestamp: number, - ) { + constructor(public readonly updates: Array) { super(); } public toArray(): Array { - return [this.updates, this.timestamp]; + return [this.updates]; } } diff --git a/shared/src/commands/types/update-game-state.ts b/shared/src/commands/types/update-game-state.ts index 7e66c19..40ce4e1 100644 --- a/shared/src/commands/types/update-game-state.ts +++ b/shared/src/commands/types/update-game-state.ts @@ -4,7 +4,7 @@ import { Command } from '../command'; @serializable export class UpdateGameState extends Command { public constructor( - public readonly blueCount: number, + public readonly declaCount: number, public readonly redCount: number, public readonly limit: number, ) { @@ -12,6 +12,6 @@ export class UpdateGameState extends Command { } public toArray(): Array { - return [this.blueCount, this.redCount, this.limit]; + return [this.declaCount, this.redCount, this.limit]; } } diff --git a/shared/src/main.ts b/shared/src/main.ts index d9af9ca..949cc2d 100644 --- a/shared/src/main.ts +++ b/shared/src/main.ts @@ -15,7 +15,6 @@ export * from './commands/command-executors'; export * from './commands/command-generator'; export * from './commands/types/actions/move-action'; export * from './commands/types/actions/primary-action'; -export * from './commands/types/actions/leap-action'; export * from './commands/types/actions/set-aspect-ratio-action'; export * from './helper/array'; export * from './helper/last'; @@ -47,13 +46,3 @@ export * from './objects/types/planet-base'; export * from './objects/types/projectile-base'; export * from './settings'; export * from './communication/transport-events'; -export * from './commands/types/input-acknowledgement'; -export * from './physics/sdf'; -export * from './physics/evaluate-sdf'; -export * from './physics/sdf-normal'; -export * from './physics/march-circle'; -export * from './physics/depenetrate-circle'; -export * from './physics/resolve-circle-movement'; -export * from './physics/planet-sdf'; -export * from './physics/interpolate-angles'; -export * from './physics/character-movement'; diff --git a/shared/src/objects/types/character-base.ts b/shared/src/objects/types/character-base.ts index b461127..69cc670 100644 --- a/shared/src/objects/types/character-base.ts +++ b/shared/src/objects/types/character-base.ts @@ -4,7 +4,7 @@ import { serializable } from '../../serialization/serializable'; import { GameObject } from '../game-object'; export enum CharacterTeam { - blue = 'blue', + decla = 'decla', neutral = 'neutral', red = 'red', } @@ -28,13 +28,9 @@ export class CharacterBase extends GameObject { // eslint-disable-next-line @typescript-eslint/no-unused-vars public onShoot(strength: number) { } - public onLeap() { } + public onHitConfirmed() { } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - public onHitConfirmed(charge?: number) { } - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - public onKillConfirmed(victimName?: string, streak?: number, charge?: number) { } + public onKillConfirmed(victimName?: string, streak?: number) { } public setHealth(health: number) { this.health = health; diff --git a/shared/src/objects/types/planet-base.ts b/shared/src/objects/types/planet-base.ts index e3835e1..ba90ff2 100644 --- a/shared/src/objects/types/planet-base.ts +++ b/shared/src/objects/types/planet-base.ts @@ -15,7 +15,6 @@ export class PlanetBase extends GameObject { id: Id, public readonly vertices: Array, public ownership: number = 0.5, - public readonly isKeystone: boolean = false, ) { super(id); this.center = vertices.reduce((sum, v) => vec2.add(sum, sum, v), vec2.create()); @@ -29,8 +28,6 @@ export class PlanetBase extends GameObject { public generatedPoints(value: number) {} // eslint-disable-next-line @typescript-eslint/no-unused-vars public onFlipped(team: CharacterTeam) {} - // eslint-disable-next-line @typescript-eslint/no-unused-vars - public setContested(contested: boolean) {} public static createPlanetVertices( center: vec2, @@ -57,6 +54,6 @@ export class PlanetBase extends GameObject { } public toArray(): Array { - return [this.id, this.vertices, this.ownership, this.isKeystone]; + return [this.id, this.vertices]; } } diff --git a/shared/src/physics/character-movement.ts b/shared/src/physics/character-movement.ts deleted file mode 100644 index a5744b9..0000000 --- a/shared/src/physics/character-movement.ts +++ /dev/null @@ -1,354 +0,0 @@ -import { vec2 } from 'gl-matrix'; -import { settings } from '../settings'; -import { GroundSurface, PhysicsBody } from './sdf'; -import { interpolateAngles } from './interpolate-angles'; - -// Body layout, copied verbatim from CharacterPhysical so the predicted body -// matches the authoritative one to the bit. The head sits this far above the -// feet; offsets are measured from the centre of mass. -export const headRadius = 50; -export const feetRadius = 20; - -const desiredHeadOffset = vec2.fromValues(0, 55); -const desiredLeftFootOffset = vec2.fromValues(-20, 0); -const desiredRightFootOffset = vec2.fromValues(20, 0); -const centerOfMass = vec2.scale( - vec2.create(), - vec2.add( - vec2.create(), - vec2.add(vec2.create(), desiredHeadOffset, desiredLeftFootOffset), - desiredRightFootOffset, - ), - 1 / 3, -); -export const headOffset = vec2.subtract(vec2.create(), desiredHeadOffset, centerOfMass); -export const leftFootOffset = vec2.subtract( - vec2.create(), - desiredLeftFootOffset, - centerOfMass, -); -export const rightFootOffset = vec2.subtract( - vec2.create(), - desiredRightFootOffset, - centerOfMass, -); -export const boundRadius = (headRadius + feetRadius * 2) * 2; - -// The character's movement state: the three body parts plus the small amount of -// carried state the step reads and writes. Backend CharacterPhysical and the -// client predictor both expose this shape. -export interface CharacterMovementState { - readonly head: PhysicsBody; - readonly leftFoot: PhysicsBody; - readonly rightFoot: PhysicsBody; - direction: number; - currentPlanet: GroundSurface | undefined; - secondsSinceOnSurface: number; - // Persistent launch momentum (leap / slingshot / recoil / death throw). - // Walking rebuilds and zeroes each body part's velocity every tick, - // so anything that should carry accumulates here, is injected into the parts - // before they step, and decays by friction. Stays zero for ordinary walking. - // The client predictor leaves this zero and relies on the reconciliation snap - // to follow server-side impulses, but the field is here so the server can - // delegate its full movement to stepCharacterMovement unchanged. - bodyVelocity: vec2; -} - -// The collision/gravity world the movement queries. The server backs this with -// its spatial container (planets + dynamics, dispatching collision reactions); -// the client backs it with the planets it knows about, dispatching nothing. -export interface CharacterWorld { - // Planets within `radius` of `center` that exert gravity / can be stood on. - groundsNear(center: vec2, radius: number): Array; - // Resolve one body part's motion this tick; returns the ground it landed on. - stepBody(body: PhysicsBody, deltaTimeInSeconds: number): GroundSurface | undefined; -} - -const applyForce = (body: PhysicsBody, force: vec2, deltaTimeInSeconds: number) => { - vec2.add( - body.velocity, - body.velocity, - vec2.scale(vec2.create(), force, deltaTimeInSeconds), - ); -}; - -// ((head + leftFoot) + rightFoot) / 3, in the exact association the server uses -// everywhere it reads the character centre — do not reassociate. -export const characterCenter = (state: CharacterMovementState): vec2 => { - const center = vec2.add(vec2.create(), state.head.center, state.leftFoot.center); - vec2.add(center, center, state.rightFoot.center); - return vec2.scale(center, center, 1 / 3); -}; - -const setDirection = (state: CharacterMovementState, direction: vec2) => { - state.direction = interpolateAngles( - state.direction, - Math.atan2(direction[1], direction[0]) + Math.PI / 2, - 0.2, - ); -}; - -const springMove = ( - state: CharacterMovementState, - body: PhysicsBody, - center: vec2, - offset: vec2, - stiffness: number, -) => { - const desiredPosition = vec2.add(vec2.create(), center, offset); - vec2.rotate(desiredPosition, desiredPosition, center, state.direction); - const positionDelta = vec2.subtract(vec2.create(), desiredPosition, body.center); - // First-order velocity relaxation toward the desired posture position, added - // to the gravity/movement velocity already accumulated this tick. The dt - // arrives later when the body integrates velocity, so the per-tick - // displacement is positionDelta * stiffness * dt. - vec2.scaleAndAdd(body.velocity, body.velocity, positionDelta, stiffness); -}; - -const keepPosture = (state: CharacterMovementState) => { - const center = characterCenter(state); - springMove(state, state.leftFoot, center, leftFootOffset, settings.postureFeetStiffness); - springMove( - state, - state.rightFoot, - center, - rightFootOffset, - settings.postureFeetStiffness, - ); - springMove(state, state.head, center, headOffset, settings.postureHeadStiffness); -}; - -// While standing on a planet, ride its spin: rigidly rotate the whole body -// about the planet centre by the same per-tick angle the collision SDF turns -// by (negative, matching R(-rotation)), so the player is carried with the -// surface instead of sliding across it. -const carryWithRotatingPlanet = ( - state: CharacterMovementState, - deltaTimeInSeconds: number, -) => { - const planet = state.currentPlanet; - if (!planet) { - return; - } - const angle = -planet.angularVelocity * deltaTimeInSeconds; - const center = planet.center; - state.head.center = vec2.rotate(vec2.create(), state.head.center, center, angle); - state.leftFoot.center = vec2.rotate(vec2.create(), state.leftFoot.center, center, angle); - state.rightFoot.center = vec2.rotate( - vec2.create(), - state.rightFoot.center, - center, - angle, - ); -}; - -// Launch off the current surface: directed by the foot contact normals plus -// the movement input, slingshotted by the planet's spin. Mutates bodyVelocity -// (which the next tick injects into the body) and detaches. Shared so the -// server's leap() and the client's prediction apply the exact same impulse. -// The caller does the gating (strength, cooldown, alive); this is a no-op when -// not on a surface. -export const applyLeapImpulse = ( - state: CharacterMovementState, - moveDirection: vec2, -) => { - const planet = state.currentPlanet; - if (!planet) { - return; - } - - const up = vec2.add( - vec2.create(), - state.leftFoot.lastNormal, - state.rightFoot.lastNormal, - ); - if (vec2.length(up) === 0) { - vec2.set(up, 0, 1); - } else { - vec2.normalize(up, up); - } - - const launch = vec2.scale(vec2.create(), up, settings.leapUpBias); - if (vec2.length(moveDirection) > 0) { - vec2.scaleAndAdd( - launch, - launch, - vec2.normalize(vec2.create(), moveDirection), - settings.leapMoveBias, - ); - } - vec2.normalize(launch, launch); - vec2.scaleAndAdd(state.bodyVelocity, state.bodyVelocity, launch, settings.leapSpeed); - - // Slingshot: add the tangential velocity of the spinning surface under the - // body (the same motion carryWithRotatingPlanet imparts). - const center = characterCenter(state); - const omega = planet.angularVelocity; - const surfaceVelocity = vec2.fromValues( - omega * (center[1] - planet.center[1]), - -omega * (center[0] - planet.center[0]), - ); - vec2.scaleAndAdd( - state.bodyVelocity, - state.bodyVelocity, - surfaceVelocity, - settings.slingshotScale, - ); - - state.currentPlanet = undefined; - state.secondsSinceOnSurface = settings.planetDetachmentSeconds; -}; - -// Time-based detachment: a grounded body that hasn't touched a surface for a -// while floats free. Kept as its own step because on the server it runs before -// the ownership/scoring blocks; the client calls it at the head of each tick. -export const tickPlanetDetachment = ( - state: CharacterMovementState, - deltaTimeInSeconds: number, -) => { - if ((state.secondsSinceOnSurface += deltaTimeInSeconds) > settings.planetDetachmentSeconds) { - state.currentPlanet = undefined; - } -}; - -// Inject the persistent launch momentum onto every body part right before they -// step, so the whole body translates rigidly without disturbing the posture -// springs (which only set up relative offsets). No-op while walking. -const applyBodyMomentum = (state: CharacterMovementState) => { - if (vec2.squaredLength(state.bodyVelocity) === 0) { - return; - } - vec2.add(state.leftFoot.velocity, state.leftFoot.velocity, state.bodyVelocity); - vec2.add(state.rightFoot.velocity, state.rightFoot.velocity, state.bodyVelocity); - vec2.add(state.head.velocity, state.head.velocity, state.bodyVelocity); -}; - -// Decay one launch-momentum vector in place by one tick. Stiff on the ground -// (skid to a stop), gentle in the air so a leap or slingshot still carries -// across the gaps. The gentle exponential only asymptotes, though, so on top of -// it a constant deceleration brakes the momentum to a definite stop in a couple -// of seconds instead of leaving a 15+ second drift, and a hard cap stops stacked -// impulses (rapid recoil, a leap into a slingshot) from building without bound. -// Shared so the living body, the client predictor, and the ragdoll corpse all -// brake identically. -export const decayMomentum = ( - bodyVelocity: vec2, - onGround: boolean, - deltaTimeInSeconds: number, -) => { - const speed = vec2.length(bodyVelocity); - if (speed === 0) { - return; - } - const friction = onGround - ? settings.groundMomentumFriction - : settings.airMomentumFriction; - const target = Math.min( - speed * Math.exp(-friction * deltaTimeInSeconds) - - settings.momentumStopDeceleration * deltaTimeInSeconds, - settings.maxBodyMomentum, - ); - if (target <= 1) { - vec2.zero(bodyVelocity); - } else { - vec2.scale(bodyVelocity, bodyVelocity, target / speed); - } -}; - -const decayBodyMomentum = ( - state: CharacterMovementState, - deltaTimeInSeconds: number, -) => { - decayMomentum(state.bodyVelocity, !!state.currentPlanet, deltaTimeInSeconds); -}; - -const sumGravity = (grounds: Array, position: vec2): vec2 => - grounds.reduce( - (sum, ground) => vec2.add(sum, sum, ground.gravityAt(position)), - vec2.create(), - ); - -const latchGround = ( - state: CharacterMovementState, - ground: GroundSurface | undefined, -) => { - if (ground) { - state.secondsSinceOnSurface = 0; - state.currentPlanet = ground; - } -}; - -// One tick of character movement. This is the exact movement block of the -// authoritative CharacterPhysical.step (gravity gather → movement force → -// on/off-planet branch → posture → step the three body parts), with all -// server-only concerns (scoring, health, shooting, spawn/death animation, -// ownership) left to the caller. `inputDirection` is the already-averaged, -// already-normalized movement direction for this tick and is consumed in place. -export const stepCharacterMovement = ( - state: CharacterMovementState, - world: CharacterWorld, - inputDirection: vec2, - deltaTimeInSeconds: number, -) => { - const center = characterCenter(state); - const grounds = world.groundsNear(center, boundRadius + settings.maxGravityDistance); - - const movementForce = vec2.scale(inputDirection, inputDirection, settings.maxAcceleration); - applyForce(state.leftFoot, movementForce, deltaTimeInSeconds); - applyForce(state.rightFoot, movementForce, deltaTimeInSeconds); - - if (!state.currentPlanet) { - const leftFootGravity = sumGravity(grounds, state.leftFoot.center); - const rightFootGravity = sumGravity(grounds, state.rightFoot.center); - - applyForce(state.leftFoot, leftFootGravity, deltaTimeInSeconds); - applyForce(state.rightFoot, rightFootGravity, deltaTimeInSeconds); - - const sumForce = vec2.subtract(vec2.create(), leftFootGravity, movementForce); - setDirection(state, vec2.length(sumForce) === 0 ? vec2.fromValues(0, -1) : sumForce); - } else { - carryWithRotatingPlanet(state, deltaTimeInSeconds); - - const leftFootGravity = state.currentPlanet.gravityAt(state.leftFoot.center); - const rightFootGravity = state.currentPlanet.gravityAt(state.rightFoot.center); - - vec2.add(leftFootGravity, leftFootGravity, rightFootGravity); - const gravity = vec2.scale(leftFootGravity, leftFootGravity, 0.5); - - if ( - vec2.dot(movementForce, gravity) < - -vec2.length(movementForce) * settings.climbDotThreshold - ) { - vec2.scale(gravity, gravity, settings.climbGravityScale); - } - - const scaledLeftFootGravity = vec2.scale( - vec2.create(), - state.leftFoot.lastNormal, - vec2.dot(state.leftFoot.lastNormal, gravity), - ); - applyForce(state.leftFoot, scaledLeftFootGravity, deltaTimeInSeconds); - - const scaledRightFootGravity = vec2.scale( - vec2.create(), - state.rightFoot.lastNormal, - vec2.dot(state.rightFoot.lastNormal, gravity), - ); - applyForce(state.rightFoot, scaledRightFootGravity, deltaTimeInSeconds); - - if (vec2.length(gravity) <= settings.planetDetachmentForceThreshold) { - state.currentPlanet = undefined; - } - setDirection(state, gravity); - } - - keepPosture(state); - - applyBodyMomentum(state); - - latchGround(state, world.stepBody(state.leftFoot, deltaTimeInSeconds)); - latchGround(state, world.stepBody(state.rightFoot, deltaTimeInSeconds)); - latchGround(state, world.stepBody(state.head, deltaTimeInSeconds)); - - decayBodyMomentum(state, deltaTimeInSeconds); -}; diff --git a/shared/src/physics/depenetrate-circle.ts b/shared/src/physics/depenetrate-circle.ts deleted file mode 100644 index 7232f73..0000000 --- a/shared/src/physics/depenetrate-circle.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { vec2 } from 'gl-matrix'; -import { PhysicsBody, Sdf } from './sdf'; -import { evaluateSdf } from './evaluate-sdf'; -import { sdfNormal } from './sdf-normal'; - -// Planet collision outlines rotate (see PlanetPhysical.distance), so a surface -// can sweep into a circle that hasn't itself moved. marchCircle assumes an -// overlap-free start — beginning inside, it registers a zero-distance hit and -// never moves again — so any overlap must be resolved here, before marching. -// Iterating handles concave spots, where leaving one face pushes into another; -// if no overlap-free position exists nearby (a crevice narrower than the -// circle), it gives up and leaves the rest to a later tick. -export const depenetrateCircle = ( - body: PhysicsBody, - possibleIntersectors: Array, -) => { - for (let i = 0; i < 4; i++) { - const distance = evaluateSdf(body.center, possibleIntersectors); - if (distance >= body.radius) { - return; - } - - const normal = sdfNormal(body.center, possibleIntersectors); - if (vec2.squaredLength(normal) === 0) { - return; - } - - vec2.copy(body.lastNormal, normal); - body.center = vec2.scaleAndAdd( - vec2.create(), - body.center, - normal, - body.radius - distance + 0.01, - ); - } -}; diff --git a/shared/src/physics/march-circle.ts b/shared/src/physics/march-circle.ts deleted file mode 100644 index 0a8f0c8..0000000 --- a/shared/src/physics/march-circle.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { vec2 } from 'gl-matrix'; -import { PhysicsBody, Sdf } from './sdf'; -import { evaluateSdf } from './evaluate-sdf'; -import { sdfNormal } from './sdf-normal'; - -export interface MarchResult { - hitSurface: boolean; - normal?: vec2; - hitObject?: Sdf; -} - -// Raymarch a circle by `delta`, stopping at the first surface it would overlap. -// Extracted verbatim from the backend's move-circle so server and client -// resolve motion identically. The collision *reaction* is no longer dispatched -// here: on a real (non-ignored) hit, `onHit(intersecting)` is invoked at the -// exact point the backend used to dispatch its ReactToCollisionCommands, and -// the backend wrapper supplies that callback. The client passes none. -export const marchCircle = ( - body: PhysicsBody, - delta: vec2, - possibleIntersectors: Array, - ignoreCollision = false, - onHit?: (intersecting: Sdf) => void, -): MarchResult => { - const direction = vec2.clone(delta); - - if (vec2.length(delta) > 0) { - vec2.normalize(direction, direction); - } - - const deltaLength = vec2.length(delta); - let travelled = 0; - const rayEnd = vec2.create(); - let prevMinDistance = 0; - while (travelled < deltaLength) { - travelled += prevMinDistance; - vec2.add( - rayEnd, - body.center, - vec2.scale(vec2.create(), direction, Math.min(travelled, deltaLength)), - ); - - const minDistance = evaluateSdf(rayEnd, possibleIntersectors); - - if (minDistance < body.radius) { - const intersecting = possibleIntersectors.find( - (i) => i.distance(rayEnd) <= body.radius, - )!; - - if (ignoreCollision) { - body.center = vec2.add(body.center, body.center, delta); - } else { - onHit?.(intersecting); - } - - vec2.add( - rayEnd, - body.center, - vec2.scale(vec2.create(), direction, travelled - prevMinDistance), - ); - - vec2.copy(body.center, rayEnd); - - const normal = sdfNormal(rayEnd, [intersecting]); - return { - hitSurface: true, - normal, - hitObject: intersecting, - }; - } - - prevMinDistance = minDistance; - } - - vec2.add(body.center, body.center, delta); - - return { - hitSurface: false, - }; -}; diff --git a/shared/src/physics/planet-sdf.ts b/shared/src/physics/planet-sdf.ts deleted file mode 100644 index 24af49d..0000000 --- a/shared/src/physics/planet-sdf.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { vec2 } from 'gl-matrix'; -import { clamp, clamp01 } from '../helper/clamp'; -import { settings } from '../settings'; - -// Rotate a world point into the planet's own frame: R(-rotation) about the -// centre, matching the shader's localTarget = center + R(rotation)*(target-center). -// cos/sin are passed in so the server can keep memoising them per angle. -export const toPlanetLocalFrame = ( - target: vec2, - center: vec2, - cos: number, - sin: number, -): vec2 => { - const dx = target[0] - center[0]; - const dy = target[1] - center[1]; - return vec2.fromValues( - center[0] + cos * dx - sin * dy, - center[1] + sin * dx + cos * dy, - ); -}; - -// Signed distance to the (smooth, noise-free) planet polygon, evaluated in the -// planet's rotating frame. Extracted verbatim from PlanetPhysical.distance so -// the client collides against the exact same outline the server does. Indexed -// vector access keeps it independent of the .x/.y prototype plugin. -export const planetDistance = ( - target: vec2, - vertices: Array, - center: vec2, - cos: number, - sin: number, -): number => { - const local = toPlanetLocalFrame(target, center, cos, sin); - - const startEnd = vertices[0]; - let vb = startEnd; - - let d = vec2.dist(local, vb); - let sign = 1; - - for (let i = 1; i <= vertices.length; i++) { - const va = vb; - vb = i === vertices.length ? startEnd : vertices[i]; - const targetFromDelta = vec2.subtract(vec2.create(), local, va); - const toFromDelta = vec2.subtract(vec2.create(), vb, va); - const h = clamp01( - vec2.dot(targetFromDelta, toFromDelta) / vec2.squaredLength(toFromDelta), - ); - - const ds = vec2.fromValues( - vec2.dist(targetFromDelta, vec2.scale(vec2.create(), toFromDelta, h)), - toFromDelta[0] * targetFromDelta[1] - toFromDelta[1] * targetFromDelta[0], - ); - - if ( - (local[1] >= va[1] && local[1] < vb[1] && ds[1] > 0) || - (local[1] < va[1] && local[1] >= vb[1] && ds[1] <= 0) - ) { - sign *= -1; - } - - d = Math.min(d, ds[0]); - } - - return sign * d; -}; - -// Gravity a planet exerts at a world position. Verbatim from -// PlanetPhysical.getForce. -export const planetGravity = (center: vec2, radius: number, position: vec2): vec2 => { - const diff = vec2.subtract(vec2.create(), center, position); - const dist = Math.max(settings.minGravityDistance, vec2.length(diff) - radius); - vec2.normalize(diff, diff); - const scale = clamp( - settings.maxGravityQ * ((settings.maxGravityDistance / dist) ** 1.5 - 1), - 0, - settings.maxGravityStrength, - ); - return vec2.scale(diff, diff, scale); -}; diff --git a/shared/src/physics/resolve-circle-movement.ts b/shared/src/physics/resolve-circle-movement.ts deleted file mode 100644 index 47f0c1c..0000000 --- a/shared/src/physics/resolve-circle-movement.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { vec2 } from 'gl-matrix'; -import { PhysicsBody, Sdf } from './sdf'; -import { depenetrateCircle } from './depenetrate-circle'; -import { marchCircle } from './march-circle'; - -// The position-resolution half of the backend's CirclePhysical.stepManually, -// extracted so server and client integrate a body identically. The caller is -// responsible for the broadphase (gathering `possibleIntersectors`, including -// the swept-radius bump) because that depends on each side's spatial structure; -// everything from depenetration onward lives here. -// -// `onHit` is threaded through to marchCircle so the backend can dispatch its -// collision reactions at the same points as before (including the second, -// post-bounce slide march); the client passes none. Velocity is reset to zero -// at the end — the character re-applies all forces from zero every tick, so a -// body that retained velocity would double-integrate and diverge. -export const resolveCircleMovement = ( - body: PhysicsBody, - deltaTimeInSeconds: number, - possibleIntersectors: Array, - onHit?: (intersecting: Sdf) => void, -): { hitObject?: Sdf; velocity: vec2 } => { - let delta = vec2.scale(vec2.create(), body.velocity, deltaTimeInSeconds); - - depenetrateCircle(body, possibleIntersectors); - - const { normal, hitSurface, hitObject } = marchCircle( - body, - delta, - possibleIntersectors, - false, - onHit, - ); - - if (hitSurface) { - vec2.copy(body.lastNormal, normal!); - - vec2.subtract( - body.velocity, - body.velocity, - vec2.scale( - normal!, - normal!, - (1 + body.restitution) * vec2.dot(normal!, body.velocity), - ), - ); - - if (vec2.length(body.velocity) > 50) { - delta = vec2.scale(vec2.create(), body.velocity, deltaTimeInSeconds); - marchCircle(body, delta, possibleIntersectors, false, onHit); - } - } - - const lastVelocity = vec2.clone(body.velocity); - vec2.zero(body.velocity); - - return { hitObject, velocity: lastVelocity }; -}; diff --git a/shared/src/physics/sdf-normal.ts b/shared/src/physics/sdf-normal.ts deleted file mode 100644 index 507a8eb..0000000 --- a/shared/src/physics/sdf-normal.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { vec2 } from 'gl-matrix'; -import { Sdf } from './sdf'; -import { evaluateSdf } from './evaluate-sdf'; - -// Central-difference gradient of the combined SDF. Can be zero where the -// samples cancel out (e.g. on the medial axis of a shape) — callers must -// handle that case. Uses indexed access so it never depends on the vec2 .x/.y -// prototype plugin (identical numerically: .x === [0]). -export const sdfNormal = (target: vec2, objects: Array): vec2 => { - const dx = - evaluateSdf(vec2.fromValues(target[0] + 0.01, target[1]), objects) - - evaluateSdf(vec2.fromValues(target[0] - 0.01, target[1]), objects); - const dy = - evaluateSdf(vec2.fromValues(target[0], target[1] + 0.01), objects) - - evaluateSdf(vec2.fromValues(target[0], target[1] - 0.01), objects); - - const normal = vec2.fromValues(dx, dy); - return vec2.squaredLength(normal) > 0 ? vec2.normalize(normal, normal) : normal; -}; diff --git a/shared/src/physics/sdf.ts b/shared/src/physics/sdf.ts deleted file mode 100644 index 38bb74c..0000000 --- a/shared/src/physics/sdf.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { vec2 } from 'gl-matrix'; - -// Minimal signed-distance collider the geometry functions need. Backend -// Physicals and the client's planet surfaces both satisfy this structurally. -export interface Sdf { - readonly canCollide: boolean; - // Planets set this true so a body landing on one can latch it as ground; - // everything else leaves it falsy. Replaces the server's - // `instanceof PlanetPhysical` check with a structural flag both sides share. - readonly isGround?: boolean; - distance(target: vec2): number; -} - -// A movable circle the geometry resolves in place. Backend CirclePhysical and -// the client predictor's plain bodies both satisfy this. -export interface PhysicsBody { - center: vec2; - radius: number; - velocity: vec2; - lastNormal: vec2; - readonly restitution: number; -} - -// A planet-like surface: collidable, exerts gravity, and spins. Drives the -// character's on-surface movement branch. -export interface GroundSurface extends Sdf { - readonly isGround: true; - readonly center: vec2; - readonly angularVelocity: number; - gravityAt(target: vec2): vec2; -} diff --git a/shared/src/settings.ts b/shared/src/settings.ts index 61041a5..f38bd81 100644 --- a/shared/src/settings.ts +++ b/shared/src/settings.ts @@ -1,13 +1,13 @@ import { rgb255 } from './helper/rgb255'; import { CharacterTeam } from './objects/types/character-base'; -const blueColor = rgb255(64, 105, 165); +const declaColor = rgb255(64, 105, 165); const neutralColor = rgb255(82, 165, 64); const redColor = rgb255(209, 86, 82); const q = 2.5; -const blueColorDim = rgb255(64 * q, 105 * q, 165 * q); +const declaColorDim = rgb255(64 * q, 105 * q, 165 * q); const redColorDim = rgb255(209 * q, 86 * q, 82 * q); -const bluePlanetColor = blueColorDim; +const declaPlanetColor = declaColorDim; const redPlanetColor = redColorDim; export const settings = { @@ -18,10 +18,6 @@ export const settings = { worldRadius: 4000, objectsOnCircleLength: 0.002, updateMessageInterval: 1 / 25, - // How far behind the estimated server time remote state is rendered: ~2.5 - // update intervals, so a late packet rarely leaves the client without a - // newer snapshot to interpolate towards. - interpolationDelaySeconds: 0.1, planetEdgeCount: 7, playerKillPoint: 25, takeControlTimeInSeconds: 2.5, @@ -33,11 +29,7 @@ export const settings = { maxGravityDistance: 800, minGravityDistance: 1, maxGravityQ: 5000, - // Half-width of the neutral dead-band around 50% ownership. A planet only - // counts as captured (team(), point generation, flip) once |ownership-0.5| - // exceeds this, and the rendered ownership ring stays neutral until the same - // point — so what you see matches what scores. - planetControlThreshold: 0.12, + planetControlThreshold: 0.2, playerMaxHealth: 100, maxGravityStrength: 50000, planetMinReferenceRadius: 150, @@ -72,8 +64,8 @@ export const settings = { chargeShotSpeedMax: 3400, playerColorIndexOffset: 3, backgroundGradient: [rgb255(90, 38, 43), rgb255(43, 39, 73)], - blueColor, - bluePlanetColor, + declaColor, + declaPlanetColor, npcNames: [ 'Adam', 'Andrew', @@ -127,12 +119,12 @@ export const settings = { redColor, redPlanetColor, colorIndices: { - [CharacterTeam.blue]: 0, + [CharacterTeam.decla]: 0, [CharacterTeam.neutral]: 1, [CharacterTeam.red]: 2, }, - palette: [blueColor, neutralColor, redColor], - paletteDim: [blueColorDim, neutralColor, redColorDim], + palette: [declaColor, neutralColor, redColor], + paletteDim: [declaColorDim, neutralColor, redColorDim], targetPhysicsDeltaTimeInSeconds: 1 / 200, inViewAreaSize: 1920 * 1080 * 4, scoreboardHalfWidthPercent: 50, @@ -145,65 +137,4 @@ export const settings = { lampFlareDecaySeconds: 0.6, maxConcurrentFlipFlares: 3, announcementVisibleSeconds: 2, - - chargedHitThreshold: 0.6, - - // Projectiles fall through planetary gravity like a free-falling character, - // so slower (charged) shots arc. Scale kept tiny: near a surface gravity is - // maxGravityStrength=50000, which at full strength would corkscrew a shot into - // the planet — 0.04 gives a readable bend instead. - projectileGravityEnabled: true, - projectileGravityScale: 0.04, - - // Speed the corpse is flung at along the killing shot's direction, lerped by - // that shot's charge. Added to whatever momentum the victim already carried. - deathImpulseMin: 280, - deathImpulseMax: 1300, - - // A planet's net team head-count drives a single capture step per tick; the - // lead multiplier is capped so a zerg can't flip instantly. Equal head-counts - // freeze the planet (contested) instead of silently cancelling. - maxContestLeadMultiplier: 2, - - // Persistent body momentum decays per second by these exponents. Airborne is - // near-frictionless so leaps and slingshots carry across the gaps; grounded is - // stiff so you skid to a stop on landing rather than sliding forever. - airMomentumFriction: 0.4, - - groundMomentumFriction: 7, - - // On top of the exponential frictions above, a constant deceleration (u/s^2) - // applied to body momentum. The exponential alone only asymptotes toward zero, - // so a fast launch keeps a slow tail for 15+ seconds — it reads as drifting - // forever with nothing slowing you. This constant brake brings the momentum to - // a definite stop in a couple of seconds, while the gentle exponential still - // lets the launch cover its distance first. - - momentumStopDeceleration: 400, - // Hard ceiling (u/s) on body momentum, so stacked impulses — rapid charged-shot - // recoil, or a leap chained into a spin slingshot — can't build speed without - // bound. Kept above the overcharge fling (1700) so single launches survive. - - maxBodyMomentum: 2000, - - // Leap: a charged-cost launch off a surface, paid from the shared shooting - // strength pool so it trades against firepower. - leapStrengthCost: 32, - - leapSpeed: 1350, - leapUpBias: 1, - leapMoveBias: 0.65, - leapCooldownSeconds: 0.35, - - // Fraction of the planet's tangential surface velocity you keep when you leave - // it (slingshot). Leap off a fast spinner to be flung far. - slingshotScale: 1, - - // Recoil speed imparted opposite a shot, scaled by its charge (0 for taps). - chargeShotRecoilMax: 650, - - // The central giant is a named, always-contested focus. Its neutral decay is - // slowed so control lingers and teams keep fighting over it; flips are - // announced to everyone and an off-screen arrow points the way. - keystoneLoseControlScale: 2.5, };