Compare commits

...

3 commits

Author SHA1 Message Date
fc9df09ee1 More fun 2026-06-14 20:58:42 +01:00
a1fb6755c7 Fix net code 2026-06-14 15:01:36 +01:00
1f10b9c750 Rebrand 2026-06-12 21:46:47 +01:00
69 changed files with 2492 additions and 646 deletions

View file

@ -3,7 +3,7 @@
"Deserializable", "Deserializable",
"Deserialization", "Deserialization",
"Respawn", "Respawn",
"decla", "doppler",
"overridable", "overridable",
"serializable" "serializable"
] ]

View file

@ -1,6 +1,6 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
# decla.red game server (the `declared-server` package). # doppler game server (the `doppler-server` package).
# The frontend is a static site and is NOT built here — see .forgejo/workflows. # The frontend is a static site and is NOT built here — see .forgejo/workflows.
# ---- Stage 1: build the shared lib, then bundle the server ------------------- # ---- Stage 1: build the shared lib, then bundle the server -------------------

View file

@ -1,8 +1,8 @@
# [decla.red](https://decla.red) # doppler
A 2-dimensional multiplayer game utilising ray tracing. A 2-dimensional multiplayer game utilising ray tracing.
> **Available at [decla.red](https://decla.red).** > **Available at [doppler.schmelczer.dev](https://doppler.schmelczer.dev).**
![screenshot of in-game fight](media/game-pc.png) ![screenshot of in-game fight](media/game-pc.png)
![three screenshots taken at iPhone SE screen size](media/collage-iphone.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: `main` it:
- builds the static frontend and rsyncs `frontend/dist/` to the `/pages/declared` - builds the static frontend and rsyncs `frontend/dist/` to the `/pages/declared`
mount on the runner host, and mount on the runner host (the mount keeps its pre-rebrand name), and
- builds the server image from the root `Dockerfile` and pushes it to the Forgejo - builds the server image from the root `Dockerfile` and pushes it to the Forgejo
container registry as `<registry>/<owner>/<repo>-server`. container registry as `<registry>/<owner>/<repo>-server`.
@ -30,6 +30,6 @@ edit it to add or remove game-server origins.
Run the server image locally: Run the server image locally:
```sh ```sh
docker build -t declared-server . docker build -t doppler-server .
docker run -p 3000:3000 declared-server --name "My server" --playerLimit 16 docker run -p 3000:3000 doppler-server --name "My server" --playerLimit 16
``` ```

View file

@ -1,14 +0,0 @@
# 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).

View file

@ -1,11 +0,0 @@
[Unit]
Description=declared-server
Requires=basic.target network.target
[Service]
Type=simple
ExecStart=/bin/sh /root/declared-servers.sh
[Install]
WantedBy=multi-user.target

View file

@ -1,5 +0,0 @@
#!/bin/sh
set -e
NODE_ENV=production declared-server --port=3000 --name="Simonyi" --playerLimit=256 --npcCount=48 --scoreLimit=20000

View file

@ -1,49 +0,0 @@
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;
}
}
}

View file

@ -1,28 +0,0 @@
#!/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

View file

@ -1,12 +1,12 @@
{ {
"name": "declared-server", "name": "doppler-server",
"version": "0.1.0", "version": "0.1.0",
"description": "Game server for decla.red", "description": "Game server for doppler",
"keywords": [], "keywords": [],
"author": "András Schmelczer <andras@schmelczer.dev> (https://schmelczer.dev/)", "author": "András Schmelczer <andras@schmelczer.dev> (https://schmelczer.dev/)",
"main": "dist/main.js", "main": "dist/main.js",
"bin": { "bin": {
"declared-server": "dist/main.js" "doppler-server": "dist/main.js"
}, },
"scripts": { "scripts": {
"build": "npx webpack --mode production", "build": "npx webpack --mode production",

View file

@ -0,0 +1,9 @@
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();
}
}

View file

@ -2,7 +2,7 @@ import { Command } from 'shared';
export class GeneratePointsCommand extends Command { export class GeneratePointsCommand extends Command {
public constructor( public constructor(
public readonly decla: number, public readonly blue: number,
public readonly red: number, public readonly red: number,
) { ) {
super(); super();

View file

@ -1,9 +1,8 @@
import { vec2 } from 'gl-matrix'; import { vec2 } from 'gl-matrix';
import { Random, PlanetBase, hsl, settings } from 'shared'; import { Random, PlanetBase, hsl, settings, evaluateSdf } from 'shared';
import { LampPhysical } from './objects/lamp-physical'; import { LampPhysical } from './objects/lamp-physical';
import { PlanetPhysical } from './objects/planet-physical'; import { PlanetPhysical } from './objects/planet-physical';
import { PhysicalContainer } from './physics/containers/physical-container'; import { PhysicalContainer } from './physics/containers/physical-container';
import { evaluateSdf } from './physics/functions/evaluate-sdf';
import { Physical } from './physics/physicals/physical'; import { Physical } from './physics/physicals/physical';
export const createWorld = (objectContainer: PhysicalContainer) => { export const createWorld = (objectContainer: PhysicalContainer) => {
@ -45,13 +44,16 @@ export const createWorld = (objectContainer: PhysicalContainer) => {
) { ) {
const planet = const planet =
objects.length === 0 objects.length === 0
? new PlanetPhysical( ? // The first, central giant is the keystone "Heart": a named,
// always-contested focal objective the whole match orbits.
new PlanetPhysical(
PlanetBase.createPlanetVertices( PlanetBase.createPlanetVertices(
position, position,
Random.getRandomInRange(1600, 2400), Random.getRandomInRange(1600, 2400),
Random.getRandomInRange(1600, 2400), Random.getRandomInRange(1600, 2400),
Random.getRandomInRange(80, 300), Random.getRandomInRange(80, 300),
), ),
true,
) )
: new PlanetPhysical( : new PlanetPhysical(
PlanetBase.createPlanetVertices( PlanetBase.createPlanetVertices(

View file

@ -21,6 +21,7 @@ import { Options } from './options';
import { PlayerContainer } from './players/player-container'; import { PlayerContainer } from './players/player-container';
import { StepCommand } from './commands/step'; import { StepCommand } from './commands/step';
import { GeneratePointsCommand } from './commands/generate-points'; import { GeneratePointsCommand } from './commands/generate-points';
import { AnnounceCommand } from './commands/announce';
const gameStateSubscribedRoom = 'gameStateSubscribedRoom'; const gameStateSubscribedRoom = 'gameStateSubscribedRoom';
@ -30,7 +31,7 @@ export class GameServer extends CommandReceiver {
private deltaTimes!: Array<number>; private deltaTimes!: Array<number>;
private deltaTimeCalculator!: DeltaTimeCalculator; private deltaTimeCalculator!: DeltaTimeCalculator;
private declaPoints = 0; private bluePoints = 0;
private redPoints = 0; private redPoints = 0;
private matchPointAnnounced: Partial<Record<CharacterTeam, boolean>> = {}; private matchPointAnnounced: Partial<Record<CharacterTeam, boolean>> = {};
@ -52,7 +53,7 @@ export class GameServer extends CommandReceiver {
); );
this.deltaTimeCalculator = new DeltaTimeCalculator(); this.deltaTimeCalculator = new DeltaTimeCalculator();
this.deltaTimes = []; this.deltaTimes = [];
this.declaPoints = 0; this.bluePoints = 0;
this.redPoints = 0; this.redPoints = 0;
this.matchPointAnnounced = {}; this.matchPointAnnounced = {};
this.isInEndGame = false; this.isInEndGame = false;
@ -63,6 +64,8 @@ export class GameServer extends CommandReceiver {
protected commandExecutors: CommandExecutors = { protected commandExecutors: CommandExecutors = {
[GeneratePointsCommand.type]: this.addPoints.bind(this), [GeneratePointsCommand.type]: this.addPoints.bind(this),
[AnnounceCommand.type]: ({ text }: AnnounceCommand) =>
this.players.queueCommandForEachClient(new ServerAnnouncement(text)),
}; };
constructor( constructor(
@ -118,19 +121,19 @@ export class GameServer extends CommandReceiver {
this.handlePhysics(); this.handlePhysics();
} }
private addPoints({ decla, red }: GeneratePointsCommand) { private addPoints({ blue, red }: GeneratePointsCommand) {
if (this.isInEndGame) { if (this.isInEndGame) {
return; return;
} }
this.declaPoints += decla; this.bluePoints += blue;
this.redPoints += red; this.redPoints += red;
if (this.declaPoints >= this.options.scoreLimit) { if (this.bluePoints >= this.options.scoreLimit) {
this.endGame(CharacterTeam.decla); this.endGame(CharacterTeam.blue);
} else if (this.redPoints >= this.options.scoreLimit) { } else if (this.redPoints >= this.options.scoreLimit) {
this.endGame(CharacterTeam.red); this.endGame(CharacterTeam.red);
} else { } else {
this.announceMatchPointOnce(CharacterTeam.decla, this.declaPoints); this.announceMatchPointOnce(CharacterTeam.blue, this.bluePoints);
this.announceMatchPointOnce(CharacterTeam.red, this.redPoints); this.announceMatchPointOnce(CharacterTeam.red, this.redPoints);
} }
} }
@ -164,6 +167,7 @@ export class GameServer extends CommandReceiver {
} }
private timeSinceLastPointUpdate = 0; private timeSinceLastPointUpdate = 0;
private physicsAccumulator = 0;
private handlePhysics() { private handlePhysics() {
const delta = this.deltaTimeCalculator.getNextDeltaTimeInSeconds({ setAsBase: true }); const delta = this.deltaTimeCalculator.getNextDeltaTimeInSeconds({ setAsBase: true });
@ -179,18 +183,32 @@ export class GameServer extends CommandReceiver {
if ((this.timeSinceLastPointUpdate += delta) > 0.5) { if ((this.timeSinceLastPointUpdate += delta) > 0.5) {
this.timeSinceLastPointUpdate = 0; this.timeSinceLastPointUpdate = 0;
this.players.queueCommandForEachClient( this.players.queueCommandForEachClient(
new UpdateGameState(this.declaPoints, this.redPoints, this.options.scoreLimit), new UpdateGameState(this.bluePoints, this.redPoints, this.options.scoreLimit),
); );
} }
let scaledDelta = delta; const fixedDelta = settings.targetPhysicsDeltaTimeInSeconds;
if (this.isInEndGame) { const maxSubstepsPerFrame = 5;
this.timeScaling *= Math.pow(settings.endGameDeltaScaling, delta);
scaledDelta /= this.timeScaling; 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.objects.handleCommand(new StepCommand(scaledDelta, this));
this.players.step(scaledDelta); this.players.step(scaledDelta);
}
this.players.stepCommunication(delta); this.players.stepCommunication(delta);
this.objects.resetRemoteCalls(); this.objects.resetRemoteCalls();
@ -198,7 +216,7 @@ export class GameServer extends CommandReceiver {
setTimeout( setTimeout(
this.handlePhysics.bind(this), this.handlePhysics.bind(this),
Math.max(0, settings.targetPhysicsDeltaTimeInSeconds - physicsDelta) * 1000, Math.max(0, fixedDelta - physicsDelta) * 1000,
); );
} }
@ -224,7 +242,7 @@ export class GameServer extends CommandReceiver {
} }
private get gameProgress(): number { private get gameProgress(): number {
return (Math.max(this.declaPoints, this.redPoints) / this.options.scoreLimit) * 100; return (Math.max(this.bluePoints, this.redPoints) / this.options.scoreLimit) * 100;
} }
public get serverInfo(): ServerInformation { public get serverInfo(): ServerInformation {

View file

@ -14,13 +14,18 @@ import {
CommandReceiver, CommandReceiver,
mix, mix,
clamp01, clamp01,
stepCharacterMovement,
applyLeapImpulse,
decayMomentum,
CharacterMovementState,
CharacterWorld,
GroundSurface,
} from 'shared'; } from 'shared';
import { DynamicPhysical } from '../physics/physicals/dynamic-physical'; import { DynamicPhysical } from '../physics/physicals/dynamic-physical';
import { CirclePhysical } from './circle-physical'; import { CirclePhysical } from './circle-physical';
import { PhysicalContainer } from '../physics/containers/physical-container'; import { PhysicalContainer } from '../physics/containers/physical-container';
import { BoundingBoxBase } from '../physics/bounding-boxes/bounding-box-base'; import { BoundingBoxBase } from '../physics/bounding-boxes/bounding-box-base';
import { ProjectilePhysical } from './projectile-physical'; import { ProjectilePhysical } from './projectile-physical';
import { interpolateAngles } from '../helper/interpolate-angles';
import { forceAtPosition } from '../physics/functions/force-at-position'; import { forceAtPosition } from '../physics/functions/force-at-position';
import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle'; import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle';
import { PlanetPhysical } from './planet-physical'; import { PlanetPhysical } from './planet-physical';
@ -92,10 +97,16 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
private currentPlanet?: PlanetPhysical; private currentPlanet?: PlanetPhysical;
private secondsSinceOnSurface = settings.planetDetachmentSeconds; private secondsSinceOnSurface = settings.planetDetachmentSeconds;
private bodyVelocity = vec2.create();
private timeSinceLastLeap = settings.leapCooldownSeconds;
public head: CirclePhysical; public head: CirclePhysical;
public leftFoot: CirclePhysical; public leftFoot: CirclePhysical;
public rightFoot: CirclePhysical; public rightFoot: CirclePhysical;
private movementState!: CharacterMovementState;
private movementWorld!: CharacterWorld;
private movementActions: Array<MoveActionCommand> = []; private movementActions: Array<MoveActionCommand> = [];
private lastMovementAction: MoveActionCommand = new MoveActionCommand(vec2.create()); private lastMovementAction: MoveActionCommand = new MoveActionCommand(vec2.create());
@ -138,6 +149,51 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
container.addObject(this.head); container.addObject(this.head);
container.addObject(this.leftFoot); container.addObject(this.leftFoot);
container.addObject(this.rightFoot); 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 { private get isSpawnProtected(): boolean {
@ -151,10 +207,10 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
private getPoints(game: CommandReceiver) { private getPoints(game: CommandReceiver) {
if (!this.isAlive && !this.hasGeneratedPoints) { if (!this.isAlive && !this.hasGeneratedPoints) {
this.hasGeneratedPoints = true; this.hasGeneratedPoints = true;
const decla = this.team === CharacterTeam.decla ? 0 : settings.playerKillPoint; const blue = this.team === CharacterTeam.blue ? 0 : settings.playerKillPoint;
const red = this.team === CharacterTeam.red ? 0 : settings.playerKillPoint; const red = this.team === CharacterTeam.red ? 0 : settings.playerKillPoint;
game.handleCommand(new GeneratePointsCommand(decla, red)); game.handleCommand(new GeneratePointsCommand(blue, red));
} }
} }
@ -170,7 +226,13 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
return this.currentPlanet; return this.currentPlanet;
} }
public addKill(victimName: string) { // Persistent launch momentum, streamed to the owning client so its predictor
// can reproduce a leap/slingshot/recoil flight instead of only snapping to it.
public get launchMomentum(): vec2 {
return this.bodyVelocity;
}
public addKill(victimName: string, charge = 0) {
this.killCount++; this.killCount++;
this.killStreak++; this.killStreak++;
this.remoteCall('setKillCount', this.killCount); this.remoteCall('setKillCount', this.killCount);
@ -180,11 +242,11 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
this.health + settings.playerKillHealthReward, this.health + settings.playerKillHealthReward,
); );
this.syncHealth(); this.syncHealth();
this.remoteCall('onKillConfirmed', victimName, this.killStreak); this.remoteCall('onKillConfirmed', victimName, this.killStreak, charge);
} }
public registerHit() { public registerHit(charge = 0) {
this.remoteCall('onHitConfirmed'); this.remoteCall('onHitConfirmed', charge);
} }
private syncHealth() { private syncHealth() {
@ -197,6 +259,9 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
public onCollision({ other }: ReactToCollisionCommand) { public onCollision({ other }: ReactToCollisionCommand) {
if ( 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 instanceof ProjectilePhysical &&
other.team !== this.team && other.team !== this.team &&
other.isAlive other.isAlive
@ -213,10 +278,17 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
this.remoteCall('setHealth', this.health); this.remoteCall('setHealth', this.health);
if (this.health <= 0 && this.isAlive) { 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(); this.onDie();
other.originator.addKill(this.name); other.originator.addKill(this.name, other.charge);
} else { } else {
other.originator.registerHit(); other.originator.registerHit(other.charge);
} }
} }
} }
@ -246,6 +318,8 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
const direction = vec2.subtract(vec2.create(), position, this.center); const direction = vec2.subtract(vec2.create(), position, this.center);
vec2.normalize(direction, direction); 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 velocity = vec2.scale(direction, direction, speed);
const projectile = new ProjectilePhysical( const projectile = new ProjectilePhysical(
vec2.clone(this.center), vec2.clone(this.center),
@ -255,12 +329,42 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
velocity, velocity,
this, this,
this.container, this.container,
c,
); );
this.container.addObject(projectile); this.container.addObject(projectile);
if (c > 0) {
vec2.scaleAndAdd(
this.bodyVelocity,
this.bodyVelocity,
shotDirection,
-settings.chargeShotRecoilMax * c,
);
}
this.remoteCall('onShoot', strength); 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 { public get boundingBox(): BoundingBoxBase {
return getBoundingBoxOfCircle(new Circle(this.center, CharacterPhysical.boundRadius)); return getBoundingBoxOfCircle(new Circle(this.center, CharacterPhysical.boundRadius));
} }
@ -363,6 +467,7 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
private step({ deltaTimeInSeconds, game }: StepCommand) { private step({ deltaTimeInSeconds, game }: StepCommand) {
this.getPoints(game); this.getPoints(game);
this.timeAlive += deltaTimeInSeconds; this.timeAlive += deltaTimeInSeconds;
this.timeSinceLastLeap += deltaTimeInSeconds;
const oldHead = new Circle(vec2.clone(this.head.center), this.head.radius); const oldHead = new Circle(vec2.clone(this.head.center), this.head.radius);
const oldLeftFoot = new Circle( const oldLeftFoot = new Circle(
vec2.clone(this.leftFoot.center), vec2.clone(this.leftFoot.center),
@ -377,6 +482,7 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
if ((this.timeSinceDying += deltaTimeInSeconds) > settings.spawnDespawnTime) { if ((this.timeSinceDying += deltaTimeInSeconds) > settings.spawnDespawnTime) {
this.destroy(); this.destroy();
} else { } else {
this.freeFallCorpse(deltaTimeInSeconds);
this.animateScaling(1 - this.timeSinceDying / settings.spawnDespawnTime); this.animateScaling(1 - this.timeSinceDying / settings.spawnDespawnTime);
} }
this.setPropertyUpdates(oldHead, oldLeftFoot, oldRightFoot, deltaTimeInSeconds); this.setPropertyUpdates(oldHead, oldLeftFoot, oldRightFoot, deltaTimeInSeconds);
@ -410,9 +516,28 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
this.regenerateHealth(deltaTimeInSeconds); this.regenerateHealth(deltaTimeInSeconds);
this.currentPlanet?.takeControl(this.team, deltaTimeInSeconds); // The planet tallies who is standing on it and resolves capture itself, so
// a contested rock can freeze instead of two squads silently cancelling.
this.currentPlanet?.registerPresence(this);
const intersectingWithForceField = this.container.findIntersecting( // The whole walking model — gravity gather, movement force, on/off-planet
// branch, posture springs, body-momentum, and stepping the three parts —
// is the shared simulation the client predicts with, so the two can never
// drift. Server-only concerns (scoring, health, shooting, spawn/death,
// ownership) stay here around it.
const direction = this.averageAndResetMovementActions();
stepCharacterMovement(
this.movementState,
this.movementWorld,
direction,
deltaTimeInSeconds,
);
this.setPropertyUpdates(oldHead, oldLeftFoot, oldRightFoot, deltaTimeInSeconds);
}
private freeFallCorpse(deltaTime: number) {
const intersecting = this.container.findIntersecting(
getBoundingBoxOfCircle( getBoundingBoxOfCircle(
new Circle( new Circle(
this.center, this.center,
@ -420,117 +545,20 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
), ),
), ),
); );
let grounded = false;
const direction = this.averageAndResetMovementActions(); for (const part of [this.leftFoot, this.rightFoot, this.head]) {
const movementForce = vec2.scale(direction, direction, settings.maxAcceleration); part.applyForce(forceAtPosition(part.center, intersecting), deltaTime);
this.leftFoot.applyForce(movementForce, deltaTimeInSeconds); vec2.add(part.velocity, part.velocity, this.bodyVelocity);
this.rightFoot.applyForce(movementForce, deltaTimeInSeconds); const { hitObject } = part.stepManually(deltaTime);
if (hitObject instanceof PlanetPhysical) {
if (!this.currentPlanet) { grounded = true;
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
this.keepPosture(); // sliding forever.
this.stepBodyPart(this.leftFoot, deltaTimeInSeconds); decayMomentum(this.bodyVelocity, grounded, deltaTime);
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) { private regenerateHealth(deltaTimeInSeconds: number) {
@ -547,14 +575,6 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
} }
} }
private stepBodyPart(part: CirclePhysical, deltaTime: number) {
const { hitObject } = part.stepManually(deltaTime);
if (hitObject instanceof PlanetPhysical) {
this.secondsSinceOnSurface = 0;
this.currentPlanet = hitObject;
}
}
public onDie() { public onDie() {
this.isDestroyed = true; this.isDestroyed = true;
this.remoteCall('onDie'); this.remoteCall('onDie');

View file

@ -4,13 +4,14 @@ import {
CommandExecutors, CommandExecutors,
CommandReceiver, CommandReceiver,
GameObject, GameObject,
resolveCircleMovement,
serializesTo, serializesTo,
} from 'shared'; } from 'shared';
import { BoundingBox } from '../physics/bounding-boxes/bounding-box'; import { BoundingBox } from '../physics/bounding-boxes/bounding-box';
import { BoundingBoxBase } from '../physics/bounding-boxes/bounding-box-base'; import { BoundingBoxBase } from '../physics/bounding-boxes/bounding-box-base';
import { moveCircle } from '../physics/functions/move-circle';
import { PhysicalContainer } from '../physics/containers/physical-container'; import { PhysicalContainer } from '../physics/containers/physical-container';
import { DynamicPhysical } from '../physics/physicals/dynamic-physical'; import { DynamicPhysical } from '../physics/physicals/dynamic-physical';
import { Physical } from '../physics/physicals/physical';
import { ReactToCollisionCommand } from '../commands/react-to-collision'; import { ReactToCollisionCommand } from '../commands/react-to-collision';
@serializesTo(Circle) @serializesTo(Circle)
@ -32,7 +33,9 @@ export class CirclePhysical extends CommandReceiver implements Circle, DynamicPh
private _radius: number, private _radius: number,
public owner: GameObject, public owner: GameObject,
private readonly container: PhysicalContainer, private readonly container: PhysicalContainer,
private restitution = 0, // Public + readonly so a CirclePhysical structurally satisfies the shared
// PhysicsBody interface the movement simulation operates on.
public readonly restitution = 0,
) { ) {
super(); super();
this._boundingBox = new BoundingBox(); this._boundingBox = new BoundingBox();
@ -88,43 +91,49 @@ export class CirclePhysical extends CommandReceiver implements Circle, DynamicPh
); );
} }
public stepManually(deltaTimeInSeconds: number): { // Position-resolution for one tick. Delegates to the shared
// resolveCircleMovement so the server integrates a body with the exact same
// geometry the client predictor runs (shared/physics) — no parallel copy to
// keep in sync. The onHit callback dispatches the collision reactions at the
// same points the old inline move-circle did (both the initial march and the
// post-bounce slide). `possibleIntersectors`, when supplied, lets a caller
// that already broadphased (e.g. a projectile's gravity query) avoid a second
// container query; otherwise it is self-gathered from the swept bounding box.
public stepManually(
deltaTimeInSeconds: number,
possibleIntersectors?: Array<Physical>,
): {
hitObject: GameObject | undefined; hitObject: GameObject | undefined;
velocity: vec2; velocity: vec2;
} { } {
let delta = vec2.scale(vec2.create(), this.velocity, deltaTimeInSeconds); const intersecting = (
possibleIntersectors ?? this.sweptBroadphase(deltaTimeInSeconds)
).filter((b) => b.gameObject !== this.gameObject && b.canCollide);
this.radius += vec2.length(delta); const { hitObject, velocity } = resolveCircleMovement(
const intersecting = this.container this,
.findIntersecting(this.boundingBox) deltaTimeInSeconds,
.filter((b) => b.gameObject !== this.gameObject && b.canCollide); intersecting,
this.radius -= vec2.length(delta); (intersected) => {
const physical = intersected as Physical;
const { normal, hitSurface, hitObject } = moveCircle(this, delta, intersecting); physical.handleCommand(new ReactToCollisionCommand(this.gameObject));
this.handleCommand(new ReactToCollisionCommand(physical.gameObject));
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) { return { hitObject: (hitObject as Physical | undefined)?.gameObject, velocity };
delta = vec2.scale(vec2.create(), this.velocity, deltaTimeInSeconds);
moveCircle(this, delta, intersecting);
}
} }
const lastVelocity = vec2.clone(this.velocity); // Query the container with the bounding box grown by this tick's travel, so a
vec2.zero(this.velocity); // fast-moving body still sees what it is about to sweep into.
private sweptBroadphase(deltaTimeInSeconds: number): Array<Physical> {
return { hitObject, velocity: lastVelocity }; const sweep = vec2.length(
vec2.scale(vec2.create(), this.velocity, deltaTimeInSeconds),
);
this.radius += sweep;
const intersecting = this.container.findIntersecting(this.boundingBox);
this.radius -= sweep;
return intersecting;
} }
public toArray(): Array<any> { public toArray(): Array<any> {

View file

@ -5,6 +5,7 @@ import {
clamp01, clamp01,
id, id,
mix, mix,
Random,
serializesTo, serializesTo,
settings, settings,
PlanetBase, PlanetBase,
@ -15,25 +16,46 @@ import {
CommandReceiver, CommandReceiver,
} from 'shared'; } from 'shared';
import { GeneratePointsCommand } from '../commands/generate-points'; import { GeneratePointsCommand } from '../commands/generate-points';
import { AnnounceCommand } from '../commands/announce';
import { StepCommand } from '../commands/step'; import { StepCommand } from '../commands/step';
import { ImmutableBoundingBox } from '../physics/bounding-boxes/immutable-bounding-box'; import { ImmutableBoundingBox } from '../physics/bounding-boxes/immutable-bounding-box';
import { StaticPhysical } from '../physics/physicals/static-physical'; import { StaticPhysical } from '../physics/physicals/static-physical';
import { LampPhysical } from './lamp-physical'; import { LampPhysical } from './lamp-physical';
import type { CharacterPhysical } from './character-physical';
@serializesTo(PlanetBase) @serializesTo(PlanetBase)
export class PlanetPhysical extends PlanetBase implements StaticPhysical { export class PlanetPhysical extends PlanetBase implements StaticPhysical {
public readonly canCollide = true; public readonly canCollide = true;
public readonly canMove = false; 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; 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 _boundingBox?: ImmutableBoundingBox;
private readonly lamps: Array<LampPhysical> = []; private readonly lamps: Array<LampPhysical> = [];
private lastTeam: CharacterTeam = CharacterTeam.neutral; private lastTeam: CharacterTeam = CharacterTeam.neutral;
// Characters standing on the planet this tick. Filled by registerPresence as
// each grounded character steps, drained when the planet resolves capture in
// its own step(). Drives the head-count tug-of-war.
private presentCharacters: Array<CharacterPhysical> = [];
private isContested = false;
protected commandExecutors: CommandExecutors = { protected commandExecutors: CommandExecutors = {
[StepCommand.type]: this.step.bind(this), [StepCommand.type]: this.step.bind(this),
}; };
@ -42,8 +64,8 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
this.lamps.push(lamp); this.lamps.push(lamp);
} }
constructor(vertices: Array<vec2>) { constructor(vertices: Array<vec2>, isKeystone = false) {
super(id(), vertices); super(id(), vertices, 0.5, isKeystone);
const sizeClass = clamp01( const sizeClass = clamp01(
(this.radius - settings.planetMinReferenceRadius) / (this.radius - settings.planetMinReferenceRadius) /
@ -51,19 +73,32 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
); );
this.sizePointMultiplier = mix(1, settings.planetSizePointMultiplierMax, sizeClass); 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 { 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]; const startEnd = this.vertices[0];
let vb = startEnd; let vb = startEnd;
let d = vec2.squaredDistance(target, vb); let d = vec2.dist(local, vb);
let sign = 1; let sign = 1;
for (let i = 1; i <= this.vertices.length; i++) { for (let i = 1; i <= this.vertices.length; i++) {
const va = vb; const va = vb;
vb = i === this.vertices.length ? startEnd : this.vertices[i]; vb = i === this.vertices.length ? startEnd : this.vertices[i];
const targetFromDelta = vec2.subtract(vec2.create(), target, va); const targetFromDelta = vec2.subtract(vec2.create(), local, va);
const toFromDelta = vec2.subtract(vec2.create(), vb, va); const toFromDelta = vec2.subtract(vec2.create(), vb, va);
const h = clamp01( const h = clamp01(
vec2.dot(targetFromDelta, toFromDelta) / vec2.squaredLength(toFromDelta), vec2.dot(targetFromDelta, toFromDelta) / vec2.squaredLength(toFromDelta),
@ -75,8 +110,8 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
); );
if ( if (
(target.y >= va.y && target.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) (local.y < va.y && local.y >= vb.y && ds.y <= 0)
) { ) {
sign *= -1; sign *= -1;
} }
@ -87,11 +122,35 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
return sign * d; 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 { public get team(): CharacterTeam {
return Math.abs(this.ownership - 0.5) < 0.1 return Math.abs(this.ownership - 0.5) < settings.planetControlThreshold
? CharacterTeam.neutral ? CharacterTeam.neutral
: this.ownership < 0.5 : this.ownership < 0.5
? CharacterTeam.decla ? CharacterTeam.blue
: CharacterTeam.red; : CharacterTeam.red;
} }
@ -105,7 +164,7 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
); );
game.handleCommand( game.handleCommand(
new GeneratePointsCommand( new GeneratePointsCommand(
this.team === CharacterTeam.decla ? value : 0, this.team === CharacterTeam.blue ? value : 0,
this.team === CharacterTeam.red ? value : 0, this.team === CharacterTeam.red ? value : 0,
), ),
); );
@ -113,12 +172,54 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
} }
private step({ deltaTimeInSeconds, game }: StepCommand) { private step({ deltaTimeInSeconds, game }: StepCommand) {
this.rotation += deltaTimeInSeconds * this.rotationSpeed;
this.timeSinceLastPointGeneration += deltaTimeInSeconds; this.timeSinceLastPointGeneration += deltaTimeInSeconds;
// In reverse order, so that teams can achieve a 100% control. // In reverse order, so that teams can achieve a 100% control.
this.getPoints(game); this.getPoints(game);
this.takeControl(CharacterTeam.neutral, deltaTimeInSeconds); this.resolveCapture(deltaTimeInSeconds);
this.detectFlip(game); 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) { private detectFlip(game: CommandReceiver) {
@ -135,10 +236,18 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
this.remoteCall('generatedPoints', reward); this.remoteCall('generatedPoints', reward);
game.handleCommand( game.handleCommand(
new GeneratePointsCommand( new GeneratePointsCommand(
currentTeam === CharacterTeam.decla ? reward : 0, currentTeam === CharacterTeam.blue ? reward : 0,
currentTeam === CharacterTeam.red ? reward : 0, currentTeam === CharacterTeam.red ? reward : 0,
), ),
); );
if (this.isKeystone) {
game.handleCommand(
new AnnounceCommand(
`Team <span class="${currentTeam}">${currentTeam}</span> captured the Heart`,
),
);
}
} }
const control = Math.abs(this.ownership - 0.5) / 0.5; const control = Math.abs(this.ownership - 0.5) / 0.5;
@ -153,11 +262,14 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
public getPropertyUpdates(): PropertyUpdatesForObject { public getPropertyUpdates(): PropertyUpdatesForObject {
return new PropertyUpdatesForObject(this.id, [ return new PropertyUpdatesForObject(this.id, [
new UpdatePropertyCommand('ownership', this.ownership, 0), 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) { public takeControl(team: CharacterTeam, deltaTime: number) {
if (team === CharacterTeam.decla) { if (team === CharacterTeam.blue) {
this.ownership -= (0.5 / settings.takeControlTimeInSeconds) * deltaTime; this.ownership -= (0.5 / settings.takeControlTimeInSeconds) * deltaTime;
} else if (team === CharacterTeam.red) { } else if (team === CharacterTeam.red) {
this.ownership += (0.5 / settings.takeControlTimeInSeconds) * deltaTime; this.ownership += (0.5 / settings.takeControlTimeInSeconds) * deltaTime;
@ -180,22 +292,20 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
public get boundingBox(): ImmutableBoundingBox { public get boundingBox(): ImmutableBoundingBox {
if (!this._boundingBox) { if (!this._boundingBox) {
const { xMin, xMax, yMin, yMax } = this.vertices.reduce( // The polygon spins about its centre (see distance), so this static box
(extremities, vertex) => ({ // has to cover every orientation, not just the spawn-time one: take the
xMin: Math.min(extremities.xMin, vertex.x), // circumscribed circle around the rotation centre.
xMax: Math.max(extremities.xMax, vertex.x), const maxVertexDistance = this.vertices.reduce(
yMin: Math.min(extremities.yMin, vertex.y), (max, vertex) => Math.max(max, vec2.distance(this.center, vertex)),
yMax: Math.max(extremities.yMax, vertex.y), 0,
}),
{
xMin: Infinity,
xMax: -Infinity,
yMin: Infinity,
yMax: -Infinity,
},
); );
this._boundingBox = new ImmutableBoundingBox(xMin, xMax, yMin, yMax); this._boundingBox = new ImmutableBoundingBox(
this.center.x - maxVertexDistance,
this.center.x + maxVertexDistance,
this.center.y - maxVertexDistance,
this.center.y + maxVertexDistance,
);
} }
return this._boundingBox; return this._boundingBox;
@ -213,6 +323,11 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
return vec2.scale(diff, diff, scale); 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 { public get gameObject(): this {
return this; return this;
} }

View file

@ -8,13 +8,16 @@ import {
PropertyUpdatesForObject, PropertyUpdatesForObject,
UpdatePropertyCommand, UpdatePropertyCommand,
CommandExecutors, CommandExecutors,
Circle,
marchCircle,
} from 'shared'; } from 'shared';
import { ImmutableBoundingBox } from '../physics/bounding-boxes/immutable-bounding-box'; import { ImmutableBoundingBox } from '../physics/bounding-boxes/immutable-bounding-box';
import { CirclePhysical } from './circle-physical'; import { CirclePhysical } from './circle-physical';
import { DynamicPhysical } from '../physics/physicals/dynamic-physical'; import { DynamicPhysical } from '../physics/physicals/dynamic-physical';
import { PhysicalContainer } from '../physics/containers/physical-container'; import { PhysicalContainer } from '../physics/containers/physical-container';
import { CharacterPhysical } from './character-physical'; import { CharacterPhysical } from './character-physical';
import { moveCircle } from '../physics/functions/move-circle'; import { forceAtPosition } from '../physics/functions/force-at-position';
import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle';
import { StepCommand } from '../commands/step'; import { StepCommand } from '../commands/step';
import { ReactToCollisionCommand } from '../commands/react-to-collision'; import { ReactToCollisionCommand } from '../commands/react-to-collision';
@ -42,6 +45,9 @@ export class ProjectilePhysical extends ProjectileBase implements DynamicPhysica
private velocity: vec2, private velocity: vec2,
public readonly originator: CharacterPhysical, public readonly originator: CharacterPhysical,
readonly container: PhysicalContainer, 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); super(id(), center, radius, team, strength);
this.object = new CirclePhysical(center, radius, this, container, 0.9); this.object = new CirclePhysical(center, radius, this, container, 0.9);
@ -71,7 +77,7 @@ export class ProjectilePhysical extends ProjectileBase implements DynamicPhysica
const intersecting = this.container const intersecting = this.container
.findIntersecting(this.boundingBox) .findIntersecting(this.boundingBox)
.filter((g) => g instanceof CharacterPhysical && g.team === this.team); .filter((g) => g instanceof CharacterPhysical && g.team === this.team);
const { hitSurface } = moveCircle(this.object, delta, intersecting, true); const { hitSurface } = marchCircle(this.object, delta, intersecting, true);
wasCollision = hitSurface; wasCollision = hitSurface;
} }
vec2.add(this.center, this.center, delta); vec2.add(this.center, this.center, delta);
@ -124,8 +130,31 @@ export class ProjectilePhysical extends ProjectileBase implements DynamicPhysica
return; 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); vec2.copy(this.object.velocity, this.velocity);
const { velocity } = this.object.stepManually(deltaTimeInSeconds); const { velocity } = this.object.stepManually(deltaTimeInSeconds, intersecting);
vec2.copy(this.velocity, velocity); vec2.copy(this.velocity, velocity);
} }
} }

View file

@ -1,5 +1,4 @@
import { Circle } from 'shared'; import { Circle, evaluateSdf } from 'shared';
import { evaluateSdf } from './evaluate-sdf';
import { PhysicalBase } from '../physicals/physical-base'; import { PhysicalBase } from '../physicals/physical-base';
export const isCircleIntersecting = ( export const isCircleIntersecting = (

View file

@ -1,89 +0,0 @@
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<Physical>,
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,
};
};

View file

@ -60,6 +60,10 @@ const npcTuning = {
spreadBase: 60, spreadBase: 60,
spreadPerDistance: 0.08, spreadPerDistance: 0.08,
spreadAggressionFalloff: 1.3, 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 { export class NPC extends PlayerBase {
@ -141,6 +145,16 @@ export class NPC extends PlayerBase {
const movement = this.decideMovement(this.nearObjects); const movement = this.decideMovement(this.nearObjects);
this.character.handleMovementAction(new MoveActionCommand(movement)); 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 ( if (
(this.timeSinceLastShoot += deltaTimeInSeconds) > npcTuning.shootIntervalSeconds (this.timeSinceLastShoot += deltaTimeInSeconds) > npcTuning.shootIntervalSeconds
) { ) {

View file

@ -79,11 +79,11 @@ export class PlayerContainer {
private getTeamOfNextPlayer(isNpc = false): CharacterTeam { private getTeamOfNextPlayer(isNpc = false): CharacterTeam {
const players = isNpc ? this.players : this._players; const players = isNpc ? this.players : this._players;
const declaCount = players.filter((p) => p.team === CharacterTeam.decla).length; const blueCount = players.filter((p) => p.team === CharacterTeam.blue).length;
const redCount = players.filter((p) => p.team === CharacterTeam.red).length; const redCount = players.filter((p) => p.team === CharacterTeam.red).length;
if ((declaCount === redCount && Random.getRandom() >= 0.5) || declaCount < redCount) { if ((blueCount === redCount && Random.getRandom() >= 0.5) || blueCount < redCount) {
return CharacterTeam.decla; return CharacterTeam.blue;
} else { } else {
return CharacterTeam.red; return CharacterTeam.red;
} }

View file

@ -1,3 +1,4 @@
import { performance } from 'perf_hooks';
import { vec2 } from 'gl-matrix'; import { vec2 } from 'gl-matrix';
import { import {
CommandExecutors, CommandExecutors,
@ -22,6 +23,8 @@ import {
PropertyUpdatesForObjects, PropertyUpdatesForObjects,
PropertyUpdatesForObject, PropertyUpdatesForObject,
PrimaryActionCommand, PrimaryActionCommand,
LeapActionCommand,
InputAcknowledgement,
} from 'shared'; } from 'shared';
import { Socket } from 'socket.io'; import { Socket } from 'socket.io';
import { BoundingBox } from '../physics/bounding-boxes/bounding-box'; import { BoundingBox } from '../physics/bounding-boxes/bounding-box';
@ -36,14 +39,27 @@ export class Player extends PlayerBase {
private timeUntilRespawn = 0; private timeUntilRespawn = 0;
private timeSinceLastMessage = 0; private timeSinceLastMessage = 0;
private objectsPreviouslyInViewArea: Array<GameObject> = []; private objectsPreviouslyInViewArea: Array<GameObject> = [];
private lastInputClientTimeMs = 0;
private lastLeapClientTimeMs = 0;
protected commandExecutors: CommandExecutors = { protected commandExecutors: CommandExecutors = {
[SetAspectRatioActionCommand.type]: (v: SetAspectRatioActionCommand) => [SetAspectRatioActionCommand.type]: (v: SetAspectRatioActionCommand) =>
(this.aspectRatio = v.aspectRatio), (this.aspectRatio = v.aspectRatio),
[MoveActionCommand.type]: (c: MoveActionCommand) => [MoveActionCommand.type]: (c: MoveActionCommand) => {
this.character?.handleMovementAction(c), // Remember how far into this client's input timeline we've consumed, to
// echo back for client-side prediction reconciliation.
this.lastInputClientTimeMs = c.clientTimeMs;
this.character?.handleMovementAction(c);
},
[PrimaryActionCommand.type]: (c: PrimaryActionCommand) => [PrimaryActionCommand.type]: (c: PrimaryActionCommand) =>
this.character?.shootTowards(c.position, c.charge), 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( constructor(
@ -99,6 +115,13 @@ export class Player extends PlayerBase {
new Set(this.objectContainer.findIntersecting(bb).map((o) => o.gameObject)), 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( const newlyIntersecting = objectsInViewArea.filter(
(o) => !this.objectsPreviouslyInViewArea.includes(o), (o) => !this.objectsPreviouslyInViewArea.includes(o),
); );
@ -126,8 +149,22 @@ export class Player extends PlayerBase {
this.objectsPreviouslyInViewArea this.objectsPreviouslyInViewArea
.map((o) => o.getPropertyUpdates()) .map((o) => o.getPropertyUpdates())
.filter((u) => u) as Array<PropertyUpdatesForObject>, .filter((u) => u) as Array<PropertyUpdatesForObject>,
performance.now() / 1000,
), ),
); );
// Tell the client how much of its own input is reflected in the snapshot it
// just received, so its predictor can replay the rest. Only while alive —
// a dead player isn't predicting.
if (this.character) {
this.queueCommandSend(
new InputAcknowledgement(
this.lastInputClientTimeMs,
this.character.launchMomentum,
this.lastLeapClientTimeMs,
),
);
}
} }
private getOtherPlayers(): Array<OtherPlayerDirection> { private getOtherPlayers(): Array<OtherPlayerDirection> {

View file

@ -1,5 +0,0 @@
{
"projects": {
"default": "decla-red
}
}

View file

@ -1,16 +0,0 @@
{
"hosting": {
"public": "dist",
"ignore": [
"firebase.json",
"**/.*",
"**/node_modules/**"
],
"rewrites": [
{
"source": "**",
"destination": "/index.html"
}
]
}
}

View file

@ -1,11 +1,11 @@
{ {
"name": "decla.red-frontend", "name": "doppler-frontend",
"version": "0.0.0", "version": "0.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "decla.red-frontend", "name": "doppler-frontend",
"version": "0.0.0", "version": "0.0.0",
"devDependencies": { "devDependencies": {
"@plausible-analytics/tracker": "^0.4.5", "@plausible-analytics/tracker": "^0.4.5",

View file

@ -1,5 +1,5 @@
{ {
"name": "decla.red-frontend", "name": "doppler-frontend",
"version": "0.0.0", "version": "0.0.0",
"description": "", "description": "",
"keywords": [], "keywords": [],

View file

@ -9,13 +9,13 @@
/> />
<meta name="theme-color" content="#b7455e" /> <meta name="theme-color" content="#b7455e" />
<meta property="og:url" content="https://decla.red" /> <meta property="og:url" content="https://doppler.schmelczer.dev" />
<meta property="og:image" content="https://decla.red/og-image.png" /> <meta property="og:image" content="https://doppler.schmelczer.dev/og-image.png" />
<meta property="og:image:width" content="2086" /> <meta property="og:image:width" content="2086" />
<meta property="og:image:height" content="940" /> <meta property="og:image:height" content="940" />
<meta <meta
property="og:image:alt" property="og:image:alt"
content="Dimly lit planets surrounding a text saying 'decla.red'" content="Dimly lit planets surrounding a text saying 'doppler'"
/> />
<meta name="theme-color" content="#b33951" /> <meta name="theme-color" content="#b33951" />
<meta <meta
@ -27,7 +27,7 @@
<link rel="icon" type="image/png" sizes="32x32" href="static/favicon-32x32.png" /> <link rel="icon" type="image/png" sizes="32x32" href="static/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="static/favicon-16x16.png" /> <link rel="icon" type="image/png" sizes="16x16" href="static/favicon-16x16.png" />
<title>decla.red</title> <title>doppler</title>
</head> </head>
<body> <body>
@ -37,7 +37,7 @@
<div id="overlay"></div> <div id="overlay"></div>
<section id="landing-ui"> <section id="landing-ui">
<h1>decla.<span class="red">red</span></h1> <h1>doppler</h1>
<form id="join-game-form"> <form id="join-game-form">
<fieldset class="content"> <fieldset class="content">
<legend>Join a game</legend> <legend>Join a game</legend>

View file

@ -41,6 +41,18 @@ h1 {
font-size: 6rem; font-size: 6rem;
text-align: center; text-align: center;
padding-bottom: $medium-padding; 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) { @media (max-width: $height-breakpoint) {
font-size: 4.5rem; font-size: 4.5rem;
@ -51,15 +63,6 @@ h1 {
} }
} }
.red {
color: $accent;
&::selection {
color: $accent;
background-color: white;
}
}
html, html,
body, body,
canvas { canvas {
@ -126,14 +129,14 @@ body {
border-radius: 1000px; border-radius: 1000px;
transition: transform 200ms; transition: transform 200ms;
&.decla { &.blue {
color: $bright-decla; color: $bright-blue;
.health { .health {
background-color: $bright-decla; background-color: $bright-blue;
&:before { &:before {
background-color: $bright-decla; background-color: $bright-blue;
opacity: 0.3; opacity: 0.3;
} }
} }
@ -204,6 +207,14 @@ body {
@include square(50px); @include square(50px);
border-radius: 1000px; border-radius: 1000px;
mask-image: url('../static/mask.svg'); mask-image: url('../static/mask.svg');
&.contested {
animation: contested-pulse 0.7s ease-in-out infinite;
}
&.keystone {
@include square(72px);
}
} }
.falling-point { .falling-point {
@ -211,8 +222,8 @@ body {
font-weight: 700; font-weight: 700;
animation: falling-point 2s ease-out forwards; animation: falling-point 2s ease-out forwards;
&.decla { &.blue {
color: $bright-decla; color: $bright-blue;
} }
&.red { &.red {
@ -232,8 +243,8 @@ body {
mask-image: url('../static/chevron.svg'); mask-image: url('../static/chevron.svg');
mask-size: contain; mask-size: contain;
&.decla { &.blue {
background-color: $bright-decla; background-color: $bright-blue;
} }
&.red { &.red {
@ -241,6 +252,34 @@ body {
} }
} }
// Off-screen pointer to the keystone "Heart", tinted by its current owner.
.keystone-arrow {
transition: transform 150ms;
opacity: 0.9;
@include square($large-icon);
@media (max-width: $breakpoint) {
@include square($small-icon);
}
mask-image: url('../static/chevron.svg');
mask-size: contain;
background-color: $bright-neutral;
&.blue {
background-color: $bright-blue;
}
&.red {
background-color: $bright-red;
}
&.neutral {
background-color: $bright-neutral;
}
}
.joystick { .joystick {
@include square($large-icon * 1.3); @include square($large-icon * 1.3);
background-color: white; background-color: white;
@ -293,6 +332,15 @@ body {
} }
} }
&.leap {
right: $medium-padding;
bottom: $medium-padding + $large-icon * 1.6 + $small-padding;
&::after {
content: '\25B2';
}
}
.strength-ring { .strength-ring {
position: absolute; position: absolute;
top: -3px; top: -3px;
@ -324,8 +372,8 @@ body {
display: none; display: none;
} }
.decla { .blue {
color: $bright-decla; color: $bright-blue;
} }
.red { .red {
@ -374,10 +422,10 @@ body {
transition: width $animation-time; transition: width $animation-time;
} }
.fill.decla { .fill.blue {
right: 50%; right: 50%;
background: $bright-decla; background: $bright-blue;
color: $bright-decla; color: $bright-blue;
border-radius: 1000px 0 0 1000px; border-radius: 1000px 0 0 1000px;
} }
@ -425,7 +473,7 @@ body {
font-size $animation-time; font-size $animation-time;
} }
.score.decla { .score.blue {
right: calc(50% + #{$small-padding}); right: calc(50% + #{$small-padding});
} }
@ -438,10 +486,10 @@ body {
font-size: 1.2rem; font-size: 1.2rem;
} }
.score.decla.leading { .score.blue.leading {
text-shadow: text-shadow:
0 0 3px rgba(0, 0, 0, 0.95), 0 0 3px rgba(0, 0, 0, 0.95),
0 0 8px $bright-decla; 0 0 8px $bright-blue;
} }
.score.red.leading { .score.red.leading {
@ -471,9 +519,9 @@ body {
border-bottom: 5px solid currentColor; border-bottom: 5px solid currentColor;
} }
&.decla { &.blue {
right: calc(50% + #{$small-padding}); right: calc(50% + #{$small-padding});
color: $bright-decla; color: $bright-blue;
} }
&.red { &.red {
@ -569,6 +617,16 @@ body {
} }
animation: hitmarker-pop 250ms ease-out forwards; animation: hitmarker-pop 250ms ease-out forwards;
&.charged {
&::before,
&::after {
width: 18px;
height: 3px;
background-color: $accent;
}
}
} }
.kill-popup { .kill-popup {
@ -581,6 +639,10 @@ body {
text-shadow: 0 0 6px rgba(0, 0, 0, 0.9); text-shadow: 0 0 6px rgba(0, 0, 0, 0.9);
animation: kill-popup-rise 1200ms ease-out forwards; animation: kill-popup-rise 1200ms ease-out forwards;
&.charged {
color: $accent;
}
.heal { .heal {
color: #6fcf6f; color: #6fcf6f;
} }
@ -623,6 +685,18 @@ body {
} }
} }
@keyframes contested-pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.35;
}
}
.charge-ring { .charge-ring {
position: fixed; position: fixed;
@include square(56px); @include square(56px);
@ -646,6 +720,7 @@ body {
} }
@keyframes match-point-pulse { @keyframes match-point-pulse {
0%, 0%,
100% { 100% {
box-shadow: 0 0 4px 0 currentColor; box-shadow: 0 0 4px 0 currentColor;

View file

@ -1,7 +1,7 @@
import { init as plausibleInit } from '@plausible-analytics/tracker'; import { init as plausibleInit } from '@plausible-analytics/tracker';
const ANALYTICS_AUTO_CAPTURE_PAGEVIEWS = true; const ANALYTICS_AUTO_CAPTURE_PAGEVIEWS = true;
const ANALYTICS_DOMAIN = 'decla.red'; const ANALYTICS_DOMAIN = 'doppler.schmelczer.dev';
const ANALYTICS_ENDPOINT = 'https://stats.schmelczer.dev/status'; const ANALYTICS_ENDPOINT = 'https://stats.schmelczer.dev/status';
const ANALYTICS_LOGGING = process.env.NODE_ENV !== 'production'; const ANALYTICS_LOGGING = process.env.NODE_ENV !== 'production';

View file

@ -1,5 +1,6 @@
import { vec2 } from 'gl-matrix'; import { vec2 } from 'gl-matrix';
import { CommandGenerator, MoveActionCommand } from 'shared'; import { CommandGenerator, LeapActionCommand, MoveActionCommand } from 'shared';
import { localCharacterPredictor } from '../helper/prediction/local-character-predictor';
export class KeyboardListener extends CommandGenerator { export class KeyboardListener extends CommandGenerator {
private keysDown: Set<string> = new Set(); private keysDown: Set<string> = new Set();
@ -13,7 +14,14 @@ export class KeyboardListener extends CommandGenerator {
} }
private keyDownListener = (event: KeyboardEvent) => { private keyDownListener = (event: KeyboardEvent) => {
this.keysDown.add(event.key.toLowerCase()); const key = event.key.toLowerCase();
// Space leaps (W / ArrowUp already cover walking up). Edge-triggered so a
// held key's auto-repeat doesn't spam leaps.
if ((key === ' ' || key === 'shift') && !this.keysDown.has(key)) {
const clientTimeMs = localCharacterPredictor.recordLeap();
this.sendCommandToSubscribers(new LeapActionCommand(clientTimeMs));
}
this.keysDown.add(key);
this.generateCommands(); this.generateCommands();
}; };
@ -28,11 +36,7 @@ export class KeyboardListener extends CommandGenerator {
}; };
private generateCommands() { private generateCommands() {
const up = ~~( const up = ~~(this.keysDown.has('w') || this.keysDown.has('arrowup'));
this.keysDown.has('w') ||
this.keysDown.has('arrowup') ||
this.keysDown.has(' ')
);
const down = ~~(this.keysDown.has('s') || this.keysDown.has('arrowdown')); const down = ~~(this.keysDown.has('s') || this.keysDown.has('arrowdown'));
const left = ~~(this.keysDown.has('a') || this.keysDown.has('arrowleft')); const left = ~~(this.keysDown.has('a') || this.keysDown.has('arrowleft'));
const right = ~~(this.keysDown.has('d') || this.keysDown.has('arrowright')); const right = ~~(this.keysDown.has('d') || this.keysDown.has('arrowright'));
@ -42,7 +46,8 @@ export class KeyboardListener extends CommandGenerator {
vec2.normalize(movement, movement); vec2.normalize(movement, movement);
} }
this.sendCommandToSubscribers(new MoveActionCommand(movement)); const clientTimeMs = localCharacterPredictor.recordInput(movement);
this.sendCommandToSubscribers(new MoveActionCommand(movement, clientTimeMs));
} }
public destroy() { public destroy() {

View file

@ -4,11 +4,13 @@ import {
MoveActionCommand, MoveActionCommand,
last, last,
PrimaryActionCommand, PrimaryActionCommand,
LeapActionCommand,
holdDurationToCharge, holdDurationToCharge,
settings, settings,
} from 'shared'; } from 'shared';
import { Game } from '../game'; import { Game } from '../game';
import { ChargeIndicator } from '../charge-indicator'; import { ChargeIndicator } from '../charge-indicator';
import { localCharacterPredictor } from '../helper/prediction/local-character-predictor';
export class TouchListener extends CommandGenerator { export class TouchListener extends CommandGenerator {
private static readonly deadZone = 8; private static readonly deadZone = 8;
@ -22,6 +24,7 @@ export class TouchListener extends CommandGenerator {
private fireButton: HTMLElement; private fireButton: HTMLElement;
private fireStrengthRing: HTMLElement; private fireStrengthRing: HTMLElement;
private leapButton: HTMLElement;
private fireDownAt: number | null = null; private fireDownAt: number | null = null;
@ -46,7 +49,12 @@ export class TouchListener extends CommandGenerator {
this.fireButton.addEventListener('touchstart', this.fireButtonDownListener); this.fireButton.addEventListener('touchstart', this.fireButtonDownListener);
this.fireButton.addEventListener('touchend', this.fireButtonUpListener); 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.fireButton);
this.overlay.appendChild(this.leapButton);
target.addEventListener('touchstart', this.touchStartListener); target.addEventListener('touchstart', this.touchStartListener);
target.addEventListener('touchmove', this.touchMoveListener); target.addEventListener('touchmove', this.touchMoveListener);
@ -101,12 +109,17 @@ export class TouchListener extends CommandGenerator {
vec2.set(delta, delta.x, -delta.y); vec2.set(delta, delta.x, -delta.y);
if (deltaLength > TouchListener.deadZone) { if (deltaLength > TouchListener.deadZone) {
const direction = vec2.normalize(delta, delta); const direction = vec2.normalize(delta, delta);
this.sendCommandToSubscribers(new MoveActionCommand(direction)); this.sendMove(direction);
} else { } else {
this.sendCommandToSubscribers(new MoveActionCommand(vec2.create())); this.sendMove(vec2.create());
} }
}; };
private sendMove(direction: vec2) {
const clientTimeMs = localCharacterPredictor.recordInput(direction);
this.sendCommandToSubscribers(new MoveActionCommand(direction, clientTimeMs));
}
private touchEndListener = (event: TouchEvent) => { private touchEndListener = (event: TouchEvent) => {
event.preventDefault(); event.preventDefault();
@ -127,7 +140,7 @@ export class TouchListener extends CommandGenerator {
} else if (event.touches.length === 0) { } else if (event.touches.length === 0) {
this.isJoystickActive = false; this.isJoystickActive = false;
this.joystick.parentElement?.removeChild(this.joystick); this.joystick.parentElement?.removeChild(this.joystick);
this.sendCommandToSubscribers(new MoveActionCommand(vec2.create())); this.sendMove(vec2.create());
} }
}; };
@ -136,6 +149,12 @@ export class TouchListener extends CommandGenerator {
event.stopPropagation(); event.stopPropagation();
}; };
private leapButtonListener = (event: TouchEvent) => {
this.swallowTouch(event);
const clientTimeMs = localCharacterPredictor.recordLeap();
this.sendCommandToSubscribers(new LeapActionCommand(clientTimeMs));
};
private fireButtonDownListener = (event: TouchEvent) => { private fireButtonDownListener = (event: TouchEvent) => {
this.swallowTouch(event); this.swallowTouch(event);
this.fireDownAt = performance.now(); this.fireDownAt = performance.now();
@ -170,6 +189,9 @@ export class TouchListener extends CommandGenerator {
if (!this.fireButton.parentElement) { if (!this.fireButton.parentElement) {
this.overlay.appendChild(this.fireButton); this.overlay.appendChild(this.fireButton);
} }
if (!this.leapButton.parentElement) {
this.overlay.appendChild(this.leapButton);
}
const character = this.game.gameObjects.player; const character = this.game.gameObjects.player;
if (character) { if (character) {
@ -186,7 +208,9 @@ export class TouchListener extends CommandGenerator {
this.fireButton.removeEventListener('touchstart', this.fireButtonDownListener); this.fireButton.removeEventListener('touchstart', this.fireButtonDownListener);
this.fireButton.removeEventListener('touchend', this.fireButtonUpListener); this.fireButton.removeEventListener('touchend', this.fireButtonUpListener);
this.leapButton.removeEventListener('touchstart', this.leapButtonListener);
this.fireButton.parentElement?.removeChild(this.fireButton); this.fireButton.parentElement?.removeChild(this.fireButton);
this.leapButton.parentElement?.removeChild(this.leapButton);
} }
} }

View file

@ -1,18 +1,20 @@
/** /**
* Hardcoded list of game servers the landing page offers to players. * Hardcoded list of game servers the landing page offers to players.
* *
* Each entry is the public origin of a dockerized `declared-server` instance. * Each entry is the public origin of a dockerized `doppler-server` instance.
* The join screen polls `<origin>/state` (see `serverInformationEndpoint`) and * The join screen polls `<origin>/state` (see `serverInformationEndpoint`) and
* only shows a server once it responds, so listing an offline origin here is * 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. * 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<string> = ['https://declared.schmelczer.dev']; const productionServers: Array<string> = ['https://declared.schmelczer.dev'];
/** /**
* When the page is served from localhost (i.e. the webpack-dev-server), also * 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 * 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). * development. The backend's default port is 3000 (see backend/src/options.ts).
* In production the hostname is `declared.schmelczer.dev`, so this never leaks. * In production the page is served from its own hostname, so this never leaks.
*/ */
const isDevelopment = const isDevelopment =
typeof location !== 'undefined' && typeof location !== 'undefined' &&

View file

@ -33,17 +33,19 @@ export abstract class FeedbackHud {
setTimeout(() => element.parentElement?.removeChild(element), lifetimeMs); setTimeout(() => element.parentElement?.removeChild(element), lifetimeMs);
} }
public static hitMarker() { public static hitMarker(charge = 0) {
const { x, y } = this.focusPoint(); const { x, y } = this.focusPoint();
const marker = document.createElement('div'); const marker = document.createElement('div');
marker.className = 'hitmarker'; marker.className =
'hitmarker' + (charge >= settings.chargedHitThreshold ? ' charged' : '');
marker.style.left = `${x}px`; marker.style.left = `${x}px`;
marker.style.top = `${y}px`; marker.style.top = `${y}px`;
this.addTransient(marker, 250); this.addTransient(marker, 250);
} }
public static killConfirmed(victimName?: string, streak = 1) { public static killConfirmed(victimName?: string, streak = 1, charge = 0) {
const { killfeed } = this.ensureRoot(); const { killfeed } = this.ensureRoot();
const charged = charge >= settings.chargedHitThreshold;
const entry = document.createElement('div'); const entry = document.createElement('div');
entry.className = 'kill-entry'; entry.className = 'kill-entry';
@ -53,13 +55,13 @@ export abstract class FeedbackHud {
const { x, y } = this.focusPoint(); const { x, y } = this.focusPoint();
const popup = document.createElement('div'); const popup = document.createElement('div');
popup.className = 'kill-popup'; popup.className = 'kill-popup' + (charged ? ' charged' : '');
popup.innerHTML = `+${settings.playerKillPoint} <span class="heal">+${settings.playerKillHealthReward}❤</span>`; popup.innerHTML = `+${settings.playerKillPoint} <span class="heal">+${settings.playerKillHealthReward}❤</span>`;
popup.style.left = `${x}px`; popup.style.left = `${x}px`;
popup.style.top = `${y}px`; popup.style.top = `${y}px`;
this.addTransient(popup, 1200); this.addTransient(popup, 1200);
const callout = this.streakName(streak); const callout = this.streakName(streak) ?? (charged ? 'Charged Kill!' : undefined);
if (callout) { if (callout) {
const el = document.createElement('div'); const el = document.createElement('div');
el.className = 'streak-callout'; el.className = 'streak-callout';

View file

@ -21,6 +21,7 @@ import {
CommandExecutors, CommandExecutors,
Command, Command,
settings, settings,
InputAcknowledgement,
} from 'shared'; } from 'shared';
import { io, Socket } from 'socket.io-client'; import { io, Socket } from 'socket.io-client';
import { KeyboardListener } from './commands/keyboard-listener'; import { KeyboardListener } from './commands/keyboard-listener';
@ -34,6 +35,8 @@ import { CharacterShape } from './shapes/character-shape';
import { PlanetShape } from './shapes/planet-shape'; import { PlanetShape } from './shapes/planet-shape';
import { RenderCommand } from './commands/types/render'; import { RenderCommand } from './commands/types/render';
import { StepCommand } from './commands/types/step'; 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 { Tutorial } from './tutorial';
import { Scoreboard } from './scoreboard'; import { Scoreboard } from './scoreboard';
@ -53,6 +56,7 @@ export class Game extends CommandReceiver {
private scoreboard = new Scoreboard(); private scoreboard = new Scoreboard();
private announcementText = document.createElement('h2'); private announcementText = document.createElement('h2');
private arrows: { [id: number]: HTMLElement } = {}; private arrows: { [id: number]: HTMLElement } = {};
private keystoneArrow?: HTMLElement;
private socketReceiver!: CommandSocket; private socketReceiver!: CommandSocket;
private tutorial!: Tutorial; private tutorial!: Tutorial;
@ -74,9 +78,12 @@ export class Game extends CommandReceiver {
this.isBetweenGames = true; this.isBetweenGames = true;
this.socket?.close(); this.socket?.close();
serverTimeline.reset();
localCharacterPredictor.reset();
this.gameObjects = new GameObjectContainer(this); this.gameObjects = new GameObjectContainer(this);
this.overlay.innerHTML = ''; this.overlay.innerHTML = '';
this.arrows = {}; this.arrows = {};
this.keystoneArrow = undefined;
this.isEnding = false; this.isEnding = false;
this.lastAnnouncementText = ''; this.lastAnnouncementText = '';
this.overlay.appendChild(this.scoreboard.element); this.overlay.appendChild(this.scoreboard.element);
@ -146,6 +153,12 @@ export class Game extends CommandReceiver {
this.timeSinceLastAnnouncement = 0; this.timeSinceLastAnnouncement = 0;
}, },
[UpdateGameState.type]: (c: UpdateGameState) => (this.lastGameState = c), [UpdateGameState.type]: (c: UpdateGameState) => (this.lastGameState = c),
[InputAcknowledgement.type]: (c: InputAcknowledgement) =>
localCharacterPredictor.acknowledge(
c.clientTimeMs,
c.bodyVelocity,
c.lastLeapClientTimeMs,
),
[GameEndCommand.type]: () => (this.isEnding = true), [GameEndCommand.type]: () => (this.isEnding = true),
[UpdateOtherPlayerDirections.type]: (c: UpdateOtherPlayerDirections) => [UpdateOtherPlayerDirections.type]: (c: UpdateOtherPlayerDirections) =>
(this.lastOtherPlayerDirections = c), (this.lastOtherPlayerDirections = c),
@ -274,6 +287,11 @@ export class Game extends CommandReceiver {
this.resolveStarted(); this.resolveStarted();
deltaTime /= 1000; 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; let shouldChangeLayout = false;
if (++this.framesSinceLastLayoutUpdate > 1) { if (++this.framesSinceLastLayoutUpdate > 1) {
shouldChangeLayout = true; shouldChangeLayout = true;
@ -318,6 +336,71 @@ export class Game extends CommandReceiver {
this.handleOtherPlayerDirections(this.lastOtherPlayerDirections); this.handleOtherPlayerDirections(this.lastOtherPlayerDirections);
} }
this.handleKeystoneArrow();
this.announcementText.innerHTML = this.lastAnnouncementText; 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)`;
}
} }

View file

@ -1,36 +0,0 @@
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;
}
}

View file

@ -1,14 +1,14 @@
import { Circle } from 'shared'; import { Circle } from 'shared';
import { LinearExtrapolator } from './linear-extrapolator'; import { LinearInterpolator } from './linear-interpolator';
import { Vec2Extrapolator } from './vec2-extrapolator'; import { Vec2Interpolator } from './vec2-interpolator';
export class CircleExtrapolator { export class CircleInterpolator {
private center: Vec2Extrapolator; private center: Vec2Interpolator;
private radius: LinearExtrapolator; private radius: LinearInterpolator;
constructor(currentValue: Circle) { constructor(currentValue: Circle) {
this.center = new Vec2Extrapolator(currentValue.center); this.center = new Vec2Interpolator(currentValue.center);
this.radius = new LinearExtrapolator(currentValue.radius); this.radius = new LinearInterpolator(currentValue.radius);
} }
public addFrame(value: Circle, rateOfChange: Circle) { public addFrame(value: Circle, rateOfChange: Circle) {

View file

@ -0,0 +1,99 @@
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<Frame> = [];
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)
);
}
}

View file

@ -1,13 +1,13 @@
import { vec2 } from 'gl-matrix'; import { vec2 } from 'gl-matrix';
import { LinearExtrapolator } from './linear-extrapolator'; import { LinearInterpolator } from './linear-interpolator';
export class Vec2Extrapolator { export class Vec2Interpolator {
private x: LinearExtrapolator; private x: LinearInterpolator;
private y: LinearExtrapolator; private y: LinearInterpolator;
constructor(currentValue: vec2) { constructor(currentValue: vec2) {
this.x = new LinearExtrapolator(currentValue.x); this.x = new LinearInterpolator(currentValue.x);
this.y = new LinearExtrapolator(currentValue.y); this.y = new LinearInterpolator(currentValue.y);
} }
public addFrame(value: vec2, rateOfChange: vec2) { public addFrame(value: vec2, rateOfChange: vec2) {

View file

@ -0,0 +1,138 @@
import { vec2 } from 'gl-matrix';
import {
CharacterWorld,
GroundSurface,
Id,
PhysicsBody,
planetDistance,
planetGravity,
resolveCircleMovement,
} from 'shared';
// What the predictor needs to know about a planet to collide and be pulled by
// it. The client reads this off its PlanetViews.
export interface PredictablePlanet {
id: Id;
vertices: Array<vec2>;
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<vec2>;
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<Id, PlanetSurface>();
private ordered: Array<PlanetSurface> = [];
// 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<PredictablePlanet>) {
const seen = new Set<Id>();
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<GroundSurface> {
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;
}
}

View file

@ -0,0 +1,47 @@
import { vec2 } from 'gl-matrix';
interface InputSample {
timeMs: number;
direction: vec2;
}
// Keep a little over a second of input — far more than any sane reconciliation
// window — so a brief stall (or a backgrounded tab catching up) can still be
// replayed, while old samples are pruned to bound memory.
const retainMs = 1500;
// A timeline of the local player's movement directions in client-clock time.
// Each generated MoveActionCommand is stamped with the time this hands out, and
// the same sample is recorded here so the predictor replays exactly the input
// the server will eventually receive. Direction is piecewise-constant: the
// active direction at any instant is the most recent sample at or before it.
export class InputHistory {
private samples: Array<InputSample> = [];
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 = [];
}
}

View file

@ -0,0 +1,328 @@
import { vec2 } from 'gl-matrix';
import {
Circle,
CharacterMovementState,
Id,
PhysicsBody,
settings,
stepCharacterMovement,
applyLeapImpulse,
tickPlanetDetachment,
feetRadius,
headRadius,
} from 'shared';
import { ClientCharacterWorld, PredictablePlanet } from './client-character-world';
import { InputHistory } from './input-history';
const stepMs = 1000 / 200; // match the server's 200 Hz fixed tick
const stepSeconds = 1 / 200;
// Don't replay more than this far back: if the last acknowledged input is older
// (a stall, or a backgrounded tab catching up) snap to the authoritative pose
// instead of grinding through hundreds of steps.
const maxReplayMs = 300;
// Render-side easing of the correction the reconciliation produces each frame,
// so a snapshot that disagrees with the prediction is smoothed out instead of
// popping. Short, so the local player still feels immediate.
const smoothSeconds = 0.06;
// A correction bigger than this isn't prediction error — it's a respawn,
// teleport, or a server-side impulse (leap / slingshot / recoil / death throw)
// the predictor doesn't model. Snap to it rather than gliding across the gap.
const snapDistance = 250;
const makeBody = (center: vec2, radius: number): PhysicsBody => ({
center: vec2.clone(center),
radius,
velocity: vec2.create(),
lastNormal: vec2.fromValues(0, 1),
restitution: 0,
});
// Predicts the local player's character so it responds to input immediately,
// instead of lagging ~100 ms + RTT behind like the interpolated remote objects.
// Each frame it resets to the latest authoritative snapshot and replays the
// player's own un-acknowledged input through the SAME movement simulation the
// server runs (shared/stepCharacterMovement), then eases the rendered pose
// toward the result. Discrete server-side impulses it can't model show up as a
// large correction and snap rather than rubber-band.
export class LocalCharacterPredictor {
private readonly inputHistory = new InputHistory();
private readonly world = new ClientCharacterWorld();
private authoritative?: { head: Circle; leftFoot: Circle; rightFoot: Circle };
private lastAckClientTimeMs?: number;
// Wall-clock (client) time the latest authoritative snapshot was received. The
// replay predicts forward from HERE by the snapshot's age, so the local pose
// advances smoothly with real time between the 25 Hz snapshots. Anchoring to
// the last *acked input* time instead breaks when input is sent only on change
// (a held key sends nothing): that time freezes, the window pins to the
// maxReplayMs clamp, the replay displacement goes constant, and the pose
// stair-steps at the snapshot rate.
private authReceiptMs = 0;
// Authoritative launch momentum at the last snapshot — seeds each replay so a
// leap/slingshot/recoil flight is reproduced and continuously corrected.
private authoritativeBodyVelocity = vec2.create();
// Wall-clock times the player issued a leap, replayed (with the impulse
// applied locally) so the launch is felt immediately, not after a round trip.
private leapHistory: Array<number> = [];
// 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<PredictablePlanet>, 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();

View file

@ -0,0 +1,84 @@
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();

View file

@ -1,4 +1,5 @@
import { import {
Circle,
Command, Command,
CommandExecutors, CommandExecutors,
CommandReceiver, CommandReceiver,
@ -9,10 +10,15 @@ import {
Id, Id,
PropertyUpdatesForObjects, PropertyUpdatesForObjects,
RemoteCallsForObjects, RemoteCallsForObjects,
settings,
UpdatePropertyCommand,
} from 'shared'; } from 'shared';
import { BeforeDestroyCommand } from '../commands/types/before-destroy'; import { BeforeDestroyCommand } from '../commands/types/before-destroy';
import { StepCommand } from '../commands/types/step'; import { StepCommand } from '../commands/types/step';
import { Game } from '../game'; 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 { Camera } from './types/camera';
import { CharacterView } from './types/character-view'; import { CharacterView } from './types/character-view';
import { PlanetView } from './types/planet-view'; import { PlanetView } from './types/planet-view';
@ -27,6 +33,9 @@ export class GameObjectContainer extends CommandReceiver {
this.player = c.character as CharacterView; this.player = c.character as CharacterView;
this.player.isMainCharacter = true; this.player.isMainCharacter = true;
this.addObject(this.player); 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) => [CreateObjectsCommand.type]: (c: CreateObjectsCommand) =>
@ -36,7 +45,19 @@ export class GameObjectContainer extends CommandReceiver {
this.defaultCommandExecutor(c); this.defaultCommandExecutor(c);
if (this.player) { if (this.player) {
this.camera.center = this.player.position; // 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);
} }
}, },
@ -45,10 +66,15 @@ export class GameObjectContainer extends CommandReceiver {
this.objects.get(c.id)?.processRemoteCalls(c.calls), this.objects.get(c.id)?.processRemoteCalls(c.calls),
), ),
[PropertyUpdatesForObjects.type]: (c: PropertyUpdatesForObjects) => [PropertyUpdatesForObjects.type]: (c: PropertyUpdatesForObjects) => {
c.updates.forEach((u) => serverTimeline.onSnapshot(c.timestamp);
u.updates.forEach((au) => this.objects.get(u.id)?.handleCommand(au)), c.updates.forEach((u) => {
), u.updates.forEach((au) => this.objects.get(u.id)?.handleCommand(au));
if (this.player && u.id === this.player.id) {
this.feedPredictor(u.updates);
}
});
},
[DeleteObjectsCommand.type]: (c: DeleteObjectsCommand) => [DeleteObjectsCommand.type]: (c: DeleteObjectsCommand) =>
c.ids.forEach((id: Id) => this.deleteObject(id)), c.ids.forEach((id: Id) => this.deleteObject(id)),
@ -73,6 +99,34 @@ export class GameObjectContainer extends CommandReceiver {
this.camera.handleCommand(c); 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<UpdatePropertyCommand>) {
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<PredictablePlanet> {
return this.planets.map((p) => ({
id: p.id,
vertices: p.vertices,
center: p.center,
radius: p.radius,
rotation: p.predictionRotation,
rotationSpeed: p.predictionRotationSpeed,
}));
}
private addObject(object: GameObject) { private addObject(object: GameObject) {
this.objects.set(object.id, object); this.objects.set(object.id, object);
} }

View file

@ -13,10 +13,28 @@ export class Camera extends CommandReceiver {
public center: vec2 = vec2.create(); public center: vec2 = vec2.create();
private aspectRatio?: number; 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) { constructor(private game: Game) {
super(); 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 = { protected commandExecutors: CommandExecutors = {
[RenderCommand.type]: this.draw.bind(this), [RenderCommand.type]: this.draw.bind(this),
}; };

View file

@ -16,8 +16,8 @@ import {
import { BeforeDestroyCommand } from '../../commands/types/before-destroy'; import { BeforeDestroyCommand } from '../../commands/types/before-destroy';
import { RenderCommand } from '../../commands/types/render'; import { RenderCommand } from '../../commands/types/render';
import { StepCommand } from '../../commands/types/step'; import { StepCommand } from '../../commands/types/step';
import { CircleExtrapolator } from '../../helper/extrapolators/circle-extrapolator'; import { CircleInterpolator } from '../../helper/interpolators/circle-interpolator';
import { LinearExtrapolator } from '../../helper/extrapolators/linear-extrapolator'; import { LinearInterpolator } from '../../helper/interpolators/linear-interpolator';
import { Pointer } from '../../helper/pointer'; import { Pointer } from '../../helper/pointer';
import { CharacterShape } from '../../shapes/character-shape'; import { CharacterShape } from '../../shapes/character-shape';
import { SoundHandler, Sounds } from '../../sound-handler'; import { SoundHandler, Sounds } from '../../sound-handler';
@ -40,7 +40,7 @@ export class CharacterView extends CharacterBase {
private muzzleFlashIntensity = 0; private muzzleFlashIntensity = 0;
private hitFlashIntensity = 0; private hitFlashIntensity = 0;
private strength = settings.playerMaxStrength; private strength = settings.playerMaxStrength;
private strengthExtrapolator = new LinearExtrapolator(settings.playerMaxStrength); private strengthInterpolator = new LinearInterpolator(settings.playerMaxStrength);
private nameElement: HTMLElement = document.createElement('div'); private nameElement: HTMLElement = document.createElement('div');
private statsElement: HTMLElement = document.createElement('div'); private statsElement: HTMLElement = document.createElement('div');
private killCountElement: HTMLElement = document.createElement('span'); private killCountElement: HTMLElement = document.createElement('span');
@ -50,9 +50,9 @@ export class CharacterView extends CharacterBase {
public isMainCharacter = false; public isMainCharacter = false;
private leftFootExtrapolator: CircleExtrapolator; private leftFootInterpolator: CircleInterpolator;
private rightFootExtrapolator: CircleExtrapolator; private rightFootInterpolator: CircleInterpolator;
private headExtrapolator: CircleExtrapolator; private headInterpolator: CircleInterpolator;
protected commandExecutors: CommandExecutors = { protected commandExecutors: CommandExecutors = {
[RenderCommand.type]: this.draw.bind(this), [RenderCommand.type]: this.draw.bind(this),
@ -80,9 +80,9 @@ export class CharacterView extends CharacterBase {
0, 0,
); );
this.leftFootExtrapolator = new CircleExtrapolator(this.leftFoot!); this.leftFootInterpolator = new CircleInterpolator(this.leftFoot!);
this.rightFootExtrapolator = new CircleExtrapolator(this.rightFoot!); this.rightFootInterpolator = new CircleInterpolator(this.rightFoot!);
this.headExtrapolator = new CircleExtrapolator(this.head!); this.headInterpolator = new CircleInterpolator(this.head!);
this.nameElement.className = 'player-tag ' + this.team; this.nameElement.className = 'player-tag ' + this.team;
this.nameElement.innerText = this.name; this.nameElement.innerText = this.name;
@ -140,16 +140,16 @@ export class CharacterView extends CharacterBase {
rateOfChange, rateOfChange,
}: UpdatePropertyCommand) { }: UpdatePropertyCommand) {
if (propertyKey === 'head') { if (propertyKey === 'head') {
this.headExtrapolator.addFrame(propertyValue, rateOfChange); this.headInterpolator.addFrame(propertyValue, rateOfChange);
} }
if (propertyKey === 'leftFoot') { if (propertyKey === 'leftFoot') {
this.leftFootExtrapolator.addFrame(propertyValue, rateOfChange); this.leftFootInterpolator.addFrame(propertyValue, rateOfChange);
} }
if (propertyKey === 'rightFoot') { if (propertyKey === 'rightFoot') {
this.rightFootExtrapolator.addFrame(propertyValue, rateOfChange); this.rightFootInterpolator.addFrame(propertyValue, rateOfChange);
} }
if (propertyKey === 'strength') { if (propertyKey === 'strength') {
this.strengthExtrapolator.addFrame(propertyValue, rateOfChange); this.strengthInterpolator.addFrame(propertyValue, rateOfChange);
} }
} }
@ -176,30 +176,41 @@ export class CharacterView extends CharacterBase {
} }
} }
public onHitConfirmed() { public onHitConfirmed(charge = 0) {
if (!this.isMainCharacter) { if (!this.isMainCharacter) {
return; return;
} }
SoundHandler.play(Sounds.click, 0.4, 1.7); // A charged hit lands lower and harder than a panic tap.
FeedbackHud.hitMarker(); SoundHandler.play(Sounds.click, mix(0.4, 0.75, charge), mix(1.7, 1.05, charge));
if (charge >= settings.chargedHitThreshold) {
VibrationHandler.vibrate(25);
}
FeedbackHud.hitMarker(charge);
} }
public onKillConfirmed(victimName?: string, streak = 1) { public onKillConfirmed(victimName?: string, streak = 1, charge = 0) {
if (!this.isMainCharacter) { if (!this.isMainCharacter) {
return; return;
} }
SoundHandler.play(Sounds.click, 1, 0.7); SoundHandler.play(Sounds.click, 1, mix(0.7, 0.5, charge));
VibrationHandler.vibrate(60); VibrationHandler.vibrate(mix(60, 110, charge));
FeedbackHud.killConfirmed(victimName, streak); FeedbackHud.killConfirmed(victimName, streak, charge);
}
public onLeap() {
if (!this.isMainCharacter) {
return;
}
SoundHandler.play(Sounds.shoot, 0.3, 1.5);
} }
private step({ deltaTimeInSeconds }: StepCommand): void { private step({ deltaTimeInSeconds }: StepCommand): void {
this.head! = this.headExtrapolator.getValue(deltaTimeInSeconds); this.head! = this.headInterpolator.getValue(deltaTimeInSeconds);
this.leftFoot! = this.leftFootExtrapolator.getValue(deltaTimeInSeconds); this.leftFoot! = this.leftFootInterpolator.getValue(deltaTimeInSeconds);
this.rightFoot! = this.rightFootExtrapolator.getValue(deltaTimeInSeconds); this.rightFoot! = this.rightFootInterpolator.getValue(deltaTimeInSeconds);
this.strength = clamp( this.strength = clamp(
this.strengthExtrapolator.getValue(deltaTimeInSeconds), this.strengthInterpolator.getValue(deltaTimeInSeconds),
0, 0,
settings.playerMaxStrength, settings.playerMaxStrength,
); );

View file

@ -12,6 +12,7 @@ import {
import { BeforeDestroyCommand } from '../../commands/types/before-destroy'; import { BeforeDestroyCommand } from '../../commands/types/before-destroy';
import { RenderCommand } from '../../commands/types/render'; import { RenderCommand } from '../../commands/types/render';
import { StepCommand } from '../../commands/types/step'; import { StepCommand } from '../../commands/types/step';
import { LinearInterpolator } from '../../helper/interpolators/linear-interpolator';
import { PlanetShape } from '../../shapes/planet-shape'; import { PlanetShape } from '../../shapes/planet-shape';
const fallingPointLifetimeMs = 2000; const fallingPointLifetimeMs = 2000;
@ -39,7 +40,10 @@ abstract class FlareBudget {
export class PlanetView extends PlanetBase { export class PlanetView extends PlanetBase {
private shape: PlanetShape; private shape: PlanetShape;
private ownershipProgress: HTMLElement; private ownershipProgress: HTMLElement;
private readonly rotationSpeed: number; // 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 flareLight?: CircleLight; private flareLight?: CircleLight;
private flareIntensity = 0; private flareIntensity = 0;
@ -52,19 +56,39 @@ export class PlanetView extends PlanetBase {
[UpdatePropertyCommand.type]: this.updateProperty.bind(this), [UpdatePropertyCommand.type]: this.updateProperty.bind(this),
}; };
constructor(id: Id, vertices: Array<vec2>, ownership: number) { constructor(id: Id, vertices: Array<vec2>, ownership = 0.5, isKeystone = false) {
super(id, vertices); super(id, vertices, ownership, isKeystone);
this.shape = new PlanetShape(vertices, ownership); this.shape = new PlanetShape(vertices, ownership);
this.shape.randomOffset = Random.getRandom(); 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 = document.createElement('div');
this.ownershipProgress.className = 'ownership'; this.ownershipProgress.className = 'ownership' + (isKeystone ? ' keystone' : '');
}
public setContested(contested: boolean) {
this.ownershipProgress.classList.toggle('contested', contested);
}
private renderedRotation = 0;
private rotationSpeed = 0;
// Newest streamed rotation VALUE — the server-current angle at the latest
// snapshot — as opposed to renderedRotation, which the interpolator holds
// ~interpolationDelaySeconds in the PAST for drawing. The predictor seeds the
// local body from the same snapshot's pose, so it must collide against the
// planet at THIS (newest) phase and advance forward from it; using the drawn
// lagged angle biases the body off the surface by omega*delay*radius and
// wobbles it whenever the spin or the interpolator's rate cursor varies.
private latestRotation = 0;
public get predictionRotation(): number {
return this.latestRotation;
}
public get predictionRotationSpeed(): number {
return this.rotationSpeed;
} }
private step({ deltaTimeInSeconds }: StepCommand): void { private step({ deltaTimeInSeconds }: StepCommand): void {
this.shape.rotation += deltaTimeInSeconds * this.rotationSpeed; this.renderedRotation = this.rotationInterpolator.getValue(deltaTimeInSeconds);
this.shape.rotation = this.renderedRotation;
this.shape.colorMixQ = this.ownership; this.shape.colorMixQ = this.ownership;
if (this.flareIntensity > 0) { if (this.flareIntensity > 0) {
@ -120,9 +144,19 @@ export class PlanetView extends PlanetBase {
this.releaseFlareSlot(); this.releaseFlareSlot();
} }
private updateProperty({ propertyValue }: UpdatePropertyCommand): void { 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; this.ownership = propertyValue;
} }
}
private draw({ renderer, overlay, shouldChangeLayout }: RenderCommand): void { private draw({ renderer, overlay, shouldChangeLayout }: RenderCommand): void {
if (shouldChangeLayout) { if (shouldChangeLayout) {
@ -137,7 +171,7 @@ export class PlanetView extends PlanetBase {
if (this.lastGeneratedPoint !== undefined) { if (this.lastGeneratedPoint !== undefined) {
const element = document.createElement('div'); const element = document.createElement('div');
element.className = 'falling-point ' + (this.ownership < 0.5 ? 'decla' : 'red'); element.className = 'falling-point ' + (this.ownership < 0.5 ? 'blue' : 'red');
element.innerText = '+' + this.lastGeneratedPoint; element.innerText = '+' + this.lastGeneratedPoint;
element.style.left = `${screenPosition.x}px`; element.style.left = `${screenPosition.x}px`;
element.style.top = `${screenPosition.y}px`; element.style.top = `${screenPosition.y}px`;
@ -159,12 +193,17 @@ export class PlanetView extends PlanetBase {
} }
private getGradient(): string { private getGradient(): string {
const sideDecla = this.ownership < 0.5; const sideBlue = this.ownership < 0.5;
const sidePercent = (Math.abs(this.ownership - 0.5) / 0.5) * 100; // Keep the ring neutral through the same dead-band that gates scoring
return sideDecla // (settings.planetControlThreshold), so "the ring fills" and "this planet
// pays my team" happen together rather than disagreeing.
const control = Math.abs(this.ownership - 0.5);
const t = settings.planetControlThreshold;
const sidePercent = control <= t ? 0 : ((control - t) / (0.5 - t)) * 100;
return sideBlue
? `conic-gradient( ? `conic-gradient(
var(--bright-decla) ${sidePercent}%, var(--bright-blue) ${sidePercent}%,
var(--bright-decla) ${sidePercent}%, var(--bright-blue) ${sidePercent}%,
rgba(0, 0, 0, 0) ${sidePercent}%, rgba(0, 0, 0, 0) ${sidePercent}%,
rgba(0, 0, 0, 0) 100% rgba(0, 0, 0, 0) 100%
)` )`

View file

@ -10,12 +10,12 @@ import {
} from 'shared'; } from 'shared';
import { RenderCommand } from '../../commands/types/render'; import { RenderCommand } from '../../commands/types/render';
import { StepCommand } from '../../commands/types/step'; import { StepCommand } from '../../commands/types/step';
import { Vec2Extrapolator } from '../../helper/extrapolators/vec2-extrapolator'; import { Vec2Interpolator } from '../../helper/interpolators/vec2-interpolator';
export class ProjectileView extends ProjectileBase { export class ProjectileView extends ProjectileBase {
private light: CircleLight; private light: CircleLight;
private centerExtrapolator: Vec2Extrapolator; private centerInterpolator: Vec2Interpolator;
protected commandExecutors: CommandExecutors = { protected commandExecutors: CommandExecutors = {
[RenderCommand.type]: this.draw.bind(this), [RenderCommand.type]: this.draw.bind(this),
@ -36,17 +36,17 @@ export class ProjectileView extends ProjectileBase {
settings.paletteDim[settings.colorIndices[team]], settings.paletteDim[settings.colorIndices[team]],
0, 0,
); );
this.centerExtrapolator = new Vec2Extrapolator(center); this.centerInterpolator = new Vec2Interpolator(center);
} }
private updateProperty({ propertyValue, rateOfChange }: UpdatePropertyCommand): void { private updateProperty({ propertyValue, rateOfChange }: UpdatePropertyCommand): void {
this.centerExtrapolator.addFrame(propertyValue, rateOfChange); this.centerInterpolator.addFrame(propertyValue, rateOfChange);
} }
private handleStep({ deltaTimeInSeconds }: StepCommand): void { private handleStep({ deltaTimeInSeconds }: StepCommand): void {
this.step(deltaTimeInSeconds); this.step(deltaTimeInSeconds);
this.center = this.centerExtrapolator.getValue(deltaTimeInSeconds); this.center = this.centerInterpolator.getValue(deltaTimeInSeconds);
this.light.center = this.center; this.light.center = this.center;
this.light.intensity = Math.min( this.light.intensity = Math.min(
0.1, 0.1,

View file

@ -8,35 +8,35 @@ import { CharacterTeam, UpdateGameState, clamp, settings } from 'shared';
export class Scoreboard { export class Scoreboard {
public readonly element = document.createElement('div'); public readonly element = document.createElement('div');
private readonly declaFill = document.createElement('div'); private readonly blueFill = document.createElement('div');
private readonly redFill = document.createElement('div'); private readonly redFill = document.createElement('div');
private readonly declaScore = document.createElement('span'); private readonly blueScore = document.createElement('span');
private readonly redScore = document.createElement('span'); private readonly redScore = document.createElement('span');
private readonly youMarker = document.createElement('div'); private readonly youMarker = document.createElement('div');
constructor() { constructor() {
this.element.className = 'planet-progress'; this.element.className = 'planet-progress';
this.declaFill.className = 'fill decla'; this.blueFill.className = 'fill blue';
this.redFill.className = 'fill red'; this.redFill.className = 'fill red';
this.declaScore.className = 'score decla'; this.blueScore.className = 'score blue';
this.redScore.className = 'score red'; this.redScore.className = 'score red';
this.youMarker.className = 'you-marker'; this.youMarker.className = 'you-marker';
this.youMarker.innerText = 'YOU'; this.youMarker.innerText = 'YOU';
this.element.append( this.element.append(
this.declaFill, this.blueFill,
this.redFill, this.redFill,
this.declaScore, this.blueScore,
this.redScore, this.redScore,
this.youMarker, this.youMarker,
); );
} }
public update( public update(
{ declaCount, redCount, limit }: UpdateGameState, { blueCount, redCount, limit }: UpdateGameState,
localTeam: CharacterTeam | undefined, localTeam: CharacterTeam | undefined,
) { ) {
const { scoreboardHalfWidthPercent, scoreboardMinFillPercent } = settings; const { scoreboardHalfWidthPercent, scoreboardMinFillPercent } = settings;
@ -46,23 +46,23 @@ export class Scoreboard {
clamp(count / limit, 0, 1) * scoreboardHalfWidthPercent, clamp(count / limit, 0, 1) * scoreboardHalfWidthPercent,
); );
this.declaFill.style.width = fraction(declaCount) + '%'; this.blueFill.style.width = fraction(blueCount) + '%';
this.redFill.style.width = fraction(redCount) + '%'; this.redFill.style.width = fraction(redCount) + '%';
const isMatchPoint = (count: number) => const isMatchPoint = (count: number) =>
count / limit >= settings.matchPointScoreRatio; count / limit >= settings.matchPointScoreRatio;
this.declaFill.classList.toggle('match-point', isMatchPoint(declaCount)); this.blueFill.classList.toggle('match-point', isMatchPoint(blueCount));
this.redFill.classList.toggle('match-point', isMatchPoint(redCount)); this.redFill.classList.toggle('match-point', isMatchPoint(redCount));
this.declaScore.innerText = String(Math.round(declaCount)); this.blueScore.innerText = String(Math.round(blueCount));
this.redScore.innerText = String(Math.round(redCount)); this.redScore.innerText = String(Math.round(redCount));
this.declaScore.classList.toggle('leading', declaCount > redCount); this.blueScore.classList.toggle('leading', blueCount > redCount);
this.redScore.classList.toggle('leading', redCount > declaCount); this.redScore.classList.toggle('leading', redCount > blueCount);
if (localTeam === CharacterTeam.decla || localTeam === CharacterTeam.red) { if (localTeam === CharacterTeam.blue || localTeam === CharacterTeam.red) {
this.youMarker.style.display = ''; this.youMarker.style.display = '';
this.youMarker.classList.toggle('decla', localTeam === CharacterTeam.decla); this.youMarker.classList.toggle('blue', localTeam === CharacterTeam.blue);
this.youMarker.classList.toggle('red', localTeam === CharacterTeam.red); this.youMarker.classList.toggle('red', localTeam === CharacterTeam.red);
} else { } else {
this.youMarker.style.display = 'none'; this.youMarker.style.display = 'none';

View file

@ -54,19 +54,24 @@ export class PlanetShape extends PolygonFactory(settings.planetEdgeCount, 0) {
float l = planetLengths[j]; float l = planetLengths[j];
float randomOffset = planetRandoms[j]; float randomOffset = planetRandoms[j];
float rotation = planetRotations[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 cr = cos(rotation);
float sr = sin(rotation); float sr = sin(rotation);
vec2 rotatedTangent = vec2(
cr * targetTangent.x - sr * targetTangent.y, // Spin the whole planet: evaluate the SDF in the planet's own
sr * targetTangent.x + cr * targetTangent.y // 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 noisyTarget = target - ( vec2 targetTangent = (localTarget - center) / clamp(targetDistance, 0.01, 1000.0);
vec2 noisyTarget = localTarget - (
targetTangent * planetTerrain(vec2( targetTangent * planetTerrain(vec2(
l * abs(atan(rotatedTangent.y, rotatedTangent.x)), l * abs(atan(targetTangent.y, targetTangent.x)),
randomOffset randomOffset
)) / 12.0 )) / 12.0
); );
@ -97,7 +102,7 @@ export class PlanetShape extends PolygonFactory(settings.planetEdgeCount, 0) {
if (dist < minDistance) { if (dist < minDistance) {
minDistance = dist; minDistance = dist;
color = mix(${colorToString(settings.declaPlanetColor)}, ${colorToString( color = mix(${colorToString(settings.bluePlanetColor)}, ${colorToString(
settings.redPlanetColor, settings.redPlanetColor,
)}, planetColorMixQ[j]); )}, planetColorMixQ[j]);
} }
@ -124,11 +129,32 @@ export class PlanetShape extends PolygonFactory(settings.planetEdgeCount, 0) {
public randomOffset = 0; public randomOffset = 0;
public rotation = 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( constructor(
public vertices: Array<vec2>, public vertices: Array<vec2>,
public colorMixQ: number, public colorMixQ: number,
) { ) {
super(vertices); 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 { protected getObjectToSerialize(transform2d: mat2d, _: number): any {

View file

@ -4,17 +4,19 @@ import {
CommandExecutors, CommandExecutors,
CommandReceiver, CommandReceiver,
Id, Id,
LeapActionCommand,
MoveActionCommand, MoveActionCommand,
PrimaryActionCommand, PrimaryActionCommand,
} from 'shared'; } from 'shared';
import { GameObjectContainer } from './objects/game-object-container'; import { GameObjectContainer } from './objects/game-object-container';
import { PlanetView } from './objects/types/planet-view'; import { PlanetView } from './objects/types/planet-view';
type StageTrigger = 'move' | 'shoot' | 'capture'; type StageTrigger = 'move' | 'shoot' | 'leap' | 'capture';
const stages: ReadonlyArray<{ hint: string; clearsOn: StageTrigger }> = [ const stages: ReadonlyArray<{ hint: string; clearsOn: StageTrigger }> = [
{ hint: 'WASD / drag to walk', clearsOn: 'move' }, { hint: 'WASD / drag to walk', clearsOn: 'move' },
{ hint: 'Click / tap to shoot', clearsOn: 'shoot' }, { 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' }, { hint: 'Stand on a planet to capture it', clearsOn: 'capture' },
]; ];
@ -42,6 +44,11 @@ export class Tutorial extends CommandReceiver {
this.advance(); this.advance();
} }
}, },
[LeapActionCommand.type]: () => {
if (this.clearsOn() === 'leap') {
this.advance();
}
},
}; };
constructor(overlay: HTMLElement) { constructor(overlay: HTMLElement) {
@ -86,7 +93,7 @@ export class Tutorial extends CommandReceiver {
} }
if (standing) { if (standing) {
const drift = standing.ownership - this.standingBaseline; const drift = standing.ownership - this.standingBaseline;
const progress = player.team === CharacterTeam.decla ? -drift : drift; const progress = player.team === CharacterTeam.blue ? -drift : drift;
if (progress > captureProgressThreshold) { if (progress > captureProgressThreshold) {
this.finish(); this.finish();
} }

View file

@ -11,12 +11,12 @@ $breakpoint: 700px;
$height-breakpoint: 500px; $height-breakpoint: 500px;
$large-icon: 48px; $large-icon: 48px;
$small-icon: 32px; $small-icon: 32px;
$bright-decla: #4069a5; $bright-blue: #4069a5;
$bright-red: #d15652; $bright-red: #d15652;
$bright-neutral: #ccc; $bright-neutral: #ccc;
:root { :root {
--bright-decla: #{$bright-decla}; --bright-blue: #{$bright-blue};
--bright-red: #{$bright-red}; --bright-red: #{$bright-red};
--bright-neutral: #{$bright-neutral}; --bright-neutral: #{$bright-neutral};
} }

View file

@ -0,0 +1,19 @@
import { serializable } from '../../../serialization/serializable';
import { Command } from '../../command';
// Sent client -> server when the player triggers a leap (Space / leap button).
// The launch direction is derived server-side from the character's surface
// normal and current movement input. clientTimeMs is the client's wall-clock at
// the press, echoed back via InputAcknowledgement so the predictor knows which
// leaps the server has already folded into the streamed launch momentum and
// must not replay again.
@serializable
export class LeapActionCommand extends Command {
public constructor(public readonly clientTimeMs: number = 0) {
super();
}
public toArray(): Array<any> {
return [this.clientTimeMs];
}
}

View file

@ -4,11 +4,19 @@ import { Command } from '../../command';
@serializable @serializable
export class MoveActionCommand extends Command { export class MoveActionCommand extends Command {
public constructor(public readonly direction: vec2) { // clientTimeMs is the client's wall-clock (integer ms, survives the
// serializer's toFixed(3)) when the input was generated. The server echoes
// the latest one back via InputAcknowledgement so the client knows how much
// of its input timeline is already baked into a snapshot and can replay the
// rest for prediction. Defaults to 0 for inputs the server itself synthesises.
public constructor(
public readonly direction: vec2,
public readonly clientTimeMs: number = 0,
) {
super(); super();
} }
public toArray(): Array<any> { public toArray(): Array<any> {
return [this.direction]; return [this.direction, this.clientTimeMs];
} }
} }

View file

@ -0,0 +1,28 @@
import { vec2 } from 'gl-matrix';
import { serializable } from '../../serialization/serializable';
import { Command } from '../command';
// Sent server -> owning client only, alongside that client's own character
// snapshot. Carries the clientTimeMs of the most recent movement input the
// server had received when the snapshot was taken (so the client's predictor
// can reset to the snapshot and replay just the inputs the server hasn't seen
// yet) and the authoritative launch momentum (so the predictor reproduces a
// leap/slingshot/recoil flight rather than only snapping to it). See
// LocalCharacterPredictor.
@serializable
export class InputAcknowledgement extends Command {
public constructor(
public readonly clientTimeMs: number,
public readonly bodyVelocity: vec2,
// clientTimeMs of the most recent leap the server has received: any leap at
// or before it is already reflected in bodyVelocity, so the predictor must
// not replay it.
public readonly lastLeapClientTimeMs: number,
) {
super();
}
public toArray(): Array<any> {
return [this.clientTimeMs, this.bodyVelocity, this.lastLeapClientTimeMs];
}
}

View file

@ -4,11 +4,16 @@ import { Command } from '../command';
@serializable @serializable
export class PropertyUpdatesForObjects extends Command { export class PropertyUpdatesForObjects extends Command {
constructor(public readonly updates: Array<PropertyUpdatesForObject>) { constructor(
public readonly updates: Array<PropertyUpdatesForObject>,
// Seconds on the server's monotonic clock at the moment this state was
// captured. Only differences between timestamps are meaningful.
public readonly timestamp: number,
) {
super(); super();
} }
public toArray(): Array<any> { public toArray(): Array<any> {
return [this.updates]; return [this.updates, this.timestamp];
} }
} }

View file

@ -4,7 +4,7 @@ import { Command } from '../command';
@serializable @serializable
export class UpdateGameState extends Command { export class UpdateGameState extends Command {
public constructor( public constructor(
public readonly declaCount: number, public readonly blueCount: number,
public readonly redCount: number, public readonly redCount: number,
public readonly limit: number, public readonly limit: number,
) { ) {
@ -12,6 +12,6 @@ export class UpdateGameState extends Command {
} }
public toArray(): Array<any> { public toArray(): Array<any> {
return [this.declaCount, this.redCount, this.limit]; return [this.blueCount, this.redCount, this.limit];
} }
} }

View file

@ -15,6 +15,7 @@ export * from './commands/command-executors';
export * from './commands/command-generator'; export * from './commands/command-generator';
export * from './commands/types/actions/move-action'; export * from './commands/types/actions/move-action';
export * from './commands/types/actions/primary-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 './commands/types/actions/set-aspect-ratio-action';
export * from './helper/array'; export * from './helper/array';
export * from './helper/last'; export * from './helper/last';
@ -46,3 +47,13 @@ export * from './objects/types/planet-base';
export * from './objects/types/projectile-base'; export * from './objects/types/projectile-base';
export * from './settings'; export * from './settings';
export * from './communication/transport-events'; 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';

View file

@ -4,7 +4,7 @@ import { serializable } from '../../serialization/serializable';
import { GameObject } from '../game-object'; import { GameObject } from '../game-object';
export enum CharacterTeam { export enum CharacterTeam {
decla = 'decla', blue = 'blue',
neutral = 'neutral', neutral = 'neutral',
red = 'red', red = 'red',
} }
@ -28,9 +28,13 @@ export class CharacterBase extends GameObject {
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
public onShoot(strength: number) { } public onShoot(strength: number) { }
public onHitConfirmed() { } public onLeap() { }
public onKillConfirmed(victimName?: string, streak?: number) { } // eslint-disable-next-line @typescript-eslint/no-unused-vars
public onHitConfirmed(charge?: number) { }
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public onKillConfirmed(victimName?: string, streak?: number, charge?: number) { }
public setHealth(health: number) { public setHealth(health: number) {
this.health = health; this.health = health;

View file

@ -15,6 +15,7 @@ export class PlanetBase extends GameObject {
id: Id, id: Id,
public readonly vertices: Array<vec2>, public readonly vertices: Array<vec2>,
public ownership: number = 0.5, public ownership: number = 0.5,
public readonly isKeystone: boolean = false,
) { ) {
super(id); super(id);
this.center = vertices.reduce((sum, v) => vec2.add(sum, sum, v), vec2.create()); this.center = vertices.reduce((sum, v) => vec2.add(sum, sum, v), vec2.create());
@ -28,6 +29,8 @@ export class PlanetBase extends GameObject {
public generatedPoints(value: number) {} public generatedPoints(value: number) {}
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
public onFlipped(team: CharacterTeam) {} public onFlipped(team: CharacterTeam) {}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public setContested(contested: boolean) {}
public static createPlanetVertices( public static createPlanetVertices(
center: vec2, center: vec2,
@ -54,6 +57,6 @@ export class PlanetBase extends GameObject {
} }
public toArray(): Array<any> { public toArray(): Array<any> {
return [this.id, this.vertices]; return [this.id, this.vertices, this.ownership, this.isKeystone];
} }
} }

View file

@ -0,0 +1,354 @@
import { vec2 } from 'gl-matrix';
import { settings } from '../settings';
import { GroundSurface, PhysicsBody } from './sdf';
import { interpolateAngles } from './interpolate-angles';
// Body layout, copied verbatim from CharacterPhysical so the predicted body
// matches the authoritative one to the bit. The head sits this far above the
// feet; offsets are measured from the centre of mass.
export const headRadius = 50;
export const feetRadius = 20;
const desiredHeadOffset = vec2.fromValues(0, 55);
const desiredLeftFootOffset = vec2.fromValues(-20, 0);
const desiredRightFootOffset = vec2.fromValues(20, 0);
const centerOfMass = vec2.scale(
vec2.create(),
vec2.add(
vec2.create(),
vec2.add(vec2.create(), desiredHeadOffset, desiredLeftFootOffset),
desiredRightFootOffset,
),
1 / 3,
);
export const headOffset = vec2.subtract(vec2.create(), desiredHeadOffset, centerOfMass);
export const leftFootOffset = vec2.subtract(
vec2.create(),
desiredLeftFootOffset,
centerOfMass,
);
export const rightFootOffset = vec2.subtract(
vec2.create(),
desiredRightFootOffset,
centerOfMass,
);
export const boundRadius = (headRadius + feetRadius * 2) * 2;
// The character's movement state: the three body parts plus the small amount of
// carried state the step reads and writes. Backend CharacterPhysical and the
// client predictor both expose this shape.
export interface CharacterMovementState {
readonly head: PhysicsBody;
readonly leftFoot: PhysicsBody;
readonly rightFoot: PhysicsBody;
direction: number;
currentPlanet: GroundSurface | undefined;
secondsSinceOnSurface: number;
// Persistent launch momentum (leap / slingshot / recoil / death throw).
// Walking rebuilds and zeroes each body part's velocity every tick,
// so anything that should carry accumulates here, is injected into the parts
// before they step, and decays by friction. Stays zero for ordinary walking.
// The client predictor leaves this zero and relies on the reconciliation snap
// to follow server-side impulses, but the field is here so the server can
// delegate its full movement to stepCharacterMovement unchanged.
bodyVelocity: vec2;
}
// The collision/gravity world the movement queries. The server backs this with
// its spatial container (planets + dynamics, dispatching collision reactions);
// the client backs it with the planets it knows about, dispatching nothing.
export interface CharacterWorld {
// Planets within `radius` of `center` that exert gravity / can be stood on.
groundsNear(center: vec2, radius: number): Array<GroundSurface>;
// 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<GroundSurface>, 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);
};

View file

@ -0,0 +1,36 @@
import { vec2 } from 'gl-matrix';
import { PhysicsBody, Sdf } from './sdf';
import { evaluateSdf } from './evaluate-sdf';
import { sdfNormal } from './sdf-normal';
// Planet collision outlines rotate (see PlanetPhysical.distance), so a surface
// can sweep into a circle that hasn't itself moved. marchCircle assumes an
// overlap-free start — beginning inside, it registers a zero-distance hit and
// never moves again — so any overlap must be resolved here, before marching.
// Iterating handles concave spots, where leaving one face pushes into another;
// if no overlap-free position exists nearby (a crevice narrower than the
// circle), it gives up and leaves the rest to a later tick.
export const depenetrateCircle = (
body: PhysicsBody,
possibleIntersectors: Array<Sdf>,
) => {
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,
);
}
};

View file

@ -1,7 +1,7 @@
import { vec2 } from 'gl-matrix'; import { vec2 } from 'gl-matrix';
import { PhysicalBase } from '../physicals/physical-base'; import { Sdf } from './sdf';
export const evaluateSdf = (target: vec2, objects: Array<PhysicalBase>) => export const evaluateSdf = (target: vec2, objects: Array<Sdf>) =>
objects objects
.filter((i) => i.canCollide) .filter((i) => i.canCollide)
.reduce((min, i) => (min = Math.min(min, i.distance(target))), 1000000); .reduce((min, i) => (min = Math.min(min, i.distance(target))), 1000000);

View file

@ -0,0 +1,80 @@
import { vec2 } from 'gl-matrix';
import { PhysicsBody, Sdf } from './sdf';
import { evaluateSdf } from './evaluate-sdf';
import { sdfNormal } from './sdf-normal';
export interface MarchResult {
hitSurface: boolean;
normal?: vec2;
hitObject?: Sdf;
}
// Raymarch a circle by `delta`, stopping at the first surface it would overlap.
// Extracted verbatim from the backend's move-circle so server and client
// resolve motion identically. The collision *reaction* is no longer dispatched
// here: on a real (non-ignored) hit, `onHit(intersecting)` is invoked at the
// exact point the backend used to dispatch its ReactToCollisionCommands, and
// the backend wrapper supplies that callback. The client passes none.
export const marchCircle = (
body: PhysicsBody,
delta: vec2,
possibleIntersectors: Array<Sdf>,
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,
};
};

View file

@ -0,0 +1,80 @@
import { vec2 } from 'gl-matrix';
import { clamp, clamp01 } from '../helper/clamp';
import { settings } from '../settings';
// Rotate a world point into the planet's own frame: R(-rotation) about the
// centre, matching the shader's localTarget = center + R(rotation)*(target-center).
// cos/sin are passed in so the server can keep memoising them per angle.
export const toPlanetLocalFrame = (
target: vec2,
center: vec2,
cos: number,
sin: number,
): vec2 => {
const dx = target[0] - center[0];
const dy = target[1] - center[1];
return vec2.fromValues(
center[0] + cos * dx - sin * dy,
center[1] + sin * dx + cos * dy,
);
};
// Signed distance to the (smooth, noise-free) planet polygon, evaluated in the
// planet's rotating frame. Extracted verbatim from PlanetPhysical.distance so
// the client collides against the exact same outline the server does. Indexed
// vector access keeps it independent of the .x/.y prototype plugin.
export const planetDistance = (
target: vec2,
vertices: Array<vec2>,
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);
};

View file

@ -0,0 +1,58 @@
import { vec2 } from 'gl-matrix';
import { PhysicsBody, Sdf } from './sdf';
import { depenetrateCircle } from './depenetrate-circle';
import { marchCircle } from './march-circle';
// The position-resolution half of the backend's CirclePhysical.stepManually,
// extracted so server and client integrate a body identically. The caller is
// responsible for the broadphase (gathering `possibleIntersectors`, including
// the swept-radius bump) because that depends on each side's spatial structure;
// everything from depenetration onward lives here.
//
// `onHit` is threaded through to marchCircle so the backend can dispatch its
// collision reactions at the same points as before (including the second,
// post-bounce slide march); the client passes none. Velocity is reset to zero
// at the end — the character re-applies all forces from zero every tick, so a
// body that retained velocity would double-integrate and diverge.
export const resolveCircleMovement = (
body: PhysicsBody,
deltaTimeInSeconds: number,
possibleIntersectors: Array<Sdf>,
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 };
};

View file

@ -0,0 +1,19 @@
import { vec2 } from 'gl-matrix';
import { Sdf } from './sdf';
import { evaluateSdf } from './evaluate-sdf';
// Central-difference gradient of the combined SDF. Can be zero where the
// samples cancel out (e.g. on the medial axis of a shape) — callers must
// handle that case. Uses indexed access so it never depends on the vec2 .x/.y
// prototype plugin (identical numerically: .x === [0]).
export const sdfNormal = (target: vec2, objects: Array<Sdf>): 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;
};

31
shared/src/physics/sdf.ts Normal file
View file

@ -0,0 +1,31 @@
import { vec2 } from 'gl-matrix';
// Minimal signed-distance collider the geometry functions need. Backend
// Physicals and the client's planet surfaces both satisfy this structurally.
export interface Sdf {
readonly canCollide: boolean;
// Planets set this true so a body landing on one can latch it as ground;
// everything else leaves it falsy. Replaces the server's
// `instanceof PlanetPhysical` check with a structural flag both sides share.
readonly isGround?: boolean;
distance(target: vec2): number;
}
// A movable circle the geometry resolves in place. Backend CirclePhysical and
// the client predictor's plain bodies both satisfy this.
export interface PhysicsBody {
center: vec2;
radius: number;
velocity: vec2;
lastNormal: vec2;
readonly restitution: number;
}
// A planet-like surface: collidable, exerts gravity, and spins. Drives the
// character's on-surface movement branch.
export interface GroundSurface extends Sdf {
readonly isGround: true;
readonly center: vec2;
readonly angularVelocity: number;
gravityAt(target: vec2): vec2;
}

View file

@ -1,13 +1,13 @@
import { rgb255 } from './helper/rgb255'; import { rgb255 } from './helper/rgb255';
import { CharacterTeam } from './objects/types/character-base'; import { CharacterTeam } from './objects/types/character-base';
const declaColor = rgb255(64, 105, 165); const blueColor = rgb255(64, 105, 165);
const neutralColor = rgb255(82, 165, 64); const neutralColor = rgb255(82, 165, 64);
const redColor = rgb255(209, 86, 82); const redColor = rgb255(209, 86, 82);
const q = 2.5; const q = 2.5;
const declaColorDim = rgb255(64 * q, 105 * q, 165 * q); const blueColorDim = rgb255(64 * q, 105 * q, 165 * q);
const redColorDim = rgb255(209 * q, 86 * q, 82 * q); const redColorDim = rgb255(209 * q, 86 * q, 82 * q);
const declaPlanetColor = declaColorDim; const bluePlanetColor = blueColorDim;
const redPlanetColor = redColorDim; const redPlanetColor = redColorDim;
export const settings = { export const settings = {
@ -18,6 +18,10 @@ export const settings = {
worldRadius: 4000, worldRadius: 4000,
objectsOnCircleLength: 0.002, objectsOnCircleLength: 0.002,
updateMessageInterval: 1 / 25, 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, planetEdgeCount: 7,
playerKillPoint: 25, playerKillPoint: 25,
takeControlTimeInSeconds: 2.5, takeControlTimeInSeconds: 2.5,
@ -29,7 +33,11 @@ export const settings = {
maxGravityDistance: 800, maxGravityDistance: 800,
minGravityDistance: 1, minGravityDistance: 1,
maxGravityQ: 5000, maxGravityQ: 5000,
planetControlThreshold: 0.2, // Half-width of the neutral dead-band around 50% ownership. A planet only
// counts as captured (team(), point generation, flip) once |ownership-0.5|
// exceeds this, and the rendered ownership ring stays neutral until the same
// point — so what you see matches what scores.
planetControlThreshold: 0.12,
playerMaxHealth: 100, playerMaxHealth: 100,
maxGravityStrength: 50000, maxGravityStrength: 50000,
planetMinReferenceRadius: 150, planetMinReferenceRadius: 150,
@ -64,8 +72,8 @@ export const settings = {
chargeShotSpeedMax: 3400, chargeShotSpeedMax: 3400,
playerColorIndexOffset: 3, playerColorIndexOffset: 3,
backgroundGradient: [rgb255(90, 38, 43), rgb255(43, 39, 73)], backgroundGradient: [rgb255(90, 38, 43), rgb255(43, 39, 73)],
declaColor, blueColor,
declaPlanetColor, bluePlanetColor,
npcNames: [ npcNames: [
'Adam', 'Adam',
'Andrew', 'Andrew',
@ -119,12 +127,12 @@ export const settings = {
redColor, redColor,
redPlanetColor, redPlanetColor,
colorIndices: { colorIndices: {
[CharacterTeam.decla]: 0, [CharacterTeam.blue]: 0,
[CharacterTeam.neutral]: 1, [CharacterTeam.neutral]: 1,
[CharacterTeam.red]: 2, [CharacterTeam.red]: 2,
}, },
palette: [declaColor, neutralColor, redColor], palette: [blueColor, neutralColor, redColor],
paletteDim: [declaColorDim, neutralColor, redColorDim], paletteDim: [blueColorDim, neutralColor, redColorDim],
targetPhysicsDeltaTimeInSeconds: 1 / 200, targetPhysicsDeltaTimeInSeconds: 1 / 200,
inViewAreaSize: 1920 * 1080 * 4, inViewAreaSize: 1920 * 1080 * 4,
scoreboardHalfWidthPercent: 50, scoreboardHalfWidthPercent: 50,
@ -137,4 +145,65 @@ export const settings = {
lampFlareDecaySeconds: 0.6, lampFlareDecaySeconds: 0.6,
maxConcurrentFlipFlares: 3, maxConcurrentFlipFlares: 3,
announcementVisibleSeconds: 2, 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,
}; };