Modernise & make fun #3
132 changed files with 11944 additions and 21194 deletions
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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"
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
**/node_modules/**/*.js
|
|
||||||
node_modules/**/*.js
|
|
||||||
**/package-lock.json
|
|
||||||
package-lock.json
|
|
||||||
|
|
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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'
|
||||||
run: |
|
run: |
|
||||||
|
|
|
||||||
1
.node-version
Normal file
1
.node-version
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
22
|
||||||
1
.nvmrc
Normal file
1
.nvmrc
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
22
|
||||||
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
|
|
@ -3,7 +3,7 @@
|
||||||
"Deserializable",
|
"Deserializable",
|
||||||
"Deserialization",
|
"Deserialization",
|
||||||
"Respawn",
|
"Respawn",
|
||||||
"decla",
|
"doppler",
|
||||||
"overridable",
|
"overridable",
|
||||||
"serializable"
|
"serializable"
|
||||||
]
|
]
|
||||||
|
|
|
||||||
12
Dockerfile
12
Dockerfile
|
|
@ -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"]
|
||||||
|
|
|
||||||
10
README.md
10
README.md
|
|
@ -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).**
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||
|
|
@ -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
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -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).
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
#!/bin/sh
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
NODE_ENV=production declared-server --port=3000 --name="Simonyi" --playerLimit=256 --npcCount=48 --scoreLimit=20000
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
9
backend/src/commands/announce.ts
Normal file
9
backend/src/commands/announce.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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.players.step(scaledDelta);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.objects.handleCommand(new StepCommand(scaledDelta, this));
|
|
||||||
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 {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
||||||
|
if (this.isSpawnProtected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.timeSinceLastDamage = 0;
|
||||||
this.health -= other.strength;
|
this.health -= other.strength;
|
||||||
|
this.lastSyncedHealth = Math.round(this.health);
|
||||||
this.remoteCall('setHealth', this.health);
|
this.remoteCall('setHealth', this.health);
|
||||||
|
|
||||||
if (this.health <= 0 && this.isAlive) {
|
if (this.health <= 0 && this.isAlive) {
|
||||||
|
// Throw the corpse along the killing shot, harder for charged hits.
|
||||||
|
vec2.scaleAndAdd(
|
||||||
|
this.bodyVelocity,
|
||||||
|
this.bodyVelocity,
|
||||||
|
other.direction,
|
||||||
|
mix(settings.deathImpulseMin, settings.deathImpulseMax, other.charge),
|
||||||
|
);
|
||||||
this.onDie();
|
this.onDie();
|
||||||
other.originator.addKill();
|
other.originator.addKill(this.name, other.charge);
|
||||||
|
} else {
|
||||||
|
other.originator.registerHit(other.charge);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public shootTowards(position: vec2) {
|
public shootTowards(position: vec2, charge = 0) {
|
||||||
if (!this.isAlive) {
|
if (
|
||||||
|
!this.isAlive ||
|
||||||
|
this.timeSinceLastShot < settings.projectileCreationInterval ||
|
||||||
|
this.projectileStrength < settings.chargeShotStrengthMin
|
||||||
|
) {
|
||||||
return;
|
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,145 +520,36 @@ 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);
|
const { hitObject } = part.stepManually(deltaTime);
|
||||||
|
if (hitObject instanceof PlanetPhysical) {
|
||||||
if (!this.currentPlanet) {
|
grounded = true;
|
||||||
const leftFootGravity = forceAtPosition(
|
|
||||||
this.leftFoot.center,
|
|
||||||
intersectingWithForceField,
|
|
||||||
);
|
|
||||||
const rightFootGravity = forceAtPosition(
|
|
||||||
this.rightFoot.center,
|
|
||||||
intersectingWithForceField,
|
|
||||||
);
|
|
||||||
|
|
||||||
this.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);
|
|
||||||
}
|
}
|
||||||
|
// Brake with the exact shared model the living body uses: stiff on contact
|
||||||
this.keepPosture(deltaTimeInSeconds);
|
// so the corpse skids to rest, gentle in the air, plus the constant stop and
|
||||||
this.stepBodyPart(this.leftFoot, deltaTimeInSeconds);
|
// the speed cap — so a flung corpse comes to a definite stop instead of
|
||||||
this.stepBodyPart(this.rightFoot, deltaTimeInSeconds);
|
// sliding forever.
|
||||||
this.stepBodyPart(this.head, deltaTimeInSeconds);
|
decayMomentum(this.bodyVelocity, grounded, deltaTime);
|
||||||
|
|
||||||
this.setPropertyUpdates(oldHead, oldLeftFoot, oldRightFoot, deltaTimeInSeconds);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private setDirection(direction: vec2) {
|
private regenerateHealth(deltaTimeInSeconds: number) {
|
||||||
this.direction = interpolateAngles(
|
this.timeSinceLastDamage += deltaTimeInSeconds;
|
||||||
this.direction,
|
if (
|
||||||
Math.atan2(direction.y, direction.x) + Math.PI / 2,
|
this.timeSinceLastDamage > settings.playerOutOfCombatDelaySeconds &&
|
||||||
0.2,
|
this.health < settings.playerMaxHealth
|
||||||
);
|
) {
|
||||||
}
|
this.health = Math.min(
|
||||||
|
settings.playerMaxHealth,
|
||||||
private keepPosture(deltaTime: number) {
|
this.health + settings.playerHealthRegenerationPerSeconds * deltaTimeInSeconds,
|
||||||
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,
|
|
||||||
);
|
);
|
||||||
|
this.syncHealth();
|
||||||
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);
|
|
||||||
if (hitObject instanceof PlanetPhysical) {
|
|
||||||
this.secondsSinceOnSurface = 0;
|
|
||||||
this.currentPlanet = hitObject;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public applyForce(circle: CirclePhysical, force: vec2, timeInSeconds: number) {
|
|
||||||
vec2.add(
|
|
||||||
circle.velocity,
|
|
||||||
circle.velocity,
|
|
||||||
vec2.scale(vec2.create(), force, timeInSeconds),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public onDie() {
|
public onDie() {
|
||||||
this.isDestroyed = true;
|
this.isDestroyed = true;
|
||||||
this.remoteCall('onDie');
|
this.remoteCall('onDie');
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
physical.handleCommand(new ReactToCollisionCommand(this.gameObject));
|
||||||
|
this.handleCommand(new ReactToCollisionCommand(physical.gameObject));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const { normal, hitSurface, hitObject } = moveCircle(this, delta, intersecting);
|
return { hitObject: (hitObject as Physical | undefined)?.gameObject, velocity };
|
||||||
|
}
|
||||||
|
|
||||||
if (hitSurface) {
|
// Query the container with the bounding box grown by this tick's travel, so a
|
||||||
vec2.copy(this.lastNormal, normal!);
|
// fast-moving body still sees what it is about to sweep into.
|
||||||
|
private sweptBroadphase(deltaTimeInSeconds: number): Array<Physical> {
|
||||||
vec2.subtract(
|
const sweep = vec2.length(
|
||||||
this.velocity,
|
vec2.scale(vec2.create(), this.velocity, deltaTimeInSeconds),
|
||||||
this.velocity,
|
);
|
||||||
vec2.scale(
|
this.radius += sweep;
|
||||||
normal!,
|
const intersecting = this.container.findIntersecting(this.boundingBox);
|
||||||
normal!,
|
this.radius -= sweep;
|
||||||
(1 + this.restitution) * vec2.dot(normal!, this.velocity),
|
return intersecting;
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (vec2.length(this.velocity) > 50) {
|
|
||||||
delta = vec2.scale(vec2.create(), this.velocity, deltaTimeInSeconds);
|
|
||||||
moveCircle(this, delta, intersecting);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const lastVelocity = vec2.clone(this.velocity);
|
|
||||||
vec2.zero(this.velocity);
|
|
||||||
|
|
||||||
return { hitObject, velocity: lastVelocity };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public toArray(): Array<any> {
|
public toArray(): Array<any> {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,46 +122,154 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
private timeSinceLastPointGeneration = 0;
|
private timeSinceLastPointGeneration = 0;
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,5 +5,4 @@ export interface Options {
|
||||||
scoreLimit: number;
|
scoreLimit: number;
|
||||||
npcCount: number;
|
npcCount: number;
|
||||||
seed: number;
|
seed: number;
|
||||||
worldSize: number;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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 = (
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
@ -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(
|
|
||||||
Random.getRandomInRange(-200, 200),
|
|
||||||
Random.getRandomInRange(-200, 200),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
this.character?.shootTowards(shootTarget);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.timeSinceLastFindShootTarget = 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.character?.handleMovementAction(new MoveActionCommand(this.direction));
|
this.dodgeCommitRemaining -= deltaTimeInSeconds;
|
||||||
|
this.dodgeCooldownRemaining -= deltaTimeInSeconds;
|
||||||
|
const movement = this.decideMovement(this.nearObjects);
|
||||||
|
this.character.handleMovementAction(new MoveActionCommand(movement));
|
||||||
|
|
||||||
|
if (
|
||||||
|
!this.isComingBack &&
|
||||||
|
this.character.groundPlanet &&
|
||||||
|
vec2.length(movement) > 0 &&
|
||||||
|
Random.getRandom() <
|
||||||
|
npcTuning.leapChancePerSecond * this.aggression * deltaTimeInSeconds
|
||||||
|
) {
|
||||||
|
this.character.leap();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
(this.timeSinceLastShoot += deltaTimeInSeconds) > npcTuning.shootIntervalSeconds
|
||||||
|
) {
|
||||||
|
this.timeSinceLastShoot = 0;
|
||||||
|
this.tryShoot(this.nearObjects);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected createCharacter() {
|
private plan() {
|
||||||
const randomPoint = vec2.rotate(
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.isWandering) {
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,47 +159,40 @@ 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> {
|
// Every living player except this one, reported by absolute world position so
|
||||||
if (!this.character) {
|
// the client can plot the whole circular arena on its minimap.
|
||||||
return [];
|
private getMinimapPlayers(): Array<MinimapPlayer> {
|
||||||
}
|
return this.playerContainer.players
|
||||||
|
.filter((p) => p !== this && p.character?.isAlive)
|
||||||
const viewArea = calculateViewArea(this.center, this.aspectRatio, 0.9);
|
.map(
|
||||||
const bb = new BoundingBox();
|
(p) =>
|
||||||
bb.topLeft = viewArea.topLeft;
|
new MinimapPlayer(p.character!.id, vec2.clone(p.character!.center), p.team),
|
||||||
bb.size = viewArea.size;
|
);
|
||||||
|
|
||||||
const playersInViewArea = this.objectContainer
|
|
||||||
.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) =>
|
|
||||||
new OtherPlayerDirection(
|
|
||||||
p.character!.id,
|
|
||||||
vec2.normalize(
|
|
||||||
vec2.create(),
|
|
||||||
vec2.subtract(vec2.create(), p.character!.center, this.character!.center),
|
|
||||||
),
|
|
||||||
p.team,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private commandsToBeSent: Array<Command> = [];
|
private commandsToBeSent: Array<Command> = [];
|
||||||
|
|
@ -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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
import { Command, GameObject } from 'shared';
|
|
||||||
|
|
||||||
export class ReactToCollisionCommand extends Command {
|
|
||||||
public constructor(public readonly other: GameObject) {
|
|
||||||
super();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
|
||||||
|
|
@ -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
32
eslint.config.js
Normal 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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
{
|
|
||||||
"projects": {
|
|
||||||
"default": "decla-red
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
{
|
|
||||||
"hosting": {
|
|
||||||
"public": "dist",
|
|
||||||
"ignore": [
|
|
||||||
"firebase.json",
|
|
||||||
"**/.*",
|
|
||||||
"**/node_modules/**"
|
|
||||||
],
|
|
||||||
"rewrites": [
|
|
||||||
{
|
|
||||||
"source": "**",
|
|
||||||
"destination": "/index.html"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
25323
frontend/package-lock.json
generated
25323
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
11
frontend/src/custom.d.ts
vendored
11
frontend/src/custom.d.ts
vendored
|
|
@ -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 } };
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
@ -107,7 +118,7 @@ body {
|
||||||
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
& > * {
|
&>* {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
&::after {
|
.score {
|
||||||
content: '';
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 50%;
|
|
||||||
top: 50%;
|
top: 50%;
|
||||||
transform: translateX(-50%) translateY(50%);
|
transform: translateY(-50%);
|
||||||
|
z-index: 3;
|
||||||
@include square(24px);
|
font-size: 0.95rem;
|
||||||
|
font-weight: 700;
|
||||||
background-image: url('../static/flag.svg');
|
line-height: 1;
|
||||||
background-size: contain;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
div {
|
.score.blue {
|
||||||
height: $height;
|
right: calc(50% + #{$small-padding});
|
||||||
box-shadow: inset 0 0 3px 0px rgba(0, 0, 0, 0.2);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
div:nth-child(1) {
|
.score.red {
|
||||||
background: $bright-decla;
|
left: calc(50% + #{$small-padding});
|
||||||
border-radius: 100px 0 0 100px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
div:nth-child(2) {
|
.score.leading {
|
||||||
background: $bright-red;
|
opacity: 1;
|
||||||
border-radius: 0 100px 100px 0;
|
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 {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
bottom: 100%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
border-left: 4px solid transparent;
|
||||||
|
border-right: 4px solid transparent;
|
||||||
|
border-bottom: 5px solid currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.blue {
|
||||||
|
right: calc(50% + #{$small-padding});
|
||||||
|
color: $bright-blue;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.red {
|
||||||
|
left: calc(50% + #{$small-padding});
|
||||||
|
color: $bright-red;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
17
frontend/src/scripts/analytics.ts
Normal file
17
frontend/src/scripts/analytics.ts
Normal 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);
|
||||||
|
}
|
||||||
58
frontend/src/scripts/charge-indicator.ts
Normal file
58
frontend/src/scripts/charge-indicator.ts
Normal 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);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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> {
|
||||||
|
|
|
||||||
120
frontend/src/scripts/feedback-hud.ts
Normal file
120
frontend/src/scripts/feedback-hud.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
});
|
|
||||||
|
|
@ -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),
|
|
||||||
});
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -42,7 +42,7 @@ export const handleFullScreen = (
|
||||||
});
|
});
|
||||||
|
|
||||||
addEventListener('resize', () => {
|
addEventListener('resize', () => {
|
||||||
if (isInFullScreen && currentWindowHeight > innerHeight) {
|
if (isInFullScreen() && currentWindowHeight > innerHeight) {
|
||||||
followToggle();
|
followToggle();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
@ -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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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) {
|
||||||
13
frontend/src/scripts/helper/pointer.ts
Normal file
13
frontend/src/scripts/helper/pointer.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
138
frontend/src/scripts/helper/prediction/client-character-world.ts
Normal file
138
frontend/src/scripts/helper/prediction/client-character-world.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
47
frontend/src/scripts/helper/prediction/input-history.ts
Normal file
47
frontend/src/scripts/helper/prediction/input-history.ts
Normal 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 = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
84
frontend/src/scripts/helper/server-timeline.ts
Normal file
84
frontend/src/scripts/helper/server-timeline.ts
Normal 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();
|
||||||
|
|
@ -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]) => {
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
126
frontend/src/scripts/minimap.ts
Normal file
126
frontend/src/scripts/minimap.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
SoundHandler.play(
|
|
||||||
Sounds.hit,
|
|
||||||
(0.4 * 2 * (previousHealth - health)) / settings.playerMaxStrength,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (this.isMainCharacter) {
|
if (damage > 0) {
|
||||||
VibrationHandler.vibrate(Math.min(200, (previousHealth - this.health) * 4));
|
SoundHandler.play(
|
||||||
|
Sounds.hit,
|
||||||
|
Math.min(1, (0.8 * damage) / settings.playerMaxStrength),
|
||||||
|
);
|
||||||
|
this.hitFlashIntensity = Math.min(1, 0.4 + damage / settings.playerMaxStrength);
|
||||||
|
|
||||||
|
if (this.isMainCharacter) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.deathBurstIntensity > 0) {
|
||||||
|
renderer.addDrawable(this.deathBurst);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private getStatsText(): string {
|
private calculateGazeTarget(renderer: Renderer): vec2 {
|
||||||
return `${this.killCount}⚔/${this.deathCount}☠`;
|
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 {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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,52 +56,106 @@ 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;
|
if (this.flareIntensity === 0) {
|
||||||
});
|
this.releaseFlareSlot();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private generatedPointElements: Array<FallingPoint> = [];
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.holdsFlareSlot) {
|
||||||
|
if (!FlareBudget.tryAcquire()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.holdsFlareSlot = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.flareIntensity = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateProperty({ propertyValue }: UpdatePropertyCommand): void {
|
private beforeDestroy(): void {
|
||||||
this.ownership = propertyValue;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private draw({ renderer, overlay, shouldChangeLayout }: RenderCommand): void {
|
private draw({ renderer, overlay, shouldChangeLayout }: RenderCommand): void {
|
||||||
|
|
@ -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%
|
||||||
)`
|
)`
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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') {
|
||||||
|
|
|
||||||
71
frontend/src/scripts/scoreboard.ts
Normal file
71
frontend/src/scripts/scoreboard.ts
Normal 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
84
frontend/src/scripts/screen-shake.ts
Normal file
84
frontend/src/scripts/screen-shake.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
219
frontend/src/scripts/shapes/character-shape.ts
Normal file
219
frontend/src/scripts/shapes/character-shape.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,9 +102,9 @@ 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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
112
frontend/src/scripts/tutorial.ts
Normal file
112
frontend/src/scripts/tutorial.ts
Normal 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 ?? '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"],
|
||||||
|
|
|
||||||
|
|
@ -1,110 +1,142 @@
|
||||||
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) => {
|
||||||
devServer: {
|
const isProduction = argv.mode !== 'development';
|
||||||
host: '0.0.0.0',
|
|
||||||
disableHostCheck: true,
|
return {
|
||||||
|
entry: {
|
||||||
|
main: path.resolve(__dirname, 'src/index.ts'),
|
||||||
|
},
|
||||||
|
output: {
|
||||||
|
filename: 'main.js',
|
||||||
|
path: path.resolve(__dirname, 'dist'),
|
||||||
|
},
|
||||||
|
devServer: {
|
||||||
|
host: '0.0.0.0',
|
||||||
|
allowedHosts: 'all',
|
||||||
|
},
|
||||||
|
// webpack-dev-server 5 no longer accepts `watchOptions` under `devServer`;
|
||||||
|
// polling is configured on the compiler instead (needed for some FS mounts).
|
||||||
watchOptions: {
|
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)$',
|
// The SVG UI icons used to be inlined into the HTML by the (abandoned,
|
||||||
}),
|
// webpack-4-only) html-webpack-inline-svg-plugin. They are now emitted as
|
||||||
new HtmlWebpackInlineSourcePlugin(HtmlWebpackPlugin),
|
// static files and referenced via `static/<name>.svg` from index.html.
|
||||||
new HtmlWebpackInlineSVGPlugin({
|
new CopyWebpackPlugin({
|
||||||
inlineAll: true,
|
patterns: [{ from: 'static/*.svg', to: 'static/[name][ext]' }],
|
||||||
svgoConfig: [
|
}),
|
||||||
|
// Inline the extracted CSS into the HTML for production builds (replaces
|
||||||
|
// the abandoned html-webpack-inline-source-plugin). In development the CSS
|
||||||
|
// stays a separate, linked file for faster rebuilds.
|
||||||
|
...(isProduction ? [new HTMLInlineCSSWebpackPlugin()] : []),
|
||||||
|
],
|
||||||
|
optimization: {
|
||||||
|
minimizer: [
|
||||||
|
new TerserJSPlugin({
|
||||||
|
exclude: /node_modules/,
|
||||||
|
// The custom serialization protocol keys on class names, so they must
|
||||||
|
// survive minification (see shared/src/serialization).
|
||||||
|
terserOptions: {
|
||||||
|
keep_classnames: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
module: {
|
||||||
|
rules: [
|
||||||
{
|
{
|
||||||
removeViewBox: false,
|
test: /\.js$/,
|
||||||
|
enforce: 'pre',
|
||||||
|
use: ['source-map-loader'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
test: /\.ts$/,
|
||||||
|
use: 'ts-loader',
|
||||||
|
exclude: /node_modules/,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
test: /\.scss$/i,
|
||||||
|
use: [
|
||||||
|
MiniCssExtractPlugin.loader,
|
||||||
|
'css-loader',
|
||||||
|
'postcss-loader',
|
||||||
|
{
|
||||||
|
loader: 'sass-loader',
|
||||||
|
options: {
|
||||||
|
sourceMap: true,
|
||||||
|
implementation: Sass,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// 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$/,
|
||||||
|
type: 'asset/inline',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Use oneOf so each asset matches exactly one rule (og-image.png would
|
||||||
|
// otherwise match both the generic png rule and its own rule).
|
||||||
|
// The directory is encoded in `name` (not `outputPath`) and has no
|
||||||
|
// leading slash: with the default `publicPath: 'auto'` a leading slash
|
||||||
|
// produces a doubled slash in the emitted URL (e.g. `//static/x.mp3`).
|
||||||
|
oneOf: [
|
||||||
|
{
|
||||||
|
test: /og-image\.png$/,
|
||||||
|
use: {
|
||||||
|
loader: 'file-loader',
|
||||||
|
options: {
|
||||||
|
name: '[name].[ext]',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
test: /\.ico$/,
|
||||||
|
use: {
|
||||||
|
loader: 'file-loader',
|
||||||
|
options: {
|
||||||
|
name: '[name].[ext]',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
test: /\.(mp3|png)$/,
|
||||||
|
use: {
|
||||||
|
loader: 'file-loader',
|
||||||
|
options: {
|
||||||
|
name: 'static/[name].[ext]',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}),
|
},
|
||||||
new TsConfigWebpackPlugin(),
|
resolve: {
|
||||||
],
|
extensions: ['.ts', '.js', '.json'],
|
||||||
optimization: {
|
},
|
||||||
minimizer: [
|
};
|
||||||
new TerserJSPlugin({
|
};
|
||||||
test: /\.js$/,
|
|
||||||
exclude: /node_modules/,
|
|
||||||
terserOptions: {
|
|
||||||
keep_classnames: true,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
module: {
|
|
||||||
rules: [
|
|
||||||
{
|
|
||||||
test: /\.js$/,
|
|
||||||
enforce: 'pre',
|
|
||||||
use: ['source-map-loader'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
test: /\.scss$/i,
|
|
||||||
use: [
|
|
||||||
MiniCssExtractPlugin.loader,
|
|
||||||
'css-loader',
|
|
||||||
'postcss-loader',
|
|
||||||
{
|
|
||||||
loader: 'sass-loader',
|
|
||||||
options: {
|
|
||||||
sourceMap: true,
|
|
||||||
implementation: Sass,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
test: /\.svg$/,
|
|
||||||
use: {
|
|
||||||
loader: 'svg-url-loader',
|
|
||||||
options: {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
test: /\.(mp3|png)$/,
|
|
||||||
use: {
|
|
||||||
loader: 'file-loader',
|
|
||||||
query: {
|
|
||||||
outputPath: '/static',
|
|
||||||
name: '[name].[ext]',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
test: /\.ico$/,
|
|
||||||
use: {
|
|
||||||
loader: 'file-loader',
|
|
||||||
query: {
|
|
||||||
outputPath: '/',
|
|
||||||
name: '[name].[ext]',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
test: /og-image.png$/,
|
|
||||||
use: {
|
|
||||||
loader: 'file-loader',
|
|
||||||
query: {
|
|
||||||
outputPath: '/',
|
|
||||||
name: '[name].[ext]',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
|
||||||
30
package.json
30
package.json
|
|
@ -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\""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
19
shared/src/commands/types/actions/leap-action.ts
Normal file
19
shared/src/commands/types/actions/leap-action.ts
Normal 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];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
28
shared/src/commands/types/input-acknowledgement.ts
Normal file
28
shared/src/commands/types/input-acknowledgement.ts
Normal 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];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue