Merge pull request 'Modernise & make fun' (#3) from asch/modernise into main
All checks were successful
Build & deploy / Build & publish server image (push) Successful in 1m26s
Build & deploy / Build & deploy website (push) Successful in 1m49s

Reviewed-on: https://home.schmelczer.dev/git/git/andras/decla-red/pulls/3
This commit is contained in:
Andras Schmelczer 2026-06-21 10:43:50 +01:00
commit 5d854440ec
132 changed files with 11944 additions and 21194 deletions

View file

@ -1,10 +0,0 @@
# Path to your oh-my-zsh installation.
export ZSH="/root/.oh-my-zsh"
# https://typewritten.dev/#/git_status_indicators
ZSH_THEME="typewritten"
# Which plugins would you like to load?
# plugins=(zsh-autosuggestions nvm git)
source $ZSH/oh-my-zsh.sh

View file

@ -1,16 +0,0 @@
ARG VARIANT="14-buster"
FROM mcr.microsoft.com/vscode/devcontainers/typescript-node:0-${VARIANT}
RUN apt update && export DEBIAN_FRONTEND=noninteractive \
&& apt -y install --no-install-recommends git-lfs \
&& rm -rf /var/lib/apt/lists/*
RUN git lfs install
RUN npm i && npm run init
ENV ZSH_CUSTOM /root/.oh-my-zsh/
RUN git clone https://github.com/reobin/typewritten.git $ZSH_CUSTOM/themes/typewritten
RUN ln -s "$ZSH_CUSTOM/themes/typewritten/typewritten.zsh-theme" "$ZSH_CUSTOM/themes/typewritten.zsh-theme"
RUN ln -s "$ZSH_CUSTOM/themes/typewritten/async.zsh" "$ZSH_CUSTOM/themes/async"
COPY .zshrc /root/.zshrc

View file

@ -1,52 +0,0 @@
{
"name": "Node.js & TypeScript",
"build": {
"dockerfile": "Dockerfile",
"args": {
"VARIANT": "14"
}
},
"settings": {
"terminal.integrated.shell.linux": "/bin/zsh",
"files.exclude": {
"**/node_modules": true,
"**/yarn.lock": true,
"**/dist": true,
"**/lib": true,
"**/.firebase": true
},
"editor.tabSize": 2,
"editor.detectIndentation": false,
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
},
"eslint.validate": ["json"],
"markdown.extension.toc.levels": "2..4",
// <style>
"workbench.iconTheme": "material-icon-theme",
"workbench.colorTheme": "Community Material Theme Palenight High Contrast",
"editor.fontFamily": "'Fira Code'",
"editor.fontLigatures": true
// </style>
},
"extensions": [
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"yzhang.markdown-all-in-one",
"hediet.vscode-drawio",
"pkief.material-icon-theme",
"equinusocio.vsc-community-material-theme"
],
"workspaceMount": "source=${localWorkspaceFolder},target=/workspace,type=bind,consistency=cached",
"workspaceFolder": "/workspace",
"mounts": [
"source=decla-red-root-node_modules-volume4,target=/workspace/node_modules,type=volume",
"source=decla-red-frontend-node_modules-volume4,target=/workspace/frontend/node_modules,type=volume",
"source=decla-red-backend-node_modules-volume4,target=/workspace/backend/node_modules,type=volume",
"source=decla-red-shared-node_modules-volume4,target=/workspace/shared/node_modules,type=volume"
],
"forwardPorts": [3000, 8080],
"postCreateCommand": "chown node:node ./**/node_modules && npm run build"
}

View file

@ -1,4 +0,0 @@
**/node_modules/**/*.js
node_modules/**/*.js
**/package-lock.json
package-lock.json

View file

@ -1,37 +0,0 @@
{
"root": true,
"env": {
"browser": true,
"es2020": true
},
"extends": [
"plugin:@typescript-eslint/recommended",
"prettier/@typescript-eslint",
"plugin:prettier/recommended"
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": 2020,
"sourceType": "module"
},
"plugins": ["unused-imports", "@typescript-eslint", "json-format", "prettier"],
"settings": {
"json/sort-package-json": true
},
"rules": {
"prettier/prettier": "error",
"no-unused-vars": "off",
"unused-imports/no-unused-imports-ts": "error",
"@typescript-eslint/no-unused-vars": [
"warn",
{
"argsIgnorePattern": "^_"
}
],
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/explicit-module-boundary-types": "off",
"@typescript-eslint/no-non-null-assertion": "off",
"@typescript-eslint/no-empty-function": "off",
"@typescript-eslint/no-var-requires": "off"
}
}

View file

@ -23,7 +23,7 @@ jobs:
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 14 node-version-file: .nvmrc
- name: Install dependencies - name: Install dependencies
# No lockfiles are committed (see .gitignore), so use `npm install`. # No lockfiles are committed (see .gitignore), so use `npm install`.
@ -32,6 +32,12 @@ jobs:
- name: Build (shared -> frontend -> backend) - name: Build (shared -> frontend -> backend)
run: npm run build run: npm run build
- name: Test
run: npm test
- name: Lint
run: npm run lint:check
- name: Deploy to host pages mount - name: Deploy to host pages mount
if: github.event_name == 'push' && github.ref == 'refs/heads/main' if: github.event_name == 'push' && github.ref == 'refs/heads/main'
uses: http://forgejo:3000/andras/ci-actions/deploy-pages@main uses: http://forgejo:3000/andras/ci-actions/deploy-pages@main

1
.node-version Normal file
View file

@ -0,0 +1 @@
22

1
.nvmrc Normal file
View file

@ -0,0 +1 @@
22

View file

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

View file

@ -1,13 +1,13 @@
# 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 -------------------
# Node 14 matches the project toolchain (webpack 4 / TypeScript 4, see # Node 22 (current LTS) matches the project toolchain (webpack 5 / TypeScript 6,
# .devcontainer). The full (non-slim) image carries the build tools that # see .devcontainer). The full (non-slim) image carries the build tools that
# socket.io's optional native deps (bufferutil/utf-8-validate) compile against. # socket.io's optional native deps (bufferutil/utf-8-validate) compile against.
FROM node:14-bullseye AS build FROM node:22-bookworm AS build
WORKDIR /app WORKDIR /app
# `shared` is consumed by the backend as `file:../shared` and is bundled into # `shared` is consumed by the backend as `file:../shared` and is bundled into
@ -28,7 +28,7 @@ RUN cd backend && npm run build
RUN cd backend && npm prune --production RUN cd backend && npm prune --production
# ---- Stage 2: minimal runtime ---------------------------------------------- # ---- Stage 2: minimal runtime ----------------------------------------------
FROM node:14-bullseye-slim FROM node:22-bookworm-slim
WORKDIR /app WORKDIR /app
ENV NODE_ENV=production ENV NODE_ENV=production
@ -46,5 +46,5 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD node -e "require('http').get('http://localhost:3000/state',r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))" CMD node -e "require('http').get('http://localhost:3000/state',r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))"
ENTRYPOINT ["node", "dist/main.js"] ENTRYPOINT ["node", "dist/main.js"]
# Override these to tune the server, e.g. `--name`, `--playerLimit`, `--worldSize`. # Override these to tune the server, e.g. `--name`, `--playerLimit`, `--scoreLimit`.
CMD ["--port", "3000"] CMD ["--port", "3000"]

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,48 +1,43 @@
{ {
"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"
},
"engines": {
"node": ">=20"
}, },
"scripts": { "scripts": {
"build": "npx webpack --mode production", "build": "npx webpack --mode production",
"start": "concurrently --kill-others-on-fail \"webpack --mode development -w\" \"nodemon --legacy-watch dist/main.js\"", "dev": "concurrently --kill-others-on-fail \"webpack --mode development -w\" \"nodemon --legacy-watch dist/main.js\"",
"try-build": "npm run build && cd dist && node main.js && cd -" "try-build": "npm run build && cd dist && node main.js && cd -"
}, },
"dependencies": { "dependencies": {
"@types/config": "0.0.36", "cors": "^2.8.6",
"cors": "^2.8.5", "express": "^5.2.1",
"express": "^4.17.1",
"gl-matrix": "3.3.0", "gl-matrix": "3.3.0",
"minimist": "^1.2.5", "minimist": "^1.2.8",
"socket.io": "^2.3.0", "socket.io": "^4.8.3",
"socket.io-msgpack-parser": "^2.0.0" "socket.io-msgpack-parser": "^3.0.2"
}, },
"devDependencies": { "devDependencies": {
"@types/cors": "^2.8.7", "@types/cors": "^2.8.19",
"@types/express": "^4.17.8", "@types/express": "^5.0.6",
"@types/minimist": "^1.2.0", "@types/minimist": "^1.2.5",
"@types/node": "^14.11.2", "@types/node": "^22.0.0",
"@types/socket.io": "^2.1.11", "clean-webpack-plugin": "^4.0.0",
"clean-webpack-plugin": "^3.0.0", "concurrently": "^10.0.3",
"concurrently": "^5.3.0", "nodemon": "^3.1.14",
"file-loader": "^6.1.0",
"html-webpack-plugin": "^4.5.0",
"nodemon": "^2.0.4",
"raw-loader": "^4.0.1",
"resolve-url-loader": "^3.1.1",
"shared": "file:../shared", "shared": "file:../shared",
"terser-webpack-plugin": "^2.3.5", "terser-webpack-plugin": "^5.6.1",
"ts-config-webpack-plugin": "^2.0.0", "ts-loader": "^9.6.0",
"ts-loader": "^8.0.3", "typescript": "^6.0.3",
"typescript": "^4.0.3", "webpack": "^5.107.2",
"webpack": "^4.44.2", "webpack-cli": "^7.0.3",
"webpack-cli": "^3.3.12", "webpack-node-externals": "^3.0.0"
"webpack-dev-server": "^3.11.0",
"webpack-node-externals": "^2.5.2"
} }
} }

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

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

View file

@ -1,16 +1,15 @@
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, worldRadius: number) => { export const createWorld = (objectContainer: PhysicalContainer) => {
const objects: Array<Physical> = []; const objects: Array<Physical> = [];
const lights: Array<Physical> = []; const lights: Array<Physical> = [];
for (let r = 0; r < worldRadius; r += settings.radiusSteps) { for (let r = 0; r < settings.worldRadius; r += settings.radiusSteps) {
const circumference = 2 * Math.PI * r; const circumference = 2 * Math.PI * r;
const stepCount = circumference * settings.objectsOnCircleLength; const stepCount = circumference * settings.objectsOnCircleLength;
for (let rad = 0; rad < 2 * Math.PI; rad += (2 * Math.PI) / stepCount) { for (let rad = 0; rad < 2 * Math.PI; rad += (2 * Math.PI) / stepCount) {
@ -45,13 +44,16 @@ export const createWorld = (objectContainer: PhysicalContainer, worldRadius: num
) { ) {
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(
@ -67,8 +69,29 @@ export const createWorld = (objectContainer: PhysicalContainer, worldRadius: num
} }
} }
} }
console.info('Generated planet count', objects.length); console.info(`Generated ${objects.length} planets`);
console.info('Generated light count', lights.length); console.info(`Generated ${lights.length} light`);
// Associate each lamp with its NEAREST planet, so a planet can repaint "its"
// lamps to the owning team's colour when it flips. Lamps are already placed by
// proximity during world-gen, so the nearest planet is the one whose capture
// they should advertise. Distances use the planet SDF (negative inside), which
// is exactly the "closest planet" metric we want.
const planets = objects.filter((o): o is PlanetPhysical => o instanceof PlanetPhysical);
lights
.filter((l): l is LampPhysical => l instanceof LampPhysical)
.forEach((lamp) => {
let nearest: PlanetPhysical | undefined;
let nearestDistance = Infinity;
planets.forEach((planet) => {
const distance = planet.distance(lamp.center);
if (distance < nearestDistance) {
nearestDistance = distance;
nearest = planet;
}
});
nearest?.addLamp(lamp);
});
[...objects, ...lights].forEach((o) => objectContainer.addObject(o)); [...objects, ...lights].forEach((o) => objectContainer.addObject(o));
}; };

View file

@ -4,8 +4,7 @@ export const defaultOptions: Options = {
port: 3000, port: 3000,
name: 'Test server', name: 'Test server',
playerLimit: 16, playerLimit: 16,
npcCount: 16, npcCount: 8,
seed: Math.random(), seed: Math.random(),
scoreLimit: 1000, scoreLimit: 2500,
worldSize: 8000,
}; };

View file

@ -1,5 +1,5 @@
import { PhysicalContainer } from './physics/containers/physical-container'; import { PhysicalContainer } from './physics/containers/physical-container';
import ioserver from 'socket.io'; import { Server, Socket } from 'socket.io';
import { import {
TransportEvents, TransportEvents,
deserialize, deserialize,
@ -13,6 +13,7 @@ import {
Command, Command,
CommandReceiver, CommandReceiver,
CommandExecutors, CommandExecutors,
ServerAnnouncement,
} from 'shared'; } from 'shared';
import { createWorld } from './create-world'; import { createWorld } from './create-world';
import { DeltaTimeCalculator } from './helper/delta-time-calculator'; import { DeltaTimeCalculator } from './helper/delta-time-calculator';
@ -20,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';
@ -29,8 +31,9 @@ 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 isInEndGame = false; private isInEndGame = false;
private timeScaling = 1; private timeScaling = 1;
@ -41,7 +44,7 @@ export class GameServer extends CommandReceiver {
private initialize() { private initialize() {
const previousPlayers = this.players; const previousPlayers = this.players;
this.objects = new PhysicalContainer(); this.objects = new PhysicalContainer();
createWorld(this.objects, this.options.worldSize); createWorld(this.objects);
this.objects.initialize(); this.objects.initialize();
this.players = new PlayerContainer( this.players = new PlayerContainer(
this.objects, this.objects,
@ -50,8 +53,9 @@ 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.isInEndGame = false; this.isInEndGame = false;
this.timeScaling = 1; this.timeScaling = 1;
previousPlayers?.queueCommandForEachClient(new GameStartCommand()); previousPlayers?.queueCommandForEachClient(new GameStartCommand());
@ -60,9 +64,14 @@ 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(private readonly io: ioserver.Server, private options: Options) { constructor(
private readonly io: Server,
private options: Options,
) {
super(); super();
this.serverName = options.name; this.serverName = options.name;
@ -70,7 +79,7 @@ export class GameServer extends CommandReceiver {
this.initialize(); this.initialize();
io.on('connection', (socket: SocketIO.Socket) => { io.on('connection', (socket: Socket) => {
socket.on(TransportEvents.PlayerJoining, (playerInfo: PlayerInformation) => { socket.on(TransportEvents.PlayerJoining, (playerInfo: PlayerInformation) => {
try { try {
const player = this.players.createPlayer(playerInfo, socket); const player = this.players.createPlayer(playerInfo, socket);
@ -90,7 +99,8 @@ export class GameServer extends CommandReceiver {
this.players.deletePlayer(player); this.players.deletePlayer(player);
this.sendServerStateUpdate(); this.sendServerStateUpdate();
}); });
} catch { } catch (e) {
console.error('Failed to register joining player; disconnecting socket', e);
socket.disconnect(); socket.disconnect();
} }
}); });
@ -112,17 +122,34 @@ 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 {
this.announceMatchPointOnce(CharacterTeam.blue, this.bluePoints);
this.announceMatchPointOnce(CharacterTeam.red, this.redPoints);
}
}
private announceMatchPointOnce(team: CharacterTeam, points: number) {
if (
!this.matchPointAnnounced[team] &&
points >= this.options.scoreLimit * settings.matchPointScoreRatio
) {
this.matchPointAnnounced[team] = true;
this.players.queueCommandForEachClient(
new ServerAnnouncement(
`Match point — team <span class="${team}">${team}</span>!`,
),
);
} }
} }
@ -141,6 +168,10 @@ export class GameServer extends CommandReceiver {
} }
private timeSinceLastPointUpdate = 0; private timeSinceLastPointUpdate = 0;
private physicsAccumulator = 0;
// Frames since the last stats report where physics ran over budget (more
// substeps than the cap). Surfaced by handleStats as a saturation signal.
private saturatedFrames = 0;
private handlePhysics() { private handlePhysics() {
const delta = this.deltaTimeCalculator.getNextDeltaTimeInSeconds({ setAsBase: true }); const delta = this.deltaTimeCalculator.getNextDeltaTimeInSeconds({ setAsBase: true });
@ -156,18 +187,44 @@ 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); // Cap on retained physics backlog when saturated, so a long stall can't
scaledDelta /= this.timeScaling; // accumulate an unrecoverable catch-up.
const maxBacklogSeconds = 0.25;
this.physicsAccumulator += delta;
let substeps = Math.floor(this.physicsAccumulator / fixedDelta);
if (substeps > maxSubstepsPerFrame) {
// Saturated: run the cap's worth of substeps but KEEP the remaining
// backlog (clamped) instead of zeroing it. Dropping it silently slowed
// simulated time for everyone — and diverged client prediction, whose
// wall-clock keeps running. Clamping bounds the catch-up so a transient
// spike recovers without a death spiral.
this.saturatedFrames++;
this.physicsAccumulator = Math.min(
this.physicsAccumulator - maxSubstepsPerFrame * fixedDelta,
maxBacklogSeconds,
);
substeps = maxSubstepsPerFrame;
} 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();
@ -175,7 +232,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,
); );
} }
@ -196,12 +253,29 @@ export class GameServer extends CommandReceiver {
console.info( console.info(
`Memory used: ${(process.memoryUsage().rss / 1024 / 1024).toFixed(2)} MB`, `Memory used: ${(process.memoryUsage().rss / 1024 / 1024).toFixed(2)} MB`,
); );
const rtts = this.players.connectedPlayerRttsMs.filter((r) => r > 0);
if (rtts.length > 0) {
rtts.sort((a, b) => a - b);
console.info(
`Player RTT median ${rtts[Math.floor(rtts.length / 2)].toFixed(0)} ms ` +
`(min ${rtts[0].toFixed(0)}, max ${rtts[rtts.length - 1].toFixed(0)}, n=${rtts.length})`,
);
}
if (this.saturatedFrames > 0) {
console.warn(
`Physics saturated on ${this.saturatedFrames} frame(s) since last report — shedding backlog`,
);
this.saturatedFrames = 0;
}
this.deltaTimes = []; this.deltaTimes = [];
} }
} }
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

@ -1,4 +1,4 @@
import ioserver from 'socket.io'; import { Server as IoServer } from 'socket.io';
import express from 'express'; import express from 'express';
import { Server } from 'http'; import { Server } from 'http';
import cors from 'cors'; import cors from 'cors';
@ -22,7 +22,13 @@ Random.seed = options.seed;
const app = express(); const app = express();
const server = new Server(app); const server = new Server(app);
const io = ioserver(server, { parser } as any); const io = new IoServer(server, {
parser,
cors: {
origin: true,
credentials: true,
},
} as any);
const gameServer = new GameServer(io, options); const gameServer = new GameServer(io, options);

View file

@ -12,13 +12,26 @@ import {
UpdatePropertyCommand, UpdatePropertyCommand,
CommandExecutors, CommandExecutors,
CommandReceiver, CommandReceiver,
mix,
clamp01,
stepCharacterMovement,
applyLeapImpulse,
decayMomentum,
CharacterMovementState,
CharacterWorld,
GroundSurface,
headRadius,
feetRadius,
headOffset,
leftFootOffset,
rightFootOffset,
boundRadius,
} 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';
@ -31,60 +44,40 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
public readonly canCollide = true; public readonly canCollide = true;
public readonly canMove = true; public readonly canMove = true;
private static readonly headRadius = 50;
private static readonly feetRadius = 20;
private projectileStrength = settings.playerMaxStrength; private projectileStrength = settings.playerMaxStrength;
// offsets are measured from (0, 0) // Body geometry (head/foot radii, posture offsets, bound radius) is defined
private static readonly desiredHeadOffset = vec2.fromValues(0, 65); // once in the shared movement module and imported here, so the authoritative
private static readonly desiredLeftFootOffset = vec2.fromValues(-20, 0); // body and the client's predicted body are bit-identical by construction
private static readonly desiredRightFootOffset = vec2.fromValues(20, 0); // instead of by a hand-synced "copied verbatim" duplicate. Re-exposed as a
private static readonly centerOfMass = vec2.scale( // static only because external callers reference CharacterPhysical.boundRadius.
vec2.create(), public static readonly boundRadius = boundRadius;
vec2.add(
vec2.create(),
vec2.add(
vec2.create(),
CharacterPhysical.desiredHeadOffset,
CharacterPhysical.desiredLeftFootOffset,
),
CharacterPhysical.desiredRightFootOffset,
),
1 / 3,
);
private static readonly headOffset = vec2.subtract(
vec2.create(),
CharacterPhysical.desiredHeadOffset,
CharacterPhysical.centerOfMass,
);
private static readonly leftFootOffset = vec2.subtract(
vec2.create(),
CharacterPhysical.desiredLeftFootOffset,
CharacterPhysical.centerOfMass,
);
private static readonly rightFootOffset = vec2.subtract(
vec2.create(),
CharacterPhysical.desiredRightFootOffset,
CharacterPhysical.centerOfMass,
);
public static readonly boundRadius =
(CharacterPhysical.headRadius + CharacterPhysical.feetRadius * 2) * 2;
private timeSinceDying = 0; private timeSinceDying = 0;
private isDestroyed = false; private isDestroyed = false;
private timeSinceBorn = 0; private timeSinceBorn = 0;
private hasJustBorn = true; private hasJustBorn = true;
private timeAlive = 0;
private timeSinceLastShot = settings.projectileCreationInterval;
private timeSinceLastDamage = settings.playerOutOfCombatDelaySeconds;
private lastSyncedHealth = settings.playerMaxHealth;
private killStreak = 0;
private direction = 0; private direction = 0;
private currentPlanet?: PlanetPhysical; private currentPlanet?: PlanetPhysical;
private secondsSinceOnSurface = 1000; 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;
public bound: 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());
@ -108,20 +101,20 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
) { ) {
super(id(), name, killCount, deathCount, team, settings.playerMaxHealth); super(id(), name, killCount, deathCount, team, settings.playerMaxHealth);
this.head = new CirclePhysical( this.head = new CirclePhysical(
vec2.add(vec2.create(), startPosition, CharacterPhysical.headOffset), vec2.add(vec2.create(), startPosition, headOffset),
CharacterPhysical.headRadius, headRadius,
this, this,
container, container,
); );
this.leftFoot = new CirclePhysical( this.leftFoot = new CirclePhysical(
vec2.add(vec2.create(), startPosition, CharacterPhysical.leftFootOffset), vec2.add(vec2.create(), startPosition, leftFootOffset),
CharacterPhysical.feetRadius, feetRadius,
this, this,
container, container,
); );
this.rightFoot = new CirclePhysical( this.rightFoot = new CirclePhysical(
vec2.add(vec2.create(), startPosition, CharacterPhysical.rightFootOffset), vec2.add(vec2.create(), startPosition, rightFootOffset),
CharacterPhysical.feetRadius, feetRadius,
this, this,
container, container,
); );
@ -129,11 +122,59 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
container.addObject(this.leftFoot); container.addObject(this.leftFoot);
container.addObject(this.rightFoot); container.addObject(this.rightFoot);
this.bound = new CirclePhysical( this.initMovementBridge();
vec2.create(), }
CharacterPhysical.boundRadius,
this, private initMovementBridge() {
container, // The movementState object-literal getters/setters below can't use `this`
// (it would bind to the literal), so alias the character instance.
// eslint-disable-next-line @typescript-eslint/no-this-alias
const self = this;
this.movementState = {
head: this.head,
leftFoot: this.leftFoot,
rightFoot: this.rightFoot,
get direction() {
return self.direction;
},
set direction(value: number) {
self.direction = value;
},
get currentPlanet() {
return self.currentPlanet;
},
set currentPlanet(value: GroundSurface | undefined) {
// On the server every ground is a PlanetPhysical (the world only ever
// hands back planets), so this narrowing is safe.
self.currentPlanet = value as PlanetPhysical | undefined;
},
get secondsSinceOnSurface() {
return self.secondsSinceOnSurface;
},
set secondsSinceOnSurface(value: number) {
self.secondsSinceOnSurface = value;
},
bodyVelocity: this.bodyVelocity,
};
this.movementWorld = {
// Same set and order forceAtPosition used: planets in the force field,
// in container-traversal order (so the f64 gravity sum is unchanged).
groundsNear: (center, radius) =>
self.container
.findIntersecting(getBoundingBoxOfCircle(new Circle(center, radius)))
.filter((o): o is PlanetPhysical => o instanceof PlanetPhysical),
stepBody: (body, deltaTimeInSeconds) => {
const { hitObject } = (body as CirclePhysical).stepManually(deltaTimeInSeconds);
return hitObject instanceof PlanetPhysical ? hitObject : undefined;
},
};
}
private get isSpawnProtected(): boolean {
return (
this.timeAlive <
settings.spawnDespawnTime + settings.spawnInvulnerabilityExtraSeconds
); );
} }
@ -141,10 +182,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));
} }
} }
@ -156,54 +197,151 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
this.movementActions.push(c); this.movementActions.push(c);
} }
public addKill() { public get groundPlanet(): PlanetPhysical | undefined {
return this.currentPlanet;
}
// Persistent launch momentum, streamed to the owning client so its predictor
// can reproduce a leap/slingshot/recoil flight instead of only snapping to it.
public get launchMomentum(): vec2 {
return this.bodyVelocity;
}
public addKill(victimName: string, charge = 0) {
this.killCount++; this.killCount++;
this.killStreak++;
this.remoteCall('setKillCount', this.killCount); this.remoteCall('setKillCount', this.killCount);
this.health = Math.min(
settings.playerMaxHealth,
this.health + settings.playerKillHealthReward,
);
this.syncHealth();
this.remoteCall('onKillConfirmed', victimName, this.killStreak, charge);
}
public registerHit(charge = 0) {
this.remoteCall('onHitConfirmed', charge);
}
private syncHealth() {
const rounded = Math.round(this.health);
if (rounded !== this.lastSyncedHealth) {
this.lastSyncedHealth = rounded;
this.remoteCall('setHealth', this.health);
}
} }
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
) { ) {
other.destroy(); other.destroy();
this.health -= other.strength;
this.remoteCall('setHealth', this.health);
if (this.health <= 0 && this.isAlive) {
this.onDie();
other.originator.addKill();
}
}
}
public shootTowards(position: vec2) { if (this.isSpawnProtected) {
if (!this.isAlive) {
return; return;
} }
this.timeSinceLastDamage = 0;
this.health -= other.strength;
this.lastSyncedHealth = Math.round(this.health);
this.remoteCall('setHealth', this.health);
if (this.health <= 0 && this.isAlive) {
// Throw the corpse along the killing shot, harder for charged hits.
vec2.scaleAndAdd(
this.bodyVelocity,
this.bodyVelocity,
other.direction,
mix(settings.deathImpulseMin, settings.deathImpulseMax, other.charge),
);
this.onDie();
other.originator.addKill(this.name, other.charge);
} else {
other.originator.registerHit(other.charge);
}
}
}
public shootTowards(position: vec2, charge = 0) {
if (
!this.isAlive ||
this.timeSinceLastShot < settings.projectileCreationInterval ||
this.projectileStrength < settings.chargeShotStrengthMin
) {
return;
}
this.timeSinceLastShot = 0;
const c = clamp01(charge);
const desiredStrength = mix(
settings.chargeShotStrengthMin,
settings.chargeShotStrengthMax,
c,
);
const strength = Math.min(desiredStrength, this.projectileStrength);
this.projectileStrength -= strength;
const radius = mix(settings.chargeShotRadiusMin, settings.chargeShotRadiusMax, c);
const speed = mix(settings.chargeShotSpeedMin, settings.chargeShotSpeedMax, c);
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);
const velocity = vec2.scale(direction, direction, settings.projectileSpeed); // Keep the unit direction before vec2.scale repurposes it as the velocity.
const strength = this.projectileStrength / 2; const shotDirection = vec2.clone(direction);
this.projectileStrength -= strength; const velocity = vec2.scale(direction, direction, speed);
const projectile = new ProjectilePhysical( const projectile = new ProjectilePhysical(
vec2.clone(this.center), vec2.clone(this.center),
20, radius,
strength, strength,
this.team, this.team,
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 {
this.bound.center = this.head.center; return getBoundingBoxOfCircle(new Circle(this.center, CharacterPhysical.boundRadius));
return this.bound.boundingBox;
} }
public get gameObject(): this { public get gameObject(): this {
@ -248,8 +386,8 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
} }
private animateScaling(q: number) { private animateScaling(q: number) {
this.head.radius = CharacterPhysical.headRadius * q; this.head.radius = headRadius * q;
this.leftFoot.radius = this.rightFoot.radius = CharacterPhysical.feetRadius * q; this.leftFoot.radius = this.rightFoot.radius = feetRadius * q;
} }
public getPropertyUpdates(): PropertyUpdatesForObject { public getPropertyUpdates(): PropertyUpdatesForObject {
@ -257,6 +395,11 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
new UpdatePropertyCommand('head', this.head, this.headVelocity), new UpdatePropertyCommand('head', this.head, this.headVelocity),
new UpdatePropertyCommand('leftFoot', this.leftFoot, this.leftFootVelocity), new UpdatePropertyCommand('leftFoot', this.leftFoot, this.leftFootVelocity),
new UpdatePropertyCommand('rightFoot', this.rightFoot, this.rightFootVelocity), new UpdatePropertyCommand('rightFoot', this.rightFoot, this.rightFootVelocity),
new UpdatePropertyCommand(
'strength',
this.projectileStrength,
settings.playerStrengthRegenerationPerSeconds,
),
]); ]);
} }
@ -298,6 +441,8 @@ 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.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),
@ -312,6 +457,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);
@ -328,19 +474,45 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
return; return;
} }
if ((this.secondsSinceOnSurface += deltaTimeInSeconds) > 0.5) { if (
(this.secondsSinceOnSurface += deltaTimeInSeconds) >
settings.planetDetachmentSeconds
) {
this.currentPlanet = undefined; this.currentPlanet = undefined;
} }
this.timeSinceLastShot += deltaTimeInSeconds;
this.projectileStrength = Math.min( this.projectileStrength = Math.min(
settings.playerMaxStrength, settings.playerMaxStrength,
this.projectileStrength + this.projectileStrength +
settings.playerStrengthRegenerationPerSeconds * deltaTimeInSeconds, settings.playerStrengthRegenerationPerSeconds * deltaTimeInSeconds,
); );
this.currentPlanet?.takeControl(this.team, deltaTimeInSeconds); this.regenerateHealth(deltaTimeInSeconds);
const intersectingWithForceField = this.container.findIntersecting( // 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);
// 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,
@ -348,143 +520,34 @@ 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.applyForce(this.leftFoot, movementForce, deltaTimeInSeconds); vec2.add(part.velocity, part.velocity, this.bodyVelocity);
this.applyForce(this.rightFoot, movementForce, deltaTimeInSeconds);
if (!this.currentPlanet) {
const leftFootGravity = forceAtPosition(
this.leftFoot.center,
intersectingWithForceField,
);
const rightFootGravity = forceAtPosition(
this.rightFoot.center,
intersectingWithForceField,
);
this.applyForce(this.leftFoot, leftFootGravity, deltaTimeInSeconds);
this.applyForce(this.rightFoot, 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) * 0.8) {
vec2.scale(gravity, gravity, 0.35);
}
const scaledLeftFootGravity = vec2.scale(
vec2.create(),
this.leftFoot.lastNormal,
vec2.dot(this.leftFoot.lastNormal, gravity),
);
this.applyForce(this.leftFoot, scaledLeftFootGravity, deltaTimeInSeconds);
const scaledRightFootGravity = vec2.scale(
vec2.create(),
this.rightFoot.lastNormal,
vec2.dot(this.rightFoot.lastNormal, gravity),
);
this.applyForce(this.rightFoot, scaledRightFootGravity, deltaTimeInSeconds);
if (vec2.length(gravity) <= 100) {
this.currentPlanet = undefined;
}
this.setDirection(gravity);
}
this.keepPosture(deltaTimeInSeconds);
this.stepBodyPart(this.leftFoot, deltaTimeInSeconds);
this.stepBodyPart(this.rightFoot, deltaTimeInSeconds);
this.stepBodyPart(this.head, deltaTimeInSeconds);
this.setPropertyUpdates(oldHead, oldLeftFoot, oldRightFoot, deltaTimeInSeconds);
}
private setDirection(direction: vec2) {
this.direction = interpolateAngles(
this.direction,
Math.atan2(direction.y, direction.x) + Math.PI / 2,
0.2,
);
}
private keepPosture(deltaTime: number) {
const center = this.center;
this.springMove(
this.leftFoot,
center,
CharacterPhysical.leftFootOffset,
deltaTime,
3000,
);
this.springMove(
this.rightFoot,
center,
CharacterPhysical.rightFootOffset,
deltaTime,
3000,
);
this.springMove(this.head, center, CharacterPhysical.headOffset, deltaTime, 7000);
}
private springMove(
object: CirclePhysical,
center: vec2,
offset: vec2,
deltaTime: number,
strength: 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);
const positionDeltaLength = vec2.length(positionDelta);
if (positionDeltaLength > 0) {
const positionDeltaDirection = vec2.normalize(vec2.create(), positionDelta);
vec2.scale(
positionDelta,
positionDeltaDirection,
positionDeltaLength ** 2 * deltaTime * strength,
);
if (vec2.length(positionDelta) * deltaTime * deltaTime > positionDeltaLength) {
vec2.scale(
positionDelta,
positionDelta,
positionDeltaLength / (vec2.length(positionDelta) * deltaTime * deltaTime),
);
}
object.applyForce(positionDelta, deltaTime);
}
}
private stepBodyPart(part: CirclePhysical, deltaTime: number) {
const { hitObject } = part.stepManually(deltaTime); const { hitObject } = part.stepManually(deltaTime);
if (hitObject instanceof PlanetPhysical) { if (hitObject instanceof PlanetPhysical) {
this.secondsSinceOnSurface = 0; grounded = true;
this.currentPlanet = hitObject;
} }
} }
// Brake with the exact shared model the living body uses: stiff on contact
// so the corpse skids to rest, gentle in the air, plus the constant stop and
// the speed cap — so a flung corpse comes to a definite stop instead of
// sliding forever.
decayMomentum(this.bodyVelocity, grounded, deltaTime);
}
public applyForce(circle: CirclePhysical, force: vec2, timeInSeconds: number) { private regenerateHealth(deltaTimeInSeconds: number) {
vec2.add( this.timeSinceLastDamage += deltaTimeInSeconds;
circle.velocity, if (
circle.velocity, this.timeSinceLastDamage > settings.playerOutOfCombatDelaySeconds &&
vec2.scale(vec2.create(), force, timeInSeconds), this.health < settings.playerMaxHealth
) {
this.health = Math.min(
settings.playerMaxHealth,
this.health + settings.playerHealthRegenerationPerSeconds * deltaTimeInSeconds,
); );
this.syncHealth();
}
} }
public onDie() { public 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,42 +91,49 @@ export class CirclePhysical extends CommandReceiver implements Circle, DynamicPh
); );
} }
// Position-resolution for one tick. Delegates to the shared
// resolveCircleMovement so the server integrates a body with the exact same
// geometry the client predictor runs (shared/physics) — no parallel copy to
// keep in sync. The onHit callback dispatches the collision reactions at the
// same points the old inline move-circle did (both the initial march and the
// post-bounce slide). `possibleIntersectors`, when supplied, lets a caller
// that already broadphased (e.g. a projectile's gravity query) avoid a second
// container query; otherwise it is self-gathered from the swept bounding box.
public stepManually( public stepManually(
deltaTimeInSeconds: number, deltaTimeInSeconds: number,
): { hitObject: GameObject | undefined; velocity: vec2 } { possibleIntersectors?: Array<Physical>,
let delta = vec2.scale(vec2.create(), this.velocity, deltaTimeInSeconds); ): {
hitObject: GameObject | undefined;
velocity: vec2;
} {
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

@ -32,6 +32,10 @@ export class LampPhysical extends LampBase implements StaticPhysical {
return this; return this;
} }
public queueSetLight(color: vec3, lightness: number) {
this.remoteCall('setLight', color, lightness);
}
public distance(target: vec2): number { public distance(target: vec2): number {
return vec2.distance(this.center, target); return vec2.distance(this.center, target);
} }

View file

@ -4,6 +4,8 @@ import {
clamp, clamp,
clamp01, clamp01,
id, id,
mix,
Random,
serializesTo, serializesTo,
settings, settings,
PlanetBase, PlanetBase,
@ -14,37 +16,89 @@ 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 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;
// 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 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),
}; };
constructor(vertices: Array<vec2>) { public addLamp(lamp: LampPhysical) {
super(id(), vertices); this.lamps.push(lamp);
}
constructor(vertices: Array<vec2>, isKeystone = false) {
super(id(), vertices, 0.5, isKeystone);
const sizeClass = clamp01(
(this.radius - settings.planetMinReferenceRadius) /
(settings.planetMaxReferenceRadius - settings.planetMinReferenceRadius),
);
this.sizePointMultiplier = mix(1, settings.planetSizePointMultiplierMax, sizeClass);
this.rotationSpeed =
(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),
@ -56,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;
} }
@ -68,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;
} }
@ -80,34 +158,118 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
private getPoints(game: CommandReceiver) { private getPoints(game: CommandReceiver) {
if (this.timeSinceLastPointGeneration > settings.planetPointGenerationInterval) { if (this.timeSinceLastPointGeneration > settings.planetPointGenerationInterval) {
this.timeSinceLastPointGeneration = 0; this.timeSinceLastPointGeneration = 0;
if (this.team !== CharacterTeam.neutral) {
this.remoteCall('generatedPoints', settings.planetPointGenerationValue);
}
const value = Math.round(
settings.planetPointGenerationValue * this.sizePointMultiplier,
);
game.handleCommand( game.handleCommand(
new GeneratePointsCommand( new GeneratePointsCommand(
this.team === CharacterTeam.decla ? settings.planetPointGenerationValue : 0, this.team === CharacterTeam.blue ? value : 0,
this.team === CharacterTeam.red ? settings.planetPointGenerationValue : 0, this.team === CharacterTeam.red ? value : 0,
), ),
); );
} }
} }
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.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) {
const currentTeam = this.team;
if (currentTeam === this.lastTeam) {
return;
}
this.lastTeam = currentTeam;
if (currentTeam !== CharacterTeam.neutral) {
const reward = Math.round(
settings.captureFlipPointReward * this.sizePointMultiplier,
);
this.remoteCall('generatedPoints', reward);
game.handleCommand(
new GeneratePointsCommand(
currentTeam === CharacterTeam.blue ? reward : 0,
currentTeam === CharacterTeam.red ? reward : 0,
),
);
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 lightness = mix(settings.lampMinLightness, settings.lampMaxLightness, control);
const color = settings.palette[settings.colorIndices[currentTeam]];
this.lamps.forEach((lamp) => lamp.queueSetLight(color, lightness));
this.remoteCall('onFlipped', currentTeam);
} }
public getPropertyUpdates(): PropertyUpdatesForObject { 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;
@ -130,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;
@ -153,7 +313,7 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
public getForce(position: vec2): vec2 { public getForce(position: vec2): vec2 {
const diff = vec2.subtract(vec2.create(), this.center, position); const diff = vec2.subtract(vec2.create(), this.center, position);
const dist = Math.max(0, vec2.length(diff) - this.radius); const dist = Math.max(settings.minGravityDistance, vec2.length(diff) - this.radius);
vec2.normalize(diff, diff); vec2.normalize(diff, diff);
const scale = clamp( const scale = clamp(
settings.maxGravityQ * ((settings.maxGravityDistance / dist) ** 1.5 - 1), settings.maxGravityQ * ((settings.maxGravityDistance / dist) ** 1.5 - 1),
@ -163,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);
@ -53,6 +59,13 @@ export class ProjectilePhysical extends ProjectileBase implements DynamicPhysica
return !this.isDestroyed; return !this.isDestroyed;
} }
public get direction(): vec2 {
const direction = vec2.clone(this.velocity);
return vec2.length(direction) > 0
? vec2.normalize(direction, direction)
: vec2.fromValues(0, -1);
}
private moveOutsideOfObject() { private moveOutsideOfObject() {
let wasCollision = true; let wasCollision = true;
const delta = vec2.scale( const delta = vec2.scale(
@ -64,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);
@ -117,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

@ -5,5 +5,4 @@ export interface Options {
scoreLimit: number; scoreLimit: number;
npcCount: number; npcCount: number;
seed: number; seed: number;
worldSize: number;
} }

View file

@ -5,7 +5,10 @@ import { StaticPhysical } from '../physicals/static-physical';
class Node { class Node {
public left: Node | null = null; public left: Node | null = null;
public right: Node | null = null; public right: Node | null = null;
constructor(public object: StaticPhysical, public parent: Node | null) {} constructor(
public object: StaticPhysical,
public parent: Node | null,
) {}
} }
export class BoundingBoxTree { export class BoundingBoxTree {

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

@ -6,6 +6,7 @@ import {
Random, Random,
MoveActionCommand, MoveActionCommand,
CharacterTeam, CharacterTeam,
Id,
} from 'shared'; } from 'shared';
import { PhysicalContainer } from '../physics/containers/physical-container'; import { PhysicalContainer } from '../physics/containers/physical-container';
import { PlayerContainer } from './player-container'; import { PlayerContainer } from './player-container';
@ -13,16 +14,81 @@ import { PlayerBase } from './player-base';
import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle'; import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle';
import { CharacterPhysical } from '../objects/character-physical'; import { CharacterPhysical } from '../objects/character-physical';
import { PlanetPhysical } from '../objects/planet-physical'; import { PlanetPhysical } from '../objects/planet-physical';
import { ProjectilePhysical } from '../objects/projectile-physical';
import { Physical } from '../physics/physicals/physical'; import { Physical } from '../physics/physicals/physical';
const npcTuning = {
planIntervalSeconds: 1,
shootIntervalSeconds: 1.5,
reactionIntervalSeconds: 1 / 10,
reactionObserveRadius: 1400,
planScanRadius: 3500,
aggressionMin: 0.25,
aggressionMax: 1,
wanderReconsiderSeconds: 5,
wanderProbability: 0.4,
wanderTurn: 0.3,
fleeBaseRange: 240,
fleeAggressionFalloff: 1.4,
chaseBaseRange: 700,
chaseAggressionRange: 1600,
captureHoldEnemyDistance: 500,
dodgeThreatRange: 450,
dodgeApproachDot: 0.6,
dodgeBaseChance: 0.25,
dodgeAggressionChance: 0.35,
dodgeCommitSeconds: 0.35,
dodgeCooldownSeconds: 0.5,
lineOfSightClearance: 20,
lineOfSightStartOffset: 100,
fireBaseChance: 0.45,
fireAggressionChance: 0.45,
chargeRangeThreshold: 500,
chargeBaseChance: 0.3,
chargeAggressionChance: 0.4,
chargeMin: 0.6,
spreadBase: 60,
spreadPerDistance: 0.08,
spreadAggressionFalloff: 1.3,
// Per-second chance to hop while grounded and on the move, so bots use the
// leap verb too instead of being grounded targets.
leapChancePerSecond: 0.35,
};
export class NPC extends PlayerBase { export class NPC extends PlayerBase {
private direction: vec2 = vec2.fromValues(Random.getRandom(), Random.getRandom()); private direction = vec2.fromValues(Random.getRandom() - 0.5, Random.getRandom() - 0.5);
private timeSinceLastFindTarget = 10000; private timeSinceLastPlan = 10000;
private timeSinceLastFindShootTarget = 10000; private timeSinceLastShoot = 10000;
private isWandering = false; private isWandering = false;
private timeSinceLastWanderingConsideration = 0; private timeSinceLastWanderingConsideration = 0;
private isComingBack = false; private isComingBack = false;
private readonly aggression = Random.getRandomInRange(
npcTuning.aggressionMin,
npcTuning.aggressionMax,
);
private aimTargetId: Id = null;
private readonly aimTargetLastPosition = vec2.create();
private readonly dodgeDirection = vec2.create();
private dodgeCommitRemaining = 0;
private dodgeCooldownRemaining = 0;
private timeSinceObserve = npcTuning.reactionIntervalSeconds;
private nearObjects: Array<Physical> = [];
constructor( constructor(
playerInfo: PlayerInformation, playerInfo: PlayerInformation,
playerContainer: PlayerContainer, playerContainer: PlayerContainer,
@ -34,172 +100,315 @@ export class NPC extends PlayerBase {
this.step(0); this.step(0);
} }
private findTarget() {
if (
(!this.isComingBack && vec2.length(this.center) > settings.worldRadius) ||
(this.isComingBack && vec2.length(this.center) > settings.worldRadius / 2)
) {
this.isComingBack = true;
vec2.subtract(this.direction, vec2.fromValues(0, 0), this.center);
return;
}
this.isComingBack = false;
const observableArea = getBoundingBoxOfCircle(new Circle(this.center, 2000));
const nearObjects = this.objectContainer.findIntersecting(observableArea);
const characters = this.findNearCharactersSorted(nearObjects);
if (characters.length > 0) {
const nearest = characters[0];
if (nearest.distance < 200) {
vec2.subtract(this.direction, this.center, nearest.character.center);
return;
}
}
const enemies = characters.filter((o) => o.character.team !== this.team);
if (enemies.length > 0) {
const nearest = enemies[0];
if (nearest.distance < 500) {
vec2.subtract(this.direction, this.center, nearest.character.center);
return;
}
}
if (enemies.length > 0) {
const nearest = enemies[0];
if (nearest.distance > 1000) {
vec2.subtract(this.direction, nearest.character.center, this.center);
return;
}
}
if (this.isWandering) {
vec2.rotate(
this.direction,
this.direction,
vec2.create(),
Random.getRandomInRange(-0.2, 0.2),
);
} else {
const planets = this.findNearPlanetsSorted(nearObjects).filter(
(p) => p.planet.team !== this.team,
);
if (planets.length > 0) {
vec2.subtract(this.direction, planets[0].planet.center, this.center);
} else {
this.isWandering = true;
}
}
}
private findNearCharactersSorted(
nearObjects: Array<Physical>,
): Array<{ character: CharacterPhysical; distance: number }> {
const characters = Array.from(
new Set(
nearObjects.filter(
(o) =>
o.gameObject instanceof CharacterPhysical && o.gameObject !== this.character,
),
),
).map((c) => ({
character: c.gameObject,
distance: vec2.distance(this.center, (c.gameObject as CharacterPhysical).center),
})) as Array<{ character: CharacterPhysical; distance: number }>;
characters.sort((a, b) => a.distance - b.distance);
return characters;
}
private findNearPlanetsSorted(
nearObjects: Array<Physical>,
): Array<{ planet: PlanetPhysical; distance: number }> {
const planets = nearObjects
.filter((o) => o.gameObject instanceof PlanetPhysical)
.map((c) => ({
planet: c.gameObject,
distance: vec2.distance(this.center, (c.gameObject as PlanetPhysical).center),
})) as Array<{ planet: PlanetPhysical; distance: number }>;
planets.sort((a, b) => a.distance - b.distance);
return planets;
}
private findShootTarget(): vec2 | undefined {
const observableArea = getBoundingBoxOfCircle(new Circle(this.center, 1000));
const nearObjects = this.objectContainer.findIntersecting(observableArea);
const enemies = nearObjects.filter(
(o) => o.gameObject instanceof CharacterPhysical && o.gameObject.team !== this.team,
);
if (enemies.length > 0) {
return (enemies[0].gameObject as CharacterPhysical).center;
}
}
private timeUntilRespawn = 0; private timeUntilRespawn = 0;
public step(deltaTimeInSeconds: number) { public step(deltaTimeInSeconds: number) {
if (this.character) { if (this.character) {
this.center = this.character?.center; this.center = this.character.center;
if (!this.character.isAlive) { if (!this.character.isAlive) {
this.sumDeaths++; this.sumDeaths++;
this.sumKills = this.character.killCount; this.sumKills = this.character.killCount;
this.character = null; this.character = null;
this.timeUntilRespawn = settings.playerDiedTimeout; this.timeUntilRespawn = settings.playerDiedTimeout;
return;
} }
} else { } else {
if ((this.timeUntilRespawn -= deltaTimeInSeconds) < 0) { if ((this.timeUntilRespawn -= deltaTimeInSeconds) < 0) {
this.createCharacter(); this.createCharacter();
this.center = this.character!.center; this.center = this.character!.center;
} }
return;
} }
if ((this.timeSinceLastWanderingConsideration += deltaTimeInSeconds) > 3) { if (
(this.timeSinceLastWanderingConsideration += deltaTimeInSeconds) >
npcTuning.wanderReconsiderSeconds
) {
this.timeSinceLastWanderingConsideration = 0; this.timeSinceLastWanderingConsideration = 0;
this.isWandering = Random.getRandom() > 0.5; this.isWandering = Random.getRandom() < npcTuning.wanderProbability;
} }
if ((this.timeSinceLastFindTarget += deltaTimeInSeconds) > 1) { if ((this.timeSinceLastPlan += deltaTimeInSeconds) > npcTuning.planIntervalSeconds) {
this.timeSinceLastFindTarget = 0; this.timeSinceLastPlan = 0;
this.findTarget(); this.plan();
} }
if ((this.timeSinceLastFindShootTarget += deltaTimeInSeconds) > 0.5) { if (
if (Random.getRandom() > 0.5) { (this.timeSinceObserve += deltaTimeInSeconds) > npcTuning.reactionIntervalSeconds
const shootTarget = this.findShootTarget(); ) {
if (shootTarget) { this.timeSinceObserve = 0;
vec2.add( this.nearObjects = this.observe(npcTuning.reactionObserveRadius);
shootTarget, }
shootTarget,
vec2.fromValues( this.dodgeCommitRemaining -= deltaTimeInSeconds;
Random.getRandomInRange(-200, 200), this.dodgeCooldownRemaining -= deltaTimeInSeconds;
Random.getRandomInRange(-200, 200), const movement = this.decideMovement(this.nearObjects);
), this.character.handleMovementAction(new MoveActionCommand(movement));
);
this.character?.shootTowards(shootTarget); if (
!this.isComingBack &&
this.character.groundPlanet &&
vec2.length(movement) > 0 &&
Random.getRandom() <
npcTuning.leapChancePerSecond * this.aggression * deltaTimeInSeconds
) {
this.character.leap();
}
if (
(this.timeSinceLastShoot += deltaTimeInSeconds) > npcTuning.shootIntervalSeconds
) {
this.timeSinceLastShoot = 0;
this.tryShoot(this.nearObjects);
} }
} }
this.timeSinceLastFindShootTarget = 0; private plan() {
const distanceFromCentre = vec2.length(this.center);
if (
(!this.isComingBack && distanceFromCentre > settings.worldRadius) ||
(this.isComingBack && distanceFromCentre > settings.worldRadius / 2)
) {
this.isComingBack = true;
vec2.negate(this.direction, this.center);
return;
}
this.isComingBack = false;
const nearObjects = this.observe(npcTuning.planScanRadius);
const enemies = this.enemiesByDistance(nearObjects);
if (enemies.length > 0) {
const nearest = enemies[0];
const fleeRange =
npcTuning.fleeBaseRange * (npcTuning.fleeAggressionFalloff - this.aggression);
if (nearest.distance < fleeRange) {
vec2.subtract(this.direction, this.center, nearest.character.center);
return;
}
} }
this.character?.handleMovementAction(new MoveActionCommand(this.direction)); if (enemies.length > 0) {
const nearest = enemies[0];
const chaseRange =
npcTuning.chaseBaseRange + npcTuning.chaseAggressionRange * this.aggression;
if (nearest.distance < chaseRange) {
vec2.subtract(this.direction, nearest.character.center, this.center);
return;
}
} }
protected createCharacter() { if (!this.isWandering) {
const randomPoint = vec2.rotate( const planet = this.capturablePlanetsByDistance(nearObjects)[0];
if (planet) {
vec2.subtract(this.direction, planet.planet.center, this.center);
return;
}
}
vec2.rotate(
this.direction,
this.direction,
vec2.create(), vec2.create(),
vec2.fromValues(Random.getRandomInRange(0, settings.worldRadius), 0), Random.getRandomInRange(-npcTuning.wanderTurn, npcTuning.wanderTurn),
vec2.create(),
Random.getRandomInRange(0, Math.PI * 2),
); );
super.createCharacter(randomPoint); }
private decideMovement(nearObjects: Array<Physical>): vec2 {
if (this.dodgeCommitRemaining > 0) {
return vec2.clone(this.dodgeDirection);
}
if (this.dodgeCooldownRemaining <= 0) {
const dodge = this.dodgeVector(nearObjects);
if (dodge) {
if (
Random.getRandom() <
npcTuning.dodgeBaseChance + npcTuning.dodgeAggressionChance * this.aggression
) {
vec2.copy(this.dodgeDirection, dodge);
this.dodgeCommitRemaining = npcTuning.dodgeCommitSeconds;
return vec2.clone(this.dodgeDirection);
}
this.dodgeCooldownRemaining = npcTuning.dodgeCooldownSeconds;
}
}
const planet = this.character!.groundPlanet;
if (planet && planet.team !== this.team) {
const enemies = this.enemiesByDistance(nearObjects);
if (
enemies.length === 0 ||
enemies[0].distance > npcTuning.captureHoldEnemyDistance
) {
return vec2.create();
}
}
return vec2.normalize(vec2.create(), this.direction);
}
private dodgeVector(nearObjects: Array<Physical>): vec2 | undefined {
let threat: ProjectilePhysical | undefined;
let threatDistance = Infinity;
for (const o of nearObjects) {
const p = o.gameObject;
if (!(p instanceof ProjectilePhysical) || p.team === this.team || !p.isAlive) {
continue;
}
const toMe = vec2.subtract(vec2.create(), this.center, p.center);
const distance = vec2.length(toMe);
if (distance > npcTuning.dodgeThreatRange || distance === 0) {
continue;
}
vec2.normalize(toMe, toMe);
if (
vec2.dot(p.direction, toMe) > npcTuning.dodgeApproachDot &&
distance < threatDistance
) {
threatDistance = distance;
threat = p;
}
}
if (!threat) {
return undefined;
}
const perpendicular = vec2.fromValues(-threat.direction.y, threat.direction.x);
const toMe = vec2.subtract(vec2.create(), this.center, threat.center);
if (vec2.dot(perpendicular, toMe) < 0) {
vec2.negate(perpendicular, perpendicular);
}
vec2.normalize(perpendicular, perpendicular);
return perpendicular;
}
private tryShoot(nearObjects: Array<Physical>) {
const enemies = this.enemiesByDistance(nearObjects);
const visible = enemies.find((e) =>
this.hasLineOfSightTo(e.character.center, nearObjects),
);
if (!visible) {
this.aimTargetId = null;
return;
}
const target = visible.character;
const distance = visible.distance;
const velocity = vec2.create();
if (this.aimTargetId === target.id) {
vec2.subtract(velocity, target.center, this.aimTargetLastPosition);
vec2.scale(velocity, velocity, 1 / npcTuning.shootIntervalSeconds);
}
this.aimTargetId = target.id;
vec2.copy(this.aimTargetLastPosition, target.center);
if (
Random.getRandom() >
npcTuning.fireBaseChance + npcTuning.fireAggressionChance * this.aggression
) {
return;
}
const charge =
distance > npcTuning.chargeRangeThreshold &&
Random.getRandom() <
npcTuning.chargeBaseChance + npcTuning.chargeAggressionChance * this.aggression
? Random.getRandomInRange(npcTuning.chargeMin, 1)
: 0;
const projectileSpeed =
charge > 0 ? settings.chargeShotSpeedMax : settings.chargeShotSpeedMin;
const leadTime = distance / projectileSpeed;
const aim = vec2.scaleAndAdd(vec2.create(), target.center, velocity, leadTime);
const spread =
(npcTuning.spreadBase + distance * npcTuning.spreadPerDistance) *
(npcTuning.spreadAggressionFalloff - this.aggression);
aim.x += Random.getRandomInRange(-spread, spread);
aim.y += Random.getRandomInRange(-spread, spread);
this.character!.shootTowards(aim, charge);
}
private hasLineOfSightTo(target: vec2, nearObjects: Array<Physical>): boolean {
const planets: Array<PlanetPhysical> = [];
for (const o of nearObjects) {
if (o.gameObject instanceof PlanetPhysical) {
planets.push(o.gameObject);
}
}
if (planets.length === 0) {
return true;
}
const direction = vec2.subtract(vec2.create(), target, this.center);
const totalDistance = vec2.length(direction);
if (totalDistance <= npcTuning.lineOfSightStartOffset) {
return true;
}
vec2.normalize(direction, direction);
let traveled = npcTuning.lineOfSightStartOffset;
const position = vec2.scaleAndAdd(vec2.create(), this.center, direction, traveled);
while (traveled < totalDistance) {
let sdf = Infinity;
for (const planet of planets) {
sdf = Math.min(sdf, planet.distance(position));
}
if (sdf < npcTuning.lineOfSightClearance) {
return false;
}
traveled += sdf;
vec2.scaleAndAdd(position, position, direction, sdf);
}
return true;
}
private observe(radius: number): Array<Physical> {
return this.objectContainer.findIntersecting(
getBoundingBoxOfCircle(new Circle(this.center, radius)),
);
}
private enemiesByDistance(
nearObjects: Array<Physical>,
): Array<{ character: CharacterPhysical; distance: number }> {
const seen = new Set<CharacterPhysical>();
const enemies: Array<{ character: CharacterPhysical; distance: number }> = [];
for (const o of nearObjects) {
const c = o.gameObject;
if (
c instanceof CharacterPhysical &&
c !== this.character &&
c.isAlive &&
c.team !== this.team &&
!seen.has(c)
) {
seen.add(c);
enemies.push({ character: c, distance: vec2.distance(this.center, c.center) });
}
}
enemies.sort((a, b) => a.distance - b.distance);
return enemies;
}
private capturablePlanetsByDistance(
nearObjects: Array<Physical>,
): Array<{ planet: PlanetPhysical; distance: number }> {
const planets = nearObjects
.filter(
(o): o is Physical =>
o.gameObject instanceof PlanetPhysical && o.gameObject.team !== this.team,
)
.map((o) => ({
planet: o.gameObject as PlanetPhysical,
distance: vec2.distance(this.center, (o.gameObject as PlanetPhysical).center),
}));
planets.sort((a, b) => a.distance - b.distance);
return planets;
} }
} }

View file

@ -1,9 +1,17 @@
import { vec2 } from 'gl-matrix'; import { vec2 } from 'gl-matrix';
import { CommandReceiver, Circle, PlayerInformation, CharacterTeam } from 'shared'; import {
CommandReceiver,
Circle,
PlayerInformation,
CharacterTeam,
Random,
settings,
} from 'shared';
import { PhysicalContainer } from '../physics/containers/physical-container'; import { PhysicalContainer } from '../physics/containers/physical-container';
import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle'; import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle';
import { isCircleIntersecting } from '../physics/functions/is-circle-intersecting'; import { isCircleIntersecting } from '../physics/functions/is-circle-intersecting';
import { CharacterPhysical } from '../objects/character-physical'; import { CharacterPhysical } from '../objects/character-physical';
import { PlanetPhysical } from '../objects/planet-physical';
import { PlayerContainer } from './player-container'; import { PlayerContainer } from './player-container';
export abstract class PlayerBase extends CommandReceiver { export abstract class PlayerBase extends CommandReceiver {
@ -22,14 +30,14 @@ export abstract class PlayerBase extends CommandReceiver {
super(); super();
} }
protected createCharacter(preferredCenter: vec2) { protected createCharacter() {
this.character = new CharacterPhysical( this.character = new CharacterPhysical(
this.playerInfo.name.slice(0, 20), this.playerInfo.name.slice(0, 20),
this.sumKills, this.sumKills,
this.sumDeaths, this.sumDeaths,
this.team, this.team,
this.objectContainer, this.objectContainer,
this.findEmptyPositionForPlayer(preferredCenter), this.findEmptyPositionForPlayer(this.findSpawnCenter()),
); );
this.objectContainer.addObject(this.character); this.objectContainer.addObject(this.character);
@ -37,11 +45,34 @@ export abstract class PlayerBase extends CommandReceiver {
public abstract step(deltaTimeInSeconds: number): void; public abstract step(deltaTimeInSeconds: number): void;
protected findEmptyPositionForPlayer(preferredCenter: vec2): vec2 { private findSpawnCenter(): vec2 {
if (!preferredCenter) { const planets = this.objectContainer
preferredCenter = vec2.create(); .findIntersecting(
getBoundingBoxOfCircle(new Circle(vec2.create(), settings.worldRadius * 2)),
)
.filter((o): o is PlanetPhysical => o instanceof PlanetPhysical);
const friendly = planets.filter((p) => p.team === this.team);
const neutral = planets.filter((p) => p.team === CharacterTeam.neutral);
const candidates = friendly.length ? friendly : neutral.length ? neutral : planets;
if (candidates.length === 0) {
return vec2.create();
} }
const isContested = (planet: PlanetPhysical) =>
this.playerContainer.players.some(
(p) =>
p.team !== this.team &&
p.character?.isAlive &&
vec2.distance(p.center, planet.center) < settings.spawnSafetyDistance,
);
const safe = candidates.filter((p) => !isContested(p));
// candidates is non-empty here, so choose() always returns a planet.
return vec2.clone(Random.choose(safe.length ? safe : candidates)!.center);
}
protected findEmptyPositionForPlayer(preferredCenter: vec2): vec2 {
let rotation = 0; let rotation = 0;
let radius = 0; let radius = 0;
for (;;) { for (;;) {
@ -56,9 +87,8 @@ export abstract class PlayerBase extends CommandReceiver {
); );
const playerBoundingBox = getBoundingBoxOfCircle(playerBoundingCircle); const playerBoundingBox = getBoundingBoxOfCircle(playerBoundingCircle);
const possibleIntersectors = this.objectContainer.findIntersecting( const possibleIntersectors =
playerBoundingBox, this.objectContainer.findIntersecting(playerBoundingBox);
);
if (!isCircleIntersecting(playerBoundingCircle, possibleIntersectors)) { if (!isCircleIntersecting(playerBoundingCircle, possibleIntersectors)) {
return playerPosition; return playerPosition;
} }

View file

@ -1,4 +1,5 @@
import { CharacterTeam, PlayerInformation, Random, settings, Command } from 'shared'; import { CharacterTeam, PlayerInformation, Random, settings, Command } from 'shared';
import { Socket } from 'socket.io';
import { PhysicalContainer } from '../physics/containers/physical-container'; import { PhysicalContainer } from '../physics/containers/physical-container';
import { NPC } from './npc'; import { NPC } from './npc';
import { Player } from './player'; import { Player } from './player';
@ -29,7 +30,7 @@ export class PlayerContainer {
} }
} }
public createPlayer(playerInfo: PlayerInformation, socket: SocketIO.Socket): Player { public createPlayer(playerInfo: PlayerInformation, socket: Socket): Player {
if (this._players.length === this.playerMaxCount) { if (this._players.length === this.playerMaxCount) {
throw new Error('Too many players'); throw new Error('Too many players');
} }
@ -56,6 +57,12 @@ export class PlayerContainer {
return this._players.length; return this._players.length;
} }
// Measured round-trip times (ms) of the real connected players, for
// server-side latency stats. NPCs have no socket and are excluded.
public get connectedPlayerRttsMs(): Array<number> {
return this._players.map((p) => p.rttMs);
}
public step(deltaTimeInSeconds: number) { public step(deltaTimeInSeconds: number) {
this.players.forEach((p) => p.step(deltaTimeInSeconds)); this.players.forEach((p) => p.step(deltaTimeInSeconds));
} }
@ -78,11 +85,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,
@ -12,37 +13,60 @@ import {
settings, settings,
PlayerInformation, PlayerInformation,
CharacterTeam, CharacterTeam,
UpdateOtherPlayerDirections, UpdateMinimap,
GameObject, GameObject,
Command, Command,
OtherPlayerDirection, MinimapPlayer,
RemoteCallsForObject, RemoteCallsForObject,
RemoteCallsForObjects, RemoteCallsForObjects,
ServerAnnouncement, ServerAnnouncement,
PropertyUpdatesForObjects, PropertyUpdatesForObjects,
PropertyUpdatesForObject, PropertyUpdatesForObject,
PrimaryActionCommand, PrimaryActionCommand,
LeapActionCommand,
InputAcknowledgement,
} from 'shared'; } from 'shared';
import { Socket } from 'socket.io';
import { BoundingBox } from '../physics/bounding-boxes/bounding-box'; import { BoundingBox } from '../physics/bounding-boxes/bounding-box';
import { PhysicalContainer } from '../physics/containers/physical-container'; import { PhysicalContainer } from '../physics/containers/physical-container';
import { CharacterPhysical } from '../objects/character-physical';
import { PlayerContainer } from './player-container'; import { PlayerContainer } from './player-container';
import { PlayerBase } from './player-base'; import { PlayerBase } from './player-base';
// How often the server pings each client to measure round-trip time.
const pingIntervalSeconds = 1;
export class Player extends PlayerBase { export class Player extends PlayerBase {
// default, until the clients sends its real value // default, until the clients sends its real value
private aspectRatio: number = 16 / 9; private aspectRatio: number = 16 / 9;
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;
// Measured round-trip time to this client (ms) — the latency primitive that
// lag compensation, a latency HUD, and adaptive interpolation build on.
public rttMs = 0;
private timeSinceLastPing = 0;
private lastPingSentMs = 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
[PrimaryActionCommand.type]: (c: PrimaryActionCommand) => { // echo back for client-side prediction reconciliation.
this.character?.shootTowards(c.position); this.lastInputClientTimeMs = c.clientTimeMs;
this.character?.handleMovementAction(c);
},
[PrimaryActionCommand.type]: (c: PrimaryActionCommand) =>
this.character?.shootTowards(c.position, c.charge),
[LeapActionCommand.type]: (c: LeapActionCommand) => {
// Record receipt (whether or not leap() accepts it): either way its effect
// on bodyVelocity is now reflected in the streamed launch momentum, so the
// predictor must stop replaying this leap.
this.lastLeapClientTimeMs = c.clientTimeMs;
this.character?.leap();
}, },
}; };
@ -51,19 +75,24 @@ export class Player extends PlayerBase {
playerContainer: PlayerContainer, playerContainer: PlayerContainer,
objectContainer: PhysicalContainer, objectContainer: PhysicalContainer,
team: CharacterTeam, team: CharacterTeam,
private readonly socket: SocketIO.Socket, private readonly socket: Socket,
) { ) {
super(playerInfo, playerContainer, objectContainer, team); super(playerInfo, playerContainer, objectContainer, team);
this.createCharacter(); this.createCharacter();
this.step(0); this.step(0);
// The client already echoes a Pong for every Ping (see game.ts). Only one
// ping is ever in flight, so RTT is simply now send-time; no payload
// needed and no client change required.
this.socket.on(TransportEvents.Pong, () => {
if (this.lastPingSentMs > 0) {
this.rttMs = performance.now() - this.lastPingSentMs;
}
});
} }
protected createCharacter() { protected createCharacter() {
const preferredCenter = this.playerContainer.players.find( super.createCharacter();
(p) => p.character?.isAlive && p.team === this.team,
)?.center;
super.createCharacter(preferredCenter ?? vec2.create());
this.objectsPreviouslyInViewArea.push(this.character!); this.objectsPreviouslyInViewArea.push(this.character!);
this.queueCommandSend(new CreatePlayerCommand(this.character!)); this.queueCommandSend(new CreatePlayerCommand(this.character!));
@ -103,6 +132,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),
); );
@ -123,46 +159,39 @@ export class Player extends PlayerBase {
this.queueCommandSend(new CreateObjectsCommand(newlyIntersecting)); this.queueCommandSend(new CreateObjectsCommand(newlyIntersecting));
} }
this.queueCommandSend(new UpdateOtherPlayerDirections(this.getOtherPlayers())); this.queueCommandSend(new UpdateMinimap(this.getMinimapPlayers()));
this.queueCommandSend( this.queueCommandSend(
new PropertyUpdatesForObjects( new PropertyUpdatesForObjects(
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> {
if (!this.character) {
return [];
} }
const viewArea = calculateViewArea(this.center, this.aspectRatio, 0.9); // Every living player except this one, reported by absolute world position so
const bb = new BoundingBox(); // the client can plot the whole circular arena on its minimap.
bb.topLeft = viewArea.topLeft; private getMinimapPlayers(): Array<MinimapPlayer> {
bb.size = viewArea.size; return this.playerContainer.players
.filter((p) => p !== this && p.character?.isAlive)
const playersInViewArea = this.objectContainer .map(
.findIntersecting(bb)
.map((o) => o.gameObject)
.filter((g) => g instanceof CharacterPhysical);
const otherPlayers = this.playerContainer.players.filter(
(p) => p.character?.isAlive && playersInViewArea.indexOf(p.character!) < 0,
);
return otherPlayers.map(
(p) => (p) =>
new OtherPlayerDirection( new MinimapPlayer(p.character!.id, vec2.clone(p.character!.center), p.team),
p.character!.id,
vec2.normalize(
vec2.create(),
vec2.subtract(vec2.create(), p.character!.center, this.character!.center),
),
p.team,
),
); );
} }
@ -180,6 +209,12 @@ export class Player extends PlayerBase {
this.queueCommandSend(new RemoteCallsForObjects(remoteCalls)); this.queueCommandSend(new RemoteCallsForObjects(remoteCalls));
} }
if ((this.timeSinceLastPing += deltaTime) > pingIntervalSeconds) {
this.timeSinceLastPing = 0;
this.lastPingSentMs = performance.now();
this.socket.emit(TransportEvents.Ping);
}
if ((this.timeSinceLastMessage += deltaTime) > settings.updateMessageInterval) { if ((this.timeSinceLastMessage += deltaTime) > settings.updateMessageInterval) {
this.handleAnnouncements(); this.handleAnnouncements();
this.handleViewAreaUpdate(); this.handleViewAreaUpdate();
@ -205,8 +240,4 @@ export class Player extends PlayerBase {
this.queueCommandSend(new ServerAnnouncement(announcement)); this.queueCommandSend(new ServerAnnouncement(announcement));
} }
} }
public destroy() {
super.destroy();
}
} }

View file

@ -1,7 +0,0 @@
import { Command, GameObject } from 'shared';
export class ReactToCollisionCommand extends Command {
public constructor(public readonly other: GameObject) {
super();
}
}

View file

@ -1,13 +1,14 @@
{ {
"compilerOptions": { "compilerOptions": {
"outDir": "dist", "outDir": "dist",
"rootDir": "src",
"skipLibCheck": true, "skipLibCheck": true,
"target": "es6", "target": "es6",
"esModuleInterop": true, "esModuleInterop": true,
"strict": true, "strict": true,
"experimentalDecorators": true, "experimentalDecorators": true,
"downlevelIteration": true,
"moduleResolution": "node", "moduleResolution": "node",
"ignoreDeprecations": "6.0",
"module": "commonjs", "module": "commonjs",
"lib": ["dom", "es2017"], "lib": ["dom", "es2017"],
"typeRoots": ["./types", "./node_modules/@types"] "typeRoots": ["./types", "./node_modules/@types"]

View file

@ -32,9 +32,9 @@ module.exports = (env, argv) => ({
minimize: argv.mode !== 'development', minimize: argv.mode !== 'development',
minimizer: [ minimizer: [
new TerserJSPlugin({ new TerserJSPlugin({
sourceMap: false,
test: /\.js$/,
exclude: /node_modules/, exclude: /node_modules/,
// The custom serialization protocol keys on class names, so they must
// survive minification (see shared/src/serialization).
terserOptions: { terserOptions: {
keep_classnames: true, keep_classnames: true,
}, },
@ -50,16 +50,6 @@ module.exports = (env, argv) => ({
], ],
module: { module: {
rules: [ rules: [
{
test: /\.html$/,
use: {
loader: 'file-loader',
query: {
outputPath: '/',
name: '[name].[ext]',
},
},
},
{ {
test: /\.ts$/, test: /\.ts$/,
use: { use: {

32
eslint.config.js Normal file
View file

@ -0,0 +1,32 @@
// Flat config (ESLint 9+/10). Ported from the former .eslintrc.json.
const tseslint = require('typescript-eslint');
const prettierRecommended = require('eslint-plugin-prettier/recommended');
const unusedImports = require('eslint-plugin-unused-imports');
module.exports = tseslint.config(
{
ignores: ['**/node_modules/**', '**/dist/**', '**/lib/**'],
},
...tseslint.configs.recommended,
prettierRecommended,
{
files: ['**/*.ts'],
plugins: {
'unused-imports': unusedImports,
},
languageOptions: {
sourceType: 'module',
},
rules: {
'no-unused-vars': 'off',
'unused-imports/no-unused-imports': 'error',
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-non-null-assertion': 'off',
'@typescript-eslint/no-empty-function': 'off',
// Renamed from the former no-var-requires; keep require() allowed.
'@typescript-eslint/no-require-imports': 'off',
},
},
);

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"
}
]
}
}

24359
frontend/package-lock.json generated

File diff suppressed because it is too large Load diff

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": [],
@ -8,9 +8,12 @@
"*.scss" "*.scss"
], ],
"main": "index.js", "main": "index.js",
"engines": {
"node": ">=20"
},
"scripts": { "scripts": {
"build": "npx webpack --mode production", "build": "npx webpack --mode production",
"start": "npx webpack-dev-server --mode development", "dev": "npx webpack-dev-server --mode development",
"try-build": "npm run build && cd dist && python3 -m http.server 8080" "try-build": "npm run build && cd dist && python3 -m http.server 8080"
}, },
"browserslist": [ "browserslist": [
@ -22,32 +25,31 @@
} }
}, },
"devDependencies": { "devDependencies": {
"@types/socket.io-client": "^1.4.34", "@plausible-analytics/tracker": "^0.4.5",
"clean-webpack-plugin": "^3.0.0", "autoprefixer": "^10.5.0",
"common-config-webpack-plugin": "^2.0.1", "clean-webpack-plugin": "^4.0.0",
"css-loader": "^1.0.1", "copy-webpack-plugin": "^14.0.0",
"css-loader": "^7.1.4",
"file-loader": "^6.2.0",
"gl-matrix": "3.3.0", "gl-matrix": "3.3.0",
"html-webpack-inline-source-plugin": "^1.0.0-beta.2", "html-inline-css-webpack-plugin": "^1.11.2",
"html-webpack-inline-svg-plugin": "^2.3.0", "html-webpack-plugin": "^5.6.7",
"html-webpack-plugin": "^4.5.0", "mini-css-extract-plugin": "^2.10.2",
"image-config-webpack-plugin": "^2.0.0", "postcss": "^8.5.15",
"mini-css-extract-plugin": "^0.9.0", "postcss-loader": "^8.2.1",
"postcss-loader": "^3.0.0",
"resize-observer-polyfill": "^1.5.1", "resize-observer-polyfill": "^1.5.1",
"resolve-url-loader": "^3.1.1", "sass": "^1.100.0",
"sass": "^1.27.0", "sass-loader": "^17.0.0",
"sass-loader": "^8.0.2", "sdf-2d": "^0.8.0",
"sdf-2d": "^0.7.2",
"shared": "file:../shared", "shared": "file:../shared",
"socket.io-client": "^2.3.1", "socket.io-client": "^4.8.3",
"socket.io-msgpack-parser": "^2.0.0", "socket.io-msgpack-parser": "^3.0.2",
"source-map-loader": "^1.1.1", "source-map-loader": "^5.0.0",
"svg-url-loader": "^6.0.0", "terser-webpack-plugin": "^5.6.1",
"terser-webpack-plugin": "^4.2.2", "ts-loader": "^9.6.0",
"ts-config-webpack-plugin": "^2.0.0", "typescript": "^6.0.3",
"typescript": "^4.0.3", "webpack": "^5.107.2",
"webpack": "^4.43.0", "webpack-cli": "^7.0.3",
"webpack-cli": "^3.3.11", "webpack-dev-server": "^5.2.4"
"webpack-dev-server": "^3.11.0"
} }
} }

View file

@ -2,3 +2,14 @@ declare module '*.mp3' {
const content: string; const content: string;
export default content; export default content;
} }
// Asset imports handled by webpack loaders. TypeScript 5.x+ requires ambient
// declarations for side-effect imports of these, so declare them here.
declare module '*.png';
declare module '*.ico';
declare module '*.svg';
declare module '*.scss';
// webpack's `mode` inlines `process.env.NODE_ENV` via DefinePlugin. Declare just
// that one global so browser code can read it without pulling in all of @types/node.
declare const process: { env: { NODE_ENV?: string } };

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>
@ -68,13 +68,13 @@
<img <img
title="Enable sounds" title="Enable sounds"
alt="speaker with sound waves" alt="speaker with sound waves"
src="../static/volume.svg" src="static/volume.svg"
/> />
</label> </label>
<label for="enable-music"> <label for="enable-music">
<input id="enable-music" type="checkbox" /> <input id="enable-music" type="checkbox" />
<img title="Enable music" alt="music note" src="../static/music.svg" /> <img title="Enable music" alt="music note" src="static/music.svg" />
</label> </label>
<label for="enable-vibration"> <label for="enable-vibration">
@ -82,7 +82,7 @@
<img <img
title="Enable vibration on mobile" title="Enable vibration on mobile"
alt="vibrating mobile" alt="vibrating mobile"
src="../static/vibrate.svg" src="static/vibrate.svg"
/> />
</label> </label>
@ -90,7 +90,7 @@
title="Exit from current game" title="Exit from current game"
id="logout" id="logout"
alt="logout" alt="logout"
src="../static/logout.svg" src="static/logout.svg"
/> />
</section> </section>
</div> </div>
@ -101,7 +101,7 @@
id="toggle-settings" id="toggle-settings"
class="icon" class="icon"
alt="toggle-settings" alt="toggle-settings"
src="../static/settings.svg" src="static/settings.svg"
/> />
</div> </div>
@ -109,17 +109,17 @@
class="full-screen-controllers" class="full-screen-controllers"
id="minimize" id="minimize"
alt="minimize-application" alt="minimize-application"
src="../static/minimize.svg" src="static/minimize.svg"
/> />
<img <img
class="full-screen-controllers" class="full-screen-controllers"
id="maximize" id="maximize"
alt="maximize-application" alt="maximize-application"
src="../static/maximize.svg" src="static/maximize.svg"
/> />
<div id="spinner-container"> <div id="spinner-container">
<img id="spinner" alt="waiting" src="../static/spinner.svg" /> <img id="spinner" alt="waiting" src="static/spinner.svg" />
</div> </div>
</body> </body>
</html> </html>

View file

@ -7,6 +7,7 @@ import {
ProjectileBase, ProjectileBase,
} from 'shared'; } from 'shared';
import './main.scss'; import './main.scss';
import './scripts/analytics';
import '../static/og-image.png'; import '../static/og-image.png';
import '../static/favicons/apple-touch-icon.png'; import '../static/favicons/apple-touch-icon.png';
import '../static/favicons/favicon-16x16.png'; import '../static/favicons/favicon-16x16.png';
@ -16,9 +17,6 @@ import { LandingPageBackground } from './scripts/landing-page-background';
import { JoinFormHandler } from './scripts/join-form-handler'; import { JoinFormHandler } from './scripts/join-form-handler';
import { handleFullScreen } from './scripts/helper/handle-full-screen'; import { handleFullScreen } from './scripts/helper/handle-full-screen';
import { Game } from './scripts/game'; import { Game } from './scripts/game';
import { handleInsights } from './scripts/handle-insights';
import { getInsightsFromRenderer } from './scripts/get-insights-from-renderer';
import { Renderer } from 'sdf-2d';
import ResizeObserver from 'resize-observer-polyfill'; import ResizeObserver from 'resize-observer-polyfill';
import { OptionsHandler } from './scripts/options-handler'; import { OptionsHandler } from './scripts/options-handler';
import { hide } from './scripts/helper/hide'; import { hide } from './scripts/helper/hide';
@ -55,39 +53,6 @@ const enableMusic = document.querySelector('#enable-music') as HTMLInputElement;
const enableVibration = document.querySelector('#enable-vibration') as HTMLInputElement; const enableVibration = document.querySelector('#enable-vibration') as HTMLInputElement;
const spinner = document.querySelector('#spinner-container') as HTMLElement; const spinner = document.querySelector('#spinner-container') as HTMLElement;
let isInGame = false;
const startInsights = (getRenderer: () => Renderer | undefined) => {
const { vendor, renderer } = getInsightsFromRenderer(getRenderer());
handleInsights(
{
vendor,
renderer,
referrer: document.referrer,
connection: (navigator as any)?.connection?.effectiveType,
devicePixelRatio: devicePixelRatio,
},
() => {
const {
fps,
renderScale,
lightScale,
canvasWidth,
canvasHeight,
} = getInsightsFromRenderer(getRenderer());
return {
isInGame,
fps,
renderScale,
lightScale,
canvasWidth,
canvasHeight,
};
},
);
};
const toggleSettings = () => { const toggleSettings = () => {
settings.className = settings.className === 'open' ? '' : 'open'; settings.className = settings.className === 'open' ? '' : 'open';
SoundHandler.play(Sounds.click); SoundHandler.play(Sounds.click);
@ -157,9 +122,6 @@ const main = async () => {
}); });
window.onpopstate = () => game.destroy(); window.onpopstate = () => game.destroy();
let backgroundRenderer: Renderer | undefined;
startInsights(() => (isInGame ? game.renderer : backgroundRenderer));
for (;;) { for (;;) {
show(spinner); show(spinner);
hide(logoutButton, true); hide(logoutButton, true);
@ -168,7 +130,7 @@ const main = async () => {
const background = new LandingPageBackground(canvas); const background = new LandingPageBackground(canvas);
const joinHandler = new JoinFormHandler(joinGameForm, serverContainer); const joinHandler = new JoinFormHandler(joinGameForm, serverContainer);
backgroundRenderer = await background.renderer; await background.renderer;
hide(spinner); hide(spinner);
const playerDecision = await joinHandler.getPlayerDecision(); const playerDecision = await joinHandler.getPlayerDecision();
@ -185,11 +147,9 @@ const main = async () => {
game = new Game(playerDecision, canvas, overlay); game = new Game(playerDecision, canvas, overlay);
const gameOver = game.start(); const gameOver = game.start();
await game.started; await game.started;
isInGame = true;
hide(spinner); hide(spinner);
show(logoutButton, true, 'block'); show(logoutButton, true, 'block');
await gameOver; await gameOver;
isInGame = false;
} }
} catch (e) { } catch (e) {
console.error(e); console.error(e);

View file

@ -20,6 +20,7 @@
html { html {
font-size: 0.85rem; font-size: 0.85rem;
@media (max-width: $breakpoint) { @media (max-width: $breakpoint) {
font-size: 0.6rem; font-size: 0.6rem;
} }
@ -30,30 +31,38 @@ img {
} }
h1 { h1 {
&, &,
* { * {
font-family: 'Comfortaa', sans-serif; font-family: 'Comfortaa', sans-serif;
font-weight: 400; font-weight: 400;
} }
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;
} }
@media (max-height: $height-breakpoint) { @media (max-height: $height-breakpoint) {
display: none; display: none;
} }
} }
.red {
color: $accent;
&::selection {
color: $accent;
background-color: white;
}
}
html, html,
body, body,
canvas { canvas {
@ -75,8 +84,10 @@ body {
top: 0; top: 0;
@include background; @include background;
#spinner { #spinner {
@include square(20vmax); @include square(20vmax);
@media (max-width: $breakpoint) { @media (max-width: $breakpoint) {
@include square(20vmax); @include square(20vmax);
} }
@ -118,12 +129,14 @@ body {
border-radius: 1000px; border-radius: 1000px;
transition: transform 200ms; transition: transform 200ms;
&.decla { &.blue {
color: $bright-decla; color: $bright-blue;
div:first-child {
background-color: $bright-decla; .health {
background-color: $bright-blue;
&:before { &:before {
background-color: $bright-decla; background-color: $bright-blue;
opacity: 0.3; opacity: 0.3;
} }
} }
@ -131,8 +144,10 @@ body {
&.red { &.red {
color: $bright-red; color: $bright-red;
div:first-child {
.health {
background-color: $bright-red; background-color: $bright-red;
&:before { &:before {
background-color: $bright-red; background-color: $bright-red;
opacity: 0.4; opacity: 0.4;
@ -140,7 +155,7 @@ body {
} }
} }
div:first-child { .health {
position: relative; position: relative;
height: 5px; height: 5px;
border-radius: 1000px; border-radius: 1000px;
@ -155,9 +170,33 @@ body {
} }
} }
div:not(:first-child) { .charge {
letter-spacing: 2px; height: 3px;
margin-top: 2px;
border-radius: 1000px;
background-color: rgba(255, 255, 255, 0.65);
}
.stats {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.8em; font-size: 0.8em;
font-weight: 700;
text-shadow: 0 0 4px rgba(0, 0, 0, 0.9);
.stat {
display: inline-flex;
align-items: center;
gap: 3px;
}
.icon {
width: 1.1em;
height: 1.1em;
fill: currentColor;
filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.9));
}
} }
} }
@ -168,36 +207,79 @@ 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 {
font-size: 1.2em; font-size: 1.2em;
&.decla { font-weight: 700;
color: $bright-decla; animation: falling-point 2s ease-out forwards;
&.blue {
color: $bright-blue;
} }
&.red { &.red {
color: $bright-red; color: $bright-red;
} }
} }
.other-player-arrow { // Top-down radar of the whole arena (see Minimap). The circular border is
// the world boundary; dots are painted onto the canvas.
.minimap {
top: $small-padding;
left: $small-padding;
@include square(132px);
// On narrow screens the centred scoreboard bar reaches close to the left
// edge, so drop the minimap below it to avoid a corner overlap.
@media (max-width: $breakpoint) {
@include square(96px);
top: calc(#{$small-padding} + 32px);
}
border-radius: 1000px;
background-color: rgba(0, 0, 0, 0.35);
box-shadow:
inset 0 0 3px 0 rgba(0, 0, 0, 0.4),
0 0 10px rgba(0, 0, 0, 0.3);
border: $border-width solid rgba(255, 255, 255, 0.25);
z-index: 100;
}
// Off-screen pointer to the keystone "Heart", tinted by its current owner.
.keystone-arrow {
transition: transform 150ms; transition: transform 150ms;
opacity: 0.9;
@include square($large-icon); @include square($large-icon);
@media (max-width: $breakpoint) { @media (max-width: $breakpoint) {
@include square($small-icon); @include square($small-icon);
} }
mask-image: url('../static/chevron.svg'); mask-image: url('../static/chevron.svg');
mask-size: contain; mask-size: contain;
background-color: $bright-neutral;
&.decla { &.blue {
background-color: $bright-decla; background-color: $bright-blue;
} }
&.red { &.red {
background-color: $bright-red; background-color: $bright-red;
} }
&.neutral {
background-color: $bright-neutral;
}
} }
.joystick { .joystick {
@ -219,6 +301,85 @@ body {
} }
} }
.touch-button {
display: none;
pointer-events: auto;
touch-action: none;
cursor: pointer;
$size: $large-icon * 1.6;
@include square($size);
top: auto;
left: auto;
bottom: $medium-padding;
border-radius: 1000px;
background-color: rgba(255, 255, 255, 0.12);
box-shadow: inset 0 0 8px 3px rgba(0, 0, 0, 0.33);
border: $border-width solid rgba(255, 255, 255, 0.4);
font-size: 1.6rem;
line-height: $size - 2 * $border-width;
text-align: center;
text-shadow: 0 0 6px rgba(0, 0, 0, 0.8);
@media (hover: none) and (pointer: coarse) {
display: inline-block;
}
&.fire {
right: $medium-padding;
&::after {
content: '\25C9';
}
// Twin-stick aim indicator: drawn from the button centre toward the
// current drag direction while aiming a shot (see TouchListener).
.aim-line {
position: absolute;
left: 50%;
top: 50%;
width: 130px;
height: 3px;
border-radius: 2px;
transform-origin: left center;
transform: translateY(-50%) rotate(0rad);
background: linear-gradient(90deg,
rgba(255, 255, 255, 0.85),
rgba(255, 255, 255, 0));
box-shadow: 0 0 6px rgba(0, 0, 0, 0.6);
opacity: 0;
pointer-events: none;
transition: opacity 80ms;
}
}
&.leap {
right: $medium-padding;
bottom: $medium-padding + $large-icon * 1.6 + $small-padding;
&::after {
content: '\25B2';
}
}
.strength-ring {
position: absolute;
top: -3px;
left: -3px;
width: calc(100% + 6px);
height: calc(100% + 6px);
border-radius: 1000px;
pointer-events: none;
-webkit-mask: radial-gradient(farthest-side,
transparent calc(100% - 5px),
#000 calc(100% - 5px));
mask: radial-gradient(farthest-side,
transparent calc(100% - 5px),
#000 calc(100% - 5px));
}
}
.announcement { .announcement {
top: 25%; top: 25%;
transform: translateX(calc(-50% + 50vw)) translateY(-50%); transform: translateX(calc(-50% + 50vw)) translateY(-50%);
@ -233,8 +394,8 @@ body {
display: none; display: none;
} }
.decla { .blue {
color: $bright-decla; color: $bright-blue;
} }
.red { .red {
@ -242,20 +403,65 @@ body {
} }
} }
.tutorial-hint {
top: auto;
bottom: calc(#{$medium-padding} + #{$large-icon} * 1.6 + #{$medium-padding});
left: 50%;
transform: translateX(-50%);
white-space: nowrap;
font-size: 1.6rem;
@include background;
z-index: 1000;
padding: $small-padding $medium-padding;
border-radius: 1000px;
opacity: 0.9;
&:empty {
display: none;
}
}
.planet-progress { .planet-progress {
top: $small-padding; top: $small-padding;
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
width: 50%; width: 50%;
display: flex; $height: 20px;
$height: 8px;
height: $height; height: $height;
justify-content: space-between;
box-shadow: inset 0 0 3px 0px rgba(0, 0, 0, 0.2);
border-radius: 4px;
z-index: 100; z-index: 100;
pointer-events: none;
background-color: rgba(0, 0, 0, 0.35);
box-shadow: inset 0 0 3px 0px rgba(0, 0, 0, 0.4);
border-radius: 1000px;
.fill {
position: absolute;
top: 0;
height: $height;
transition: width $animation-time;
}
.fill.blue {
right: 50%;
background: $bright-blue;
color: $bright-blue;
border-radius: 1000px 0 0 1000px;
}
.fill.red {
left: 50%;
background: $bright-red;
color: $bright-red;
border-radius: 0 1000px 1000px 0;
}
.fill.match-point {
z-index: 1;
animation: match-point-pulse 1.2s ease-in-out infinite;
}
&::before { &::before {
content: ''; content: '';
@ -263,38 +469,87 @@ body {
left: 50%; left: 50%;
top: 50%; top: 50%;
transform: translateX(-50%) translateY(-50%); transform: translateX(-50%) translateY(-50%);
background-color: #888; background-color: #fff;
height: 24px; height: $height + 8px;
width: 4px; width: 3px;
border-radius: 1000px; border-radius: 1000px;
z-index: 2;
} }
.score {
position: absolute;
top: 50%;
transform: translateY(-50%);
z-index: 3;
font-size: 0.95rem;
font-weight: 700;
line-height: 1;
color: #fff;
text-shadow:
0 0 3px rgba(0, 0, 0, 0.95),
0 0 6px rgba(0, 0, 0, 0.8);
opacity: 0.8;
transition:
opacity $animation-time,
text-shadow $animation-time,
font-size $animation-time;
}
.score.blue {
right: calc(50% + #{$small-padding});
}
.score.red {
left: calc(50% + #{$small-padding});
}
.score.leading {
opacity: 1;
font-size: 1.2rem;
}
.score.blue.leading {
text-shadow:
0 0 3px rgba(0, 0, 0, 0.95),
0 0 8px $bright-blue;
}
.score.red.leading {
text-shadow:
0 0 3px rgba(0, 0, 0, 0.95),
0 0 8px $bright-red;
}
.you-marker {
position: absolute;
top: 100%;
margin-top: 4px;
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.08em;
white-space: nowrap;
text-shadow: 0 0 4px rgba(0, 0, 0, 0.9);
&::after { &::after {
content: ''; content: '';
position: absolute; position: absolute;
left: 50%; left: 50%;
top: 50%; bottom: 100%;
transform: translateX(-50%) translateY(50%); transform: translateX(-50%);
border-left: 4px solid transparent;
@include square(24px); border-right: 4px solid transparent;
border-bottom: 5px solid currentColor;
background-image: url('../static/flag.svg');
background-size: contain;
} }
div { &.blue {
height: $height; right: calc(50% + #{$small-padding});
box-shadow: inset 0 0 3px 0px rgba(0, 0, 0, 0.2); color: $bright-blue;
} }
div:nth-child(1) { &.red {
background: $bright-decla; left: calc(50% + #{$small-padding});
border-radius: 100px 0 0 100px; color: $bright-red;
} }
div:nth-child(2) {
background: $bright-red;
border-radius: 0 100px 100px 0;
} }
} }
} }
@ -308,6 +563,7 @@ body {
background-color: transparent; background-color: transparent;
width: 3px; width: 3px;
} }
&::-webkit-scrollbar-thumb { &::-webkit-scrollbar-thumb {
background-color: $accent; background-color: $accent;
border-radius: $border-radius; border-radius: $border-radius;
@ -321,6 +577,10 @@ body {
bottom: 0; bottom: 0;
left: 0; left: 0;
@media (hover: none) and (pointer: coarse) {
display: none;
}
box-sizing: content-box; box-sizing: content-box;
user-select: none; user-select: none;
@ -328,6 +588,7 @@ body {
padding: $medium-padding; padding: $medium-padding;
@include square($large-icon); @include square($large-icon);
@media (max-width: $breakpoint) { @media (max-width: $breakpoint) {
@include square($small-icon); @include square($small-icon);
padding: $small-padding; padding: $small-padding;
@ -338,3 +599,342 @@ body {
} }
} }
} }
.feedback-hud {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
user-select: none;
z-index: 2000;
overflow: hidden;
.hitmarker {
position: absolute;
transform: translate(-50%, -50%);
@include square(34px);
opacity: 0.95;
&::before,
&::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 16px;
height: 3px;
border-radius: 2px;
background-color: white;
box-shadow:
0 0 6px rgba(255, 255, 255, 0.9),
0 0 3px rgba(0, 0, 0, 0.9);
transform-origin: center;
}
&::before {
transform: translate(-50%, -50%) rotate(45deg);
}
&::after {
transform: translate(-50%, -50%) rotate(-45deg);
}
animation: hitmarker-pop 220ms cubic-bezier(0.2, 0.9, 0.3, 1) forwards;
&.charged {
@include square(44px);
&::before,
&::after {
width: 24px;
height: 4px;
background-color: $accent;
box-shadow:
0 0 10px rgba($accent, 0.9),
0 0 4px rgba(0, 0, 0, 0.9);
}
}
}
.kill-flash {
position: absolute;
inset: 0;
pointer-events: none;
background: radial-gradient(ellipse at center,
transparent 42%,
rgba($accent, 0) 56%,
rgba($accent, 0.55) 100%);
animation: kill-flash 420ms ease-out forwards;
&.charged {
background: radial-gradient(ellipse at center,
transparent 36%,
rgba($accent, 0) 50%,
rgba($accent, 0.8) 100%);
}
}
.kill-popup {
position: absolute;
transform: translate(-50%, -50%);
font-weight: 800;
font-size: 1.7rem;
white-space: nowrap;
color: $bright-neutral;
text-shadow:
0 0 10px rgba(0, 0, 0, 0.9),
0 0 18px rgba(255, 255, 255, 0.35);
animation: kill-popup-rise 1200ms cubic-bezier(0.18, 0.9, 0.3, 1) forwards;
&.charged {
color: $accent;
text-shadow:
0 0 10px rgba(0, 0, 0, 0.9),
0 0 18px rgba($accent, 0.6);
}
.heal {
color: #6fcf6f;
}
}
.streak-callout {
position: absolute;
top: 33%;
left: 50%;
transform: translateX(-50%);
font-weight: 800;
font-size: 3rem;
color: $accent;
text-shadow:
0 0 12px rgba(0, 0, 0, 0.9),
0 0 24px rgba($accent, 0.7);
white-space: nowrap;
animation: streak-pop 1400ms cubic-bezier(0.18, 0.9, 0.3, 1) forwards;
}
.killfeed {
position: absolute;
top: calc(#{$small-padding} + 36px);
right: $medium-padding;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 6px;
.kill-entry {
font-size: 1rem;
padding: 4px 10px;
border-radius: 1000px;
@include background;
text-shadow: 0 0 4px rgba(0, 0, 0, 0.9);
animation: kill-entry-fade 4500ms ease-out forwards;
b {
color: $accent;
}
}
}
// Centred "Eliminated" overlay shown while the local player is dead; the
// respawn countdown is the server-driven "Reviving in N…" announcement.
.elimination {
position: absolute;
top: 42%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
animation: elimination-in 220ms ease-out;
.elimination-title {
font-weight: 800;
font-size: 3.4rem;
letter-spacing: 0.04em;
color: $accent;
text-shadow:
0 0 12px rgba(0, 0, 0, 0.9),
0 0 28px rgba($accent, 0.6);
}
.elimination-sub {
margin-top: 6px;
font-size: 1.3rem;
opacity: 0.85;
text-shadow: 0 0 8px rgba(0, 0, 0, 0.9);
}
}
}
@keyframes contested-pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.35;
}
}
.charge-ring {
position: fixed;
@include square(56px);
margin: -28px 0 0 -28px;
border-radius: 1000px;
pointer-events: none;
user-select: none;
z-index: 2000;
opacity: 0;
transition: opacity 100ms;
-webkit-mask: radial-gradient(farthest-side,
transparent calc(100% - 6px),
#000 calc(100% - 6px));
mask: radial-gradient(farthest-side,
transparent calc(100% - 6px),
#000 calc(100% - 6px));
&.full {
filter: drop-shadow(0 0 6px rgba(255, 255, 255, 0.8));
}
}
@keyframes match-point-pulse {
0%,
100% {
box-shadow: 0 0 4px 0 currentColor;
}
50% {
box-shadow: 0 0 14px 3px currentColor;
}
}
@keyframes falling-point {
0% {
opacity: 1;
transform: translate(-50%, -50%);
}
100% {
opacity: 0;
transform: translate(-50%, calc(-50% - 90px));
}
}
@keyframes hitmarker-pop {
0% {
opacity: 0;
transform: translate(-50%, -50%) scale(2);
}
18% {
opacity: 1;
transform: translate(-50%, -50%) scale(0.82);
}
36% {
transform: translate(-50%, -50%) scale(1.06);
}
100% {
opacity: 0;
transform: translate(-50%, -50%) scale(0.95);
}
}
@keyframes kill-flash {
0% {
opacity: 0;
}
12% {
opacity: 1;
}
100% {
opacity: 0;
}
}
@keyframes kill-popup-rise {
0% {
opacity: 0;
transform: translate(-50%, -20%) scale(0.6);
}
12% {
opacity: 1;
transform: translate(-50%, -56%) scale(1.25);
}
28% {
transform: translate(-50%, -62%) scale(1);
}
100% {
opacity: 0;
transform: translate(-50%, -170%) scale(1);
}
}
@keyframes streak-pop {
0% {
opacity: 0;
transform: translateX(-50%) scale(0.5);
}
16% {
opacity: 1;
transform: translateX(-50%) scale(1.25);
}
32% {
transform: translateX(-50%) scale(0.95);
}
70% {
opacity: 1;
transform: translateX(-50%) scale(1);
}
100% {
opacity: 0;
transform: translateX(-50%) scale(1);
}
}
@keyframes kill-entry-fade {
0% {
opacity: 0;
transform: translateX(20px);
}
10% {
opacity: 1;
transform: translateX(0);
}
80% {
opacity: 1;
}
100% {
opacity: 0;
}
}
@keyframes elimination-in {
0% {
opacity: 0;
transform: translate(-50%, -42%) scale(0.9);
}
100% {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
}

View file

@ -0,0 +1,17 @@
import { init as plausibleInit } from '@plausible-analytics/tracker';
const ANALYTICS_AUTO_CAPTURE_PAGEVIEWS = true;
const ANALYTICS_DOMAIN = 'doppler.schmelczer.dev';
const ANALYTICS_ENDPOINT = 'https://stats.schmelczer.dev/status';
const ANALYTICS_LOGGING = process.env.NODE_ENV !== 'production';
try {
plausibleInit({
domain: ANALYTICS_DOMAIN,
endpoint: ANALYTICS_ENDPOINT,
autoCapturePageviews: ANALYTICS_AUTO_CAPTURE_PAGEVIEWS,
logging: ANALYTICS_LOGGING,
});
} catch (error) {
console.warn('Could not initialize analytics.', error);
}

View file

@ -0,0 +1,58 @@
import { holdDurationToCharge } from 'shared';
import { Pointer } from './helper/pointer';
export abstract class ChargeIndicator {
private static element?: HTMLElement;
private static heldSince = 0;
private static raf = 0;
private static followPointer = false;
public static begin(x: number, y: number, followPointer = false) {
this.end();
const element = document.createElement('div');
element.className = 'charge-ring';
element.style.left = `${x}px`;
element.style.top = `${y}px`;
document.body.appendChild(element);
this.element = element;
this.heldSince = performance.now();
this.followPointer = followPointer;
this.raf = requestAnimationFrame(this.update);
}
public static end() {
if (this.element) {
cancelAnimationFrame(this.raf);
this.element.parentElement?.removeChild(this.element);
this.element = undefined;
}
}
private static update = () => {
const element = ChargeIndicator.element;
if (!element) {
return;
}
const charge = holdDurationToCharge(
(performance.now() - ChargeIndicator.heldSince) / 1000,
);
element.style.opacity = charge < 0.12 ? '0' : '1';
element.style.background = `conic-gradient(rgba(255, 255, 255, 0.85) ${
charge * 360
}deg, rgba(255, 255, 255, 0.15) 0deg)`;
element.classList.toggle('full', charge >= 1);
if (ChargeIndicator.followPointer) {
const cursor = Pointer.getDisplayPosition();
if (cursor) {
element.style.left = `${cursor.x}px`;
element.style.top = `${cursor.y}px`;
}
}
ChargeIndicator.raf = requestAnimationFrame(ChargeIndicator.update);
};
}

View file

@ -1,7 +1,8 @@
import { Command, CommandReceiver, serialize, TransportEvents } from 'shared'; import { Command, CommandReceiver, serialize, TransportEvents } from 'shared';
import { Socket } from 'socket.io-client';
export class CommandSocket extends CommandReceiver { export class CommandSocket extends CommandReceiver {
constructor(private readonly socket: SocketIOClient.Socket) { constructor(private readonly socket: Socket) {
super(); super();
} }

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

@ -1,28 +1,58 @@
import { vec2 } from 'gl-matrix'; import { vec2 } from 'gl-matrix';
import { CommandGenerator, PrimaryActionCommand, SecondaryActionCommand } from 'shared'; import { CommandGenerator, PrimaryActionCommand, holdDurationToCharge } from 'shared';
import { Game } from '../game'; import { Game } from '../game';
import { ChargeIndicator } from '../charge-indicator';
import { Pointer } from '../helper/pointer';
export class MouseListener extends CommandGenerator { export class MouseListener extends CommandGenerator {
constructor(private target: HTMLElement, private readonly game: Game) { // Timestamp (ms) of the primary press, or null when not held. On release the
// held duration is mapped to the charge scalar; a quick tap reads as ~0.
private primaryDownAt: number | null = null;
constructor(
private target: HTMLElement,
private readonly game: Game,
) {
super(); super();
target.addEventListener('mousedown', this.mouseDownListener); target.addEventListener('mousedown', this.mouseDownListener);
target.addEventListener('mouseup', this.mouseUpListener);
target.addEventListener('mousemove', this.mouseMoveListener);
target.addEventListener('contextmenu', this.contextMenuListener); target.addEventListener('contextmenu', this.contextMenuListener);
} }
private mouseDownListener = (event: MouseEvent) => { // Only the screen position is stored; it is reprojected to world space each
const position = this.positionFromEvent(event); // frame so the gaze stays correct even while the camera pans under a still
// cursor.
private mouseMoveListener = (event: MouseEvent) => {
Pointer.setDisplayPosition(event.clientX, event.clientY);
};
private mouseDownListener = (event: MouseEvent) => {
if (event.button === 0) { if (event.button === 0) {
this.sendCommandToSubscribers(new PrimaryActionCommand(position)); this.primaryDownAt = performance.now();
// The ring follows the cursor and only fades in once this press has
// clearly become a hold.
ChargeIndicator.begin(event.clientX, event.clientY, true);
} }
}; };
private mouseUpListener = (event: MouseEvent) => {
if (event.button !== 0 || this.primaryDownAt === null) {
return;
}
ChargeIndicator.end();
const charge = holdDurationToCharge((performance.now() - this.primaryDownAt) / 1000);
this.primaryDownAt = null;
this.sendCommandToSubscribers(
new PrimaryActionCommand(this.positionFromEvent(event), charge),
);
};
// Suppress the browser context menu on the canvas; right-click has no action.
private contextMenuListener = (event: MouseEvent) => { private contextMenuListener = (event: MouseEvent) => {
event.preventDefault(); event.preventDefault();
const position = this.positionFromEvent(event);
this.sendCommandToSubscribers(new SecondaryActionCommand(position));
}; };
private positionFromEvent(event: MouseEvent): vec2 { private positionFromEvent(event: MouseEvent): vec2 {
@ -32,7 +62,10 @@ export class MouseListener extends CommandGenerator {
} }
public destroy() { public destroy() {
ChargeIndicator.end();
this.target.removeEventListener('mousedown', this.mouseDownListener); this.target.removeEventListener('mousedown', this.mouseDownListener);
this.target.removeEventListener('mouseup', this.mouseUpListener);
this.target.removeEventListener('mousemove', this.mouseMoveListener);
this.target.removeEventListener('contextmenu', this.contextMenuListener); this.target.removeEventListener('contextmenu', this.contextMenuListener);
} }
} }

View file

@ -1,15 +1,38 @@
import { vec2 } from 'gl-matrix'; import { vec2 } from 'gl-matrix';
import { CommandGenerator, MoveActionCommand, last, PrimaryActionCommand } from 'shared'; import {
CommandGenerator,
MoveActionCommand,
last,
PrimaryActionCommand,
LeapActionCommand,
holdDurationToCharge,
settings,
} from 'shared';
import { Game } from '../game'; import { Game } from '../game';
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;
private static readonly deltaScaling = 0.4; private static readonly deltaScaling = 0.4;
// Min screen drag (px) from the fire button before a shot is aimed by the
// drag direction instead of firing straight ahead.
private static readonly aimDeadZone = 18;
private joystick: HTMLElement; private joystick: HTMLElement;
private joystickButton: HTMLElement; private joystickButton: HTMLElement;
private isJoystickActive = false; private isJoystickActive = false;
private touchStartPosition!: vec2; private touchStartPosition!: vec2;
private primaryDownAt: number | null = null;
private fireButton: HTMLElement;
private fireStrengthRing: HTMLElement;
private fireAimLine!: HTMLElement;
private leapButton: HTMLElement;
private fireDownAt: number | null = null;
private fireButtonCenter: vec2 | null = null;
private fireAimScreen: vec2 | null = null;
constructor( constructor(
private target: HTMLElement, private target: HTMLElement,
@ -23,6 +46,26 @@ export class TouchListener extends CommandGenerator {
this.joystickButton = document.createElement('div'); this.joystickButton = document.createElement('div');
this.joystick.appendChild(this.joystickButton); this.joystick.appendChild(this.joystickButton);
this.fireButton = document.createElement('div');
this.fireButton.className = 'touch-button fire';
this.fireStrengthRing = document.createElement('div');
this.fireStrengthRing.className = 'strength-ring';
this.fireButton.appendChild(this.fireStrengthRing);
this.fireAimLine = document.createElement('div');
this.fireAimLine.className = 'aim-line';
this.fireButton.appendChild(this.fireAimLine);
this.fireButton.addEventListener('touchstart', this.fireButtonDownListener);
this.fireButton.addEventListener('touchmove', this.fireButtonMoveListener);
this.fireButton.addEventListener('touchend', this.fireButtonUpListener);
this.leapButton = document.createElement('div');
this.leapButton.className = 'touch-button leap';
this.leapButton.addEventListener('touchstart', this.leapButtonListener);
this.overlay.appendChild(this.fireButton);
this.overlay.appendChild(this.leapButton);
target.addEventListener('touchstart', this.touchStartListener); target.addEventListener('touchstart', this.touchStartListener);
target.addEventListener('touchmove', this.touchMoveListener); target.addEventListener('touchmove', this.touchMoveListener);
target.addEventListener('touchend', this.touchEndListener); target.addEventListener('touchend', this.touchEndListener);
@ -43,6 +86,8 @@ export class TouchListener extends CommandGenerator {
event.touches[0].clientX, event.touches[0].clientX,
event.touches[0].clientY, event.touches[0].clientY,
); );
this.primaryDownAt = performance.now();
ChargeIndicator.begin(this.touchStartPosition.x, this.touchStartPosition.y);
} }
}; };
@ -60,6 +105,8 @@ export class TouchListener extends CommandGenerator {
if (!this.isJoystickActive && deltaLength > TouchListener.deadZone) { if (!this.isJoystickActive && deltaLength > TouchListener.deadZone) {
this.isJoystickActive = true; this.isJoystickActive = true;
this.primaryDownAt = null;
ChargeIndicator.end();
this.overlay.appendChild(this.joystick); this.overlay.appendChild(this.joystick);
this.joystickButton.style.transform = `translateX(-50%) translateY(-50%)`; this.joystickButton.style.transform = `translateX(-50%) translateY(-50%)`;
this.joystick.style.transform = `translateX(${this.touchStartPosition.x}px) translateY(${this.touchStartPosition.y}px) translateX(-50%) translateY(-50%)`; this.joystick.style.transform = `translateX(${this.touchStartPosition.x}px) translateY(${this.touchStartPosition.y}px) translateX(-50%) translateY(-50%)`;
@ -71,33 +118,156 @@ 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) {
this.sendCommandToSubscribers(new MoveActionCommand(vec2.normalize(delta, delta))); const direction = vec2.normalize(delta, delta);
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();
if (!this.isJoystickActive) { if (!this.isJoystickActive) {
ChargeIndicator.end();
const charge =
this.primaryDownAt === null
? 0
: holdDurationToCharge((performance.now() - this.primaryDownAt) / 1000);
this.primaryDownAt = null;
const center = vec2.fromValues( const center = vec2.fromValues(
event.changedTouches[0].clientX, event.changedTouches[0].clientX,
event.changedTouches[0].clientY, event.changedTouches[0].clientY,
); );
this.sendCommandToSubscribers( this.sendCommandToSubscribers(
new PrimaryActionCommand(this.game.displayToWorldCoordinates(center)), new PrimaryActionCommand(this.game.displayToWorldCoordinates(center), charge),
); );
} 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());
} }
}; };
private swallowTouch = (event: TouchEvent) => {
event.preventDefault();
event.stopPropagation();
};
private leapButtonListener = (event: TouchEvent) => {
this.swallowTouch(event);
const clientTimeMs = localCharacterPredictor.recordLeap();
this.sendCommandToSubscribers(new LeapActionCommand(clientTimeMs));
};
private fireButtonDownListener = (event: TouchEvent) => {
this.swallowTouch(event);
this.fireDownAt = performance.now();
const rect = this.fireButton.getBoundingClientRect();
this.fireButtonCenter = vec2.fromValues(
rect.left + rect.width / 2,
rect.top + rect.height / 2,
);
this.fireAimScreen = null;
ChargeIndicator.begin(this.fireButtonCenter[0], this.fireButtonCenter[1]);
};
// Dragging from the fire button aims the shot: the drag vector sets the
// direction, decoupling aim from movement so a touch player can fire one way
// while walking another. A tap with no meaningful drag fires straight ahead.
private fireButtonMoveListener = (event: TouchEvent) => {
this.swallowTouch(event);
if (this.fireDownAt === null || !this.fireButtonCenter) {
return;
}
const touch = event.targetTouches[0] ?? event.changedTouches[0];
if (!touch) {
return;
}
this.fireAimScreen = vec2.fromValues(touch.clientX, touch.clientY);
const dx = this.fireAimScreen[0] - this.fireButtonCenter[0];
const dy = this.fireAimScreen[1] - this.fireButtonCenter[1];
if (dx * dx + dy * dy > TouchListener.aimDeadZone * TouchListener.aimDeadZone) {
this.fireAimLine.style.opacity = '1';
this.fireAimLine.style.transform = `translateY(-50%) rotate(${Math.atan2(dy, dx)}rad)`;
} else {
this.fireAimLine.style.opacity = '0';
}
};
private fireButtonUpListener = (event: TouchEvent) => {
this.swallowTouch(event);
ChargeIndicator.end();
this.fireAimLine.style.opacity = '0';
if (this.fireDownAt === null) {
return;
}
const charge = holdDurationToCharge((performance.now() - this.fireDownAt) / 1000);
this.fireDownAt = null;
const character = this.game.gameObjects.player;
if (!character) {
this.fireButtonCenter = null;
this.fireAimScreen = null;
return;
}
// Screen drag → world aim direction (flip Y: screen +y is down). Below the
// dead-zone it's a tap, so fall back to firing along the facing direction.
let direction = character.facingDirection;
if (this.fireButtonCenter && this.fireAimScreen) {
const dx = this.fireAimScreen[0] - this.fireButtonCenter[0];
const dy = this.fireAimScreen[1] - this.fireButtonCenter[1];
if (dx * dx + dy * dy > TouchListener.aimDeadZone * TouchListener.aimDeadZone) {
direction = vec2.normalize(vec2.create(), vec2.fromValues(dx, -dy));
}
}
this.fireButtonCenter = null;
this.fireAimScreen = null;
const aim = vec2.scaleAndAdd(
vec2.create(),
character.bodyCenter,
direction,
settings.touchAimRange,
);
this.sendCommandToSubscribers(new PrimaryActionCommand(aim, charge));
};
public update(_deltaTimeInSeconds: number) {
if (!this.fireButton.parentElement) {
this.overlay.appendChild(this.fireButton);
}
if (!this.leapButton.parentElement) {
this.overlay.appendChild(this.leapButton);
}
const character = this.game.gameObjects.player;
if (character) {
this.fireStrengthRing.style.background = `conic-gradient(rgba(255, 255, 255, 0.75) ${
character.strengthFraction * 360
}deg, transparent 0deg)`;
}
}
public destroy() { public destroy() {
ChargeIndicator.end();
this.target.removeEventListener('touchstart', this.touchStartListener); this.target.removeEventListener('touchstart', this.touchStartListener);
this.target.removeEventListener('touchmove', this.touchMoveListener); this.target.removeEventListener('touchmove', this.touchMoveListener);
this.target.removeEventListener('touchend', this.touchEndListener); this.target.removeEventListener('touchend', this.touchEndListener);
this.fireButton.removeEventListener('touchstart', this.fireButtonDownListener);
this.fireButton.removeEventListener('touchmove', this.fireButtonMoveListener);
this.fireButton.removeEventListener('touchend', this.fireButtonUpListener);
this.leapButton.removeEventListener('touchstart', this.leapButtonListener);
this.fireButton.parentElement?.removeChild(this.fireButton);
this.leapButton.parentElement?.removeChild(this.leapButton);
} }
} }

View file

@ -1,12 +1,28 @@
/** /**
* 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.
*/ */
const servers: Array<string> = ['https://server.decla.red']; // 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'];
/**
* When the page is served from localhost (i.e. the webpack-dev-server), also
* offer the local backend so a `npm start` server can be joined during
* development. The backend's default port is 3000 (see backend/src/options.ts).
* In production the page is served from its own hostname, so this never leaks.
*/
const isDevelopment =
typeof location !== 'undefined' &&
(location.hostname === 'localhost' || location.hostname === '127.0.0.1');
const servers: Array<string> = isDevelopment
? [`http://${location.hostname}:3000`, ...productionServers]
: productionServers;
export abstract class Configuration { export abstract class Configuration {
public static async initialize(): Promise<void> { public static async initialize(): Promise<void> {

View file

@ -0,0 +1,120 @@
import { settings } from 'shared';
import { Pointer } from './helper/pointer';
export abstract class FeedbackHud {
private static root?: HTMLElement;
private static killfeed?: HTMLElement;
private static elimination?: HTMLElement;
private static ensureRoot(): { root: HTMLElement; killfeed: HTMLElement } {
if (!this.root || !this.killfeed) {
this.root = document.createElement('div');
this.root.className = 'feedback-hud';
this.killfeed = document.createElement('div');
this.killfeed.className = 'killfeed';
this.root.appendChild(this.killfeed);
document.body.appendChild(this.root);
}
return { root: this.root, killfeed: this.killfeed };
}
private static focusPoint(): { x: number; y: number } {
const cursor = Pointer.getDisplayPosition();
if (cursor) {
return { x: cursor.x, y: cursor.y };
}
return { x: window.innerWidth / 2, y: window.innerHeight / 2 };
}
private static addTransient(element: HTMLElement, lifetimeMs: number) {
const { root } = this.ensureRoot();
root.appendChild(element);
setTimeout(() => element.parentElement?.removeChild(element), lifetimeMs);
}
public static hitMarker(charge = 0) {
const { x, y } = this.focusPoint();
const marker = document.createElement('div');
marker.className =
'hitmarker' + (charge >= settings.chargedHitThreshold ? ' charged' : '');
marker.style.left = `${x}px`;
marker.style.top = `${y}px`;
this.addTransient(marker, 250);
}
public static killConfirmed(victimName?: string, streak = 1, charge = 0) {
const { killfeed } = this.ensureRoot();
const charged = charge >= settings.chargedHitThreshold;
// A quick crimson vignette pulse around the whole frame to punctuate the kill.
const flash = document.createElement('div');
flash.className = 'kill-flash' + (charged ? ' charged' : '');
this.addTransient(flash, 420);
const entry = document.createElement('div');
entry.className = 'kill-entry';
entry.innerHTML = `Eliminated <b>${this.escape(victimName ?? 'enemy')}</b>`;
killfeed.insertBefore(entry, killfeed.firstChild);
setTimeout(() => entry.parentElement?.removeChild(entry), 4500);
const { x, y } = this.focusPoint();
const popup = document.createElement('div');
popup.className = 'kill-popup' + (charged ? ' charged' : '');
popup.innerHTML = `+${settings.playerKillPoint} <span class="heal">+${settings.playerKillHealthReward}❤</span>`;
popup.style.left = `${x}px`;
popup.style.top = `${y}px`;
this.addTransient(popup, 1200);
const callout = this.streakName(streak) ?? (charged ? 'Charged Kill!' : undefined);
if (callout) {
const el = document.createElement('div');
el.className = 'streak-callout';
el.innerText = callout;
this.addTransient(el, 1400);
}
}
// Persistent centred overlay shown while the local player is dead and waiting
// to respawn. The countdown itself is the server-driven "Reviving in N…"
// announcement; this makes the death state unmistakable and stays up until
// hideElimination() is called on respawn.
public static showElimination(): void {
if (this.elimination) {
return;
}
const { root } = this.ensureRoot();
const el = document.createElement('div');
el.className = 'elimination';
el.innerHTML =
'<div class="elimination-title">Eliminated</div>' +
'<div class="elimination-sub">Respawning…</div>';
root.appendChild(el);
this.elimination = el;
}
public static hideElimination(): void {
this.elimination?.parentElement?.removeChild(this.elimination);
this.elimination = undefined;
}
private static streakName(streak: number): string | undefined {
switch (streak) {
case 2:
return 'Double Kill!';
case 3:
return 'Triple Kill!';
case 4:
return 'Quad Kill!';
default:
return streak >= 5 ? 'Rampage!' : undefined;
}
}
private static escape(text: string): string {
const div = document.createElement('div');
div.innerText = text;
return div.innerHTML;
}
}

View file

@ -11,8 +11,7 @@ import {
deserialize, deserialize,
TransportEvents, TransportEvents,
SetAspectRatioActionCommand, SetAspectRatioActionCommand,
UpdateOtherPlayerDirections, UpdateMinimap,
clamp,
UpdateGameState, UpdateGameState,
GameEndCommand, GameEndCommand,
ServerAnnouncement, ServerAnnouncement,
@ -21,8 +20,9 @@ import {
CommandExecutors, CommandExecutors,
Command, Command,
settings, settings,
InputAcknowledgement,
} from 'shared'; } from 'shared';
import io from 'socket.io-client'; import { io, Socket } from 'socket.io-client';
import { KeyboardListener } from './commands/keyboard-listener'; import { KeyboardListener } from './commands/keyboard-listener';
import { MouseListener } from './commands/mouse-listener'; import { MouseListener } from './commands/mouse-listener';
import { TouchListener } from './commands/touch-listener'; import { TouchListener } from './commands/touch-listener';
@ -30,15 +30,21 @@ import { CommandSocket } from './commands/command-socket';
import { PlayerDecision } from './join-form-handler'; import { PlayerDecision } from './join-form-handler';
import { GameObjectContainer } from './objects/game-object-container'; import { GameObjectContainer } from './objects/game-object-container';
import parser from 'socket.io-msgpack-parser'; import parser from 'socket.io-msgpack-parser';
import { BlobShape } from './shapes/blob-shape'; 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 { Scoreboard } from './scoreboard';
import { Minimap } from './minimap';
import { ScreenShake } from './screen-shake';
export class Game extends CommandReceiver { export class Game extends CommandReceiver {
public gameObjects = new GameObjectContainer(this); public gameObjects = new GameObjectContainer(this);
public renderer?: Renderer; public renderer?: Renderer;
private socket!: SocketIOClient.Socket; private socket!: Socket;
private isBetweenGames = false; private isBetweenGames = false;
public started: Promise<void>; public started: Promise<void>;
@ -48,12 +54,12 @@ export class Game extends CommandReceiver {
private mouseListener: MouseListener; private mouseListener: MouseListener;
private touchListener: TouchListener; private touchListener: TouchListener;
private declaPlanetCountElement = document.createElement('div'); private scoreboard = new Scoreboard();
private redPlanetCountElement = document.createElement('div'); private minimap = new Minimap();
private announcementText = document.createElement('h2'); private announcementText = document.createElement('h2');
private progressBar = document.createElement('div'); private keystoneArrow?: HTMLElement;
private arrows: { [id: number]: HTMLElement } = {};
private socketReceiver!: CommandSocket; private socketReceiver!: CommandSocket;
private tutorial!: Tutorial;
constructor( constructor(
private readonly playerDecision: PlayerDecision, private readonly playerDecision: PlayerDecision,
@ -63,9 +69,6 @@ export class Game extends CommandReceiver {
super(); super();
this.started = new Promise((r) => (this.resolveStarted = r)); this.started = new Promise((r) => (this.resolveStarted = r));
this.announcementText.className = 'announcement'; this.announcementText.className = 'announcement';
this.progressBar.className = 'planet-progress';
this.progressBar.appendChild(this.declaPlanetCountElement);
this.progressBar.appendChild(this.redPlanetCountElement);
this.keyboardListener = new KeyboardListener(); this.keyboardListener = new KeyboardListener();
this.mouseListener = new MouseListener(this.canvas, this); this.mouseListener = new MouseListener(this.canvas, this);
@ -76,14 +79,23 @@ export class Game extends CommandReceiver {
this.isBetweenGames = true; this.isBetweenGames = true;
this.socket?.close(); this.socket?.close();
serverTimeline.reset();
localCharacterPredictor.reset();
// Clear any leftover shake/zoom so a kill at the end of one match can't bleed
// its camera impact into the next.
ScreenShake.reset();
this.gameObjects = new GameObjectContainer(this); this.gameObjects = new GameObjectContainer(this);
this.overlay.innerHTML = ''; this.overlay.innerHTML = '';
this.keystoneArrow = undefined;
this.lastMinimap = undefined;
this.isEnding = false; this.isEnding = false;
this.lastAnnouncementText = ''; this.lastAnnouncementText = '';
this.overlay.appendChild(this.progressBar); this.overlay.appendChild(this.scoreboard.element);
this.overlay.appendChild(this.minimap.element);
this.announcementText.innerText = ''; this.announcementText.innerText = '';
this.timeScaling = 1; this.timeScaling = 1;
this.overlay.appendChild(this.announcementText); this.overlay.appendChild(this.announcementText);
this.tutorial = new Tutorial(this.overlay);
this.socket = io(this.playerDecision.server, { this.socket = io(this.playerDecision.server, {
reconnectionDelayMax: 10000, reconnectionDelayMax: 10000,
@ -92,7 +104,9 @@ export class Game extends CommandReceiver {
parser, parser,
} as any); } as any);
this.socket.on('reconnect_attempt', () => { // In socket.io-client v4 reconnection events are emitted by the Manager
// (`socket.io`), not the Socket itself.
this.socket.io.on('reconnect_attempt', () => {
this.socket.io.opts.transports = ['polling', 'websocket']; this.socket.io.opts.transports = ['polling', 'websocket'];
}); });
@ -112,12 +126,17 @@ export class Game extends CommandReceiver {
}); });
this.socketReceiver = new CommandSocket(this.socket); this.socketReceiver = new CommandSocket(this.socket);
// The tutorial listens to the same input streams as the socket, so its
// stages clear off the player's own commands without any server involvement.
this.keyboardListener.clearSubscribers(); this.keyboardListener.clearSubscribers();
this.keyboardListener.subscribe(this.socketReceiver); this.keyboardListener.subscribe(this.socketReceiver);
this.keyboardListener.subscribe(this.tutorial);
this.mouseListener.clearSubscribers(); this.mouseListener.clearSubscribers();
this.mouseListener.subscribe(this.socketReceiver); this.mouseListener.subscribe(this.socketReceiver);
this.mouseListener.subscribe(this.tutorial);
this.touchListener.clearSubscribers(); this.touchListener.clearSubscribers();
this.touchListener.subscribe(this.socketReceiver); this.touchListener.subscribe(this.socketReceiver);
this.touchListener.subscribe(this.tutorial);
this.isBetweenGames = false; this.isBetweenGames = false;
@ -139,69 +158,18 @@ 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) => [UpdateMinimap.type]: (c: UpdateMinimap) => (this.lastMinimap = c),
(this.lastOtherPlayerDirections = c),
[GameStartCommand.type]: this.initialize.bind(this), [GameStartCommand.type]: this.initialize.bind(this),
}; };
private lastOtherPlayerDirections?: UpdateOtherPlayerDirections; private lastMinimap?: UpdateMinimap;
private handleOtherPlayerDirections(command: UpdateOtherPlayerDirections) {
command.otherPlayerDirections.forEach((d) => {
if (!(d.id! in this.arrows)) {
const element = document.createElement('div');
this.arrows[d.id!] = element;
this.overlay.appendChild(element);
}
const e = this.arrows[d.id!];
const direction = d.direction;
const team = d.team;
const angle = Math.atan2(direction.y, direction.x);
e.className = 'other-player-arrow ' + team;
if (!this.renderer) {
return;
}
const width = this.renderer.canvasSize.x;
const height = this.renderer.canvasSize.y;
const aspectRatio = width / height;
const directionRatio = direction.x / direction.y;
let deltaX: number, deltaY: number;
if (aspectRatio < Math.abs(directionRatio)) {
deltaX = (width / 2) * Math.sign(direction.x);
deltaY = deltaX / directionRatio;
} else {
deltaY = (height / 2) * Math.sign(direction.y);
deltaX = deltaY * directionRatio;
}
const delta = vec2.fromValues(deltaX, deltaY);
const center = vec2.fromValues(width / 2, height / 2);
const p = vec2.add(center, center, delta);
const arrowPadding = 24;
vec2.set(
p,
clamp(p.x, arrowPadding, width - arrowPadding),
clamp(height - p.y, arrowPadding, height - arrowPadding),
);
e.style.transform = `translateX(${p.x}px) translateY(${
p.y
}px) translateX(-50%) translateY(-50%) rotate(${-angle + Math.PI / 2}rad) `;
});
for (const id in this.arrows) {
if (
Object.prototype.hasOwnProperty.call(this.arrows, id) &&
command.otherPlayerDirections.find((v) => v.id?.toString() === id) === undefined
) {
this.arrows[id].parentElement?.removeChild(this.arrows[id]);
delete this.arrows[id];
}
}
}
public async start(): Promise<void> { public async start(): Promise<void> {
const noiseTexture = await renderNoise([256, 256], 2, 1); const noiseTexture = await renderNoise([256, 256], 2, 1);
@ -212,7 +180,7 @@ export class Game extends CommandReceiver {
this.canvas, this.canvas,
[ [
PlanetShape.descriptor, PlanetShape.descriptor,
BlobShape.descriptor, CharacterShape.descriptor,
{ {
...CircleLight.descriptor, ...CircleLight.descriptor,
shaderCombinationSteps: [0, 1, 2, 4, 8, 16], shaderCombinationSteps: [0, 1, 2, 4, 8, 16],
@ -221,10 +189,11 @@ export class Game extends CommandReceiver {
this.gameLoop.bind(this), this.gameLoop.bind(this),
{ {
shadowTraceCount: 16, shadowTraceCount: 16,
paletteSize: settings.palette.length, paletteSize: settings.paletteDim.length,
colorPalette: settings.palette, colorPalette: settings.paletteDim,
enableHighDpiRendering: true, enableHighDpiRendering: true,
lightCutoffDistance: settings.lightCutoffDistance, lightCutoffDistance: settings.lightCutoffDistance,
lightOverlapReduction: settings.lightOverlapReduction,
textures: { textures: {
noiseTexture: { noiseTexture: {
source: noiseTexture, source: noiseTexture,
@ -267,6 +236,16 @@ export class Game extends CommandReceiver {
this.resolveStarted(); this.resolveStarted();
deltaTime /= 1000; deltaTime /= 1000;
// Decay the camera impact effects on raw wall-clock time, before any of the
// end-game slow-motion scaling below. These only adjust the rendered view,
// never the simulation, so they stay decoupled from prediction and netcode.
ScreenShake.step(deltaTime);
// 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;
@ -274,7 +253,9 @@ export class Game extends CommandReceiver {
this.draw(); this.draw();
} }
if ((this.timeSinceLastAnnouncement += deltaTime) > 0.5) { if (
(this.timeSinceLastAnnouncement += deltaTime) > settings.announcementVisibleSeconds
) {
this.lastAnnouncementText = ''; this.lastAnnouncementText = '';
} }
@ -290,6 +271,10 @@ export class Game extends CommandReceiver {
new RenderCommand(this.renderer, this.overlay, shouldChangeLayout), new RenderCommand(this.renderer, this.overlay, shouldChangeLayout),
); );
this.touchListener.update(deltaTime);
this.tutorial.step(this.gameObjects);
this.socketReceiver.sendQueuedCommands(); this.socketReceiver.sendQueuedCommands();
return this.isActive; return this.isActive;
@ -297,16 +282,80 @@ export class Game extends CommandReceiver {
private draw() { private draw() {
if (this.lastGameState) { if (this.lastGameState) {
this.declaPlanetCountElement.style.width = // The local player's team is read off the main character once it exists.
(this.lastGameState.declaCount / this.lastGameState.limit) * 50 + '%'; this.scoreboard.update(this.lastGameState, this.gameObjects.player?.team);
this.redPlanetCountElement.style.width =
(this.lastGameState.redCount / this.lastGameState.limit) * 50 + '%';
} }
if (this.lastOtherPlayerDirections) { this.minimap.update(
this.handleOtherPlayerDirections(this.lastOtherPlayerDirections); this.gameObjects.localPlayerPosition,
} this.lastMinimap?.players ?? [],
);
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,21 +0,0 @@
import { Renderer } from 'sdf-2d';
export const getInsightsFromRenderer = (
renderer?: Renderer,
): {
vendor?: string;
renderer?: string;
fps?: number;
renderScale?: number;
lightScale?: number;
canvasWidth?: number;
canvasHeight?: number;
} => ({
fps: renderer?.insights?.fps,
vendor: renderer?.insights?.vendor,
renderer: renderer?.insights?.renderer,
renderScale: renderer?.insights?.renderPasses.distance.renderScale,
lightScale: renderer?.insights?.renderPasses.lights.renderScale,
canvasWidth: renderer?.canvasSize.x,
canvasHeight: renderer?.canvasSize.y,
});

View file

@ -1,28 +0,0 @@
const baseUri = 'https://insights.decla.red';
const type = 'decla-red-frontend';
const updateTime = 15000;
export const handleInsights = async (initialData: any, getFrameData: () => any) => {
const sessionId = await createSession(initialData);
setInterval(() => createFrame(sessionId, getFrameData()), updateTime);
};
const createSession = async (data: any): Promise<string> => {
const response = await sendPostRequest(`${baseUri}/${type}/sessions/`, data);
const { sessionId } = await response.json();
return sessionId;
};
const createFrame = async (sessionId: string, data: any): Promise<unknown> =>
await sendPostRequest(`${baseUri}/${type}/sessions/${sessionId}`, data);
const sendPostRequest = async (uri: string, data: any): Promise<Response> =>
await fetch(uri, {
method: 'POST',
mode: 'cors',
redirect: 'follow',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});

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

@ -42,7 +42,7 @@ export const handleFullScreen = (
}); });
addEventListener('resize', () => { addEventListener('resize', () => {
if (isInFullScreen && currentWindowHeight > innerHeight) { if (isInFullScreen() && currentWindowHeight > innerHeight) {
followToggle(); followToggle();
} }
}); });

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,13 @@
import { vec2 } from 'gl-matrix';
export class Pointer {
private static displayPosition: vec2 | null = null;
public static setDisplayPosition(x: number, y: number): void {
Pointer.displayPosition = vec2.fromValues(x, y);
}
public static getDisplayPosition(): vec2 | null {
return Pointer.displayPosition;
}
}

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,350 @@
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;
// Clock source for the predictor. Injectable so deterministic reconciliation
// tests can drive prediction without real time passing; defaults to the
// browser wall clock in production.
let nowMs: () => number = () => performance.now();
export const setPredictorClockForTesting = (clock: () => number): void => {
nowMs = clock;
};
// 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;
// Whether the local body is alive on the server. While dead (awaiting respawn)
// prediction is suppressed so the corpse/ghost can't keep walking in response
// to input — the server ignores a dead player's movement, so a predicted body
// that still moved would be a pure client-side desync.
private alive = true;
// 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(nowMs());
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(nowMs());
}
// 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(nowMs());
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 setAlive(alive: boolean): void {
this.alive = alive;
}
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;
this.alive = true;
}
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.alive || !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(nowMs());
// 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,5 +1,5 @@
import { ServerInformation, serverInformationEndpoint, TransportEvents } from 'shared'; import { ServerInformation, serverInformationEndpoint, TransportEvents } from 'shared';
import io from 'socket.io-client'; import { io, Socket } from 'socket.io-client';
import { Configuration } from './configuration'; import { Configuration } from './configuration';
import parser from 'socket.io-msgpack-parser'; import parser from 'socket.io-msgpack-parser';
import { SoundHandler, Sounds } from './sound-handler'; import { SoundHandler, Sounds } from './sound-handler';
@ -16,12 +16,19 @@ export class JoinFormHandler {
private resolvePlayerDecision!: (d: PlayerDecision) => void; private resolvePlayerDecision!: (d: PlayerDecision) => void;
private pollServersTimer: any; private pollServersTimer: any;
private keyUpListener = (e: KeyboardEvent) => { private keyUpListener = (e: KeyboardEvent) => {
if (e.key === 'enter') { // KeyboardEvent.key for Return is 'Enter' (capital E); the old lowercase
this.form.submit(); // comparison never matched, so pressing Enter silently did nothing.
// requestSubmit() (unlike submit()) fires the form's onsubmit handler and
// runs HTML5 validation, so Enter behaves exactly like clicking Join.
if (e.key === 'Enter' && !this.joinButton.disabled) {
this.form.requestSubmit();
} }
}; };
constructor(private form: HTMLFormElement, private readonly container: HTMLElement) { constructor(
private form: HTMLFormElement,
private readonly container: HTMLElement,
) {
this.joinButton = form.querySelector('button[type="submit"]') as HTMLButtonElement; this.joinButton = form.querySelector('button[type="submit"]') as HTMLButtonElement;
this.joinButton.disabled = true; this.joinButton.disabled = true;
this.waitingForDecision = new Promise((r) => (this.resolvePlayerDecision = r)); this.waitingForDecision = new Promise((r) => (this.resolvePlayerDecision = r));
@ -32,9 +39,9 @@ export class JoinFormHandler {
form.onsubmit = (e) => { form.onsubmit = (e) => {
SoundHandler.play(Sounds.click); SoundHandler.play(Sounds.click);
const result: PlayerDecision = (Array.from( const result: PlayerDecision = (
(new FormData(form) as any).entries(), Array.from((new FormData(form) as any).entries()) as Array<[string, any]>
) as Array<[string, any]>).reduce((result, [name, value]) => { ).reduce((result, [name, value]) => {
(result as any)[name] = value; (result as any)[name] = value;
return result; return result;
}, {}) as any; }, {}) as any;
@ -99,7 +106,7 @@ export class JoinFormHandler {
private removeServer(server: ServerChooserOption) { private removeServer(server: ServerChooserOption) {
this.servers = this.servers.filter((s) => s !== server); this.servers = this.servers.filter((s) => s !== server);
if (this.servers.length) { if (!this.servers.length) {
this.joinButton.disabled = true; this.joinButton.disabled = true;
} }
} }
@ -116,7 +123,7 @@ class ServerChooserOption {
private serverNameElement = document.createElement('span'); private serverNameElement = document.createElement('span');
private completionElement = document.createElement('span'); private completionElement = document.createElement('span');
private socket: SocketIOClient.Socket; private socket: Socket;
constructor( constructor(
private content: ServerInformation, private content: ServerInformation,
@ -140,15 +147,17 @@ class ServerChooserOption {
this.setServerInfoLabelText(); this.setServerInfoLabelText();
this.socket = io(url, { this.socket = io(url, {
reconnection: false, reconnection: true,
reconnectionAttempts: 5,
timeout: 4000, timeout: 4000,
parser, parser,
} as any); } as any);
this.socket.on('connect_error', this.destroy.bind(this)); this.socket.io.on('reconnect_failed', this.destroy.bind(this));
this.socket.on('connect_timeout', this.destroy.bind(this));
this.socket.on('disconnect', this.destroy.bind(this)); this.socket.on('connect', () =>
this.socket.emit(TransportEvents.SubscribeForServerInfoUpdates); this.socket.emit(TransportEvents.SubscribeForServerInfoUpdates),
);
this.socket.on( this.socket.on(
TransportEvents.ServerInfoUpdate, TransportEvents.ServerInfoUpdate,
([playerCount, gameState]: [number, number]) => { ([playerCount, gameState]: [number, number]) => {

View file

@ -3,19 +3,19 @@ import {
CircleLight, CircleLight,
FilteringOptions, FilteringOptions,
hsl, hsl,
NoisyPolygonFactory,
Renderer, Renderer,
renderNoise, renderNoise,
runAnimation, runAnimation,
WrapOptions, WrapOptions,
} from 'sdf-2d'; } from 'sdf-2d';
import { settings, rgb, PlanetBase, Random } from 'shared'; import { settings, rgb, PlanetBase, Random } from 'shared';
import { PlanetShape } from './shapes/planet-shape';
const landingPageVertexCount = 9; // PlanetShape colours by mixing blue (0) -> red (1). The two backdrop planets
const LandingPagePolygon = NoisyPolygonFactory( // read as the two in-game teams; the red planet is pulled a little off the
landingPageVertexCount, // pure-red end so it shows as a more muted, less saturated red.
rgb(0.5, 0.4, 0.7), const bluePlanet = 0;
); const redPlanet = 0.85;
export class LandingPageBackground { export class LandingPageBackground {
private isActive = true; private isActive = true;
@ -34,7 +34,7 @@ export class LandingPageBackground {
canvas, canvas,
[ [
{ {
...LandingPagePolygon.descriptor, ...PlanetShape.descriptor,
shaderCombinationSteps: [0, 1, 2], shaderCombinationSteps: [0, 1, 2],
}, },
{ {
@ -74,34 +74,41 @@ export class LandingPageBackground {
0.7 * renderer.canvasSize.y, 0.7 * renderer.canvasSize.y,
); );
const topPlanet = new LandingPagePolygon( const topPlanet = new PlanetShape(
PlanetBase.createPlanetVertices( PlanetBase.createPlanetVertices(
topPlanetPosition, topPlanetPosition,
Random.getRandomInRange(150, 400), Random.getRandomInRange(150, 400),
Random.getRandomInRange(150, 400), Random.getRandomInRange(150, 400),
Random.getRandomInRange(10, 20), Random.getRandomInRange(10, 20),
landingPageVertexCount,
), ),
redPlanet,
); );
(topPlanet as any).randomOffset = 0.5 + time / 3500; // Fixed terrain phase (no longer animated -> no pulsing); the planet spins
// instead, the same way in-game planets do: PlanetShape's rotation uniform
// turns the whole body, terrain and outline together, in its own frame.
// Speeds sit in the game's per-planet range (~0.05-0.12 rad/s) and
// counter-rotate so the two planets don't drift in lockstep.
topPlanet.randomOffset = Random.getRandom();
topPlanet.rotation = (time / 1000) * 0.09;
const bottomPlanetPosition = vec2.fromValues( const bottomPlanetPosition = vec2.fromValues(
0.3 * renderer.canvasSize.x, 0.3 * renderer.canvasSize.x,
0.3 * renderer.canvasSize.y, 0.3 * renderer.canvasSize.y,
); );
const bottomPlanet = new LandingPagePolygon( const bottomPlanet = new PlanetShape(
PlanetBase.createPlanetVertices( PlanetBase.createPlanetVertices(
bottomPlanetPosition, bottomPlanetPosition,
Random.getRandomInRange(150, 800), Random.getRandomInRange(150, 800),
Random.getRandomInRange(150, 400), Random.getRandomInRange(150, 400),
Random.getRandomInRange(10, 40), Random.getRandomInRange(10, 40),
landingPageVertexCount,
), ),
bluePlanet,
); );
(bottomPlanet as any).randomOffset = time / 2500; bottomPlanet.randomOffset = Random.getRandom();
bottomPlanet.rotation = (time / 1000) * -0.06;
const planetDistance = vec2.subtract( const planetDistance = vec2.subtract(
vec2.create(), vec2.create(),

View file

@ -0,0 +1,126 @@
import { vec2 } from 'gl-matrix';
import { CharacterTeam, Id, settings } from 'shared';
export interface MinimapBlip {
id: Id;
position: vec2;
team: CharacterTeam;
}
// Top-down radar of the whole circular arena, pinned to the top-left. The map's
// circular border is the world boundary (its radius maps to worldRadius), so the
// local player's bright dot reads as a true position in the arena and every other
// living player shows as a team-coloured dot. Owns its own <canvas>, so the Game
// just appends `element` to the overlay and feeds it `update()` each frame.
// Replaces the off-screen chevrons that used to point at other players.
export class Minimap {
public readonly element = document.createElement('canvas');
private readonly ctx: CanvasRenderingContext2D;
private readonly colors: Record<CharacterTeam, string>;
// World-space positions, smoothed per player so the 25 Hz snapshots glide
// rather than step between frames.
private readonly smoothed = new Map<Id, vec2>();
private bufferSize = 0;
constructor() {
this.element.className = 'minimap';
this.ctx = this.element.getContext('2d')!;
const theme = getComputedStyle(document.documentElement);
const read = (name: string, fallback: string) =>
theme.getPropertyValue(name).trim() || fallback;
this.colors = {
[CharacterTeam.blue]: read('--bright-blue', '#4069a5'),
[CharacterTeam.red]: read('--bright-red', '#d15652'),
[CharacterTeam.neutral]: read('--bright-neutral', '#ccc'),
};
}
public update(localPosition: vec2 | undefined, players: Array<MinimapBlip>) {
const size = this.element.clientWidth;
if (size === 0) {
return; // not laid out yet
}
this.syncBufferSize(size);
const ctx = this.ctx;
ctx.clearRect(0, 0, size, size);
const center = size / 2;
const innerRadius = center - 5;
const scale = innerRadius / settings.worldRadius;
const toMap = (world: vec2): { x: number; y: number } => {
let dx = world.x * scale;
let dy = -world.y * scale; // world Y points up, canvas Y points down
const distance = Math.hypot(dx, dy);
if (distance > innerRadius) {
dx = (dx / distance) * innerRadius;
dy = (dy / distance) * innerRadius;
}
return { x: center + dx, y: center + dy };
};
// Faint marker at the world's centre to aid orientation.
ctx.fillStyle = 'rgba(255, 255, 255, 0.12)';
ctx.beginPath();
ctx.arc(center, center, 1.5, 0, Math.PI * 2);
ctx.fill();
const present = new Set<Id>();
for (const blip of players) {
present.add(blip.id);
let world = this.smoothed.get(blip.id);
if (world) {
vec2.lerp(world, world, blip.position, 0.3);
} else {
world = vec2.clone(blip.position);
this.smoothed.set(blip.id, world);
}
const { x, y } = toMap(world);
this.drawDot(x, y, 3, this.colors[blip.team]);
}
for (const id of this.smoothed.keys()) {
if (!present.has(id)) {
this.smoothed.delete(id);
}
}
// The local player rides on top, drawn in white with a ring so "you" is
// unmistakable amongst the team-coloured dots.
if (localPosition) {
const { x, y } = toMap(localPosition);
this.drawDot(x, y, 3.5, '#ffffff');
ctx.beginPath();
ctx.arc(x, y, 6, 0, Math.PI * 2);
ctx.strokeStyle = 'rgba(255, 255, 255, 0.65)';
ctx.lineWidth = 1.5;
ctx.stroke();
}
}
private drawDot(x: number, y: number, radius: number, color: string) {
const ctx = this.ctx;
ctx.beginPath();
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.fillStyle = color;
ctx.shadowColor = color;
ctx.shadowBlur = radius * 2;
ctx.fill();
ctx.shadowBlur = 0;
}
private syncBufferSize(cssSize: number) {
const dpr = window.devicePixelRatio || 1;
const target = Math.round(cssSize * dpr);
if (this.bufferSize !== target) {
this.bufferSize = target;
this.element.width = target;
this.element.height = target;
}
// Setting the buffer size resets the transform, so (re)establish it every
// frame and draw in CSS pixels regardless of the device pixel ratio.
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
}

View file

@ -1,4 +1,6 @@
import { vec2 } from 'gl-matrix';
import { import {
Circle,
Command, Command,
CommandExecutors, CommandExecutors,
CommandReceiver, CommandReceiver,
@ -9,23 +11,37 @@ 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 { FeedbackHud } from '../feedback-hud';
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';
export class GameObjectContainer extends CommandReceiver { export class GameObjectContainer extends CommandReceiver {
protected objects: Map<Id, GameObject> = new Map(); protected objects: Map<Id, GameObject> = new Map();
public player!: CharacterView; public player!: CharacterView;
public camera: Camera = new Camera(this.game); public camera: Camera = new Camera(this.game);
private wasLocalPlayerAlive = false;
protected commandExecutors: CommandExecutors = { protected commandExecutors: CommandExecutors = {
[CreatePlayerCommand.type]: (c: CreatePlayerCommand) => { [CreatePlayerCommand.type]: (c: CreatePlayerCommand) => {
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();
// Respawned — clear the elimination overlay.
FeedbackHud.hideElimination();
this.wasLocalPlayerAlive = true;
}, },
[CreateObjectsCommand.type]: (c: CreateObjectsCommand) => [CreateObjectsCommand.type]: (c: CreateObjectsCommand) =>
@ -34,8 +50,37 @@ export class GameObjectContainer extends CommandReceiver {
[StepCommand.type]: (c: StepCommand) => { [StepCommand.type]: (c: StepCommand) => {
this.defaultCommandExecutor(c); this.defaultCommandExecutor(c);
if (this.player) { // The local body is alive only while its object still exists (the server
this.camera.center = this.player.position; // deletes it on death) and its health is above zero — `player` keeps
// pointing at the now-stale view after death, so both checks are needed.
const bodyPresent = !!this.player && this.objects.has(this.player.id);
const alive = bodyPresent && this.player.health > 0;
// Show the elimination overlay on the alive→dead edge; CreatePlayerCommand
// clears it on respawn.
if (this.wasLocalPlayerAlive && !alive) {
FeedbackHud.showElimination();
}
this.wasLocalPlayerAlive = alive;
if (bodyPresent) {
// Override the interpolated pose of the local player with the predicted
// one so it responds to input immediately. Suppressed while dead so the
// corpse can't be walked around (the server ignores a dead player's
// input — a moving predicted body would be a pure client-side desync).
// A large correction (respawn / death) snaps inside the predictor.
localCharacterPredictor.setAlive(alive);
localCharacterPredictor.setStrength(
this.player.strengthFraction * settings.playerMaxStrength,
);
if (
localCharacterPredictor.update(this.predictablePlanets(), c.deltaTimeInSeconds)
) {
this.player.head = localCharacterPredictor.head;
this.player.leftFoot = localCharacterPredictor.leftFoot;
this.player.rightFoot = localCharacterPredictor.rightFoot;
}
this.camera.follow(this.player.position, c.deltaTimeInSeconds);
} }
}, },
@ -44,10 +89,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)),
@ -57,11 +107,59 @@ export class GameObjectContainer extends CommandReceiver {
super(); super();
} }
// The local player's world position, but only while the body is alive. On
// death the server deletes the character object (yet `player` keeps pointing
// at the now-stale view), so gate on the object still being present — otherwise
// the minimap would pin the "you" dot at the death spot for the whole respawn.
public get localPlayerPosition(): vec2 | undefined {
return this.player && this.objects.has(this.player.id)
? this.player.position
: undefined;
}
public get planets(): Array<PlanetView> {
const planets: Array<PlanetView> = [];
this.objects.forEach((o) => {
if (o instanceof PlanetView) {
planets.push(o);
}
});
return planets;
}
protected defaultCommandExecutor(c: Command) { protected defaultCommandExecutor(c: Command) {
this.objects.forEach((o) => o.handleCommand(c)); this.objects.forEach((o) => o.handleCommand(c));
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

@ -8,15 +8,34 @@ import {
} from 'shared'; } from 'shared';
import { RenderCommand } from '../../commands/types/render'; import { RenderCommand } from '../../commands/types/render';
import { Game } from '../../game'; import { Game } from '../../game';
import { ScreenShake } from '../../screen-shake';
export class Camera extends CommandReceiver { 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),
}; };
@ -28,7 +47,16 @@ export class Camera extends CommandReceiver {
this.game.aspectRatioChanged(canvasAspectRatio); this.game.aspectRatioChanged(canvasAspectRatio);
} }
const viewArea = calculateViewArea(this.center, canvasAspectRatio); // Shake displaces only the rendered view centre and the zoom-punch shrinks
// only the rendered view area — neither touches the followed position, so
// impacts jolt the frame without nudging the camera off the player. Passing
// the zoom as oversizeRatio scales the area about the (shaken) centre.
const shakenCenter = vec2.fromValues(
this.center[0] + ScreenShake.offsetX,
this.center[1] + ScreenShake.offsetY,
);
const scale = ScreenShake.viewScale;
const viewArea = calculateViewArea(shakenCenter, canvasAspectRatio, scale * scale);
renderer.setViewArea(viewArea.topLeft, viewArea.size); renderer.setViewArea(viewArea.topLeft, viewArea.size);
renderer.setRuntimeSettings({ renderer.setRuntimeSettings({

View file

@ -1,4 +1,5 @@
import { vec2 } from 'gl-matrix'; import { vec2, vec3 } from 'gl-matrix';
import { CircleLight, Renderer } from 'sdf-2d';
import { import {
Circle, Circle,
@ -6,28 +7,62 @@ import {
CharacterBase, CharacterBase,
CharacterTeam, CharacterTeam,
settings, settings,
clamp,
clamp01,
mix,
CommandExecutors, CommandExecutors,
UpdatePropertyCommand, UpdatePropertyCommand,
} from 'shared'; } from 'shared';
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 { BlobShape } from '../../shapes/blob-shape'; import { LinearInterpolator } from '../../helper/interpolators/linear-interpolator';
import { Pointer } from '../../helper/pointer';
import { CharacterShape } from '../../shapes/character-shape';
import { SoundHandler, Sounds } from '../../sound-handler'; import { SoundHandler, Sounds } from '../../sound-handler';
import { VibrationHandler } from '../../vibration-handler'; import { VibrationHandler } from '../../vibration-handler';
import { FeedbackHud } from '../../feedback-hud';
import { ScreenShake } from '../../screen-shake';
const muzzleFlashDecaySeconds = 0.12;
const hitFlashDecaySeconds = 0.15;
// A white-hot pop of light thrown at the spot a character dies, seen by everyone
// who can see the body. No radius knob on a CircleLight, so the burst is sold by
// a bright (HDR) colour with a fast-decaying intensity envelope.
const deathBurstDecaySeconds = 0.42;
const deathBurstMaxIntensity = 1.7;
const deathBurstColor = vec3.fromValues(2.5, 2.3, 2.1);
const killIcon =
'<svg class="icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">' +
'<path d="M6.2,2.44L18.1,14.34L20.22,12.22L21.63,13.63L19.16,16.1L22.34,19.28C22.73,19.67 22.73,20.3 22.34,20.69L21.63,21.4C21.24,21.79 20.61,21.79 20.22,21.4L17,18.23L14.56,20.7L13.15,19.29L15.27,17.17L3.37,5.27V2.44H6.2M15.89,10L20.63,5.26V2.44H17.8L13.06,7.18L15.89,10M10.94,15L8.11,12.13L5.9,14.34L3.78,12.22L2.37,13.63L4.84,16.1L1.66,19.28C1.27,19.67 1.27,20.3 1.66,20.69L2.37,21.4C2.76,21.79 3.39,21.79 3.78,21.4L7,18.23L9.42,20.7L10.83,19.29L8.71,17.17L10.94,15Z"/></svg>';
const deathIcon =
'<svg class="icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">' +
'<path d="M12,2A9,9 0 0,0 3,11C3,14.03 4.53,16.82 7,18.47V22H9V19H11V22H13V19H15V18.46C17.47,16.81 19,14.03 19,11A9,9 0 0,0 12,2M8,11A2,2 0 0,1 10,13A2,2 0 0,1 8,15A2,2 0 0,1 6,13A2,2 0 0,1 8,11M16,11A2,2 0 0,1 18,13A2,2 0 0,1 16,15A2,2 0 0,1 14,13A2,2 0 0,1 16,11Z"/></svg>';
export class CharacterView extends CharacterBase { export class CharacterView extends CharacterBase {
private shape: BlobShape; private shape: CharacterShape;
private muzzleFlash: CircleLight;
private muzzleFlashIntensity = 0;
private hitFlashIntensity = 0;
private deathBurst: CircleLight;
private deathBurstIntensity = 0;
private strength = settings.playerMaxStrength;
private strengthInterpolator = new LinearInterpolator(settings.playerMaxStrength);
private nameElement: HTMLElement = document.createElement('div'); private nameElement: HTMLElement = document.createElement('div');
private statsElement: HTMLElement = document.createElement('div'); private statsElement: HTMLElement = document.createElement('div');
private killCountElement: HTMLElement = document.createElement('span');
private deathCountElement: HTMLElement = document.createElement('span');
private healthElement: HTMLElement = document.createElement('div'); private healthElement: HTMLElement = document.createElement('div');
private chargeElement: HTMLElement = document.createElement('div');
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),
@ -48,15 +83,38 @@ export class CharacterView extends CharacterBase {
rightFoot?: Circle, rightFoot?: Circle,
) { ) {
super(id, name, killCount, deathCount, team, health, head, leftFoot, rightFoot); super(id, name, killCount, deathCount, team, health, head, leftFoot, rightFoot);
this.shape = new BlobShape(settings.colorIndices[team]); this.shape = new CharacterShape(settings.colorIndices[team]);
this.muzzleFlash = new CircleLight(
vec2.clone(this.head!.center),
settings.paletteDim[settings.colorIndices[team]],
0,
);
this.deathBurst = new CircleLight(vec2.clone(this.head!.center), deathBurstColor, 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;
this.healthElement.className = 'health';
this.chargeElement.className = 'charge';
this.statsElement.className = 'stats';
this.killCountElement.className = 'value';
this.deathCountElement.className = 'value';
const killStat = document.createElement('span');
killStat.className = 'stat kills';
killStat.innerHTML = killIcon;
killStat.appendChild(this.killCountElement);
const deathStat = document.createElement('span');
deathStat.className = 'stat deaths';
deathStat.innerHTML = deathIcon;
deathStat.appendChild(this.deathCountElement);
this.statsElement.append(killStat, deathStat);
this.nameElement.appendChild(this.healthElement); this.nameElement.appendChild(this.healthElement);
this.nameElement.appendChild(this.chargeElement);
this.nameElement.appendChild(this.statsElement); this.nameElement.appendChild(this.statsElement);
} }
@ -64,32 +122,65 @@ export class CharacterView extends CharacterBase {
return this.head!.center; return this.head!.center;
} }
public get bodyCenter(): vec2 {
const center = vec2.add(vec2.create(), this.head!.center, this.leftFoot!.center);
vec2.add(center, center, this.rightFoot!.center);
return vec2.scale(center, center, 1 / 3);
}
public get facingDirection(): vec2 {
const footAverage = vec2.add(
vec2.create(),
this.leftFoot!.center,
this.rightFoot!.center,
);
vec2.scale(footAverage, footAverage, 0.5);
const forward = vec2.subtract(footAverage, this.head!.center, footAverage);
return vec2.length(forward) > 0
? vec2.normalize(forward, forward)
: vec2.fromValues(0, 1);
}
public get strengthFraction(): number {
return clamp01(this.strength / settings.playerMaxStrength);
}
private updateProperty({ private updateProperty({
propertyKey, propertyKey,
propertyValue, propertyValue,
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') {
this.strengthInterpolator.addFrame(propertyValue, rateOfChange);
} }
} }
public setHealth(health: number) { public setHealth(health: number) {
const previousHealth = this.health; const damage = this.health - health;
super.setHealth(health); super.setHealth(health);
if (damage > 0) {
SoundHandler.play( SoundHandler.play(
Sounds.hit, Sounds.hit,
(0.4 * 2 * (previousHealth - health)) / settings.playerMaxStrength, Math.min(1, (0.8 * damage) / settings.playerMaxStrength),
); );
this.hitFlashIntensity = Math.min(1, 0.4 + damage / settings.playerMaxStrength);
if (this.isMainCharacter) { if (this.isMainCharacter) {
VibrationHandler.vibrate(Math.min(200, (previousHealth - this.health) * 4)); VibrationHandler.vibrate(Math.min(200, damage * 4));
// Getting hit jolts the frame too, so taking fire has weight, not just
// dealing it.
ScreenShake.add(clamp01(0.12 + (0.5 * damage) / settings.playerMaxStrength));
}
} }
} }
@ -97,16 +188,95 @@ export class CharacterView extends CharacterBase {
if (this.isMainCharacter) { if (this.isMainCharacter) {
VibrationHandler.vibrate(150); VibrationHandler.vibrate(150);
} }
// Visible to everyone who can see the body: a white-hot flash plus a
// full-body whiteout, so a kill reads as a violent burst rather than the
// character quietly blinking out.
this.deathBurstIntensity = 1;
this.hitFlashIntensity = 1;
}
public onHitConfirmed(charge = 0) {
if (!this.isMainCharacter) {
return;
}
// Layer a meaty thud under the crisp confirmation tick; a charged hit lands
// lower and harder than a panic tap.
SoundHandler.play(Sounds.hit, mix(0.35, 0.7, charge), mix(1.3, 0.95, charge));
SoundHandler.play(Sounds.click, mix(0.4, 0.75, charge), mix(1.7, 1.1, charge));
ScreenShake.add(mix(0.22, 0.5, charge));
VibrationHandler.vibrate(Math.round(mix(12, 35, charge)));
FeedbackHud.hitMarker(charge);
}
public onKillConfirmed(victimName?: string, streak = 1, charge = 0) {
if (!this.isMainCharacter) {
return;
}
// A heavy low thud for the kill with a brighter confirmation over the top,
// a hard frame jolt, a zoom-punch toward the action for weight, and a
// double-thump rumble.
SoundHandler.play(Sounds.hit, 1, mix(0.62, 0.5, charge));
SoundHandler.play(Sounds.click, 0.9, mix(0.6, 0.45, charge));
ScreenShake.add(mix(0.75, 1, charge));
ScreenShake.addPunch(mix(0.7, 1, charge));
VibrationHandler.vibrate([45, 35, Math.round(mix(80, 130, charge))]);
FeedbackHud.killConfirmed(victimName, streak, charge);
}
public onLeap() {
if (!this.isMainCharacter) {
return;
}
SoundHandler.play(Sounds.shoot, 0.3, 1.5);
} }
private step({ deltaTimeInSeconds }: StepCommand): void { 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.strengthInterpolator.getValue(deltaTimeInSeconds),
0,
settings.playerMaxStrength,
);
if (this.muzzleFlashIntensity > 0) {
this.muzzleFlashIntensity = Math.max(
0,
this.muzzleFlashIntensity - deltaTimeInSeconds / muzzleFlashDecaySeconds,
);
this.muzzleFlash.center = this.head!.center;
this.muzzleFlash.intensity = this.muzzleFlashIntensity;
}
if (this.hitFlashIntensity > 0) {
this.hitFlashIntensity = Math.max(
0,
this.hitFlashIntensity - deltaTimeInSeconds / hitFlashDecaySeconds,
);
}
if (this.deathBurstIntensity > 0) {
this.deathBurstIntensity = Math.max(
0,
this.deathBurstIntensity - deltaTimeInSeconds / deathBurstDecaySeconds,
);
this.deathBurst.center = this.bodyCenter;
// Square the envelope so the flash blooms then drops off sharply rather
// than fading out in a flat ramp.
this.deathBurst.intensity =
deathBurstMaxIntensity * this.deathBurstIntensity * this.deathBurstIntensity;
}
} }
public onShoot(strength: number) { public onShoot(strength: number) {
SoundHandler.play(Sounds.shoot, (0.6 * strength) / settings.playerMaxStrength); const q = clamp01(
(strength - settings.chargeShotStrengthMin) /
(settings.chargeShotStrengthMax - settings.chargeShotStrengthMin),
);
SoundHandler.play(Sounds.shoot, mix(0.55, 1, q), mix(1.15, 0.8, q));
this.muzzleFlashIntensity = mix(0.35, 1, q);
} }
private beforeDestroy(): void { private beforeDestroy(): void {
@ -127,15 +297,38 @@ export class CharacterView extends CharacterBase {
this.healthElement.style.width = this.healthElement.style.width =
(50 * this.health) / settings.playerMaxHealth + 'px'; (50 * this.health) / settings.playerMaxHealth + 'px';
this.statsElement.innerText = this.getStatsText(); this.chargeElement.style.width =
(50 * this.strength) / settings.playerMaxStrength + 'px';
this.killCountElement.innerText = String(this.killCount);
this.deathCountElement.innerText = String(this.deathCount);
} }
this.shape.hitFlash = this.hitFlashIntensity;
this.shape.gazeTarget = this.calculateGazeTarget(renderer);
this.shape.setCircles([this.head!, this.leftFoot!, this.rightFoot!]); this.shape.setCircles([this.head!, this.leftFoot!, this.rightFoot!]);
renderer.addDrawable(this.shape); renderer.addDrawable(this.shape);
if (this.muzzleFlashIntensity > 0) {
renderer.addDrawable(this.muzzleFlash);
} }
private getStatsText(): string { if (this.deathBurstIntensity > 0) {
return `${this.killCount}⚔/${this.deathCount}`; renderer.addDrawable(this.deathBurst);
}
}
private calculateGazeTarget(renderer: Renderer): vec2 {
const cursor = Pointer.getDisplayPosition();
if (this.isMainCharacter && cursor) {
return renderer.displayToWorldCoordinates(cursor);
}
return vec2.scaleAndAdd(
vec2.create(),
this.head!.center,
this.facingDirection,
this.head!.radius * 8,
);
} }
private calculateTextPosition(): vec2 { private calculateTextPosition(): vec2 {

View file

@ -1,18 +1,36 @@
import { vec2, vec3 } from 'gl-matrix'; import { vec2, vec3 } from 'gl-matrix';
import { CircleLight } from 'sdf-2d'; import { CircleLight } from 'sdf-2d';
import { CommandExecutors, Id, LampBase } from 'shared'; import { CommandExecutors, Id, LampBase, mixRgb, settings } from 'shared';
import { RenderCommand } from '../../commands/types/render'; import { RenderCommand } from '../../commands/types/render';
import { StepCommand } from '../../commands/types/step';
export class LampView extends LampBase { export class LampView extends LampBase {
private light: CircleLight; private light: CircleLight;
private targetColor: vec3;
private targetLightness: number;
protected commandExecutors: CommandExecutors = { protected commandExecutors: CommandExecutors = {
[RenderCommand.type]: this.draw.bind(this), [RenderCommand.type]: this.draw.bind(this),
[StepCommand.type]: this.step.bind(this),
}; };
constructor(id: Id, center: vec2, color: vec3, lightness: number) { constructor(id: Id, center: vec2, color: vec3, lightness: number) {
super(id, center, color, lightness); super(id, center, color, lightness);
this.light = new CircleLight(center, color, lightness); this.light = new CircleLight(vec2.clone(center), vec3.clone(color), lightness);
this.targetColor = vec3.clone(color);
this.targetLightness = lightness;
}
public setLight(color: vec3, lightness: number) {
this.targetColor = vec3.clone(color);
this.targetLightness = lightness;
}
private step({ deltaTimeInSeconds }: StepCommand): void {
const t = 1 - Math.exp(-deltaTimeInSeconds / settings.lampLerpSeconds);
this.light.color = mixRgb(this.light.color, this.targetColor, t);
this.light.intensity += (this.targetLightness - this.light.intensity) * t;
} }
private draw({ renderer }: RenderCommand): void { private draw({ renderer }: RenderCommand): void {

View file

@ -1,21 +1,53 @@
import { vec2 } from 'gl-matrix'; import { vec2, vec3 } from 'gl-matrix';
import { Id, Random, PlanetBase, UpdatePropertyCommand, CommandExecutors } from 'shared'; import { CircleLight } from 'sdf-2d';
import {
Id,
Random,
PlanetBase,
UpdatePropertyCommand,
CommandExecutors,
CharacterTeam,
settings,
} from 'shared';
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';
type FallingPoint = { const fallingPointLifetimeMs = 2000;
velocity: vec2;
position: vec2; // Global budget for simultaneously-lit capture flares, so a clustered wave of
element: HTMLElement; // captures can never white out the SDF exposure — excess flips still pulse the
addedToOverlay: boolean; // ring and toast, they just skip the extra light. The acquire/release pair keeps
timeToLive: number; // the count in one place instead of being hand-maintained at every call site.
}; abstract class FlareBudget {
private static active = 0;
public static tryAcquire(): boolean {
if (FlareBudget.active >= settings.maxConcurrentFlipFlares) {
return false;
}
FlareBudget.active++;
return true;
}
public static release(): void {
FlareBudget.active = Math.max(0, FlareBudget.active - 1);
}
}
export class PlanetView extends PlanetBase { export class PlanetView extends PlanetBase {
private shape: PlanetShape; private shape: PlanetShape;
private ownershipProgress: HTMLElement; private ownershipProgress: HTMLElement;
// Rotation is owned by the backend (it drives the collision polygon too) and
// streamed in; the interpolator replays the angle on the shared snapshot
// timeline, in sync with the characters standing on the surface.
private readonly rotationInterpolator = new LinearInterpolator(0);
private flareLight?: CircleLight;
private flareIntensity = 0;
private holdsFlareSlot = false;
protected commandExecutors: CommandExecutors = { protected commandExecutors: CommandExecutors = {
[RenderCommand.type]: this.draw.bind(this), [RenderCommand.type]: this.draw.bind(this),
@ -24,53 +56,107 @@ 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 as any).randomOffset = Random.getRandom(); this.shape.randomOffset = Random.getRandom();
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.randomOffset += deltaTimeInSeconds / 4; this.renderedRotation = this.rotationInterpolator.getValue(deltaTimeInSeconds);
this.shape.rotation = this.renderedRotation;
this.shape.colorMixQ = this.ownership; this.shape.colorMixQ = this.ownership;
this.generatedPointElements.forEach((p) => { if (this.flareIntensity > 0) {
vec2.add( this.flareIntensity = Math.max(
p.velocity, 0,
p.velocity, this.flareIntensity - deltaTimeInSeconds / settings.lampFlareDecaySeconds,
vec2.scale(vec2.create(), vec2.fromValues(0, 50), deltaTimeInSeconds),
); );
vec2.add( if (this.flareLight) {
p.position, this.flareLight.intensity =
p.position, settings.lampFlareIntensity * this.flareIntensity * this.flareIntensity;
vec2.scale(vec2.create(), p.velocity, deltaTimeInSeconds),
);
p.timeToLive -= deltaTimeInSeconds;
});
} }
private generatedPointElements: Array<FallingPoint> = []; if (this.flareIntensity === 0) {
this.releaseFlareSlot();
}
}
}
private releaseFlareSlot(): void {
if (this.holdsFlareSlot) {
this.holdsFlareSlot = false;
FlareBudget.release();
}
}
private lastGeneratedPoint?: number; private lastGeneratedPoint?: number;
public generatedPoints(value: number) { public generatedPoints(value: number) {
this.lastGeneratedPoint = value; this.lastGeneratedPoint = value;
} }
private beforeDestroy(): void { public onFlipped(team: CharacterTeam): void {
this.ownershipProgress.parentElement?.removeChild(this.ownershipProgress); const color = settings.palette[settings.colorIndices[team]];
this.generatedPointElements.forEach((p) =>
p.element.parentElement?.removeChild(p.element), if (!this.flareLight) {
); this.flareLight = new CircleLight(vec2.clone(this.center), vec3.clone(color), 0);
} else {
this.flareLight.color = vec3.clone(color);
} }
private updateProperty({ propertyValue }: UpdatePropertyCommand): void { if (!this.holdsFlareSlot) {
if (!FlareBudget.tryAcquire()) {
return;
}
this.holdsFlareSlot = true;
}
this.flareIntensity = 1;
}
private beforeDestroy(): void {
this.ownershipProgress.parentElement?.removeChild(this.ownershipProgress);
this.releaseFlareSlot();
}
private updateProperty({
propertyKey,
propertyValue,
rateOfChange,
}: UpdatePropertyCommand): void {
if (propertyKey === 'rotation') {
this.rotationInterpolator.addFrame(propertyValue, rateOfChange);
this.latestRotation = propertyValue;
this.rotationSpeed = rateOfChange;
} else {
this.ownership = propertyValue; this.ownership = propertyValue;
} }
}
private draw({ renderer, overlay, shouldChangeLayout }: RenderCommand): void { private draw({ renderer, overlay, shouldChangeLayout }: RenderCommand): void {
if (shouldChangeLayout) { if (shouldChangeLayout) {
@ -80,55 +166,44 @@ export class PlanetView extends PlanetBase {
const screenPosition = renderer.worldToDisplayCoordinates(this.center); const screenPosition = renderer.worldToDisplayCoordinates(this.center);
this.generatedPointElements.forEach((p) => {
if (!p.addedToOverlay) {
overlay.appendChild(p.element);
}
p.element.style.transform = `translateX(${
screenPosition.x + p.position.x
}px) translateY(${screenPosition.y + p.position.y}px)`;
if (p.timeToLive <= 0) {
p.element.parentElement?.removeChild(p.element);
} else {
p.element.style.opacity = Math.min(1, p.timeToLive).toString();
}
});
this.generatedPointElements = this.generatedPointElements.filter(
(p) => p.timeToLive > 0,
);
this.ownershipProgress.style.transform = `translateX(${screenPosition.x}px) translateY(${screenPosition.y}px) translateX(-50%) translateY(-50%)`; this.ownershipProgress.style.transform = `translateX(${screenPosition.x}px) translateY(${screenPosition.y}px) translateX(-50%) translateY(-50%)`;
this.ownershipProgress.style.background = this.getGradient(); this.ownershipProgress.style.background = this.getGradient();
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;
this.generatedPointElements.push({ element.style.left = `${screenPosition.x}px`;
element, element.style.top = `${screenPosition.y}px`;
addedToOverlay: false, overlay.appendChild(element);
timeToLive: Random.getRandomInRange(2, 3), setTimeout(
position: vec2.create(), () => element.parentElement?.removeChild(element),
velocity: vec2.fromValues(Random.getRandomInRange(-30, 30), 0), fallingPointLifetimeMs,
}); );
this.lastGeneratedPoint = undefined; this.lastGeneratedPoint = undefined;
} }
} }
renderer.addDrawable(this.shape); renderer.addDrawable(this.shape);
if (this.flareIntensity > 0 && this.flareLight) {
renderer.addDrawable(this.flareLight);
}
} }
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,19 +36,22 @@ 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 = (0.15 * this.strength) / settings.projectileMaxStrength; this.light.intensity = Math.min(
0.1,
(0.15 * this.strength) / settings.projectileMaxStrength,
);
} }
private draw({ renderer }: RenderCommand): void { private draw({ renderer }: RenderCommand): void {

View file

@ -15,9 +15,9 @@ export abstract class OptionsHandler {
musicEnabled: true, musicEnabled: true,
}; };
public static initialize( public static initialize(inputElements: {
inputElements: { [k in Extract<keyof Options, string>]: HTMLInputElement }, [k in Extract<keyof Options, string>]: HTMLInputElement;
) { }) {
if (localStorage.getItem('options')) { if (localStorage.getItem('options')) {
const stored: Partial<Options> | null = JSON.parse( const stored: Partial<Options> | null = JSON.parse(
localStorage.getItem('options')!, localStorage.getItem('options')!,
@ -44,7 +44,11 @@ export abstract class OptionsHandler {
} }
if (k === 'musicEnabled') { if (k === 'musicEnabled') {
this.checked ? SoundHandler.playAmbient() : SoundHandler.stopAmbient(); if (this.checked) {
SoundHandler.playAmbient();
} else {
SoundHandler.stopAmbient();
}
} }
if (this.checked && k === 'vibrationEnabled') { if (this.checked && k === 'vibrationEnabled') {

View file

@ -0,0 +1,71 @@
import { CharacterTeam, UpdateGameState, clamp, settings } from 'shared';
// Centred tug-of-war territory bar. The two fills meet at the bar's centre and
// grow outward by each team's share of the score limit, so the meeting point
// reads as the lead at a glance. Numeric scores are overlaid (the leader's is
// emphasised) and a "YOU" tick marks the local team's half. Owns its own DOM so
// the Game just appends `element` to the overlay and feeds it `update()`.
export class Scoreboard {
public readonly element = document.createElement('div');
private readonly blueFill = document.createElement('div');
private readonly redFill = document.createElement('div');
private readonly blueScore = document.createElement('span');
private readonly redScore = document.createElement('span');
private readonly youMarker = document.createElement('div');
constructor() {
this.element.className = 'planet-progress';
this.blueFill.className = 'fill blue';
this.redFill.className = 'fill red';
this.blueScore.className = 'score blue';
this.redScore.className = 'score red';
this.youMarker.className = 'you-marker';
this.youMarker.innerText = 'YOU';
this.element.append(
this.blueFill,
this.redFill,
this.blueScore,
this.redScore,
this.youMarker,
);
}
public update(
{ blueCount, redCount, limit }: UpdateGameState,
localTeam: CharacterTeam | undefined,
) {
const { scoreboardHalfWidthPercent, scoreboardMinFillPercent } = settings;
const fraction = (count: number) =>
Math.max(
count > 0 ? scoreboardMinFillPercent : 0,
clamp(count / limit, 0, 1) * scoreboardHalfWidthPercent,
);
this.blueFill.style.width = fraction(blueCount) + '%';
this.redFill.style.width = fraction(redCount) + '%';
const isMatchPoint = (count: number) =>
count / limit >= settings.matchPointScoreRatio;
this.blueFill.classList.toggle('match-point', isMatchPoint(blueCount));
this.redFill.classList.toggle('match-point', isMatchPoint(redCount));
this.blueScore.innerText = String(Math.round(blueCount));
this.redScore.innerText = String(Math.round(redCount));
this.blueScore.classList.toggle('leading', blueCount > redCount);
this.redScore.classList.toggle('leading', redCount > blueCount);
if (localTeam === CharacterTeam.blue || localTeam === CharacterTeam.red) {
this.youMarker.style.display = '';
this.youMarker.classList.toggle('blue', localTeam === CharacterTeam.blue);
this.youMarker.classList.toggle('red', localTeam === CharacterTeam.red);
} else {
this.youMarker.style.display = 'none';
}
}
}

View file

@ -0,0 +1,84 @@
// Camera impact effects fed by combat hits, sampled by the camera at render
// time. Kept as a process-wide singleton so any view can feed it without
// threading a camera reference through the deserialized object graph. Both
// effects touch only the *rendered* view (centre offset + view-area zoom), never
// the followed position or the simulation clock — so they can't drift the camera
// off the player, and they're completely decoupled from prediction/netcode.
//
// Two effects:
// - shake: a trauma model (Eiserloh) — the felt shake is trauma SQUARED so
// small taps stay subtle while a kill punches hard; it bleeds off linearly.
// - punch: a brief zoom-in on a kill that snaps back fast, for "weight".
//
// Honours prefers-reduced-motion: callers can fire freely and it simply no-ops
// for players who have asked the OS to minimise motion.
export abstract class ScreenShake {
private static trauma = 0;
private static punch = 0;
// Peak translation, in world units, at full trauma. The visible world is
// ~3800 units wide, so this tops out at a few percent of the screen.
private static readonly maxTranslation = 150;
// Fraction the view zooms in at full punch (smaller view area = zoomed in).
private static readonly maxZoom = 0.07;
private static readonly traumaDecayPerSecond = 1.7;
// Snaps back quickly so the punch reads as a sharp bite, not a slow drift.
private static readonly punchDecayPerSecond = 6;
private static offsetXValue = 0;
private static offsetYValue = 0;
private static get reducedMotion(): boolean {
return (
typeof window !== 'undefined' &&
typeof window.matchMedia === 'function' &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches
);
}
public static add(trauma: number): void {
if (this.reducedMotion) {
return;
}
this.trauma = Math.min(1, this.trauma + trauma);
}
public static addPunch(amount: number): void {
if (this.reducedMotion) {
return;
}
this.punch = Math.min(1, this.punch + amount);
}
public static step(deltaTimeInSeconds: number): void {
this.trauma = Math.max(
0,
this.trauma - this.traumaDecayPerSecond * deltaTimeInSeconds,
);
this.punch = Math.max(0, this.punch - this.punchDecayPerSecond * deltaTimeInSeconds);
const shake = this.trauma * this.trauma;
this.offsetXValue = this.maxTranslation * shake * (Math.random() * 2 - 1);
this.offsetYValue = this.maxTranslation * shake * (Math.random() * 2 - 1);
}
public static get offsetX(): number {
return this.offsetXValue;
}
public static get offsetY(): number {
return this.offsetYValue;
}
// Multiplier for the view-area size: <1 zooms in. Squared so the punch bites
// sharply near its peak rather than ramping linearly.
public static get viewScale(): number {
return 1 - this.maxZoom * this.punch * this.punch;
}
public static reset(): void {
this.trauma = 0;
this.punch = 0;
this.offsetXValue = 0;
this.offsetYValue = 0;
}
}

View file

@ -1,125 +0,0 @@
import { mat2d, vec2 } from 'gl-matrix';
import { Drawable, DrawableDescriptor } from 'sdf-2d';
import { Circle } from 'shared';
export class BlobShape extends Drawable {
public static descriptor: DrawableDescriptor = {
sdf: {
shader: `
uniform vec2 headCenters[BLOB_COUNT];
uniform vec2 leftFootCenters[BLOB_COUNT];
uniform vec2 rightFootCenters[BLOB_COUNT];
uniform float headRadii[BLOB_COUNT];
uniform float footRadii[BLOB_COUNT];
uniform int blobColors[BLOB_COUNT];
float blobSmoothMin(float a, float b)
{
const highp float k = 300.0;
highp float res = exp2(-k * a) + exp2(-k * b);
return -log2(res) / k;
}
float circleDistance(vec2 circleCenter, float radius, vec2 target) {
return distance(target, circleCenter) - radius;
}
float blobMinDistance(vec2 target, out vec4 color) {
float minDistance = 1000.0;
for (int i = 0; i < BLOB_COUNT; i++) {
float headDistance = circleDistance(headCenters[i], headRadii[i], target);
float leftFootDistance = circleDistance(leftFootCenters[i], footRadii[i], target);
float rightFootDistance = circleDistance(rightFootCenters[i], footRadii[i], target);
float res = min(
blobSmoothMin(headDistance, leftFootDistance),
blobSmoothMin(headDistance, rightFootDistance)
);
vec2 leftEyeOffset = vec2(-headRadii[i] * 0.35, headRadii[i] * 0.2);
vec2 rightEyeOffset = vec2(headRadii[i] * 0.35, headRadii[i] * 0.2);
float eyeDistance = min(
circleDistance(headCenters[i] + rightEyeOffset, headRadii[i] * 0.25, target),
circleDistance(headCenters[i] + leftEyeOffset, headRadii[i] * 0.25, target)
);
eyeDistance = max(
eyeDistance,
-circleDistance(headCenters[i] + leftEyeOffset + vec2(0, -headRadii[i] * 0.175), headRadii[i] * 0.2, target)
);
eyeDistance = max(
eyeDistance,
-circleDistance(headCenters[i] + rightEyeOffset + vec2(0, -headRadii[i] * 0.175), headRadii[i] * 0.2, target)
);
if (res < minDistance) {
minDistance = res;
color = eyeDistance < 0.0 ? vec4(1.0) : readFromPalette(blobColors[i]);
}
}
return minDistance;
}
`,
distanceFunctionName: 'blobMinDistance',
},
propertyUniformMapping: {
footRadius: 'footRadii',
headRadius: 'headRadii',
rightFootCenter: 'rightFootCenters',
leftFootCenter: 'leftFootCenters',
headCenter: 'headCenters',
color: 'blobColors',
},
uniformCountMacroName: 'BLOB_COUNT',
shaderCombinationSteps: [0, 1, 2, 8],
empty: new BlobShape(0),
};
protected head!: Circle;
protected leftFoot!: Circle;
protected rightFoot!: Circle;
public constructor(private readonly color: number) {
super();
const circle = new Circle(vec2.create(), 200);
this.setCircles([circle, circle, circle]);
}
public setCircles([head, leftFoot, rightFoot]: [Circle, Circle, Circle]) {
this.head = head;
this.leftFoot = leftFoot;
this.rightFoot = rightFoot;
}
public minDistance(target: vec2): number {
return Math.min(
this.head.distance(target),
this.leftFoot.distance(target),
this.rightFoot.distance(target),
);
}
protected getObjectToSerialize(transform2d: mat2d, transform1d: number): any {
return {
headCenter: vec2.transformMat2d(vec2.create(), this.head.center, transform2d),
leftFootCenter: vec2.transformMat2d(
vec2.create(),
this.leftFoot.center,
transform2d,
),
rightFootCenter: vec2.transformMat2d(
vec2.create(),
this.rightFoot.center,
transform2d,
),
headRadius: this.head.radius * transform1d,
footRadius: this.leftFoot.radius * transform1d,
color: this.color,
};
}
}

View file

@ -0,0 +1,219 @@
import { mat2d, vec2 } from 'gl-matrix';
import { Drawable, DrawableDescriptor } from 'sdf-2d';
import { Circle } from 'shared';
// A single character silhouette shared by every team (teams differ only by
// colour): a circular head sitting on two thin line-legs that run down to the
// two feet.
export class CharacterShape extends Drawable {
public static descriptor: DrawableDescriptor = {
sdf: {
shader: `
uniform vec2 characterHeadCenters[CHARACTER_COUNT];
uniform vec2 characterLeftFeet[CHARACTER_COUNT];
uniform vec2 characterRightFeet[CHARACTER_COUNT];
uniform vec2 characterGazeTargets[CHARACTER_COUNT];
uniform float characterHeadRadii[CHARACTER_COUNT];
uniform float characterFootRadii[CHARACTER_COUNT];
uniform int characterColors[CHARACTER_COUNT];
uniform float characterFlash[CHARACTER_COUNT];
float circleDistance(vec2 circleCenter, float radius, vec2 target) {
return distance(target, circleCenter) - radius;
}
// Distance to the segment a->b; subtract a radius to get a capsule (a
// rounded thick line).
float segmentDistance(vec2 a, vec2 b, vec2 target) {
vec2 pa = target - a;
vec2 ba = b - a;
float h = clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0);
return length(pa - ba * h);
}
float characterMinDistance(vec2 target, out vec4 color) {
float minDistance = 1000.0;
for (int i = 0; i < CHARACTER_COUNT; i++) {
vec2 head = characterHeadCenters[i];
vec2 leftFoot = characterLeftFeet[i];
vec2 rightFoot = characterRightFeet[i];
float headRadius = characterHeadRadii[i];
float footRadius = characterFootRadii[i];
// The server rotates the whole posture toward travel, so the head
// leads: forward is the facing direction, perp its 90deg rotation, so
// the face sits on the leading side and reads at any rotation.
vec2 footAverage = (leftFoot + rightFoot) * 0.5;
vec2 toHead = head - footAverage;
float toHeadLength = length(toHead);
vec2 forward = toHeadLength > 0.001 ? toHead / toHeadLength : vec2(0.0, 1.0);
vec2 perp = vec2(-forward.y, forward.x);
// A circular head, drawn a bit smaller than the head->feet gap so the
// legs below it read as two distinct LINES rather than being swallowed
// by the head.
float renderRadius = headRadius * 0.75;
float headDistance = circleDistance(head, renderRadius, target);
// Legs as thin line-segments (capsules) from the head down to each
// foot, hard-min'd onto the head so the head stays a crisp circle.
float legThickness = footRadius * 0.35;
float leftLeg = segmentDistance(head, leftFoot, target) - legThickness;
float rightLeg = segmentDistance(head, rightFoot, target) - legThickness;
// Rounded feet at the ends of the lines, kept large enough to stay
// visible even when the leg is short and the head nearly reaches them.
float footRender = footRadius * 0.7;
float leftFootDistance = circleDistance(leftFoot, footRender, target);
float rightFootDistance = circleDistance(rightFoot, footRender, target);
float body = min(
headDistance,
min(min(leftLeg, rightLeg), min(leftFootDistance, rightFootDistance))
);
// Real eyes painted on the leading face — a bright white sclera with a
// dark pupil — rather than carved holes, so the character reads as
// alive instead of hollow-socketed.
// Big white sclera, small pupil: the eye must read as mostly white so
// it looks like an eye, not a dark socket. (An 8-bit albedo caps the
// sclera at pure white, so its area — not its colour — is what makes
// the eye read brighter against the body.)
vec2 eyeBase = head + forward * (renderRadius * 0.26);
vec2 leftEyeCenter = eyeBase + perp * (renderRadius * 0.38);
vec2 rightEyeCenter = eyeBase - perp * (renderRadius * 0.38);
float scleraRadius = renderRadius * 0.26;
float pupilRadius = renderRadius * 0.11;
// The pupil slides toward the gaze target (the cursor for the local
// player, otherwise the travel direction), kept well inside the rim so
// a generous ring of white always frames it. The normalize is guarded
// so a cursor resting exactly on an eye can't produce NaNs.
vec2 gazeTarget = characterGazeTargets[i];
float pupilReach = (scleraRadius - pupilRadius) * 0.6;
vec2 toLeftGaze = gazeTarget - leftEyeCenter;
vec2 toRightGaze = gazeTarget - rightEyeCenter;
vec2 leftGaze = length(toLeftGaze) > 0.001 ? normalize(toLeftGaze) : forward;
vec2 rightGaze = length(toRightGaze) > 0.001 ? normalize(toRightGaze) : forward;
float sclera = min(
circleDistance(leftEyeCenter, scleraRadius, target),
circleDistance(rightEyeCenter, scleraRadius, target)
);
float pupil = min(
circleDistance(leftEyeCenter + leftGaze * pupilReach, pupilRadius, target),
circleDistance(rightEyeCenter + rightGaze * pupilReach, pupilRadius, target)
);
// Soften each eye edge by at least one screen texel so the colour
// boundary antialiases instead of staircasing when the character is
// small on screen. The fixed world-space term sets a floor on the
// softness when zoomed in; fwidth widens the band to a texel as the
// head shrinks. Computed here in uniform control flow — NOT inside the
// body branch below — so the screen-space derivatives stay defined.
// fwidth needs WebGL2 derivatives (core in GLSL ES 3.00), so the
// WebGL1 fallback keeps the plain world-space band.
float eyeAaBase = renderRadius * 0.025;
#ifdef WEBGL2_IS_AVAILABLE
float scleraAa = max(eyeAaBase, fwidth(sclera));
float pupilAa = max(eyeAaBase, fwidth(pupil));
#else
float scleraAa = eyeAaBase;
float pupilAa = eyeAaBase;
#endif
if (body < minDistance) {
minDistance = body;
// Brief white punch when taking a hit.
color = mix(
readFromPalette(characterColors[i]),
vec4(1.0),
clamp(characterFlash[i], 0.0, 1.0)
);
// Paint the eyes over the body colour. The sclera albedo is HDR
// (>> 1): the shading pass multiplies it by the (often dim, reddish)
// scene light before clamping to the screen, so an over-bright white
// reads as a clean white eye everywhere instead of dimming into a
// dark socket. Needs the float colour buffer in sdf-2d; on 8-bit
// fallback it simply clamps back to plain white. The smoothstep
// widths (scleraAa / pupilAa, computed above) antialias the colour
// boundary in a zoom-aware way.
color = mix(color, vec4(10.0, 10.0, 10.0, 1.0), 1.0 - smoothstep(-scleraAa, scleraAa, sclera));
color = mix(color, vec4(0.04, 0.04, 0.07, 1.0), 1.0 - smoothstep(-pupilAa, pupilAa, pupil));
}
}
return minDistance;
}
`,
distanceFunctionName: 'characterMinDistance',
},
propertyUniformMapping: {
footRadius: 'characterFootRadii',
headRadius: 'characterHeadRadii',
rightFootCenter: 'characterRightFeet',
leftFootCenter: 'characterLeftFeet',
headCenter: 'characterHeadCenters',
gazeTarget: 'characterGazeTargets',
color: 'characterColors',
flash: 'characterFlash',
},
uniformCountMacroName: 'CHARACTER_COUNT',
shaderCombinationSteps: [0, 1, 2, 8],
empty: new CharacterShape(0),
};
protected head!: Circle;
protected leftFoot!: Circle;
protected rightFoot!: Circle;
// 0..1 transient white flash on taking a hit. Set per frame.
public hitFlash = 0;
// World point the eyes look at; the pupils slide toward it. Set per frame
// (the cursor for the local player, the travel direction for everyone else).
public gazeTarget = vec2.create();
public constructor(private readonly color: number) {
super();
const circle = new Circle(vec2.create(), 200);
this.setCircles([circle, circle, circle]);
}
public setCircles([head, leftFoot, rightFoot]: [Circle, Circle, Circle]) {
this.head = head;
this.leftFoot = leftFoot;
this.rightFoot = rightFoot;
}
public minDistance(target: vec2): number {
return Math.min(
this.head.distance(target),
this.leftFoot.distance(target),
this.rightFoot.distance(target),
);
}
protected getObjectToSerialize(transform2d: mat2d, transform1d: number): any {
return {
headCenter: vec2.transformMat2d(vec2.create(), this.head.center, transform2d),
leftFootCenter: vec2.transformMat2d(
vec2.create(),
this.leftFoot.center,
transform2d,
),
rightFootCenter: vec2.transformMat2d(
vec2.create(),
this.rightFoot.center,
transform2d,
),
gazeTarget: vec2.transformMat2d(vec2.create(), this.gazeTarget, transform2d),
headRadius: this.head.radius * transform1d,
footRadius: this.leftFoot.radius * transform1d,
color: this.color,
flash: this.hitFlash,
};
}
}

View file

@ -14,6 +14,7 @@ export class PlanetShape extends PolygonFactory(settings.planetEdgeCount, 0) {
uniform float planetLengths[PLANET_COUNT]; uniform float planetLengths[PLANET_COUNT];
uniform float planetRandoms[PLANET_COUNT]; uniform float planetRandoms[PLANET_COUNT];
uniform float planetColorMixQ[PLANET_COUNT]; uniform float planetColorMixQ[PLANET_COUNT];
uniform float planetRotations[PLANET_COUNT];
uniform sampler2D noiseTexture; uniform sampler2D noiseTexture;
@ -52,10 +53,23 @@ export class PlanetShape extends PolygonFactory(settings.planetEdgeCount, 0) {
vec2 center = planetCenters[j]; vec2 center = planetCenters[j];
float l = planetLengths[j]; float l = planetLengths[j];
float randomOffset = planetRandoms[j]; float randomOffset = planetRandoms[j];
float rotation = planetRotations[j];
float cr = cos(rotation);
float sr = sin(rotation);
// Spin the whole planet: evaluate the SDF in the planet's own
// rotating frame so the polygon outline turns together with its
// terrain, instead of the terrain sliding over a fixed outline.
vec2 targetCenterDelta = target - center; vec2 targetCenterDelta = target - center;
float targetDistance = length(targetCenterDelta); float targetDistance = length(targetCenterDelta);
vec2 targetTangent = targetCenterDelta / clamp(targetDistance, 0.01, 1000.0); vec2 localTarget = center + vec2(
vec2 noisyTarget = target - ( cr * targetCenterDelta.x - sr * targetCenterDelta.y,
sr * targetCenterDelta.x + cr * targetCenterDelta.y
);
vec2 targetTangent = (localTarget - center) / clamp(targetDistance, 0.01, 1000.0);
vec2 noisyTarget = localTarget - (
targetTangent * planetTerrain(vec2( targetTangent * planetTerrain(vec2(
l * abs(atan(targetTangent.y, targetTangent.x)), l * abs(atan(targetTangent.y, targetTangent.x)),
randomOffset randomOffset
@ -88,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]);
} }
@ -105,6 +119,7 @@ export class PlanetShape extends PolygonFactory(settings.planetEdgeCount, 0) {
center: 'planetCenters', center: 'planetCenters',
vertices: 'planetVertices', vertices: 'planetVertices',
colorMixQ: 'planetColorMixQ', colorMixQ: 'planetColorMixQ',
rotation: 'planetRotations',
}, },
uniformCountMacroName: `PLANET_COUNT`, uniformCountMacroName: `PLANET_COUNT`,
shaderCombinationSteps: [0, 1, 2, 3], shaderCombinationSteps: [0, 1, 2, 3],
@ -112,9 +127,31 @@ export class PlanetShape extends PolygonFactory(settings.planetEdgeCount, 0) {
}; };
public randomOffset = 0; public randomOffset = 0;
public rotation = 0;
constructor(public vertices: Array<vec2>, public colorMixQ: number) { // Circle about the rotation centre (the vertex centroid, which is what the
// shader spins around — see planetMinDistance above). The vertices never
// change after construction, so cache it once.
private readonly cullCenter: vec2;
private readonly cullRadius: number;
constructor(
public vertices: Array<vec2>,
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 {
@ -139,6 +176,7 @@ export class PlanetShape extends PolygonFactory(settings.planetEdgeCount, 0) {
length, length,
random: this.randomOffset, random: this.randomOffset,
colorMixQ: this.colorMixQ, colorMixQ: this.colorMixQ,
rotation: this.rotation,
}; };
} }

View file

@ -52,7 +52,7 @@ export abstract class SoundHandler {
return sound; return sound;
} }
public static play(sound: Sounds, volume = 1) { public static play(sound: Sounds, volume = 1, playbackRate = 1) {
if (!this.initialized || !OptionsHandler.options.soundsEnabled) { if (!this.initialized || !OptionsHandler.options.soundsEnabled) {
return; return;
} }
@ -61,7 +61,8 @@ export abstract class SoundHandler {
this.sounds[sound].currentTime > 0 this.sounds[sound].currentTime > 0
? (this.sounds[sound].cloneNode(true) as HTMLAudioElement) ? (this.sounds[sound].cloneNode(true) as HTMLAudioElement)
: this.sounds[sound]; : this.sounds[sound];
audio.volume = volume; audio.volume = Math.max(0, Math.min(1, volume));
audio.playbackRate = playbackRate;
audio.play(); audio.play();
} }

View file

@ -0,0 +1,112 @@
import { vec2 } from 'gl-matrix';
import {
CharacterTeam,
CommandExecutors,
CommandReceiver,
Id,
LeapActionCommand,
MoveActionCommand,
PrimaryActionCommand,
} from 'shared';
import { GameObjectContainer } from './objects/game-object-container';
import { PlanetView } from './objects/types/planet-view';
type StageTrigger = 'move' | 'shoot' | 'leap' | 'capture';
const stages: ReadonlyArray<{ hint: string; clearsOn: StageTrigger }> = [
{ hint: 'WASD / drag to walk', clearsOn: 'move' },
{ hint: 'Click / tap to shoot', clearsOn: 'shoot' },
{ hint: 'Space / leap button to launch off a planet', clearsOn: 'leap' },
{ hint: 'Stand on a planet to capture it', clearsOn: 'capture' },
];
const captureProgressThreshold = 0.05;
const standingSlack = 200;
export class Tutorial extends CommandReceiver {
private stage = 0;
private element = document.createElement('div');
private characterId?: Id;
private standingPlanet?: PlanetView;
private standingBaseline = 0;
protected commandExecutors: CommandExecutors = {
[MoveActionCommand.type]: (c: MoveActionCommand) => {
if (this.clearsOn() === 'move' && vec2.length(c.direction) > 0) {
this.advance();
}
},
[PrimaryActionCommand.type]: () => {
if (this.clearsOn() === 'shoot') {
this.advance();
}
},
[LeapActionCommand.type]: () => {
if (this.clearsOn() === 'leap') {
this.advance();
}
},
};
constructor(overlay: HTMLElement) {
super();
this.element.className = 'tutorial-hint';
this.element.innerText = stages[0].hint;
overlay.appendChild(this.element);
}
private clearsOn(): StageTrigger | undefined {
return stages[this.stage]?.clearsOn;
}
private advance() {
this.setStage(this.stage + 1);
}
public step(gameObjects: GameObjectContainer) {
if (this.stage >= stages.length) {
return;
}
const player = gameObjects.player;
if (!player) {
return;
}
if (this.characterId === undefined) {
this.characterId = player.id;
} else if (player.id !== this.characterId) {
this.finish();
return;
}
if (this.clearsOn() === 'capture') {
const standing = gameObjects.planets.find(
(p) => vec2.distance(p.center, player.bodyCenter) < p.radius + standingSlack,
);
if (standing !== this.standingPlanet) {
this.standingPlanet = standing;
this.standingBaseline = standing?.ownership ?? 0;
}
if (standing) {
const drift = standing.ownership - this.standingBaseline;
const progress = player.team === CharacterTeam.blue ? -drift : drift;
if (progress > captureProgressThreshold) {
this.finish();
}
}
}
}
private finish() {
this.setStage(stages.length);
}
private setStage(stage: number) {
this.stage = stage;
this.element.innerText = stages[stage]?.hint ?? '';
}
}

View file

@ -1,9 +1,11 @@
import { OptionsHandler } from './options-handler'; import { OptionsHandler } from './options-handler';
export abstract class VibrationHandler { export abstract class VibrationHandler {
public static vibrate(time: number): void { // Accepts either a single duration or an on/off pattern (e.g. a double-thump
// [40, 30, 90] for a kill).
public static vibrate(pattern: number | number[]): void {
if (OptionsHandler.options.vibrationEnabled && this.isVibrationEnabled) { if (OptionsHandler.options.vibrationEnabled && this.isVibrationEnabled) {
navigator.vibrate(time); navigator.vibrate(pattern);
} }
} }

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

@ -1,10 +1,11 @@
@use 'sass:math';
@use 'vars' as *; @use 'vars' as *;
@use 'mixins' as *; @use 'mixins' as *;
form { form {
@include background; @include background;
padding: $small-padding / 2 $small-padding $small-padding $small-padding; padding: math.div($small-padding, 2) $small-padding $small-padding $small-padding;
border-radius: $border-radius; border-radius: $border-radius;
input:-webkit-autofill, input:-webkit-autofill,
@ -38,7 +39,7 @@ form {
position: relative; position: relative;
padding: 15px 0 0; padding: 15px 0 0;
margin-top: 10px; margin-top: 10px;
margin: 10px $small-padding / 2 $small-padding $small-padding / 2; margin: 10px math.div($small-padding, 2) $small-padding math.div($small-padding, 2);
input { input {
font-family: inherit; font-family: inherit;
@ -99,7 +100,7 @@ form {
cursor: pointer; cursor: pointer;
margin: $border-width-focused - $border-width $border-width-focused - margin: $border-width-focused - $border-width $border-width-focused -
$border-width + $small-padding / 2; $border-width + math.div($small-padding, 2);
.completion { .completion {
font-size: 0.7em; font-size: 0.7em;
@ -115,7 +116,7 @@ form {
&:checked + label { &:checked + label {
border-color: $accent; border-color: $accent;
border-width: $border-width-focused; border-width: $border-width-focused;
margin: 0 $small-padding / 2; margin: 0 math.div($small-padding, 2);
} }
} }
} }

View file

@ -1,3 +1,4 @@
@use 'sass:math';
@use 'vars' as *; @use 'vars' as *;
@use 'mixins' as *; @use 'mixins' as *;
@ -107,7 +108,7 @@
$height: 4px; $height: 4px;
width: $height; width: $height;
top: $height / 2; top: math.div($height, 2);
right: 0; right: 0;
transform-origin: top right; transform-origin: top right;
@ -119,7 +120,7 @@
@media (max-width: $breakpoint) { @media (max-width: $breakpoint) {
$height: 2px; $height: 2px;
width: $height; width: $height;
top: $height / 2; top: math.div($height, 2);
} }
} }
@ -152,11 +153,11 @@
label:not(:first-child) { label:not(:first-child) {
input[type='checkbox']:after { input[type='checkbox']:after {
$height: 4px; $height: 4px;
top: $height / 2 + $small-padding; top: math.div($height, 2) + $small-padding;
@media (max-width: $breakpoint) { @media (max-width: $breakpoint) {
right: $small-padding; right: $small-padding;
top: $height / 2; top: math.div($height, 2);
} }
} }
} }

View file

@ -1,12 +1,13 @@
{ {
"compilerOptions": { "compilerOptions": {
"outDir": "dist", "outDir": "dist",
"rootDir": "src",
"target": "es6", "target": "es6",
"esModuleInterop": true, "esModuleInterop": true,
"strict": true, "strict": true,
"experimentalDecorators": true, "experimentalDecorators": true,
"downlevelIteration": true,
"moduleResolution": "node", "moduleResolution": "node",
"ignoreDeprecations": "6.0",
"skipLibCheck": true, "skipLibCheck": true,
"module": "commonjs", "module": "commonjs",
"lib": ["dom", "es2017"], "lib": ["dom", "es2017"],

View file

@ -1,45 +1,57 @@
const CleanWebpackPlugin = require('clean-webpack-plugin').CleanWebpackPlugin; const path = require('path');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin'); const HtmlWebpackPlugin = require('html-webpack-plugin');
const TsConfigWebpackPlugin = require('ts-config-webpack-plugin');
const HtmlWebpackInlineSVGPlugin = require('html-webpack-inline-svg-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin'); const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const TerserJSPlugin = require('terser-webpack-plugin'); const TerserJSPlugin = require('terser-webpack-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin');
const HTMLInlineCSSWebpackPlugin = require('html-inline-css-webpack-plugin').default;
// const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin; // const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
const HtmlWebpackInlineSourcePlugin = require('html-webpack-inline-source-plugin');
const Sass = require('sass'); const Sass = require('sass');
module.exports = (env, argv) => ({ module.exports = (env, argv) => {
const isProduction = argv.mode !== 'development';
return {
entry: {
main: path.resolve(__dirname, 'src/index.ts'),
},
output: {
filename: 'main.js',
path: path.resolve(__dirname, 'dist'),
},
devServer: { devServer: {
host: '0.0.0.0', host: '0.0.0.0',
disableHostCheck: true, allowedHosts: 'all',
},
// webpack-dev-server 5 no longer accepts `watchOptions` under `devServer`;
// polling is configured on the compiler instead (needed for some FS mounts).
watchOptions: { watchOptions: {
poll: true, poll: true,
}, },
},
plugins: [ plugins: [
new CleanWebpackPlugin(), new CleanWebpackPlugin(),
new MiniCssExtractPlugin(), new MiniCssExtractPlugin(),
//new BundleAnalyzerPlugin(), //new BundleAnalyzerPlugin(),
new HtmlWebpackPlugin({ new HtmlWebpackPlugin({
template: './src/index.html', template: './src/index.html',
inlineSource: argv.mode === 'development' ? '' : '.(css)$',
}), }),
new HtmlWebpackInlineSourcePlugin(HtmlWebpackPlugin), // The SVG UI icons used to be inlined into the HTML by the (abandoned,
new HtmlWebpackInlineSVGPlugin({ // webpack-4-only) html-webpack-inline-svg-plugin. They are now emitted as
inlineAll: true, // static files and referenced via `static/<name>.svg` from index.html.
svgoConfig: [ new CopyWebpackPlugin({
{ patterns: [{ from: 'static/*.svg', to: 'static/[name][ext]' }],
removeViewBox: false,
},
],
}), }),
new TsConfigWebpackPlugin(), // Inline the extracted CSS into the HTML for production builds (replaces
// the abandoned html-webpack-inline-source-plugin). In development the CSS
// stays a separate, linked file for faster rebuilds.
...(isProduction ? [new HTMLInlineCSSWebpackPlugin()] : []),
], ],
optimization: { optimization: {
minimizer: [ minimizer: [
new TerserJSPlugin({ new TerserJSPlugin({
test: /\.js$/,
exclude: /node_modules/, exclude: /node_modules/,
// The custom serialization protocol keys on class names, so they must
// survive minification (see shared/src/serialization).
terserOptions: { terserOptions: {
keep_classnames: true, keep_classnames: true,
}, },
@ -53,6 +65,11 @@ module.exports = (env, argv) => ({
enforce: 'pre', enforce: 'pre',
use: ['source-map-loader'], use: ['source-map-loader'],
}, },
{
test: /\.ts$/,
use: 'ts-loader',
exclude: /node_modules/,
},
{ {
test: /\.scss$/i, test: /\.scss$/i,
use: [ use: [
@ -69,18 +86,29 @@ module.exports = (env, argv) => ({
], ],
}, },
{ {
// SVGs referenced from CSS (`mask-image`/`background-image`) and JS
// must be inlined as real `data:` URIs. svg-url-loader emits a CJS
// module (`module.exports = "data:..."`) which webpack 5 + css-loader 7
// write out verbatim as a `.svg` asset file, so the browser fetches JS
// instead of an image and the mask/background silently fails. webpack 5's
// built-in `asset/inline` produces a proper data URI instead.
// (HTML <img> icons are unaffected: they are copied by CopyWebpackPlugin
// and referenced by literal `static/*.svg` paths, not through this rule.)
test: /\.svg$/, test: /\.svg$/,
use: { type: 'asset/inline',
loader: 'svg-url-loader',
options: {},
},
}, },
{ {
test: /\.(mp3|png)$/, // Use oneOf so each asset matches exactly one rule (og-image.png would
// otherwise match both the generic png rule and its own rule).
// The directory is encoded in `name` (not `outputPath`) and has no
// leading slash: with the default `publicPath: 'auto'` a leading slash
// produces a doubled slash in the emitted URL (e.g. `//static/x.mp3`).
oneOf: [
{
test: /og-image\.png$/,
use: { use: {
loader: 'file-loader', loader: 'file-loader',
query: { options: {
outputPath: '/static',
name: '[name].[ext]', name: '[name].[ext]',
}, },
}, },
@ -89,22 +117,26 @@ module.exports = (env, argv) => ({
test: /\.ico$/, test: /\.ico$/,
use: { use: {
loader: 'file-loader', loader: 'file-loader',
query: { options: {
outputPath: '/',
name: '[name].[ext]', name: '[name].[ext]',
}, },
}, },
}, },
{ {
test: /og-image.png$/, test: /\.(mp3|png)$/,
use: { use: {
loader: 'file-loader', loader: 'file-loader',
query: { options: {
outputPath: '/', name: 'static/[name].[ext]',
name: '[name].[ext]',
}, },
}, },
}, },
], ],
}, },
}); ],
},
resolve: {
extensions: ['.ts', '.js', '.json'],
},
};
};

View file

@ -1,23 +1,27 @@
{ {
"name": "root", "name": "root",
"private": true, "private": true,
"engines": {
"node": ">=20"
},
"devDependencies": { "devDependencies": {
"@typescript-eslint/eslint-plugin": "^3.9.1", "concurrently": "^10.0.3",
"@typescript-eslint/parser": "^3.9.1", "eslint": "^10.4.1",
"concurrently": "^5.3.0", "eslint-config-prettier": "^10.1.8",
"eslint": "^7.2.0", "eslint-plugin-prettier": "^5.5.6",
"eslint-config-prettier": "^6.11.0", "eslint-plugin-unused-imports": "^4.4.1",
"eslint-plugin-import": "^2.21.2", "prettier": "^3.8.3",
"eslint-plugin-json-format": "^2.0.1", "typescript": "^6.0.3",
"eslint-plugin-prettier": "^3.1.4", "typescript-eslint": "^8.60.1",
"eslint-plugin-unused-imports": "^0.1.3", "vitest": "^4.1.9"
"prettier": "^2.0.5",
"typescript": "^4.0.3"
}, },
"scripts": { "scripts": {
"build": "cd shared && npm run build && cd ../frontend && npm run build && cd ../backend && npm run build", "build": "cd shared && npm run build && cd ../frontend && npm run build && cd ../backend && npm run build",
"lint": "eslint ./**/src/**/*.{js,ts,json} --fix && prettier --write ./**/src/**/*.{js,ts,json}", "lint": "eslint ./**/src/**/*.{js,ts} --fix && prettier --write ./**/src/**/*.{js,ts,json}",
"lint:check": "eslint ./**/src/**/*.{js,ts} && prettier --check ./**/src/**/*.{js,ts,json}",
"test": "vitest run",
"test:watch": "vitest",
"init": "cd shared && npm install && cd ../frontend && npm install && cd ../backend && npm install", "init": "cd shared && npm install && cd ../frontend && npm install && cd ../backend && npm install",
"start": "concurrently --kill-others-on-fail \"cd shared && npm start\" \"cd backend && npm start\" \"cd frontend && npm start\"" "dev": "concurrently --kill-others-on-fail \"cd shared && npm dev\" \"cd backend && npm dev\" \"cd frontend && npm dev\""
} }
} }

View file

@ -7,20 +7,23 @@
"files": [ "files": [
"lib" "lib"
], ],
"engines": {
"node": ">=20"
},
"scripts": { "scripts": {
"build": "npx webpack --mode production", "build": "npx webpack --mode production",
"start": "npx webpack --mode development --watch", "dev": "npx webpack --mode development --watch",
"try-build-before": "npx webpack --mode production" "try-build-before": "npx webpack --mode production"
}, },
"devDependencies": { "devDependencies": {
"clean-webpack-plugin": "^3.0.0", "clean-webpack-plugin": "^4.0.0",
"file-loader": "^6.1.0", "file-loader": "^6.2.0",
"gl-matrix": "3.3.0", "gl-matrix": "^3.3.0",
"prettier": "^2.0.5", "prettier": "^3.8.3",
"terser-webpack-plugin": "^2.3.8", "terser-webpack-plugin": "^5.6.1",
"ts-loader": "^8.0.3", "ts-loader": "^9.6.0",
"typescript": "^4.0.3", "typescript": "^6.0.3",
"webpack": "^4.43.0", "webpack": "^5.107.2",
"webpack-cli": "^3.3.12" "webpack-cli": "^7.0.3"
} }
} }

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

@ -4,11 +4,14 @@ import { Command } from '../../command';
@serializable @serializable
export class PrimaryActionCommand extends Command { export class PrimaryActionCommand extends Command {
public constructor(public readonly position: vec2) { public constructor(
public readonly position: vec2,
public readonly charge: number = 0,
) {
super(); super();
} }
public toArray(): Array<any> { public toArray(): Array<any> {
return [this.position]; return [this.position, this.charge];
} }
} }

View file

@ -1,14 +0,0 @@
import { vec2 } from 'gl-matrix';
import { serializable } from '../../../serialization/serializable';
import { Command } from '../../command';
@serializable
export class SecondaryActionCommand extends Command {
public constructor(public readonly position: vec2) {
super();
}
public toArray(): Array<any> {
return [this.position];
}
}

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];
} }
} }

Some files were not shown because too many files have changed in this diff Show more