Compare commits
103 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5d854440ec | |||
| 614db16e41 | |||
| f19ada7a1c | |||
| d9b80b92ca | |||
| b6db7e8dc7 | |||
| d0265ad90e | |||
| 73f5f45322 | |||
| fc9df09ee1 | |||
| a1fb6755c7 | |||
| 1f10b9c750 | |||
| ec579650d7 | |||
| e3c44f775b | |||
| 3848e460cd | |||
| 793d9a81e8 | |||
| e50cd2a79a | |||
| 10d4f73562 | |||
| 7412bc8af5 | |||
| 913abb7642 | |||
| 52ca1b8844 | |||
| 018df228df | |||
| 7a285549b5 | |||
| a5d1ed6e29 | |||
| 0ed480777e | |||
| 379ee14739 | |||
| bde0ad8f25 | |||
| 4e231821c7 | |||
|
|
00d8e7ae81 | ||
|
|
e6376ad059 | ||
|
|
e5e928d99a | ||
|
|
561a66c196 | ||
|
|
a4bab29d84 | ||
|
|
7cf33b9f1a | ||
|
|
503c99cb1f | ||
|
|
be26ab422c | ||
|
|
f0fc6135b4 | ||
|
|
42e4de28b5 | ||
|
|
57d7009342 | ||
|
|
b774357807 | ||
|
|
99cdb62928 | ||
|
|
1ce961d6c2 | ||
|
|
c0a588fb73 | ||
|
|
27df7e628c | ||
|
|
fd26ed7d59 | ||
|
|
b48bf076ad | ||
|
|
f7420777f0 | ||
|
|
1c4b7a3483 | ||
|
|
45b6588701 | ||
|
|
7162dc69b9 | ||
|
|
db2c4579b6 | ||
|
|
d746854307 | ||
|
|
99d923c221 | ||
|
|
c9eae3adeb | ||
|
|
7e188c2b9a | ||
|
|
3dd3e21bb8 | ||
|
|
271d4ba171 | ||
|
|
4dd90b9860 | ||
|
|
b8ef90c100 | ||
|
|
e4cd3284b7 | ||
|
|
efa838a2ad | ||
|
|
a66fa63b4b | ||
|
|
0e22f9b33d | ||
|
|
e7f9007b23 | ||
|
|
34dae300da | ||
|
|
d79900e3ea | ||
|
|
f6f54483db | ||
|
|
b55e927a34 | ||
|
|
1cd8f5fbe6 | ||
|
|
c74b2f14a0 | ||
|
|
8b49b44ebf | ||
|
|
23fa7646d6 | ||
|
|
49c410d8ff | ||
|
|
ffc89e5590 | ||
|
|
4a520d52f8 | ||
|
|
938e7f3c36 | ||
|
|
0058f40f1f | ||
|
|
1d1bc6655c | ||
|
|
af616042f3 | ||
|
|
dd21c20b3a | ||
|
|
dee72440bb | ||
|
|
6694e353ad | ||
|
|
f1a2131540 | ||
|
|
81a8834c4d | ||
|
|
2509199abc | ||
|
|
c844e6c52b | ||
|
|
fd64100dc9 | ||
|
|
eb7240b40d | ||
|
|
91079c0420 | ||
|
|
909b69392d | ||
|
|
021cac655c | ||
|
|
7c76b16d13 | ||
|
|
e02a5b264c | ||
|
|
ec0520698e | ||
|
|
e83c58e1a5 | ||
|
|
fd80a299b6 | ||
|
|
2d67b7d939 | ||
|
|
f7e7522379 | ||
|
|
e2129bbb26 | ||
|
|
89fafeafd3 | ||
|
|
155e360c8e | ||
|
|
cd65cb5b6c | ||
|
|
ba4dd5c470 | ||
|
|
555be9d602 | ||
|
|
f9f6825776 |
240 changed files with 17312 additions and 2026 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,14 +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
|
||||
|
||||
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,53 +0,0 @@
|
|||
{
|
||||
"name": "Node.js & TypeScript",
|
||||
"build": {
|
||||
"dockerfile": "Dockerfile",
|
||||
"args": {
|
||||
"VARIANT": "14"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"terminal.integrated.shell.linux": "/bin/zsh",
|
||||
"files.exclude": {
|
||||
"**/node_modules": true,
|
||||
"**/package-lock.json": true,
|
||||
"**/dist": true,
|
||||
"**/lib": true,
|
||||
"**/.firebase": true
|
||||
},
|
||||
"editor.tabSize": 2,
|
||||
"editor.detectIndentation": false,
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"eslint.enable": true,
|
||||
"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-volume,target=/workspace/node_modules,type=volume",
|
||||
"source=decla-red-frontend-node_modules-volume,target=/workspace/frontend/node_modules,type=volume",
|
||||
"source=decla-red-backend-node_modules-volume,target=/workspace/backend/node_modules,type=volume",
|
||||
"source=decla-red-shared-node_modules-volume,target=/workspace/shared/node_modules,type=volume"
|
||||
],
|
||||
"forwardPorts": [3000, 8080],
|
||||
"postCreateCommand": "chown node:node ./**/node_modules && npm install && npm run initialize && npm run build"
|
||||
}
|
||||
14
.dockerignore
Normal file
14
.dockerignore
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
# The server image only needs `shared/` and `backend/`.
|
||||
**/node_modules
|
||||
**/dist
|
||||
**/lib
|
||||
.git
|
||||
.github
|
||||
.forgejo
|
||||
.devcontainer
|
||||
.vscode
|
||||
frontend
|
||||
media
|
||||
*.log
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
63
.forgejo/workflows/deploy.yml
Normal file
63
.forgejo/workflows/deploy.yml
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
name: Build & deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ['v*']
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ gitea.workflow }}-${{ gitea.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# ---- Static website -> /pages mount on the runner host --------------------
|
||||
website:
|
||||
name: Build & deploy website
|
||||
runs-on: docker
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
|
||||
- name: Install dependencies
|
||||
# No lockfiles are committed (see .gitignore), so use `npm install`.
|
||||
run: npm install && npm run init
|
||||
|
||||
- name: Build (shared -> frontend -> backend)
|
||||
run: npm run build
|
||||
|
||||
- name: Test
|
||||
run: npm test
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint:check
|
||||
|
||||
- name: Deploy to host pages mount
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
uses: http://forgejo:3000/andras/ci-actions/deploy-pages@main
|
||||
with:
|
||||
source: frontend/dist
|
||||
target: declared
|
||||
|
||||
# ---- Server Docker image -> Forgejo container registry --------------------
|
||||
server-image:
|
||||
name: Build & publish server image
|
||||
runs-on: docker
|
||||
# No registry push on PRs; build validation still happens in the website job.
|
||||
if: github.event_name != 'pull_request'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build and publish server image
|
||||
uses: http://forgejo:3000/andras/ci-actions/docker-publish@main
|
||||
with:
|
||||
context: .
|
||||
image-suffix: -server
|
||||
token: ${{ secrets.FORGEJO_PACKAGE_TOKEN }}
|
||||
54
.github/workflows/main.yaml
vendored
54
.github/workflows/main.yaml
vendored
|
|
@ -1,54 +0,0 @@
|
|||
name: Build and deploy project
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
env:
|
||||
CONTAINER_REGISTRY: schmelczera
|
||||
DOMAIN: '174.138.103.56'
|
||||
|
||||
jobs:
|
||||
build-project:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout current branch with lfs
|
||||
uses: actions/checkout@master
|
||||
with:
|
||||
lfs: true
|
||||
- name: Build project
|
||||
run: |
|
||||
npm install
|
||||
npm run initialize
|
||||
npm run build
|
||||
- name: Deploy frontend
|
||||
uses: w9jds/firebase-action@master
|
||||
with:
|
||||
args: deploy --only hosting --project decla-red
|
||||
env:
|
||||
FIREBASE_TOKEN: ${{ secrets.FIREBASE_TOKEN }}
|
||||
PROJECT_PATH: frontend
|
||||
- name: Authenticate with dockerhub
|
||||
run: |
|
||||
docker login -u ${{ secrets.DOCKER_USER }} -p ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Install buildx
|
||||
id: buildx
|
||||
uses: crazy-max/ghaction-docker-buildx@v1
|
||||
with:
|
||||
version: latest
|
||||
- name: Build and push server image
|
||||
run: |
|
||||
docker buildx build \
|
||||
--tag $CONTAINER_REGISTRY/decla-red-server:latest \
|
||||
--platform linux/amd64,linux/arm/v7,linux/arm64 . --push
|
||||
working-directory: backend
|
||||
- name: Setup auth tokens
|
||||
run: |
|
||||
mkdir ~/.ssh
|
||||
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519
|
||||
chmod 400 ~/.ssh/id_ed25519
|
||||
ssh -o StrictHostKeyChecking=no root@$DOMAIN uptime
|
||||
- name: Stack deploy
|
||||
run: |
|
||||
DOCKER_HOST=ssh://root@$DOMAIN docker login -u ${{ secrets.DOCKER_USER }} -p ${{ secrets.DOCKER_PASSWORD }}
|
||||
DOCKER_HOST=ssh://root@$DOMAIN docker stack deploy decla-red-server -c docker-compose.yml --with-registry-auth
|
||||
working-directory: backend
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -1,5 +1,8 @@
|
|||
dist
|
||||
lib
|
||||
node_modules
|
||||
package-lock.json
|
||||
.firebase
|
||||
yarm-error.log
|
||||
yarn.lock
|
||||
package-lock.json
|
||||
.DS_Store
|
||||
|
|
|
|||
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
|
||||
16
.vscode/launch.json
vendored
Normal file
16
.vscode/launch.json
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
// Use IntelliSense to learn about possible attributes.
|
||||
// Hover to view descriptions of existing attributes.
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "Launch Program",
|
||||
"skipFiles": ["<node_internals>/**"],
|
||||
"program": "${workspaceFolder}/backend/dist/main.js",
|
||||
"outFiles": ["${workspaceFolder}/**/*.js"]
|
||||
}
|
||||
]
|
||||
}
|
||||
10
.vscode/settings.json
vendored
Normal file
10
.vscode/settings.json
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"cSpell.words": [
|
||||
"Deserializable",
|
||||
"Deserialization",
|
||||
"Respawn",
|
||||
"doppler",
|
||||
"overridable",
|
||||
"serializable"
|
||||
]
|
||||
}
|
||||
49
Dockerfile
49
Dockerfile
|
|
@ -1,17 +1,50 @@
|
|||
FROM node:14.13.0-alpine3.10 as base
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
COPY . .
|
||||
# doppler game server (the `doppler-server` package).
|
||||
# The frontend is a static site and is NOT built here — see .forgejo/workflows.
|
||||
|
||||
RUN npm install && npm run initialize && npm run build
|
||||
# ---- Stage 1: build the shared lib, then bundle the server -------------------
|
||||
# Node 22 (current LTS) matches the project toolchain (webpack 5 / TypeScript 6,
|
||||
# see .devcontainer). The full (non-slim) image carries the build tools that
|
||||
# socket.io's optional native deps (bufferutil/utf-8-validate) compile against.
|
||||
FROM node:22-bookworm AS build
|
||||
WORKDIR /app
|
||||
|
||||
FROM node:14.13.0-alpine3.10
|
||||
# `shared` is consumed by the backend as `file:../shared` and is bundled into
|
||||
# the server bundle by webpack, so it must be installed and built first.
|
||||
COPY shared/package.json shared/
|
||||
RUN cd shared && npm install
|
||||
COPY shared/ shared/
|
||||
RUN cd shared && npm run build
|
||||
|
||||
COPY backend/package.json .
|
||||
# Install the backend deps. `file:../shared` now resolves to the built /app/shared.
|
||||
COPY backend/package.json backend/
|
||||
RUN cd backend && npm install
|
||||
COPY backend/ backend/
|
||||
RUN cd backend && npm run build
|
||||
|
||||
RUN npm install --production
|
||||
# Drop devDependencies; the runtime only needs the production deps that webpack
|
||||
# left external (express, socket.io, cors, gl-matrix, minimist, msgpack parser).
|
||||
RUN cd backend && npm prune --production
|
||||
|
||||
COPY --from=base backend/dist/main.js main.js
|
||||
# ---- Stage 2: minimal runtime ----------------------------------------------
|
||||
FROM node:22-bookworm-slim
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Run as an unprivileged user.
|
||||
RUN groupadd -r app && useradd -r -g app -d /app app
|
||||
|
||||
COPY --from=build /app/backend/dist ./dist
|
||||
COPY --from=build /app/backend/node_modules ./node_modules
|
||||
|
||||
USER app
|
||||
EXPOSE 3000
|
||||
|
||||
CMD [ "node", "main.js" ]
|
||||
# Hits the same /state endpoint the website polls (see serverInformationEndpoint).
|
||||
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))"
|
||||
|
||||
ENTRYPOINT ["node", "dist/main.js"]
|
||||
# Override these to tune the server, e.g. `--name`, `--playerLimit`, `--scoreLimit`.
|
||||
CMD ["--port", "3000"]
|
||||
|
|
|
|||
36
README.md
36
README.md
|
|
@ -1,3 +1,35 @@
|
|||
# decla.red
|
||||
# doppler
|
||||
|
||||

|
||||
A 2-dimensional multiplayer game utilising ray tracing.
|
||||
|
||||
> **Available at [doppler.schmelczer.dev](https://doppler.schmelczer.dev).**
|
||||
|
||||

|
||||

|
||||
|
||||
For optimised 2D ray tracing, [SDF-2D](https://github.com/schmelczerandras/sdf-2d) is used.
|
||||
|
||||
## Deployment
|
||||
|
||||
CI/CD runs on Forgejo Actions (`.forgejo/workflows/deploy.yml`). On a push to
|
||||
`main` it:
|
||||
|
||||
- builds the static frontend and rsyncs `frontend/dist/` to the `/pages/declared`
|
||||
mount on the runner host (the mount keeps its pre-rebrand name), and
|
||||
- builds the server image from the root `Dockerfile` and pushes it to the Forgejo
|
||||
container registry as `<registry>/<owner>/<repo>-server`.
|
||||
|
||||
The registry job needs a `FORGEJO_PACKAGE_TOKEN` secret (with package write
|
||||
scope) and, optionally, a `CONTAINER_REGISTRY_HOST` variable to override the
|
||||
registry host.
|
||||
|
||||
The website's server list is hardcoded in
|
||||
[`frontend/src/scripts/configuration.ts`](frontend/src/scripts/configuration.ts) —
|
||||
edit it to add or remove game-server origins.
|
||||
|
||||
Run the server image locally:
|
||||
|
||||
```sh
|
||||
docker build -t doppler-server .
|
||||
docker run -p 3000:3000 doppler-server --name "My server" --playerLimit 16
|
||||
```
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
.dockerignore
|
||||
Dockerfile
|
||||
node_modules
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
FROM node:14.13.0-alpine3.10
|
||||
|
||||
COPY package.json .
|
||||
|
||||
RUN npm install --production
|
||||
|
||||
COPY dist/main.js main.js
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD [ "node", "main.js" ]
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
version: '3.8'
|
||||
|
||||
services:
|
||||
decla-red-server:
|
||||
init: true
|
||||
image: schmelczera/decla-red-server
|
||||
networks:
|
||||
- network
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '1.0'
|
||||
memory: 256M
|
||||
reservations:
|
||||
cpus: '0.25'
|
||||
memory: 256M
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
window: 30s
|
||||
|
||||
networks:
|
||||
network:
|
||||
|
|
@ -1,35 +1,43 @@
|
|||
{
|
||||
"name": "decla.red-server",
|
||||
"private": true,
|
||||
"description": "Game server for decla.red",
|
||||
"name": "doppler-server",
|
||||
"version": "0.1.0",
|
||||
"description": "Game server for doppler",
|
||||
"keywords": [],
|
||||
"author": "András Schmelczer <andras@schmelczer.dev> (https://schmelczer.dev/)",
|
||||
"main": "index.js",
|
||||
"main": "dist/main.js",
|
||||
"bin": {
|
||||
"doppler-server": "dist/main.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "npx webpack --mode production",
|
||||
"initialize": "npm install",
|
||||
"start": "concurrently --kill-others \"npx webpack --mode development -w\" \"nodemon --legacy-watch dist/main.js\"",
|
||||
"try-build": "npm run build && node 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 -"
|
||||
},
|
||||
"dependencies": {
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.17.1",
|
||||
"http": "0.0.1-security",
|
||||
"socket.io": "^2.3.0",
|
||||
"uws": "^10.148.1",
|
||||
"webpack-node-externals": "^2.5.2"
|
||||
"cors": "^2.8.6",
|
||||
"express": "^5.2.1",
|
||||
"gl-matrix": "3.3.0",
|
||||
"minimist": "^1.2.8",
|
||||
"socket.io": "^4.8.3",
|
||||
"socket.io-msgpack-parser": "^3.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"clean-webpack-plugin": "^3.0.0",
|
||||
"concurrently": "^5.3.0",
|
||||
"esbuild-loader": "^2.4.0",
|
||||
"nodemon": "^2.0.4",
|
||||
"raw-loader": "^4.0.1",
|
||||
"resolve-url-loader": "^3.1.1",
|
||||
"terser-webpack-plugin": "^2.3.5",
|
||||
"typescript": "^4.0.3",
|
||||
"webpack": "^4.43.0",
|
||||
"webpack-cli": "^3.3.11",
|
||||
"webpack-dev-server": "^3.10.3"
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/minimist": "^1.2.5",
|
||||
"@types/node": "^22.0.0",
|
||||
"clean-webpack-plugin": "^4.0.0",
|
||||
"concurrently": "^10.0.3",
|
||||
"nodemon": "^3.1.14",
|
||||
"shared": "file:../shared",
|
||||
"terser-webpack-plugin": "^5.6.1",
|
||||
"ts-loader": "^9.6.0",
|
||||
"typescript": "^6.0.3",
|
||||
"webpack": "^5.107.2",
|
||||
"webpack-cli": "^7.0.3",
|
||||
"webpack-node-externals": "^3.0.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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();
|
||||
}
|
||||
}
|
||||
10
backend/src/commands/generate-points.ts
Normal file
10
backend/src/commands/generate-points.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { Command } from 'shared';
|
||||
|
||||
export class GeneratePointsCommand extends Command {
|
||||
public constructor(
|
||||
public readonly blue: number,
|
||||
public readonly red: number,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
7
backend/src/commands/react-to-collision.ts
Normal file
7
backend/src/commands/react-to-collision.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { Command, GameObject } from 'shared';
|
||||
|
||||
export class ReactToCollisionCommand extends Command {
|
||||
public constructor(public readonly other: GameObject) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
10
backend/src/commands/step.ts
Normal file
10
backend/src/commands/step.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { Command, CommandReceiver } from 'shared';
|
||||
|
||||
export class StepCommand extends Command {
|
||||
public constructor(
|
||||
public readonly deltaTimeInSeconds: number,
|
||||
public readonly game: CommandReceiver,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
97
backend/src/create-world.ts
Normal file
97
backend/src/create-world.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
import { Random, PlanetBase, hsl, settings, evaluateSdf } from 'shared';
|
||||
import { LampPhysical } from './objects/lamp-physical';
|
||||
import { PlanetPhysical } from './objects/planet-physical';
|
||||
import { PhysicalContainer } from './physics/containers/physical-container';
|
||||
import { Physical } from './physics/physicals/physical';
|
||||
|
||||
export const createWorld = (objectContainer: PhysicalContainer) => {
|
||||
const objects: Array<Physical> = [];
|
||||
const lights: Array<Physical> = [];
|
||||
|
||||
for (let r = 0; r < settings.worldRadius; r += settings.radiusSteps) {
|
||||
const circumference = 2 * Math.PI * r;
|
||||
const stepCount = circumference * settings.objectsOnCircleLength;
|
||||
for (let rad = 0; rad < 2 * Math.PI; rad += (2 * Math.PI) / stepCount) {
|
||||
const position = vec2.rotate(
|
||||
vec2.create(),
|
||||
vec2.fromValues(r, 0),
|
||||
vec2.create(),
|
||||
rad,
|
||||
);
|
||||
|
||||
if (objects.length !== 0 && Random.getRandom() > 0.5) {
|
||||
if (
|
||||
evaluateSdf(position, objects) > 200 &&
|
||||
!lights.find((l) => l.distance(position) < 2500)
|
||||
) {
|
||||
lights.push(
|
||||
new LampPhysical(
|
||||
position,
|
||||
hsl(
|
||||
(rad / (2 * Math.PI)) * 360,
|
||||
Random.getRandomInRange(50, 100),
|
||||
Random.getRandomInRange(40, 50),
|
||||
),
|
||||
Random.getRandomInRange(0.35, 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (
|
||||
evaluateSdf(position, objects) > 1400 &&
|
||||
!lights.find((l) => l.distance(position) < 1700)
|
||||
) {
|
||||
const planet =
|
||||
objects.length === 0
|
||||
? // The first, central giant is the keystone "Heart": a named,
|
||||
// always-contested focal objective the whole match orbits.
|
||||
new PlanetPhysical(
|
||||
PlanetBase.createPlanetVertices(
|
||||
position,
|
||||
Random.getRandomInRange(1600, 2400),
|
||||
Random.getRandomInRange(1600, 2400),
|
||||
Random.getRandomInRange(80, 300),
|
||||
),
|
||||
true,
|
||||
)
|
||||
: new PlanetPhysical(
|
||||
PlanetBase.createPlanetVertices(
|
||||
position,
|
||||
Random.getRandomInRange(300, 1600),
|
||||
Random.getRandomInRange(300, 1600),
|
||||
Random.getRandomInRange(20, 100),
|
||||
),
|
||||
);
|
||||
|
||||
objects.push(planet);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
console.info(`Generated ${objects.length} planets`);
|
||||
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));
|
||||
};
|
||||
10
backend/src/default-options.ts
Normal file
10
backend/src/default-options.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { Options } from './options';
|
||||
|
||||
export const defaultOptions: Options = {
|
||||
port: 3000,
|
||||
name: 'Test server',
|
||||
playerLimit: 16,
|
||||
npcCount: 8,
|
||||
seed: Math.random(),
|
||||
scoreLimit: 2500,
|
||||
};
|
||||
289
backend/src/game-server.ts
Normal file
289
backend/src/game-server.ts
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
import { PhysicalContainer } from './physics/containers/physical-container';
|
||||
import { Server, Socket } from 'socket.io';
|
||||
import {
|
||||
TransportEvents,
|
||||
deserialize,
|
||||
settings,
|
||||
ServerInformation,
|
||||
PlayerInformation,
|
||||
UpdateGameState,
|
||||
CharacterTeam,
|
||||
GameEndCommand,
|
||||
GameStartCommand,
|
||||
Command,
|
||||
CommandReceiver,
|
||||
CommandExecutors,
|
||||
ServerAnnouncement,
|
||||
} from 'shared';
|
||||
import { createWorld } from './create-world';
|
||||
import { DeltaTimeCalculator } from './helper/delta-time-calculator';
|
||||
import { Options } from './options';
|
||||
import { PlayerContainer } from './players/player-container';
|
||||
import { StepCommand } from './commands/step';
|
||||
import { GeneratePointsCommand } from './commands/generate-points';
|
||||
import { AnnounceCommand } from './commands/announce';
|
||||
|
||||
const gameStateSubscribedRoom = 'gameStateSubscribedRoom';
|
||||
|
||||
export class GameServer extends CommandReceiver {
|
||||
private objects!: PhysicalContainer;
|
||||
private players!: PlayerContainer;
|
||||
private deltaTimes!: Array<number>;
|
||||
private deltaTimeCalculator!: DeltaTimeCalculator;
|
||||
|
||||
private bluePoints = 0;
|
||||
private redPoints = 0;
|
||||
private matchPointAnnounced: Partial<Record<CharacterTeam, boolean>> = {};
|
||||
|
||||
private isInEndGame = false;
|
||||
private timeScaling = 1;
|
||||
|
||||
private serverName: string;
|
||||
private playerLimit: number;
|
||||
|
||||
private initialize() {
|
||||
const previousPlayers = this.players;
|
||||
this.objects = new PhysicalContainer();
|
||||
createWorld(this.objects);
|
||||
this.objects.initialize();
|
||||
this.players = new PlayerContainer(
|
||||
this.objects,
|
||||
this.options.playerLimit,
|
||||
this.options.npcCount,
|
||||
);
|
||||
this.deltaTimeCalculator = new DeltaTimeCalculator();
|
||||
this.deltaTimes = [];
|
||||
this.bluePoints = 0;
|
||||
this.redPoints = 0;
|
||||
this.matchPointAnnounced = {};
|
||||
this.isInEndGame = false;
|
||||
this.timeScaling = 1;
|
||||
previousPlayers?.queueCommandForEachClient(new GameStartCommand());
|
||||
previousPlayers?.sendQueuedCommands();
|
||||
}
|
||||
|
||||
protected commandExecutors: CommandExecutors = {
|
||||
[GeneratePointsCommand.type]: this.addPoints.bind(this),
|
||||
[AnnounceCommand.type]: ({ text }: AnnounceCommand) =>
|
||||
this.players.queueCommandForEachClient(new ServerAnnouncement(text)),
|
||||
};
|
||||
|
||||
constructor(
|
||||
private readonly io: Server,
|
||||
private options: Options,
|
||||
) {
|
||||
super();
|
||||
|
||||
this.serverName = options.name;
|
||||
this.playerLimit = options.playerLimit;
|
||||
|
||||
this.initialize();
|
||||
|
||||
io.on('connection', (socket: Socket) => {
|
||||
socket.on(TransportEvents.PlayerJoining, (playerInfo: PlayerInformation) => {
|
||||
try {
|
||||
const player = this.players.createPlayer(playerInfo, socket);
|
||||
socket.on(TransportEvents.PlayerToServer, (json: string) => {
|
||||
try {
|
||||
const commands: Array<Command> = deserialize(json);
|
||||
commands.forEach((c) => player.handleCommand(c));
|
||||
} catch (e) {
|
||||
console.error('Error while processing command', e);
|
||||
}
|
||||
});
|
||||
|
||||
this.sendServerStateUpdate();
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
player.destroy();
|
||||
this.players.deletePlayer(player);
|
||||
this.sendServerStateUpdate();
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Failed to register joining player; disconnecting socket', e);
|
||||
socket.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
socket.on(TransportEvents.SubscribeForServerInfoUpdates, () => {
|
||||
socket.join(gameStateSubscribedRoom);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private timeSinceLastServerStateUpdate = 0;
|
||||
public sendServerStateUpdate() {
|
||||
this.io
|
||||
.to(gameStateSubscribedRoom)
|
||||
.emit(TransportEvents.ServerInfoUpdate, [this.players.count, this.gameProgress]);
|
||||
}
|
||||
|
||||
public start() {
|
||||
this.handlePhysics();
|
||||
}
|
||||
|
||||
private addPoints({ blue, red }: GeneratePointsCommand) {
|
||||
if (this.isInEndGame) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.bluePoints += blue;
|
||||
this.redPoints += red;
|
||||
if (this.bluePoints >= this.options.scoreLimit) {
|
||||
this.endGame(CharacterTeam.blue);
|
||||
} else if (this.redPoints >= this.options.scoreLimit) {
|
||||
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>!`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private endGame(winningTeam: CharacterTeam) {
|
||||
this.isInEndGame = true;
|
||||
const endTitleLength = 6;
|
||||
this.players.endGame(winningTeam);
|
||||
this.players.queueCommandForEachClient(
|
||||
new GameEndCommand(winningTeam, endTitleLength, true),
|
||||
);
|
||||
setTimeout(() => this.destroy(), endTitleLength * 1000 * 1.1);
|
||||
}
|
||||
|
||||
private destroy() {
|
||||
this.initialize();
|
||||
}
|
||||
|
||||
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() {
|
||||
const delta = this.deltaTimeCalculator.getNextDeltaTimeInSeconds({ setAsBase: true });
|
||||
this.deltaTimes.push(delta);
|
||||
|
||||
this.handleStats();
|
||||
|
||||
if ((this.timeSinceLastServerStateUpdate += delta) > 4) {
|
||||
this.timeSinceLastServerStateUpdate = 0;
|
||||
this.sendServerStateUpdate();
|
||||
}
|
||||
|
||||
if ((this.timeSinceLastPointUpdate += delta) > 0.5) {
|
||||
this.timeSinceLastPointUpdate = 0;
|
||||
this.players.queueCommandForEachClient(
|
||||
new UpdateGameState(this.bluePoints, this.redPoints, this.options.scoreLimit),
|
||||
);
|
||||
}
|
||||
|
||||
const fixedDelta = settings.targetPhysicsDeltaTimeInSeconds;
|
||||
const maxSubstepsPerFrame = 5;
|
||||
// Cap on retained physics backlog when saturated, so a long stall can't
|
||||
// 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.players.stepCommunication(delta);
|
||||
this.objects.resetRemoteCalls();
|
||||
|
||||
const physicsDelta = this.deltaTimeCalculator.getNextDeltaTimeInSeconds();
|
||||
|
||||
setTimeout(
|
||||
this.handlePhysics.bind(this),
|
||||
Math.max(0, fixedDelta - physicsDelta) * 1000,
|
||||
);
|
||||
}
|
||||
|
||||
private handleStats() {
|
||||
const framesBetweenDeltaTimeCalculation = 10000;
|
||||
|
||||
if (this.deltaTimes.length > framesBetweenDeltaTimeCalculation) {
|
||||
this.deltaTimes.sort((a, b) => a - b);
|
||||
console.info(
|
||||
`Median physics time: ${(
|
||||
this.deltaTimes[Math.floor(framesBetweenDeltaTimeCalculation / 2)] * 1000
|
||||
).toFixed(2)} ms`,
|
||||
);
|
||||
console.info(
|
||||
'Tail times: ',
|
||||
this.deltaTimes.slice(-20).map((v) => `${(v * 1000).toFixed(2)} ms`),
|
||||
);
|
||||
console.info(
|
||||
`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 = [];
|
||||
}
|
||||
}
|
||||
|
||||
private get gameProgress(): number {
|
||||
return (Math.max(this.bluePoints, this.redPoints) / this.options.scoreLimit) * 100;
|
||||
}
|
||||
|
||||
public get serverInfo(): ServerInformation {
|
||||
return {
|
||||
serverName: this.serverName,
|
||||
playerCount: this.players.count,
|
||||
playerLimit: this.playerLimit,
|
||||
gameStatePercent: this.gameProgress,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,20 +1,13 @@
|
|||
export class DeltaTimeCalculator {
|
||||
private previousTime: [number, number] = process.hrtime();
|
||||
|
||||
public getNextDeltaTimeInMilliseconds(): number {
|
||||
const deltaTime = process.hrtime(this.previousTime);
|
||||
this.previousTime = process.hrtime();
|
||||
|
||||
const [seconds, nanoSeconds] = deltaTime;
|
||||
|
||||
return seconds * 1000 + nanoSeconds / 1000 / 1000;
|
||||
}
|
||||
|
||||
public getDeltaTimeInMilliseconds(): number {
|
||||
const deltaTime = process.hrtime(this.previousTime);
|
||||
|
||||
const [seconds, nanoSeconds] = deltaTime;
|
||||
|
||||
return seconds * 1000 + nanoSeconds / 1000 / 1000;
|
||||
public getNextDeltaTimeInSeconds(
|
||||
{ setAsBase = false }: { setAsBase: boolean } = { setAsBase: false },
|
||||
): number {
|
||||
const [seconds, nanoSeconds] = process.hrtime(this.previousTime);
|
||||
if (setAsBase) {
|
||||
this.previousTime = process.hrtime();
|
||||
}
|
||||
return seconds + nanoSeconds / 1e9;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +0,0 @@
|
|||
export const getTimeInMilliseconds = (): number => {
|
||||
const [seconds, nanoSeconds] = process.hrtime();
|
||||
|
||||
return seconds * 1000 + nanoSeconds / 1000 / 1000;
|
||||
};
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/2.3.1/socket.io.js"
|
||||
integrity="sha512-AcZyhRP/tbAEsXCCGlziPun5iFvcSUpEz2jKkx0blkYKbxU81F+iq8FURwPn1sYFeksJ+sDDrI5XujsqSobWdQ=="
|
||||
crossorigin="anonymous"></script>
|
||||
<title>Server info</title>
|
||||
|
||||
</head>
|
||||
|
||||
<body></body>
|
||||
<script>
|
||||
const socket = io({
|
||||
transports: ['websocket'],
|
||||
});
|
||||
|
||||
socket.on('reconnect_attempt', () => {
|
||||
socket.io.opts.transports = ['polling', 'websocket'];
|
||||
});
|
||||
|
||||
socket.emit('join', 'insights');
|
||||
|
||||
socket.on('insights', (message) => {
|
||||
document.body.innerText += message;
|
||||
});
|
||||
</script>
|
||||
|
||||
</html>
|
||||
|
|
@ -1,124 +1,52 @@
|
|||
import ioserver, { Socket } from 'socket.io';
|
||||
import { Server as IoServer } from 'socket.io';
|
||||
import express from 'express';
|
||||
import { Server } from 'http';
|
||||
import cors from 'cors';
|
||||
import {
|
||||
applyArrayPlugins,
|
||||
Random,
|
||||
TransportEvents,
|
||||
deserialize,
|
||||
StepCommand,
|
||||
settings,
|
||||
} from 'shared';
|
||||
import './index.html';
|
||||
import { Player } from './players/player';
|
||||
import { PhysicalContainer } from './physics/containers/physical-container';
|
||||
import { createDungeon } from './map/create-dungeon';
|
||||
import { applyArrayPlugins, Random, serverInformationEndpoint } from 'shared';
|
||||
import minimist from 'minimist';
|
||||
import { glMatrix } from 'gl-matrix';
|
||||
import { DeltaTimeCalculator } from './helper/delta-time-calculator';
|
||||
import { GameServer } from './game-server';
|
||||
import { defaultOptions } from './default-options';
|
||||
import parser from 'socket.io-msgpack-parser';
|
||||
|
||||
glMatrix.setMatrixArrayType(Array);
|
||||
|
||||
applyArrayPlugins();
|
||||
|
||||
Random.seed = 42;
|
||||
const optionOverrides = minimist(process.argv.slice(2));
|
||||
const options = {
|
||||
...defaultOptions,
|
||||
...optionOverrides,
|
||||
};
|
||||
|
||||
const objects = new PhysicalContainer();
|
||||
createDungeon(objects);
|
||||
createDungeon(objects);
|
||||
createDungeon(objects);
|
||||
createDungeon(objects);
|
||||
|
||||
objects.initialize();
|
||||
|
||||
let players: Array<Player> = [];
|
||||
Random.seed = options.seed;
|
||||
|
||||
const app = express();
|
||||
const deltaTimeCalculator = new DeltaTimeCalculator();
|
||||
const server = new Server(app);
|
||||
const io = new IoServer(server, {
|
||||
parser,
|
||||
cors: {
|
||||
origin: true,
|
||||
credentials: true,
|
||||
},
|
||||
} as any);
|
||||
|
||||
const gameServer = new GameServer(io, options);
|
||||
|
||||
app.use(
|
||||
cors({
|
||||
origin: (origin, callback) => {
|
||||
origin: (_, callback) => {
|
||||
callback(null, true);
|
||||
},
|
||||
credentials: true,
|
||||
}),
|
||||
);
|
||||
|
||||
const port = 3000;
|
||||
const server = new Server(app);
|
||||
const io = ioserver(server);
|
||||
|
||||
/*
|
||||
const log = (text: string) => {
|
||||
io.to('insights').emit('insights', text + '\n');
|
||||
};
|
||||
*/
|
||||
|
||||
app.get('/', function (req, res) {
|
||||
res.sendFile('dist/index.html', { root: '.' });
|
||||
app.get(serverInformationEndpoint, (_, res) => {
|
||||
res.json(gameServer.serverInfo);
|
||||
});
|
||||
|
||||
io.on('connection', (socket: SocketIO.Socket) => {
|
||||
socket.on(TransportEvents.PlayerJoining, () => {
|
||||
const player = new Player(objects, socket);
|
||||
players.push(player);
|
||||
socket.on(TransportEvents.PlayerToServer, (json: string) => {
|
||||
const command = deserialize(json);
|
||||
player.sendCommand(command);
|
||||
});
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
player.destroy();
|
||||
players = players.filter((p) => p !== player);
|
||||
});
|
||||
});
|
||||
|
||||
socket.on('join', (room_name: string) => {
|
||||
socket.join(room_name);
|
||||
});
|
||||
server.listen(options.port, () => {
|
||||
console.info(`Server started on port ${options.port}`);
|
||||
});
|
||||
|
||||
server.listen(port, () => {
|
||||
console.log(`server started at http://localhost:${port}`);
|
||||
});
|
||||
|
||||
let deltas: Array<number> = [];
|
||||
|
||||
const handlePhysics = () => {
|
||||
const delta = deltaTimeCalculator.getNextDeltaTimeInMilliseconds();
|
||||
deltas.push(delta);
|
||||
const step = new StepCommand(delta);
|
||||
if (deltas.length > 100) {
|
||||
deltas.sort((a, b) => a - b);
|
||||
console.log(`Median physics time: ${deltas[50].toFixed(2)} ms`);
|
||||
console.log(
|
||||
`Memory used: ${(process.memoryUsage().rss / 1024 / 1024).toFixed(2)} MB`,
|
||||
);
|
||||
deltas = [];
|
||||
console.log(players.map((p) => p.latency));
|
||||
}
|
||||
|
||||
if (deltas.length > 100) {
|
||||
deltas.sort((a, b) => a - b);
|
||||
console.log(`Median physics time: ${deltas[50].toFixed(2)} ms`);
|
||||
console.log(
|
||||
`Memory used: ${(process.memoryUsage().rss / 1024 / 1024).toFixed(2)} MB`,
|
||||
);
|
||||
deltas = [];
|
||||
}
|
||||
|
||||
objects.sendCommand(step);
|
||||
players.forEach((p) => p.sendCommand(step));
|
||||
|
||||
const physicsDelta = deltaTimeCalculator.getDeltaTimeInMilliseconds();
|
||||
deltas.push(physicsDelta);
|
||||
const sleepTime = settings.targetPhysicsDeltaTimeInMilliseconds - physicsDelta;
|
||||
if (sleepTime >= settings.minPhysicsSleepTime) {
|
||||
setTimeout(handlePhysics, sleepTime);
|
||||
} else {
|
||||
setImmediate(handlePhysics);
|
||||
}
|
||||
};
|
||||
|
||||
handlePhysics();
|
||||
gameServer.start();
|
||||
|
|
|
|||
|
|
@ -1,45 +0,0 @@
|
|||
import { vec2, vec3 } from 'gl-matrix';
|
||||
import { Random } from 'shared';
|
||||
import { LampPhysical } from '../objects/lamp-physical';
|
||||
import { TunnelPhysical } from '../objects/tunnel-physical';
|
||||
import { PhysicalContainer } from '../physics/containers/physical-container';
|
||||
|
||||
export const createDungeon = (objects: PhysicalContainer) => {
|
||||
let previousRadius = 350;
|
||||
let previousEnd = vec2.create();
|
||||
|
||||
let tunnelsCountSinceLastLight = 0;
|
||||
|
||||
for (let i = 0; i < 50000; i += 500) {
|
||||
const deltaHeight = (Random.getRandom() - 0.5) * 500;
|
||||
const height = previousEnd.y + deltaHeight;
|
||||
const currentEnd = vec2.fromValues(i, height);
|
||||
const currentToRadius = Random.getRandom() * 300 + 150;
|
||||
|
||||
const tunnel = new TunnelPhysical(
|
||||
previousEnd,
|
||||
currentEnd,
|
||||
previousRadius,
|
||||
currentToRadius,
|
||||
);
|
||||
|
||||
objects.addObject(tunnel);
|
||||
|
||||
if (++tunnelsCountSinceLastLight > 3 && Random.getRandom() > 0.7) {
|
||||
objects.addObject(
|
||||
new LampPhysical(
|
||||
currentEnd,
|
||||
vec3.normalize(
|
||||
vec3.create(),
|
||||
vec3.fromValues(Random.getRandom(), 0, Random.getRandom()),
|
||||
),
|
||||
0.5,
|
||||
),
|
||||
);
|
||||
tunnelsCountSinceLastLight = 0;
|
||||
}
|
||||
|
||||
previousEnd = currentEnd;
|
||||
previousRadius = currentToRadius;
|
||||
}
|
||||
};
|
||||
|
|
@ -1,87 +1,357 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
import {
|
||||
id,
|
||||
CharacterBase,
|
||||
StepCommand,
|
||||
settings,
|
||||
CommandExecutors,
|
||||
MoveActionCommand,
|
||||
serializesTo,
|
||||
clamp,
|
||||
last,
|
||||
Circle,
|
||||
CharacterBase,
|
||||
CharacterTeam,
|
||||
PropertyUpdatesForObject,
|
||||
UpdatePropertyCommand,
|
||||
CommandExecutors,
|
||||
CommandReceiver,
|
||||
mix,
|
||||
clamp01,
|
||||
stepCharacterMovement,
|
||||
applyLeapImpulse,
|
||||
decayMomentum,
|
||||
CharacterMovementState,
|
||||
CharacterWorld,
|
||||
GroundSurface,
|
||||
headRadius,
|
||||
feetRadius,
|
||||
headOffset,
|
||||
leftFootOffset,
|
||||
rightFootOffset,
|
||||
boundRadius,
|
||||
} from 'shared';
|
||||
import { ImmutableBoundingBox } from '../physics/bounding-boxes/immutable-bounding-box';
|
||||
import { DynamicPhysical } from '../physics/physicals/dynamic-physical';
|
||||
import { CirclePhysical } from './circle-physical';
|
||||
import { Physical } from '../physics/physical';
|
||||
import { PhysicalContainer } from '../physics/containers/physical-container';
|
||||
import { Spring } from './spring';
|
||||
import { BoundingBoxBase } from '../physics/bounding-boxes/bounding-box-base';
|
||||
import { ProjectilePhysical } from './projectile-physical';
|
||||
import { forceAtPosition } from '../physics/functions/force-at-position';
|
||||
import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle';
|
||||
import { PlanetPhysical } from './planet-physical';
|
||||
import { StepCommand } from '../commands/step';
|
||||
import { ReactToCollisionCommand } from '../commands/react-to-collision';
|
||||
import { GeneratePointsCommand } from '../commands/generate-points';
|
||||
|
||||
@serializesTo(CharacterBase)
|
||||
export class CharacterPhysical extends CharacterBase implements Physical {
|
||||
export class CharacterPhysical extends CharacterBase implements DynamicPhysical {
|
||||
public readonly canCollide = true;
|
||||
public readonly isInverted = false;
|
||||
public readonly canMove = true;
|
||||
|
||||
private jumpEnergyLeft = settings.defaultJumpEnergy;
|
||||
private projectileStrength = settings.playerMaxStrength;
|
||||
|
||||
// Body geometry (head/foot radii, posture offsets, bound radius) is defined
|
||||
// once in the shared movement module and imported here, so the authoritative
|
||||
// body and the client's predicted body are bit-identical by construction
|
||||
// instead of by a hand-synced "copied verbatim" duplicate. Re-exposed as a
|
||||
// static only because external callers reference CharacterPhysical.boundRadius.
|
||||
public static readonly boundRadius = boundRadius;
|
||||
|
||||
private timeSinceDying = 0;
|
||||
private isDestroyed = false;
|
||||
private timeSinceBorn = 0;
|
||||
private hasJustBorn = true;
|
||||
private timeAlive = 0;
|
||||
|
||||
private timeSinceLastShot = settings.projectileCreationInterval;
|
||||
private timeSinceLastDamage = settings.playerOutOfCombatDelaySeconds;
|
||||
private lastSyncedHealth = settings.playerMaxHealth;
|
||||
|
||||
private killStreak = 0;
|
||||
|
||||
private direction = 0;
|
||||
private currentPlanet?: PlanetPhysical;
|
||||
private secondsSinceOnSurface = settings.planetDetachmentSeconds;
|
||||
|
||||
private bodyVelocity = vec2.create();
|
||||
private timeSinceLastLeap = settings.leapCooldownSeconds;
|
||||
|
||||
public head: CirclePhysical;
|
||||
public leftFoot: CirclePhysical;
|
||||
public rightFoot: CirclePhysical;
|
||||
|
||||
private movementState!: CharacterMovementState;
|
||||
private movementWorld!: CharacterWorld;
|
||||
|
||||
private movementActions: Array<MoveActionCommand> = [];
|
||||
private lastMovementAction: MoveActionCommand = new MoveActionCommand(vec2.create());
|
||||
|
||||
private headVelocity = new Circle(vec2.create(), 0);
|
||||
private leftFootVelocity = new Circle(vec2.create(), 0);
|
||||
private rightFootVelocity = new Circle(vec2.create(), 0);
|
||||
|
||||
protected commandExecutors: CommandExecutors = {
|
||||
[StepCommand.type]: this.step.bind(this),
|
||||
[MoveActionCommand.type]: (c: MoveActionCommand) => this.movementActions.push(c),
|
||||
[ReactToCollisionCommand.type]: this.onCollision.bind(this),
|
||||
};
|
||||
|
||||
private static readonly headOffset = vec2.fromValues(0, 40);
|
||||
private static readonly leftFootOffset = vec2.fromValues(-20, -35);
|
||||
private static readonly rightFootOffset = vec2.fromValues(20, -35);
|
||||
|
||||
constructor(private readonly container: PhysicalContainer) {
|
||||
super(id());
|
||||
constructor(
|
||||
name: string,
|
||||
killCount: number,
|
||||
deathCount: number,
|
||||
team: CharacterTeam,
|
||||
private readonly container: PhysicalContainer,
|
||||
startPosition: vec2,
|
||||
) {
|
||||
super(id(), name, killCount, deathCount, team, settings.playerMaxHealth);
|
||||
this.head = new CirclePhysical(
|
||||
vec2.clone(CharacterPhysical.headOffset),
|
||||
50,
|
||||
vec2.add(vec2.create(), startPosition, headOffset),
|
||||
headRadius,
|
||||
this,
|
||||
container,
|
||||
);
|
||||
this.leftFoot = new CirclePhysical(
|
||||
vec2.clone(CharacterPhysical.leftFootOffset),
|
||||
20,
|
||||
vec2.add(vec2.create(), startPosition, leftFootOffset),
|
||||
feetRadius,
|
||||
this,
|
||||
container,
|
||||
);
|
||||
this.rightFoot = new CirclePhysical(
|
||||
vec2.clone(CharacterPhysical.rightFootOffset),
|
||||
20,
|
||||
vec2.add(vec2.create(), startPosition, rightFootOffset),
|
||||
feetRadius,
|
||||
this,
|
||||
container,
|
||||
);
|
||||
container.addObject(this.head);
|
||||
container.addObject(this.leftFoot);
|
||||
container.addObject(this.rightFoot);
|
||||
|
||||
this.initMovementBridge();
|
||||
}
|
||||
|
||||
private _boundingBox?: ImmutableBoundingBox;
|
||||
private initMovementBridge() {
|
||||
// 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,
|
||||
};
|
||||
|
||||
public get boundingBox(): ImmutableBoundingBox {
|
||||
if (!this._boundingBox) {
|
||||
this._boundingBox = (this.head as CirclePhysical).boundingBox;
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
private hasGeneratedPoints = false;
|
||||
private getPoints(game: CommandReceiver) {
|
||||
if (!this.isAlive && !this.hasGeneratedPoints) {
|
||||
this.hasGeneratedPoints = true;
|
||||
const blue = this.team === CharacterTeam.blue ? 0 : settings.playerKillPoint;
|
||||
const red = this.team === CharacterTeam.red ? 0 : settings.playerKillPoint;
|
||||
|
||||
game.handleCommand(new GeneratePointsCommand(blue, red));
|
||||
}
|
||||
}
|
||||
|
||||
public get isAlive(): boolean {
|
||||
return !this.isDestroyed;
|
||||
}
|
||||
|
||||
public handleMovementAction(c: MoveActionCommand) {
|
||||
this.movementActions.push(c);
|
||||
}
|
||||
|
||||
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.killStreak++;
|
||||
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) {
|
||||
if (
|
||||
// A corpse keeps its collidable circles for the despawn animation; the
|
||||
// isAlive guard stops a flying corpse from eating shots aimed past it.
|
||||
this.isAlive &&
|
||||
other instanceof ProjectilePhysical &&
|
||||
other.team !== this.team &&
|
||||
other.isAlive
|
||||
) {
|
||||
other.destroy();
|
||||
|
||||
if (this.isSpawnProtected) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.timeSinceLastDamage = 0;
|
||||
this.health -= other.strength;
|
||||
this.lastSyncedHealth = Math.round(this.health);
|
||||
this.remoteCall('setHealth', this.health);
|
||||
|
||||
if (this.health <= 0 && this.isAlive) {
|
||||
// Throw the corpse along the killing shot, harder for charged hits.
|
||||
vec2.scaleAndAdd(
|
||||
this.bodyVelocity,
|
||||
this.bodyVelocity,
|
||||
other.direction,
|
||||
mix(settings.deathImpulseMin, settings.deathImpulseMax, other.charge),
|
||||
);
|
||||
this.onDie();
|
||||
other.originator.addKill(this.name, other.charge);
|
||||
} else {
|
||||
other.originator.registerHit(other.charge);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public shootTowards(position: vec2, charge = 0) {
|
||||
if (
|
||||
!this.isAlive ||
|
||||
this.timeSinceLastShot < settings.projectileCreationInterval ||
|
||||
this.projectileStrength < settings.chargeShotStrengthMin
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
return this._boundingBox;
|
||||
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);
|
||||
vec2.normalize(direction, direction);
|
||||
// Keep the unit direction before vec2.scale repurposes it as the velocity.
|
||||
const shotDirection = vec2.clone(direction);
|
||||
const velocity = vec2.scale(direction, direction, speed);
|
||||
const projectile = new ProjectilePhysical(
|
||||
vec2.clone(this.center),
|
||||
radius,
|
||||
strength,
|
||||
this.team,
|
||||
velocity,
|
||||
this,
|
||||
this.container,
|
||||
c,
|
||||
);
|
||||
this.container.addObject(projectile);
|
||||
|
||||
if (c > 0) {
|
||||
vec2.scaleAndAdd(
|
||||
this.bodyVelocity,
|
||||
this.bodyVelocity,
|
||||
shotDirection,
|
||||
-settings.chargeShotRecoilMax * c,
|
||||
);
|
||||
}
|
||||
|
||||
this.remoteCall('onShoot', strength);
|
||||
}
|
||||
|
||||
public get gameObject(): CharacterPhysical {
|
||||
public leap() {
|
||||
if (
|
||||
!this.isAlive ||
|
||||
this.hasJustBorn ||
|
||||
!this.currentPlanet ||
|
||||
this.timeSinceLastLeap < settings.leapCooldownSeconds ||
|
||||
this.projectileStrength < settings.leapStrengthCost
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.timeSinceLastLeap = 0;
|
||||
this.projectileStrength -= settings.leapStrengthCost;
|
||||
|
||||
// Same impulse the client predicts with (shared), so a leap launches
|
||||
// identically on both sides.
|
||||
applyLeapImpulse(this.movementState, this.lastMovementAction.direction);
|
||||
this.remoteCall('onLeap');
|
||||
}
|
||||
|
||||
public get boundingBox(): BoundingBoxBase {
|
||||
return getBoundingBoxOfCircle(new Circle(this.center, CharacterPhysical.boundRadius));
|
||||
}
|
||||
|
||||
public get gameObject(): this {
|
||||
return this;
|
||||
}
|
||||
|
||||
public get center(): vec2 {
|
||||
return this.head.center;
|
||||
const bodyCenter = vec2.add(vec2.create(), this.head.center, this.leftFoot.center);
|
||||
vec2.add(bodyCenter, bodyCenter, this.rightFoot.center);
|
||||
return vec2.scale(bodyCenter, bodyCenter, 1 / 3);
|
||||
}
|
||||
|
||||
public distance(target: vec2): number {
|
||||
|
|
@ -90,11 +360,11 @@ export class CharacterPhysical extends CharacterBase implements Physical {
|
|||
this.head.distance(target),
|
||||
this.leftFoot.distance(target),
|
||||
this.rightFoot.distance(target),
|
||||
) - 20
|
||||
) - 5
|
||||
);
|
||||
}
|
||||
|
||||
private sumAndResetMovementActions(): vec2 {
|
||||
private averageAndResetMovementActions(): vec2 {
|
||||
let direction: vec2;
|
||||
if (this.movementActions.length === 0) {
|
||||
direction = vec2.clone(this.lastMovementAction.direction);
|
||||
|
|
@ -110,74 +380,183 @@ export class CharacterPhysical extends CharacterBase implements Physical {
|
|||
this.movementActions = [];
|
||||
}
|
||||
|
||||
return direction;
|
||||
return vec2.length(direction) > 0
|
||||
? vec2.normalize(direction, direction)
|
||||
: vec2.create();
|
||||
}
|
||||
|
||||
public step(c: StepCommand) {
|
||||
const deltaTime = c.deltaTimeInMiliseconds / 1000;
|
||||
|
||||
const direction = this.sumAndResetMovementActions();
|
||||
const isAirborne = this.leftFoot.isAirborne && this.rightFoot.isAirborne;
|
||||
this.jumpEnergyLeft += isAirborne ? -deltaTime : deltaTime;
|
||||
this.jumpEnergyLeft = clamp(this.jumpEnergyLeft, 0, settings.defaultJumpEnergy);
|
||||
|
||||
const xMax = deltaTime * settings.maxAccelerationX;
|
||||
const yMax = this.jumpEnergyLeft > 0 ? settings.maxAccelerationY : 0;
|
||||
const movementForce = vec2.multiply(
|
||||
direction,
|
||||
direction,
|
||||
vec2.fromValues(xMax, yMax),
|
||||
);
|
||||
|
||||
const sumBody = vec2.add(vec2.create(), this.head.center, this.leftFoot.center);
|
||||
vec2.add(sumBody, sumBody, this.rightFoot.center);
|
||||
vec2.scale(sumBody, sumBody, 1 / 3);
|
||||
|
||||
const headPosition = vec2.add(vec2.create(), sumBody, CharacterPhysical.headOffset);
|
||||
|
||||
Spring.step(new Circle(headPosition, 0), this.head, 0, 30, deltaTime);
|
||||
|
||||
const footDistance = vec2.distance(
|
||||
CharacterPhysical.headOffset,
|
||||
CharacterPhysical.leftFootOffset,
|
||||
);
|
||||
|
||||
Spring.step(
|
||||
new Circle(this.head.center, this.head.radius),
|
||||
this.leftFoot,
|
||||
footDistance,
|
||||
25,
|
||||
deltaTime,
|
||||
);
|
||||
Spring.step(
|
||||
new Circle(this.head.center, this.head.radius),
|
||||
this.rightFoot,
|
||||
footDistance,
|
||||
25,
|
||||
deltaTime,
|
||||
);
|
||||
Spring.step(
|
||||
this.leftFoot,
|
||||
this.rightFoot,
|
||||
vec2.distance(CharacterPhysical.leftFootOffset, CharacterPhysical.rightFootOffset),
|
||||
100,
|
||||
deltaTime,
|
||||
);
|
||||
|
||||
this.head.applyForce(movementForce, deltaTime);
|
||||
this.leftFoot.applyForce(movementForce, deltaTime);
|
||||
this.rightFoot.applyForce(movementForce, deltaTime);
|
||||
|
||||
this.head.applyForce(settings.gravitationalForce, deltaTime);
|
||||
this.leftFoot.applyForce(settings.gravitationalForce, deltaTime);
|
||||
this.rightFoot.applyForce(settings.gravitationalForce, deltaTime);
|
||||
|
||||
this.head.step(deltaTime);
|
||||
this.leftFoot.step(deltaTime);
|
||||
this.rightFoot.step(deltaTime);
|
||||
private animateScaling(q: number) {
|
||||
this.head.radius = headRadius * q;
|
||||
this.leftFoot.radius = this.rightFoot.radius = feetRadius * q;
|
||||
}
|
||||
|
||||
public destroy() {
|
||||
public getPropertyUpdates(): PropertyUpdatesForObject {
|
||||
return new PropertyUpdatesForObject(this.id, [
|
||||
new UpdatePropertyCommand('head', this.head, this.headVelocity),
|
||||
new UpdatePropertyCommand('leftFoot', this.leftFoot, this.leftFootVelocity),
|
||||
new UpdatePropertyCommand('rightFoot', this.rightFoot, this.rightFootVelocity),
|
||||
new UpdatePropertyCommand(
|
||||
'strength',
|
||||
this.projectileStrength,
|
||||
settings.playerStrengthRegenerationPerSeconds,
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
private setPropertyUpdates(
|
||||
oldHead: Circle,
|
||||
oldLeftFoot: Circle,
|
||||
oldRightFoot: Circle,
|
||||
deltaTime: number,
|
||||
) {
|
||||
this.headVelocity = new Circle(
|
||||
vec2.scale(
|
||||
oldHead.center,
|
||||
vec2.subtract(oldHead.center, this.head.center, oldHead.center),
|
||||
1 / deltaTime,
|
||||
),
|
||||
(this.head.radius - oldHead.radius) / deltaTime,
|
||||
);
|
||||
|
||||
this.leftFootVelocity = new Circle(
|
||||
vec2.scale(
|
||||
oldLeftFoot.center,
|
||||
vec2.subtract(oldLeftFoot.center, this.leftFoot.center, oldLeftFoot.center),
|
||||
1 / deltaTime,
|
||||
),
|
||||
(this.leftFoot.radius - oldLeftFoot.radius) / deltaTime,
|
||||
);
|
||||
|
||||
this.rightFootVelocity = new Circle(
|
||||
vec2.scale(
|
||||
oldRightFoot.center,
|
||||
vec2.subtract(oldRightFoot.center, this.rightFoot.center, oldRightFoot.center),
|
||||
1 / deltaTime,
|
||||
),
|
||||
(this.rightFoot.radius - oldRightFoot.radius) / deltaTime,
|
||||
);
|
||||
|
||||
this.animateScaling(1);
|
||||
}
|
||||
|
||||
private step({ deltaTimeInSeconds, game }: StepCommand) {
|
||||
this.getPoints(game);
|
||||
this.timeAlive += deltaTimeInSeconds;
|
||||
this.timeSinceLastLeap += deltaTimeInSeconds;
|
||||
const oldHead = new Circle(vec2.clone(this.head.center), this.head.radius);
|
||||
const oldLeftFoot = new Circle(
|
||||
vec2.clone(this.leftFoot.center),
|
||||
this.leftFoot.radius,
|
||||
);
|
||||
const oldRightFoot = new Circle(
|
||||
vec2.clone(this.rightFoot.center),
|
||||
this.rightFoot.radius,
|
||||
);
|
||||
|
||||
if (this.isDestroyed) {
|
||||
if ((this.timeSinceDying += deltaTimeInSeconds) > settings.spawnDespawnTime) {
|
||||
this.destroy();
|
||||
} else {
|
||||
this.freeFallCorpse(deltaTimeInSeconds);
|
||||
this.animateScaling(1 - this.timeSinceDying / settings.spawnDespawnTime);
|
||||
}
|
||||
this.setPropertyUpdates(oldHead, oldLeftFoot, oldRightFoot, deltaTimeInSeconds);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.hasJustBorn) {
|
||||
if ((this.timeSinceBorn += deltaTimeInSeconds) > settings.spawnDespawnTime) {
|
||||
this.hasJustBorn = false;
|
||||
} else {
|
||||
this.animateScaling(this.timeSinceBorn / settings.spawnDespawnTime);
|
||||
}
|
||||
this.setPropertyUpdates(oldHead, oldLeftFoot, oldRightFoot, deltaTimeInSeconds);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
(this.secondsSinceOnSurface += deltaTimeInSeconds) >
|
||||
settings.planetDetachmentSeconds
|
||||
) {
|
||||
this.currentPlanet = undefined;
|
||||
}
|
||||
|
||||
this.timeSinceLastShot += deltaTimeInSeconds;
|
||||
|
||||
this.projectileStrength = Math.min(
|
||||
settings.playerMaxStrength,
|
||||
this.projectileStrength +
|
||||
settings.playerStrengthRegenerationPerSeconds * deltaTimeInSeconds,
|
||||
);
|
||||
|
||||
this.regenerateHealth(deltaTimeInSeconds);
|
||||
|
||||
// The planet tallies who is standing on it and resolves capture itself, so
|
||||
// a contested rock can freeze instead of two squads silently cancelling.
|
||||
this.currentPlanet?.registerPresence(this);
|
||||
|
||||
// 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(
|
||||
new Circle(
|
||||
this.center,
|
||||
CharacterPhysical.boundRadius + settings.maxGravityDistance,
|
||||
),
|
||||
),
|
||||
);
|
||||
let grounded = false;
|
||||
for (const part of [this.leftFoot, this.rightFoot, this.head]) {
|
||||
part.applyForce(forceAtPosition(part.center, intersecting), deltaTime);
|
||||
vec2.add(part.velocity, part.velocity, this.bodyVelocity);
|
||||
const { hitObject } = part.stepManually(deltaTime);
|
||||
if (hitObject instanceof PlanetPhysical) {
|
||||
grounded = true;
|
||||
}
|
||||
}
|
||||
// Brake with the exact shared model the living body uses: stiff on contact
|
||||
// so the corpse skids to rest, gentle in the air, plus the constant stop and
|
||||
// the speed cap — so a flung corpse comes to a definite stop instead of
|
||||
// sliding forever.
|
||||
decayMomentum(this.bodyVelocity, grounded, deltaTime);
|
||||
}
|
||||
|
||||
private regenerateHealth(deltaTimeInSeconds: number) {
|
||||
this.timeSinceLastDamage += deltaTimeInSeconds;
|
||||
if (
|
||||
this.timeSinceLastDamage > settings.playerOutOfCombatDelaySeconds &&
|
||||
this.health < settings.playerMaxHealth
|
||||
) {
|
||||
this.health = Math.min(
|
||||
settings.playerMaxHealth,
|
||||
this.health + settings.playerHealthRegenerationPerSeconds * deltaTimeInSeconds,
|
||||
);
|
||||
this.syncHealth();
|
||||
}
|
||||
}
|
||||
|
||||
public onDie() {
|
||||
this.isDestroyed = true;
|
||||
this.remoteCall('onDie');
|
||||
}
|
||||
|
||||
private destroy() {
|
||||
this.container.removeObject(this);
|
||||
this.container.removeObject(this.head);
|
||||
this.container.removeObject(this.leftFoot);
|
||||
this.container.removeObject(this.rightFoot);
|
||||
|
|
|
|||
|
|
@ -1,33 +1,43 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
import { Circle, clamp, GameObject, serializesTo, settings } from 'shared';
|
||||
import { Physical } from '../physics/physical';
|
||||
|
||||
import {
|
||||
Circle,
|
||||
CommandExecutors,
|
||||
CommandReceiver,
|
||||
GameObject,
|
||||
resolveCircleMovement,
|
||||
serializesTo,
|
||||
} from 'shared';
|
||||
import { BoundingBox } from '../physics/bounding-boxes/bounding-box';
|
||||
import { BoundingBoxBase } from '../physics/bounding-boxes/bounding-box-base';
|
||||
import { moveCircle } from '../physics/move-circle';
|
||||
import { PhysicalContainer } from '../physics/containers/physical-container';
|
||||
import { DynamicPhysical } from '../physics/physicals/dynamic-physical';
|
||||
import { Physical } from '../physics/physicals/physical';
|
||||
import { ReactToCollisionCommand } from '../commands/react-to-collision';
|
||||
|
||||
@serializesTo(Circle)
|
||||
export class CirclePhysical implements Circle, Physical {
|
||||
readonly isInverted = false;
|
||||
export class CirclePhysical extends CommandReceiver implements Circle, DynamicPhysical {
|
||||
readonly canCollide = true;
|
||||
readonly canMove = true;
|
||||
|
||||
private _isAirborne = true;
|
||||
private velocity = vec2.create();
|
||||
|
||||
public get isAirborne(): boolean {
|
||||
return this._isAirborne;
|
||||
}
|
||||
public velocity = vec2.create();
|
||||
public lastNormal = vec2.fromValues(0, 1);
|
||||
|
||||
private _boundingBox: BoundingBox;
|
||||
|
||||
protected commandExecutors: CommandExecutors = {
|
||||
[ReactToCollisionCommand.type]: this.onCollision.bind(this),
|
||||
};
|
||||
|
||||
constructor(
|
||||
private _center: vec2,
|
||||
private _radius: number,
|
||||
public owner: GameObject,
|
||||
private readonly container: PhysicalContainer,
|
||||
// Public + readonly so a CirclePhysical structurally satisfies the shared
|
||||
// PhysicsBody interface the movement simulation operates on.
|
||||
public readonly restitution = 0,
|
||||
) {
|
||||
super();
|
||||
this._boundingBox = new BoundingBox();
|
||||
this.recalculateBoundingBox();
|
||||
}
|
||||
|
|
@ -45,6 +55,10 @@ export class CirclePhysical implements Circle, Physical {
|
|||
this.recalculateBoundingBox();
|
||||
}
|
||||
|
||||
public onCollision(c: ReactToCollisionCommand) {
|
||||
this.owner.handleCommand(c);
|
||||
}
|
||||
|
||||
public get gameObject(): GameObject {
|
||||
return this.owner;
|
||||
}
|
||||
|
|
@ -62,31 +76,6 @@ export class CirclePhysical implements Circle, Physical {
|
|||
return vec2.distance(target, this.center) - this.radius;
|
||||
}
|
||||
|
||||
public distanceBetween(target: Circle): number {
|
||||
return vec2.distance(target.center, this.center) - this.radius - target.radius;
|
||||
}
|
||||
|
||||
public areIntersecting(other: Physical): boolean {
|
||||
return other.distance(this.center) < this.radius;
|
||||
}
|
||||
|
||||
public isInside(other: Physical): boolean {
|
||||
return other.distance(this.center) < -this.radius;
|
||||
}
|
||||
|
||||
public getPerimeterPoints(count: number): Array<vec2> {
|
||||
const result: Array<vec2> = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
result.push(
|
||||
vec2.fromValues(
|
||||
Math.cos((2 * Math.PI * i) / count) * this.radius + this.center.x,
|
||||
Math.sin((2 * Math.PI * i) / count) * this.radius + this.center.y,
|
||||
),
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private recalculateBoundingBox() {
|
||||
this._boundingBox.xMin = this.center.x - this._radius;
|
||||
this._boundingBox.xMax = this.center.x + this._radius;
|
||||
|
|
@ -100,54 +89,51 @@ export class CirclePhysical implements Circle, Physical {
|
|||
this.velocity,
|
||||
vec2.scale(vec2.create(), force, timeInSeconds),
|
||||
);
|
||||
|
||||
vec2.set(
|
||||
this.velocity,
|
||||
clamp(this.velocity.x, -settings.maxVelocityX, settings.maxVelocityX),
|
||||
clamp(this.velocity.y, -settings.maxVelocityY, settings.maxVelocityY),
|
||||
);
|
||||
}
|
||||
|
||||
public resetVelocity() {
|
||||
this.velocity = vec2.create();
|
||||
}
|
||||
// Position-resolution for one tick. Delegates to the shared
|
||||
// resolveCircleMovement so the server integrates a body with the exact same
|
||||
// geometry the client predictor runs (shared/physics) — no parallel copy to
|
||||
// keep in sync. The onHit callback dispatches the collision reactions at the
|
||||
// same points the old inline move-circle did (both the initial march and the
|
||||
// post-bounce slide). `possibleIntersectors`, when supplied, lets a caller
|
||||
// that already broadphased (e.g. a projectile's gravity query) avoid a second
|
||||
// container query; otherwise it is self-gathered from the swept bounding box.
|
||||
public stepManually(
|
||||
deltaTimeInSeconds: number,
|
||||
possibleIntersectors?: Array<Physical>,
|
||||
): {
|
||||
hitObject: GameObject | undefined;
|
||||
velocity: vec2;
|
||||
} {
|
||||
const intersecting = (
|
||||
possibleIntersectors ?? this.sweptBroadphase(deltaTimeInSeconds)
|
||||
).filter((b) => b.gameObject !== this.gameObject && b.canCollide);
|
||||
|
||||
public step(deltaTimeInSeconds: number): boolean {
|
||||
vec2.scale(
|
||||
this.velocity,
|
||||
this.velocity,
|
||||
Math.pow(settings.velocityAttenuation, deltaTimeInSeconds),
|
||||
const { hitObject, velocity } = resolveCircleMovement(
|
||||
this,
|
||||
deltaTimeInSeconds,
|
||||
intersecting,
|
||||
(intersected) => {
|
||||
const physical = intersected as Physical;
|
||||
physical.handleCommand(new ReactToCollisionCommand(this.gameObject));
|
||||
this.handleCommand(new ReactToCollisionCommand(physical.gameObject));
|
||||
},
|
||||
);
|
||||
|
||||
const distance = vec2.scale(vec2.create(), this.velocity, deltaTimeInSeconds);
|
||||
return { hitObject: (hitObject as Physical | undefined)?.gameObject, velocity };
|
||||
}
|
||||
|
||||
const distanceLength = vec2.length(distance);
|
||||
const stepCount = Math.ceil(distanceLength / settings.physicsMaxStep);
|
||||
vec2.scale(distance, distance, 1 / stepCount);
|
||||
|
||||
let wasHit = false;
|
||||
|
||||
for (let i = 0; i < stepCount; i++) {
|
||||
const { tangent, hitSurface } = moveCircle(
|
||||
this,
|
||||
vec2.clone(distance),
|
||||
this.container.findIntersecting(this.boundingBox),
|
||||
);
|
||||
|
||||
if (hitSurface) {
|
||||
vec2.scale(this.velocity, tangent!, vec2.dot(tangent!, this.velocity));
|
||||
if (
|
||||
vec2.length(this.velocity) <
|
||||
settings.frictionMinVelocity * deltaTimeInSeconds
|
||||
) {
|
||||
this.velocity = vec2.create();
|
||||
}
|
||||
wasHit = true;
|
||||
}
|
||||
}
|
||||
|
||||
this._isAirborne = !wasHit;
|
||||
return wasHit;
|
||||
// Query the container with the bounding box grown by this tick's travel, so a
|
||||
// fast-moving body still sees what it is about to sweep into.
|
||||
private sweptBroadphase(deltaTimeInSeconds: number): Array<Physical> {
|
||||
const sweep = vec2.length(
|
||||
vec2.scale(vec2.create(), this.velocity, deltaTimeInSeconds),
|
||||
);
|
||||
this.radius += sweep;
|
||||
const intersecting = this.container.findIntersecting(this.boundingBox);
|
||||
this.radius -= sweep;
|
||||
return intersecting;
|
||||
}
|
||||
|
||||
public toArray(): Array<any> {
|
||||
|
|
|
|||
|
|
@ -2,13 +2,11 @@ import { vec2, vec3 } from 'gl-matrix';
|
|||
import { LampBase, settings, id, serializesTo } from 'shared';
|
||||
|
||||
import { ImmutableBoundingBox } from '../physics/bounding-boxes/immutable-bounding-box';
|
||||
|
||||
import { Physical } from '../physics/physical';
|
||||
import { StaticPhysical } from '../physics/physicals/static-physical';
|
||||
|
||||
@serializesTo(LampBase)
|
||||
export class LampPhysical extends LampBase implements Physical {
|
||||
export class LampPhysical extends LampBase implements StaticPhysical {
|
||||
public readonly canCollide = false;
|
||||
public readonly isInverted = false;
|
||||
public readonly canMove = false;
|
||||
|
||||
constructor(center: vec2, color: vec3, lightness: number) {
|
||||
|
|
@ -30,12 +28,15 @@ export class LampPhysical extends LampBase implements Physical {
|
|||
return this._boundingBox;
|
||||
}
|
||||
|
||||
public get gameObject(): LampPhysical {
|
||||
public get gameObject(): this {
|
||||
return this;
|
||||
}
|
||||
|
||||
// todo
|
||||
public distance(_: vec2): number {
|
||||
return 0;
|
||||
public queueSetLight(color: vec3, lightness: number) {
|
||||
this.remoteCall('setLight', color, lightness);
|
||||
}
|
||||
|
||||
public distance(target: vec2): number {
|
||||
return vec2.distance(this.center, target);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
334
backend/src/objects/planet-physical.ts
Normal file
334
backend/src/objects/planet-physical.ts
Normal file
|
|
@ -0,0 +1,334 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
|
||||
import {
|
||||
clamp,
|
||||
clamp01,
|
||||
id,
|
||||
mix,
|
||||
Random,
|
||||
serializesTo,
|
||||
settings,
|
||||
PlanetBase,
|
||||
CharacterTeam,
|
||||
PropertyUpdatesForObject,
|
||||
UpdatePropertyCommand,
|
||||
CommandExecutors,
|
||||
CommandReceiver,
|
||||
} from 'shared';
|
||||
import { GeneratePointsCommand } from '../commands/generate-points';
|
||||
import { AnnounceCommand } from '../commands/announce';
|
||||
import { StepCommand } from '../commands/step';
|
||||
|
||||
import { ImmutableBoundingBox } from '../physics/bounding-boxes/immutable-bounding-box';
|
||||
import { StaticPhysical } from '../physics/physicals/static-physical';
|
||||
import { LampPhysical } from './lamp-physical';
|
||||
import type { CharacterPhysical } from './character-physical';
|
||||
|
||||
@serializesTo(PlanetBase)
|
||||
export class PlanetPhysical extends PlanetBase implements StaticPhysical {
|
||||
public readonly canCollide = true;
|
||||
public readonly canMove = false;
|
||||
// Marks this as standable ground for the shared movement simulation (a body
|
||||
// landing on it latches it as currentPlanet). See shared GroundSurface.
|
||||
public readonly isGround = true;
|
||||
|
||||
public readonly sizePointMultiplier: number;
|
||||
|
||||
// Planets slowly spin. The angle is authoritative here and streamed to the
|
||||
// client (see getPropertyUpdates), so the rendered outline and this collision
|
||||
// polygon turn as one rigid body. cos/sin are memoised per angle because
|
||||
// distance() is called many times per tick by the raymarcher and SDF sampling.
|
||||
private rotation = 0;
|
||||
private readonly rotationSpeed: number;
|
||||
private cachedRotation = Number.NaN;
|
||||
private cosRotation = 1;
|
||||
private sinRotation = 0;
|
||||
|
||||
private _boundingBox?: ImmutableBoundingBox;
|
||||
|
||||
private readonly lamps: Array<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 = {
|
||||
[StepCommand.type]: this.step.bind(this),
|
||||
};
|
||||
|
||||
public addLamp(lamp: LampPhysical) {
|
||||
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 {
|
||||
// Evaluate the SDF in the planet's own rotating frame so this collision
|
||||
// outline turns in lockstep with the rendered planet (see planet-shape.ts).
|
||||
const local = this.toLocalFrame(target);
|
||||
|
||||
const startEnd = this.vertices[0];
|
||||
let vb = startEnd;
|
||||
|
||||
let d = vec2.dist(local, vb);
|
||||
let sign = 1;
|
||||
|
||||
for (let i = 1; i <= this.vertices.length; i++) {
|
||||
const va = vb;
|
||||
vb = i === this.vertices.length ? startEnd : this.vertices[i];
|
||||
const targetFromDelta = vec2.subtract(vec2.create(), local, va);
|
||||
const toFromDelta = vec2.subtract(vec2.create(), vb, va);
|
||||
const h = clamp01(
|
||||
vec2.dot(targetFromDelta, toFromDelta) / vec2.squaredLength(toFromDelta),
|
||||
);
|
||||
|
||||
const ds = vec2.fromValues(
|
||||
vec2.dist(targetFromDelta, vec2.scale(vec2.create(), toFromDelta, h)),
|
||||
toFromDelta.x * targetFromDelta.y - toFromDelta.y * targetFromDelta.x,
|
||||
);
|
||||
|
||||
if (
|
||||
(local.y >= va.y && local.y < vb.y && ds.y > 0) ||
|
||||
(local.y < va.y && local.y >= vb.y && ds.y <= 0)
|
||||
) {
|
||||
sign *= -1;
|
||||
}
|
||||
|
||||
d = Math.min(d, ds.x);
|
||||
}
|
||||
|
||||
return sign * d;
|
||||
}
|
||||
|
||||
// Rotate a world point by -rotation about the centre, matching the shader's
|
||||
// `localTarget = center + R(rotation) * (target - center)` transform exactly.
|
||||
private toLocalFrame(target: vec2): vec2 {
|
||||
if (this.rotation !== this.cachedRotation) {
|
||||
this.cachedRotation = this.rotation;
|
||||
this.cosRotation = Math.cos(this.rotation);
|
||||
this.sinRotation = Math.sin(this.rotation);
|
||||
}
|
||||
|
||||
const dx = target.x - this.center.x;
|
||||
const dy = target.y - this.center.y;
|
||||
|
||||
return vec2.fromValues(
|
||||
this.center.x + this.cosRotation * dx - this.sinRotation * dy,
|
||||
this.center.y + this.sinRotation * dx + this.cosRotation * dy,
|
||||
);
|
||||
}
|
||||
|
||||
// Signed angular velocity in rad/s, exposed so a character standing on the
|
||||
// planet can ride its spin (see carryWithRotatingPlanet in shared).
|
||||
public get angularVelocity(): number {
|
||||
return this.rotationSpeed;
|
||||
}
|
||||
|
||||
public get team(): CharacterTeam {
|
||||
return Math.abs(this.ownership - 0.5) < settings.planetControlThreshold
|
||||
? CharacterTeam.neutral
|
||||
: this.ownership < 0.5
|
||||
? CharacterTeam.blue
|
||||
: CharacterTeam.red;
|
||||
}
|
||||
|
||||
private timeSinceLastPointGeneration = 0;
|
||||
private getPoints(game: CommandReceiver) {
|
||||
if (this.timeSinceLastPointGeneration > settings.planetPointGenerationInterval) {
|
||||
this.timeSinceLastPointGeneration = 0;
|
||||
|
||||
const value = Math.round(
|
||||
settings.planetPointGenerationValue * this.sizePointMultiplier,
|
||||
);
|
||||
game.handleCommand(
|
||||
new GeneratePointsCommand(
|
||||
this.team === CharacterTeam.blue ? value : 0,
|
||||
this.team === CharacterTeam.red ? value : 0,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private step({ deltaTimeInSeconds, game }: StepCommand) {
|
||||
this.rotation += deltaTimeInSeconds * this.rotationSpeed;
|
||||
this.timeSinceLastPointGeneration += deltaTimeInSeconds;
|
||||
|
||||
// In reverse order, so that teams can achieve a 100% control.
|
||||
this.getPoints(game);
|
||||
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 {
|
||||
return new PropertyUpdatesForObject(this.id, [
|
||||
new UpdatePropertyCommand('ownership', this.ownership, 0),
|
||||
// Stream the spin rate as the rate-of-change so the client can keep the
|
||||
// angle moving when snapshots run late (see planet-view.ts).
|
||||
new UpdatePropertyCommand('rotation', this.rotation, this.rotationSpeed),
|
||||
]);
|
||||
}
|
||||
|
||||
public takeControl(team: CharacterTeam, deltaTime: number) {
|
||||
if (team === CharacterTeam.blue) {
|
||||
this.ownership -= (0.5 / settings.takeControlTimeInSeconds) * deltaTime;
|
||||
} else if (team === CharacterTeam.red) {
|
||||
this.ownership += (0.5 / settings.takeControlTimeInSeconds) * deltaTime;
|
||||
} else {
|
||||
const previous = this.ownership;
|
||||
this.ownership +=
|
||||
-Math.sign(this.ownership - 0.5) *
|
||||
(0.5 / settings.loseControlTimeInSeconds) *
|
||||
deltaTime;
|
||||
if (
|
||||
(previous < 0.5 && this.ownership > 0.5) ||
|
||||
(previous > 0.5 && this.ownership < 0.5)
|
||||
) {
|
||||
this.ownership = 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
this.ownership = clamp01(this.ownership);
|
||||
}
|
||||
|
||||
public get boundingBox(): ImmutableBoundingBox {
|
||||
if (!this._boundingBox) {
|
||||
// The polygon spins about its centre (see distance), so this static box
|
||||
// has to cover every orientation, not just the spawn-time one: take the
|
||||
// circumscribed circle around the rotation centre.
|
||||
const maxVertexDistance = this.vertices.reduce(
|
||||
(max, vertex) => Math.max(max, vec2.distance(this.center, vertex)),
|
||||
0,
|
||||
);
|
||||
|
||||
this._boundingBox = new ImmutableBoundingBox(
|
||||
this.center.x - maxVertexDistance,
|
||||
this.center.x + maxVertexDistance,
|
||||
this.center.y - maxVertexDistance,
|
||||
this.center.y + maxVertexDistance,
|
||||
);
|
||||
}
|
||||
|
||||
return this._boundingBox;
|
||||
}
|
||||
|
||||
public getForce(position: vec2): vec2 {
|
||||
const diff = vec2.subtract(vec2.create(), this.center, position);
|
||||
const dist = Math.max(settings.minGravityDistance, vec2.length(diff) - this.radius);
|
||||
vec2.normalize(diff, diff);
|
||||
const scale = clamp(
|
||||
settings.maxGravityQ * ((settings.maxGravityDistance / dist) ** 1.5 - 1),
|
||||
0,
|
||||
settings.maxGravityStrength,
|
||||
);
|
||||
return vec2.scale(diff, diff, scale);
|
||||
}
|
||||
|
||||
// GroundSurface alias the shared movement simulation calls for gravity.
|
||||
public gravityAt(position: vec2): vec2 {
|
||||
return this.getForce(position);
|
||||
}
|
||||
|
||||
public get gameObject(): this {
|
||||
return this;
|
||||
}
|
||||
}
|
||||
160
backend/src/objects/projectile-physical.ts
Normal file
160
backend/src/objects/projectile-physical.ts
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
import {
|
||||
id,
|
||||
settings,
|
||||
serializesTo,
|
||||
ProjectileBase,
|
||||
CharacterTeam,
|
||||
PropertyUpdatesForObject,
|
||||
UpdatePropertyCommand,
|
||||
CommandExecutors,
|
||||
Circle,
|
||||
marchCircle,
|
||||
} from 'shared';
|
||||
import { ImmutableBoundingBox } from '../physics/bounding-boxes/immutable-bounding-box';
|
||||
import { CirclePhysical } from './circle-physical';
|
||||
import { DynamicPhysical } from '../physics/physicals/dynamic-physical';
|
||||
import { PhysicalContainer } from '../physics/containers/physical-container';
|
||||
import { CharacterPhysical } from './character-physical';
|
||||
import { forceAtPosition } from '../physics/functions/force-at-position';
|
||||
import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle';
|
||||
import { StepCommand } from '../commands/step';
|
||||
import { ReactToCollisionCommand } from '../commands/react-to-collision';
|
||||
|
||||
@serializesTo(ProjectileBase)
|
||||
export class ProjectilePhysical extends ProjectileBase implements DynamicPhysical {
|
||||
public readonly canCollide = true;
|
||||
public readonly canMove = true;
|
||||
|
||||
private isDestroyed = false;
|
||||
private bounceCount = 0;
|
||||
private _boundingBox?: ImmutableBoundingBox;
|
||||
|
||||
public object: CirclePhysical;
|
||||
|
||||
protected commandExecutors: CommandExecutors = {
|
||||
[StepCommand.type]: this.handleStep.bind(this),
|
||||
[ReactToCollisionCommand.type]: this.onCollision.bind(this),
|
||||
};
|
||||
|
||||
constructor(
|
||||
center: vec2,
|
||||
radius: number,
|
||||
public strength: number,
|
||||
team: CharacterTeam,
|
||||
private velocity: vec2,
|
||||
public readonly originator: CharacterPhysical,
|
||||
readonly container: PhysicalContainer,
|
||||
// Normalised charge (0..1) of the shot that fired this, used by the victim
|
||||
// to scale hit/kill feedback and the death fling.
|
||||
public readonly charge: number = 0,
|
||||
) {
|
||||
super(id(), center, radius, team, strength);
|
||||
this.object = new CirclePhysical(center, radius, this, container, 0.9);
|
||||
|
||||
this.moveOutsideOfObject();
|
||||
}
|
||||
|
||||
public get isAlive(): boolean {
|
||||
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() {
|
||||
let wasCollision = true;
|
||||
const delta = vec2.scale(
|
||||
vec2.create(),
|
||||
vec2.normalize(vec2.create(), this.velocity),
|
||||
10,
|
||||
);
|
||||
while (wasCollision) {
|
||||
const intersecting = this.container
|
||||
.findIntersecting(this.boundingBox)
|
||||
.filter((g) => g instanceof CharacterPhysical && g.team === this.team);
|
||||
const { hitSurface } = marchCircle(this.object, delta, intersecting, true);
|
||||
wasCollision = hitSurface;
|
||||
}
|
||||
vec2.add(this.center, this.center, delta);
|
||||
vec2.add(this.center, this.center, delta);
|
||||
}
|
||||
|
||||
public get boundingBox(): ImmutableBoundingBox {
|
||||
if (!this._boundingBox) {
|
||||
this._boundingBox = (this.object as CirclePhysical).boundingBox;
|
||||
}
|
||||
|
||||
return this._boundingBox;
|
||||
}
|
||||
|
||||
public get gameObject(): this {
|
||||
return this;
|
||||
}
|
||||
|
||||
public distance(target: vec2): number {
|
||||
return this.object.distance(target);
|
||||
}
|
||||
|
||||
public destroy() {
|
||||
if (!this.isDestroyed) {
|
||||
this.isDestroyed = true;
|
||||
this.container.removeObject(this);
|
||||
}
|
||||
}
|
||||
|
||||
public onCollision({ other }: ReactToCollisionCommand) {
|
||||
if (
|
||||
!(other instanceof CharacterPhysical && other.team === this.team) &&
|
||||
this.bounceCount++ === settings.projectileMaxBounceCount
|
||||
) {
|
||||
this.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
public getPropertyUpdates(): PropertyUpdatesForObject {
|
||||
return new PropertyUpdatesForObject(this.id, [
|
||||
new UpdatePropertyCommand('center', this.center, this.velocity),
|
||||
]);
|
||||
}
|
||||
|
||||
private handleStep({ deltaTimeInSeconds }: StepCommand) {
|
||||
super.step(deltaTimeInSeconds);
|
||||
|
||||
if (this.strength <= 0) {
|
||||
this.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
// Curveball: the same planetary gravity that pulls on a free-falling
|
||||
// character bends the shot, so slower (charged) shots arc and can be lobbed
|
||||
// over a planet's horizon. Scale is tiny — near-surface gravity is huge.
|
||||
// This single broadphase is reused for the step below: the gravity radius
|
||||
// (maxGravityDistance) dwarfs one tick's travel, so the set is a superset of
|
||||
// the swept-collision box and the step needn't query the container again.
|
||||
const intersecting = settings.projectileGravityEnabled
|
||||
? this.container.findIntersecting(
|
||||
getBoundingBoxOfCircle(
|
||||
new Circle(this.center, this.object.radius + settings.maxGravityDistance),
|
||||
),
|
||||
)
|
||||
: undefined;
|
||||
|
||||
if (intersecting) {
|
||||
vec2.scaleAndAdd(
|
||||
this.velocity,
|
||||
this.velocity,
|
||||
forceAtPosition(this.center, intersecting),
|
||||
settings.projectileGravityScale * deltaTimeInSeconds,
|
||||
);
|
||||
}
|
||||
|
||||
vec2.copy(this.object.velocity, this.velocity);
|
||||
const { velocity } = this.object.stepManually(deltaTimeInSeconds, intersecting);
|
||||
vec2.copy(this.velocity, velocity);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
import { Circle } from 'shared';
|
||||
import { CirclePhysical } from './circle-physical';
|
||||
|
||||
export class Spring {
|
||||
constructor(
|
||||
private a: CirclePhysical | Circle,
|
||||
private b: CirclePhysical | Circle,
|
||||
private distance: number,
|
||||
private strength: number,
|
||||
) {}
|
||||
|
||||
public step(deltaTimeInSeconds: number) {
|
||||
Spring.step(this.a, this.b, this.distance, this.strength, deltaTimeInSeconds);
|
||||
}
|
||||
|
||||
public static step(
|
||||
a: CirclePhysical | Circle,
|
||||
b: CirclePhysical | Circle,
|
||||
distance: number,
|
||||
strength: number,
|
||||
deltaTimeInSeconds: number,
|
||||
) {
|
||||
const length = vec2.dist(a.center, b.center) - distance;
|
||||
|
||||
const abDirection = vec2.subtract(vec2.create(), b.center, a.center);
|
||||
vec2.normalize(abDirection, abDirection);
|
||||
const force = vec2.scale(abDirection, abDirection, strength * length);
|
||||
if (a instanceof CirclePhysical) {
|
||||
a.applyForce(force, deltaTimeInSeconds);
|
||||
}
|
||||
if (b instanceof CirclePhysical) {
|
||||
vec2.scale(force, force, -1);
|
||||
b.applyForce(force, deltaTimeInSeconds);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
import { clamp01, mix, TunnelBase, id, serializesTo } from 'shared';
|
||||
|
||||
import { ImmutableBoundingBox } from '../physics/bounding-boxes/immutable-bounding-box';
|
||||
import { StaticPhysical } from '../physics/containers/static-physical-object';
|
||||
|
||||
@serializesTo(TunnelBase)
|
||||
export class TunnelPhysical extends TunnelBase implements StaticPhysical {
|
||||
public readonly canCollide = true;
|
||||
public readonly isInverted = true;
|
||||
public readonly canMove = false;
|
||||
|
||||
private _boundingBox?: ImmutableBoundingBox;
|
||||
|
||||
constructor(from: vec2, to: vec2, fromRadius: number, toRadius: number) {
|
||||
super(id(), from, to, fromRadius, toRadius);
|
||||
}
|
||||
|
||||
public distance(target: vec2): number {
|
||||
const toFromDelta = vec2.subtract(vec2.create(), this.to, this.from);
|
||||
const targetFromDelta = vec2.subtract(vec2.create(), target, this.from);
|
||||
|
||||
const h = clamp01(
|
||||
vec2.dot(targetFromDelta, toFromDelta) / vec2.dot(toFromDelta, toFromDelta),
|
||||
);
|
||||
|
||||
return (
|
||||
vec2.distance(targetFromDelta, vec2.scale(vec2.create(), toFromDelta, h)) -
|
||||
mix(this.fromRadius, this.toRadius, h)
|
||||
);
|
||||
}
|
||||
|
||||
public get boundingBox(): ImmutableBoundingBox {
|
||||
if (!this._boundingBox) {
|
||||
const xMin = Math.min(this.from.x - this.fromRadius, this.to.x - this.toRadius);
|
||||
const yMin = Math.min(this.from.y - this.fromRadius, this.to.y - this.toRadius);
|
||||
const xMax = Math.max(this.from.x + this.fromRadius, this.to.x + this.toRadius);
|
||||
const yMax = Math.max(this.from.y + this.fromRadius, this.to.y + this.toRadius);
|
||||
this._boundingBox = new ImmutableBoundingBox(xMin, xMax, yMin, yMax);
|
||||
}
|
||||
|
||||
return this._boundingBox;
|
||||
}
|
||||
|
||||
public get gameObject(): TunnelPhysical {
|
||||
return this;
|
||||
}
|
||||
}
|
||||
8
backend/src/options.ts
Normal file
8
backend/src/options.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
export interface Options {
|
||||
port: number;
|
||||
name: string;
|
||||
playerLimit: number;
|
||||
scoreLimit: number;
|
||||
npcCount: number;
|
||||
seed: number;
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
|
||||
export class BoundingBoxBase {
|
||||
// axis-aligned
|
||||
export abstract class BoundingBoxBase {
|
||||
constructor(
|
||||
protected _xMin: number = 0,
|
||||
protected _xMax: number = 0,
|
||||
|
|
@ -8,6 +9,8 @@ export class BoundingBoxBase {
|
|||
protected _yMax: number = 0,
|
||||
) {}
|
||||
|
||||
[key: number]: number | undefined;
|
||||
|
||||
public get 0(): number {
|
||||
return this._xMin;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,21 +1,25 @@
|
|||
import { Physical } from '../physical';
|
||||
import { DynamicPhysical } from '../physicals/dynamic-physical';
|
||||
import { BoundingBoxBase } from '../bounding-boxes/bounding-box-base';
|
||||
|
||||
export class BoundingBoxList {
|
||||
constructor(private objects: Array<Physical> = []) {}
|
||||
constructor(private objects: Array<DynamicPhysical> = []) {}
|
||||
|
||||
public insert(object: Physical) {
|
||||
public insert(object: DynamicPhysical) {
|
||||
this.objects.push(object);
|
||||
}
|
||||
|
||||
public remove(object: Physical) {
|
||||
public remove(object: DynamicPhysical) {
|
||||
this.objects.splice(
|
||||
this.objects.findIndex((i) => i === object),
|
||||
1,
|
||||
);
|
||||
}
|
||||
|
||||
public findIntersecting(boundingBox: BoundingBoxBase): Array<Physical> {
|
||||
public forEach(func: (object: DynamicPhysical) => unknown) {
|
||||
this.objects.forEach(func);
|
||||
}
|
||||
|
||||
public findIntersecting(boundingBox: BoundingBoxBase): Array<DynamicPhysical> {
|
||||
return this.objects.filter((b) => b.boundingBox.intersects(boundingBox));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,18 @@
|
|||
import { BoundingBoxBase } from '../bounding-boxes/bounding-box-base';
|
||||
import { StaticPhysical } from './static-physical-object';
|
||||
// source: https://github.com/ubilabs/kd-tree-javascript/blob/master/kdTree.js
|
||||
import { StaticPhysical } from '../physicals/static-physical';
|
||||
|
||||
// source: https://github.com/ubilabs/kd-tree-javascript/blob/master/kdTree.js
|
||||
class Node {
|
||||
public left?: Node = null;
|
||||
public right?: Node = null;
|
||||
constructor(public object: StaticPhysical, public parent: Node) {}
|
||||
public left: Node | null = null;
|
||||
public right: Node | null = null;
|
||||
constructor(
|
||||
public object: StaticPhysical,
|
||||
public parent: Node | null,
|
||||
) {}
|
||||
}
|
||||
|
||||
export class BoundingBoxTree {
|
||||
root?: Node;
|
||||
root?: Node | null;
|
||||
|
||||
constructor(objects: Array<StaticPhysical> = []) {
|
||||
this.build(objects);
|
||||
|
|
@ -22,8 +25,8 @@ export class BoundingBoxTree {
|
|||
private buildRecursive(
|
||||
objects: Array<StaticPhysical>,
|
||||
depth: number,
|
||||
parent: Node,
|
||||
): Node {
|
||||
parent: Node | null,
|
||||
): Node | null {
|
||||
if (objects.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -34,7 +37,7 @@ export class BoundingBoxTree {
|
|||
|
||||
const dimension = depth % 4;
|
||||
|
||||
objects.sort((a, b) => a.boundingBox[dimension] - b.boundingBox[dimension]);
|
||||
objects.sort((a, b) => a.boundingBox[dimension]! - b.boundingBox[dimension]!);
|
||||
|
||||
const median = Math.floor(objects.length / 2);
|
||||
|
||||
|
|
@ -54,7 +57,9 @@ export class BoundingBoxTree {
|
|||
const node = new Node(object, insertPosition);
|
||||
const dimension = depth % 4;
|
||||
|
||||
if (object.boundingBox[dimension] < insertPosition.object.boundingBox[dimension]) {
|
||||
if (
|
||||
object.boundingBox[dimension]! < insertPosition.object.boundingBox[dimension]!
|
||||
) {
|
||||
insertPosition.left = node;
|
||||
} else {
|
||||
insertPosition.right = node;
|
||||
|
|
@ -63,14 +68,14 @@ export class BoundingBoxTree {
|
|||
}
|
||||
|
||||
public findIntersecting(boundingBox: BoundingBoxBase): Array<StaticPhysical> {
|
||||
const maybeResults = this.findMaybeIntersecting(boundingBox, this.root, 0);
|
||||
const maybeResults = this.findMaybeIntersecting(boundingBox, this.root!, 0);
|
||||
const results = maybeResults.filter((b) => b.boundingBox.intersects(boundingBox));
|
||||
return results;
|
||||
}
|
||||
|
||||
private findMaybeIntersecting(
|
||||
boundingBox: BoundingBoxBase,
|
||||
node: Node,
|
||||
node: Node | null,
|
||||
depth: number,
|
||||
): Array<StaticPhysical> {
|
||||
if (node === null) {
|
||||
|
|
@ -102,17 +107,17 @@ export class BoundingBoxTree {
|
|||
|
||||
private findParent(
|
||||
object: StaticPhysical,
|
||||
node: Node,
|
||||
node: Node | null | undefined,
|
||||
depth: number,
|
||||
parent: Node,
|
||||
): [Node, number] {
|
||||
if (node === null) {
|
||||
parent: Node | null,
|
||||
): [Node | null, number] {
|
||||
if (!node) {
|
||||
return [parent, depth - 1];
|
||||
}
|
||||
|
||||
const dimension = depth % 4;
|
||||
|
||||
if (object.boundingBox[dimension] < node.object.boundingBox[dimension]) {
|
||||
if (object.boundingBox[dimension]! < node.object.boundingBox[dimension]!) {
|
||||
return this.findParent(object, node.left, depth + 1, node);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,39 +1,30 @@
|
|||
import { GameObject, Id } from 'shared';
|
||||
import { BoundingBoxBase } from '../bounding-boxes/bounding-box-base';
|
||||
|
||||
import { BoundingBoxList } from './bounding-box-list';
|
||||
import { BoundingBoxTree } from './bounding-box-tree';
|
||||
import { Command } from 'shared';
|
||||
import { Physical } from '../physical';
|
||||
import { StaticPhysical } from './static-physical-object';
|
||||
import { Physical } from '../physicals/physical';
|
||||
import { StaticPhysical } from '../physicals/static-physical';
|
||||
import { DynamicPhysical } from '../physicals/dynamic-physical';
|
||||
import { Command, CommandReceiver } from 'shared';
|
||||
|
||||
export class PhysicalContainer {
|
||||
export class PhysicalContainer extends CommandReceiver {
|
||||
private isTreeInitialized = false;
|
||||
private staticBoundingBoxesWaitList: Array<StaticPhysical> = [];
|
||||
private staticBoundingBoxes = new BoundingBoxTree();
|
||||
private dynamicBoundingBoxes = new BoundingBoxList();
|
||||
private objects: Array<Physical> = [];
|
||||
|
||||
protected objects: Map<Id, GameObject> = new Map();
|
||||
private objectsGroupedByAbilities: Map<string, Array<GameObject>> = new Map();
|
||||
protected defaultCommandExecutor(c: Command) {
|
||||
this.objects.forEach((o) => o.handleCommand(c));
|
||||
}
|
||||
|
||||
public initialize() {
|
||||
this.staticBoundingBoxes.build(this.staticBoundingBoxesWaitList);
|
||||
this.isTreeInitialized = true;
|
||||
}
|
||||
|
||||
public addObject(object: Physical) {
|
||||
this.objects.set(object.gameObject.id, object.gameObject);
|
||||
public addObject(physical: Physical) {
|
||||
this.objects.push(physical);
|
||||
|
||||
for (const command of this.objectsGroupedByAbilities.keys()) {
|
||||
if (object.gameObject.reactsToCommand(command)) {
|
||||
this.objectsGroupedByAbilities.get(command).push(object.gameObject);
|
||||
}
|
||||
}
|
||||
|
||||
this.addPhysical(object);
|
||||
}
|
||||
|
||||
public addPhysical(physical: Physical) {
|
||||
if (physical.canMove) {
|
||||
this.dynamicBoundingBoxes.insert(physical);
|
||||
} else {
|
||||
|
|
@ -45,39 +36,13 @@ export class PhysicalContainer {
|
|||
}
|
||||
}
|
||||
|
||||
public removeObject(object: Physical) {
|
||||
this.objects.delete(object.gameObject.id);
|
||||
|
||||
for (const command of this.objectsGroupedByAbilities.keys()) {
|
||||
if (object.gameObject.reactsToCommand(command)) {
|
||||
const array = this.objectsGroupedByAbilities.get(command);
|
||||
array.splice(
|
||||
array.findIndex((i) => i.id == object.gameObject.id),
|
||||
1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public removeObject(object: DynamicPhysical) {
|
||||
this.objects = this.objects.filter((p) => p !== object);
|
||||
this.dynamicBoundingBoxes.remove(object);
|
||||
}
|
||||
|
||||
public sendCommand(e: Command) {
|
||||
if (!this.objectsGroupedByAbilities.has(e.type)) {
|
||||
this.createGroupForCommand(e.type);
|
||||
}
|
||||
|
||||
this.objectsGroupedByAbilities.get(e.type).forEach((o, _) => o.sendCommand(e));
|
||||
}
|
||||
|
||||
private createGroupForCommand(commandType: string) {
|
||||
const objectsReactingToCommand = [];
|
||||
this.objects.forEach((o, _) => {
|
||||
if (o.reactsToCommand(commandType)) {
|
||||
objectsReactingToCommand.push(o);
|
||||
}
|
||||
});
|
||||
|
||||
this.objectsGroupedByAbilities.set(commandType, objectsReactingToCommand);
|
||||
public resetRemoteCalls() {
|
||||
this.objects.forEach((o) => o.gameObject.resetRemoteCalls());
|
||||
}
|
||||
|
||||
public findIntersecting(box: BoundingBoxBase): Array<Physical> {
|
||||
|
|
|
|||
12
backend/src/physics/functions/force-at-position.ts
Normal file
12
backend/src/physics/functions/force-at-position.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
import { PlanetPhysical } from '../../objects/planet-physical';
|
||||
import { Physical } from '../physicals/physical';
|
||||
|
||||
export const forceAtPosition = (position: vec2, objects: Array<Physical>) =>
|
||||
objects
|
||||
.filter((o) => o instanceof PlanetPhysical)
|
||||
.reduce(
|
||||
(sum: vec2, o: Physical) =>
|
||||
vec2.add(sum, sum, (o as PlanetPhysical).getForce(position)),
|
||||
vec2.create(),
|
||||
);
|
||||
11
backend/src/physics/functions/get-bounding-box-of-circle.ts
Normal file
11
backend/src/physics/functions/get-bounding-box-of-circle.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { Circle } from 'shared';
|
||||
import { BoundingBoxBase } from '../bounding-boxes/bounding-box-base';
|
||||
import { ImmutableBoundingBox } from '../bounding-boxes/immutable-bounding-box';
|
||||
|
||||
export const getBoundingBoxOfCircle = (circle: Circle): BoundingBoxBase =>
|
||||
new ImmutableBoundingBox(
|
||||
circle.center.x - circle.radius,
|
||||
circle.center.x + circle.radius,
|
||||
circle.center.y - circle.radius,
|
||||
circle.center.y + circle.radius,
|
||||
);
|
||||
7
backend/src/physics/functions/is-circle-intersecting.ts
Normal file
7
backend/src/physics/functions/is-circle-intersecting.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { Circle, evaluateSdf } from 'shared';
|
||||
import { PhysicalBase } from '../physicals/physical-base';
|
||||
|
||||
export const isCircleIntersecting = (
|
||||
circle: Circle,
|
||||
intersectors: Array<PhysicalBase>,
|
||||
): boolean => evaluateSdf(circle.center, intersectors) < circle.radius;
|
||||
|
|
@ -1,83 +0,0 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
import { rotate90Deg, settings } from 'shared';
|
||||
import { CirclePhysical } from '../objects/circle-physical';
|
||||
import { Physical } from './physical';
|
||||
|
||||
export const moveCircle = (
|
||||
circle: CirclePhysical,
|
||||
delta: vec2,
|
||||
possibleIntersectors: Array<Physical>,
|
||||
): {
|
||||
realDelta: vec2;
|
||||
hitSurface: boolean;
|
||||
normal?: vec2;
|
||||
tangent?: vec2;
|
||||
} => {
|
||||
circle.center = vec2.add(circle.center, circle.center, delta);
|
||||
|
||||
const intersecting = possibleIntersectors.filter(
|
||||
(b) =>
|
||||
b.gameObject !== circle.gameObject && circle.areIntersecting(b) && b.canCollide,
|
||||
);
|
||||
|
||||
if (intersecting.length === 0) {
|
||||
return {
|
||||
realDelta: delta,
|
||||
hitSurface: false,
|
||||
};
|
||||
}
|
||||
|
||||
const points = circle.getPerimeterPoints(settings.hitDetectionCirclePointCount);
|
||||
|
||||
const distancesOfPoints = points
|
||||
.map((point) => ({
|
||||
point,
|
||||
closest: intersecting
|
||||
.map((i) => ({
|
||||
inverted: i.isInverted,
|
||||
distance: i.distance(point),
|
||||
}))
|
||||
.sort((a, b) => a.distance - b.distance)[0],
|
||||
}))
|
||||
.filter((i) => i.closest);
|
||||
|
||||
const distancesOfIntersectingPoints = distancesOfPoints.filter(
|
||||
(d) =>
|
||||
(d.closest.distance > 0 && d.closest.inverted) ||
|
||||
(d.closest.distance < 0 && !d.closest.inverted),
|
||||
);
|
||||
|
||||
if (distancesOfIntersectingPoints.length === 0) {
|
||||
return {
|
||||
realDelta: delta,
|
||||
hitSurface: false,
|
||||
};
|
||||
}
|
||||
|
||||
const deltas = distancesOfIntersectingPoints.map((pointDistance) => {
|
||||
vec2.subtract(pointDistance.point, circle.center, pointDistance.point);
|
||||
vec2.normalize(pointDistance.point, pointDistance.point);
|
||||
vec2.scale(
|
||||
pointDistance.point,
|
||||
pointDistance.point,
|
||||
(pointDistance.closest.inverted ? 1 : -1) * pointDistance.closest.distance,
|
||||
);
|
||||
return pointDistance.point;
|
||||
});
|
||||
|
||||
const approxNormal = deltas.reduce(
|
||||
(sum, current) => vec2.add(sum, sum, current),
|
||||
vec2.create(),
|
||||
);
|
||||
vec2.scale(approxNormal, approxNormal, 1 / deltas.length);
|
||||
|
||||
circle.center = vec2.add(circle.center, circle.center, approxNormal);
|
||||
|
||||
vec2.normalize(approxNormal, approxNormal);
|
||||
return {
|
||||
realDelta: delta,
|
||||
hitSurface: true,
|
||||
normal: approxNormal,
|
||||
tangent: rotate90Deg(approxNormal),
|
||||
};
|
||||
};
|
||||
5
backend/src/physics/physicals/dynamic-physical.ts
Normal file
5
backend/src/physics/physicals/dynamic-physical.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { PhysicalBase } from './physical-base';
|
||||
|
||||
export interface DynamicPhysical extends PhysicalBase {
|
||||
readonly canMove: true;
|
||||
}
|
||||
|
|
@ -1,9 +1,8 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
import { GameObject } from 'shared';
|
||||
import { BoundingBoxBase } from './bounding-boxes/bounding-box-base';
|
||||
import { CommandReceiver, GameObject } from 'shared';
|
||||
import { BoundingBoxBase } from '../bounding-boxes/bounding-box-base';
|
||||
|
||||
export interface Physical {
|
||||
readonly isInverted: boolean;
|
||||
export interface PhysicalBase extends CommandReceiver {
|
||||
readonly canCollide: boolean;
|
||||
readonly canMove: boolean;
|
||||
readonly boundingBox: BoundingBoxBase;
|
||||
4
backend/src/physics/physicals/physical.ts
Normal file
4
backend/src/physics/physicals/physical.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import { DynamicPhysical } from './dynamic-physical';
|
||||
import { StaticPhysical } from './static-physical';
|
||||
|
||||
export type Physical = StaticPhysical | DynamicPhysical;
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { Physical } from '../physical';
|
||||
import { PhysicalBase } from './physical-base';
|
||||
import { ImmutableBoundingBox } from '../bounding-boxes/immutable-bounding-box';
|
||||
|
||||
export interface StaticPhysical extends Physical {
|
||||
export interface StaticPhysical extends PhysicalBase {
|
||||
readonly canMove: false;
|
||||
readonly boundingBox: ImmutableBoundingBox;
|
||||
}
|
||||
414
backend/src/players/npc.ts
Normal file
414
backend/src/players/npc.ts
Normal file
|
|
@ -0,0 +1,414 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
import {
|
||||
PlayerInformation,
|
||||
settings,
|
||||
Circle,
|
||||
Random,
|
||||
MoveActionCommand,
|
||||
CharacterTeam,
|
||||
Id,
|
||||
} from 'shared';
|
||||
import { PhysicalContainer } from '../physics/containers/physical-container';
|
||||
import { PlayerContainer } from './player-container';
|
||||
import { PlayerBase } from './player-base';
|
||||
import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle';
|
||||
import { CharacterPhysical } from '../objects/character-physical';
|
||||
import { PlanetPhysical } from '../objects/planet-physical';
|
||||
import { ProjectilePhysical } from '../objects/projectile-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 {
|
||||
private direction = vec2.fromValues(Random.getRandom() - 0.5, Random.getRandom() - 0.5);
|
||||
private timeSinceLastPlan = 10000;
|
||||
private timeSinceLastShoot = 10000;
|
||||
private isWandering = false;
|
||||
private timeSinceLastWanderingConsideration = 0;
|
||||
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(
|
||||
playerInfo: PlayerInformation,
|
||||
playerContainer: PlayerContainer,
|
||||
objectContainer: PhysicalContainer,
|
||||
team: CharacterTeam,
|
||||
) {
|
||||
super(playerInfo, playerContainer, objectContainer, team);
|
||||
this.createCharacter();
|
||||
this.step(0);
|
||||
}
|
||||
|
||||
private timeUntilRespawn = 0;
|
||||
public step(deltaTimeInSeconds: number) {
|
||||
if (this.character) {
|
||||
this.center = this.character.center;
|
||||
|
||||
if (!this.character.isAlive) {
|
||||
this.sumDeaths++;
|
||||
this.sumKills = this.character.killCount;
|
||||
this.character = null;
|
||||
this.timeUntilRespawn = settings.playerDiedTimeout;
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
if ((this.timeUntilRespawn -= deltaTimeInSeconds) < 0) {
|
||||
this.createCharacter();
|
||||
this.center = this.character!.center;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
(this.timeSinceLastWanderingConsideration += deltaTimeInSeconds) >
|
||||
npcTuning.wanderReconsiderSeconds
|
||||
) {
|
||||
this.timeSinceLastWanderingConsideration = 0;
|
||||
this.isWandering = Random.getRandom() < npcTuning.wanderProbability;
|
||||
}
|
||||
|
||||
if ((this.timeSinceLastPlan += deltaTimeInSeconds) > npcTuning.planIntervalSeconds) {
|
||||
this.timeSinceLastPlan = 0;
|
||||
this.plan();
|
||||
}
|
||||
|
||||
if (
|
||||
(this.timeSinceObserve += deltaTimeInSeconds) > npcTuning.reactionIntervalSeconds
|
||||
) {
|
||||
this.timeSinceObserve = 0;
|
||||
this.nearObjects = this.observe(npcTuning.reactionObserveRadius);
|
||||
}
|
||||
|
||||
this.dodgeCommitRemaining -= deltaTimeInSeconds;
|
||||
this.dodgeCooldownRemaining -= deltaTimeInSeconds;
|
||||
const movement = this.decideMovement(this.nearObjects);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
private plan() {
|
||||
const distanceFromCentre = vec2.length(this.center);
|
||||
if (
|
||||
(!this.isComingBack && distanceFromCentre > settings.worldRadius) ||
|
||||
(this.isComingBack && distanceFromCentre > settings.worldRadius / 2)
|
||||
) {
|
||||
this.isComingBack = true;
|
||||
vec2.negate(this.direction, this.center);
|
||||
return;
|
||||
}
|
||||
this.isComingBack = false;
|
||||
|
||||
const nearObjects = this.observe(npcTuning.planScanRadius);
|
||||
const enemies = this.enemiesByDistance(nearObjects);
|
||||
|
||||
if (enemies.length > 0) {
|
||||
const nearest = enemies[0];
|
||||
const fleeRange =
|
||||
npcTuning.fleeBaseRange * (npcTuning.fleeAggressionFalloff - this.aggression);
|
||||
if (nearest.distance < fleeRange) {
|
||||
vec2.subtract(this.direction, this.center, nearest.character.center);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
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(),
|
||||
Random.getRandomInRange(-npcTuning.wanderTurn, npcTuning.wanderTurn),
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
104
backend/src/players/player-base.ts
Normal file
104
backend/src/players/player-base.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
import {
|
||||
CommandReceiver,
|
||||
Circle,
|
||||
PlayerInformation,
|
||||
CharacterTeam,
|
||||
Random,
|
||||
settings,
|
||||
} from 'shared';
|
||||
import { PhysicalContainer } from '../physics/containers/physical-container';
|
||||
import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle';
|
||||
import { isCircleIntersecting } from '../physics/functions/is-circle-intersecting';
|
||||
import { CharacterPhysical } from '../objects/character-physical';
|
||||
import { PlanetPhysical } from '../objects/planet-physical';
|
||||
import { PlayerContainer } from './player-container';
|
||||
|
||||
export abstract class PlayerBase extends CommandReceiver {
|
||||
public character?: CharacterPhysical | null;
|
||||
public center: vec2 = vec2.create();
|
||||
|
||||
protected sumKills = 0;
|
||||
protected sumDeaths = 0;
|
||||
|
||||
constructor(
|
||||
protected readonly playerInfo: PlayerInformation,
|
||||
protected readonly playerContainer: PlayerContainer,
|
||||
protected readonly objectContainer: PhysicalContainer,
|
||||
public readonly team: CharacterTeam,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
protected createCharacter() {
|
||||
this.character = new CharacterPhysical(
|
||||
this.playerInfo.name.slice(0, 20),
|
||||
this.sumKills,
|
||||
this.sumDeaths,
|
||||
this.team,
|
||||
this.objectContainer,
|
||||
this.findEmptyPositionForPlayer(this.findSpawnCenter()),
|
||||
);
|
||||
|
||||
this.objectContainer.addObject(this.character);
|
||||
}
|
||||
|
||||
public abstract step(deltaTimeInSeconds: number): void;
|
||||
|
||||
private findSpawnCenter(): vec2 {
|
||||
const planets = this.objectContainer
|
||||
.findIntersecting(
|
||||
getBoundingBoxOfCircle(new Circle(vec2.create(), settings.worldRadius * 2)),
|
||||
)
|
||||
.filter((o): o is PlanetPhysical => o instanceof PlanetPhysical);
|
||||
|
||||
const 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 radius = 0;
|
||||
for (;;) {
|
||||
const playerPosition = vec2.fromValues(
|
||||
radius * Math.cos(rotation) + preferredCenter.x,
|
||||
radius * Math.sin(rotation) + preferredCenter.y,
|
||||
);
|
||||
|
||||
const playerBoundingCircle = new Circle(
|
||||
playerPosition,
|
||||
CharacterPhysical.boundRadius,
|
||||
);
|
||||
|
||||
const playerBoundingBox = getBoundingBoxOfCircle(playerBoundingCircle);
|
||||
const possibleIntersectors =
|
||||
this.objectContainer.findIntersecting(playerBoundingBox);
|
||||
if (!isCircleIntersecting(playerBoundingCircle, possibleIntersectors)) {
|
||||
return playerPosition;
|
||||
}
|
||||
|
||||
rotation += Math.PI / 8;
|
||||
radius += 30;
|
||||
}
|
||||
}
|
||||
|
||||
public destroy() {
|
||||
this.character?.onDie();
|
||||
}
|
||||
}
|
||||
102
backend/src/players/player-container.ts
Normal file
102
backend/src/players/player-container.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import { CharacterTeam, PlayerInformation, Random, settings, Command } from 'shared';
|
||||
import { Socket } from 'socket.io';
|
||||
import { PhysicalContainer } from '../physics/containers/physical-container';
|
||||
import { NPC } from './npc';
|
||||
import { Player } from './player';
|
||||
import { PlayerBase } from './player-base';
|
||||
|
||||
export class PlayerContainer {
|
||||
private _players: Array<Player> = [];
|
||||
private _npcs: Array<NPC> = [];
|
||||
|
||||
constructor(
|
||||
private readonly objects: PhysicalContainer,
|
||||
private readonly playerMaxCount: number,
|
||||
private readonly npcMaxCount: number,
|
||||
) {
|
||||
this.createNPCs();
|
||||
}
|
||||
|
||||
public createNPCs() {
|
||||
const newNpcCount = Math.min(
|
||||
this.playerMaxCount - this._players.length - this._npcs.length,
|
||||
this.npcMaxCount - this._npcs.length,
|
||||
);
|
||||
for (let i = 0; i < newNpcCount; i++) {
|
||||
const name = `🤖 ${Random.choose(settings.npcNames)}`;
|
||||
this._npcs.push(
|
||||
new NPC({ name }, this, this.objects, this.getTeamOfNextPlayer(true)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public createPlayer(playerInfo: PlayerInformation, socket: Socket): Player {
|
||||
if (this._players.length === this.playerMaxCount) {
|
||||
throw new Error('Too many players');
|
||||
}
|
||||
|
||||
const team = this.getTeamOfNextPlayer();
|
||||
let npcToReplace = this._npcs.find((n) => n.team === team);
|
||||
if (!npcToReplace) {
|
||||
npcToReplace = this._npcs.find((n) => n.team !== team);
|
||||
}
|
||||
npcToReplace?.destroy();
|
||||
this._npcs = this._npcs.filter((n) => n !== npcToReplace);
|
||||
|
||||
const player = new Player(playerInfo, this, this.objects, team, socket);
|
||||
this._players.push(player);
|
||||
|
||||
return player;
|
||||
}
|
||||
|
||||
public get players(): Array<PlayerBase> {
|
||||
return [...this._players, ...this._npcs];
|
||||
}
|
||||
|
||||
public get count(): number {
|
||||
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) {
|
||||
this.players.forEach((p) => p.step(deltaTimeInSeconds));
|
||||
}
|
||||
|
||||
public stepCommunication(deltaTimeInSeconds: number) {
|
||||
this._players.forEach((p) => p.stepCommunications(deltaTimeInSeconds));
|
||||
}
|
||||
|
||||
public endGame(winner: CharacterTeam) {
|
||||
this._players.forEach((p) => p.onGameEnded(winner));
|
||||
}
|
||||
|
||||
public queueCommandForEachClient(command: Command) {
|
||||
this._players.forEach((p) => p.queueCommandSend(command));
|
||||
}
|
||||
|
||||
public sendQueuedCommands() {
|
||||
this._players.forEach((p) => p.sendQueuedCommandsToClient());
|
||||
}
|
||||
|
||||
private getTeamOfNextPlayer(isNpc = false): CharacterTeam {
|
||||
const players = isNpc ? this.players : this._players;
|
||||
const blueCount = players.filter((p) => p.team === CharacterTeam.blue).length;
|
||||
const redCount = players.filter((p) => p.team === CharacterTeam.red).length;
|
||||
|
||||
if ((blueCount === redCount && Random.getRandom() >= 0.5) || blueCount < redCount) {
|
||||
return CharacterTeam.blue;
|
||||
} else {
|
||||
return CharacterTeam.red;
|
||||
}
|
||||
}
|
||||
|
||||
public deletePlayer(player: Player) {
|
||||
this._players = this._players.filter((p) => p !== player);
|
||||
this.createNPCs();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,133 +1,243 @@
|
|||
import { performance } from 'perf_hooks';
|
||||
import { vec2 } from 'gl-matrix';
|
||||
import {
|
||||
CommandExecutors,
|
||||
CommandReceiver,
|
||||
CreateObjectsCommand,
|
||||
CreatePlayerCommand,
|
||||
DeleteObjectsCommand,
|
||||
MoveActionCommand,
|
||||
serialize,
|
||||
TransportEvents,
|
||||
UpdateObjectsCommand,
|
||||
StepCommand,
|
||||
SetAspectRatioActionCommand,
|
||||
calculateViewArea,
|
||||
settings,
|
||||
PlayerInformation,
|
||||
CharacterTeam,
|
||||
UpdateMinimap,
|
||||
GameObject,
|
||||
Command,
|
||||
MinimapPlayer,
|
||||
RemoteCallsForObject,
|
||||
RemoteCallsForObjects,
|
||||
ServerAnnouncement,
|
||||
PropertyUpdatesForObjects,
|
||||
PropertyUpdatesForObject,
|
||||
PrimaryActionCommand,
|
||||
LeapActionCommand,
|
||||
InputAcknowledgement,
|
||||
} from 'shared';
|
||||
import { getTimeInMilliseconds } from '../helper/get-time-in-milliseconds';
|
||||
import { CharacterPhysical } from '../objects/character-physical';
|
||||
|
||||
import { Socket } from 'socket.io';
|
||||
import { BoundingBox } from '../physics/bounding-boxes/bounding-box';
|
||||
import { PhysicalContainer } from '../physics/containers/physical-container';
|
||||
import { Physical } from '../physics/physical';
|
||||
import { PlayerContainer } from './player-container';
|
||||
import { PlayerBase } from './player-base';
|
||||
|
||||
export class Player extends CommandReceiver {
|
||||
private character: CharacterPhysical;
|
||||
// How often the server pings each client to measure round-trip time.
|
||||
const pingIntervalSeconds = 1;
|
||||
|
||||
export class Player extends PlayerBase {
|
||||
// default, until the clients sends its real value
|
||||
private aspectRatio: number = 16 / 9;
|
||||
private isActive = true;
|
||||
private timeUntilRespawn = 0;
|
||||
private timeSinceLastMessage = 0;
|
||||
private objectsPreviouslyInViewArea: Array<GameObject> = [];
|
||||
private lastInputClientTimeMs = 0;
|
||||
private lastLeapClientTimeMs = 0;
|
||||
|
||||
private objectsPreviouslyInViewArea: Array<Physical> = [];
|
||||
private objectsInViewArea: Array<Physical> = [];
|
||||
|
||||
private pingTime?: number;
|
||||
private _latency?: number;
|
||||
public measureLatency() {
|
||||
this.pingTime = getTimeInMilliseconds();
|
||||
this.socket.emit(TransportEvents.Ping);
|
||||
if (this.isActive) {
|
||||
setTimeout(this.measureLatency.bind(this), 10000);
|
||||
}
|
||||
}
|
||||
|
||||
public get latency(): number | undefined {
|
||||
return this._latency;
|
||||
}
|
||||
// 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 = {
|
||||
[StepCommand.type]: this.sendObjects.bind(this),
|
||||
[SetAspectRatioActionCommand.type]: (v: SetAspectRatioActionCommand) =>
|
||||
(this.aspectRatio = v.aspectRatio),
|
||||
[MoveActionCommand.type]: (c: MoveActionCommand) => this.character.sendCommand(c),
|
||||
[MoveActionCommand.type]: (c: MoveActionCommand) => {
|
||||
// Remember how far into this client's input timeline we've consumed, to
|
||||
// echo back for client-side prediction reconciliation.
|
||||
this.lastInputClientTimeMs = c.clientTimeMs;
|
||||
this.character?.handleMovementAction(c);
|
||||
},
|
||||
[PrimaryActionCommand.type]: (c: PrimaryActionCommand) =>
|
||||
this.character?.shootTowards(c.position, c.charge),
|
||||
[LeapActionCommand.type]: (c: LeapActionCommand) => {
|
||||
// Record receipt (whether or not leap() accepts it): either way its effect
|
||||
// on bodyVelocity is now reflected in the streamed launch momentum, so the
|
||||
// predictor must stop replaying this leap.
|
||||
this.lastLeapClientTimeMs = c.clientTimeMs;
|
||||
this.character?.leap();
|
||||
},
|
||||
};
|
||||
|
||||
constructor(
|
||||
private readonly objects: PhysicalContainer,
|
||||
private readonly socket: SocketIO.Socket,
|
||||
playerInfo: PlayerInformation,
|
||||
playerContainer: PlayerContainer,
|
||||
objectContainer: PhysicalContainer,
|
||||
team: CharacterTeam,
|
||||
private readonly socket: Socket,
|
||||
) {
|
||||
super();
|
||||
this.character = new CharacterPhysical(objects);
|
||||
this.objectsPreviouslyInViewArea.push(this.character);
|
||||
this.objectsInViewArea.push(this.character);
|
||||
super(playerInfo, playerContainer, objectContainer, team);
|
||||
this.createCharacter();
|
||||
this.step(0);
|
||||
|
||||
this.objects.addObject(this.character);
|
||||
|
||||
socket.emit(
|
||||
TransportEvents.ServerToPlayer,
|
||||
serialize(new CreatePlayerCommand(this.character)),
|
||||
);
|
||||
|
||||
socket.on(
|
||||
TransportEvents.Pong,
|
||||
() => (this._latency = getTimeInMilliseconds() - this.pingTime!),
|
||||
);
|
||||
|
||||
this.measureLatency();
|
||||
|
||||
this.sendObjects();
|
||||
// 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;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public sendObjects() {
|
||||
const viewArea = calculateViewArea(this.character.center, this.aspectRatio, 1.5);
|
||||
protected createCharacter() {
|
||||
super.createCharacter();
|
||||
|
||||
this.objectsPreviouslyInViewArea.push(this.character!);
|
||||
this.queueCommandSend(new CreatePlayerCommand(this.character!));
|
||||
}
|
||||
|
||||
private winnerTeam?: CharacterTeam;
|
||||
public onGameEnded(winnerTeam: CharacterTeam) {
|
||||
this.winnerTeam = winnerTeam;
|
||||
}
|
||||
|
||||
public step(deltaTimeInSeconds: number) {
|
||||
if (this.character) {
|
||||
this.center = this.character?.center;
|
||||
|
||||
if (!this.character.isAlive) {
|
||||
this.sumDeaths++;
|
||||
this.sumKills = this.character.killCount;
|
||||
|
||||
this.character = null;
|
||||
this.timeUntilRespawn = settings.playerDiedTimeout;
|
||||
}
|
||||
} else {
|
||||
if ((this.timeUntilRespawn -= deltaTimeInSeconds) < 0) {
|
||||
this.createCharacter();
|
||||
this.center = this.character!.center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleViewAreaUpdate() {
|
||||
const viewArea = calculateViewArea(this.center, this.aspectRatio, 1.2);
|
||||
const bb = new BoundingBox();
|
||||
bb.topLeft = viewArea.topLeft;
|
||||
bb.size = viewArea.size;
|
||||
|
||||
this.objectsInViewArea = this.objects.findIntersecting(bb);
|
||||
const objectsInViewArea = Array.from(
|
||||
new Set(this.objectContainer.findIntersecting(bb).map((o) => o.gameObject)),
|
||||
);
|
||||
|
||||
const newlyIntersecting = this.objectsInViewArea.filter(
|
||||
// The owning character must always be in its own snapshot, regardless of the
|
||||
// view-area query, so the client predictor never loses its authoritative
|
||||
// anchor (the body can ride a fast spinner to the very edge of the box).
|
||||
if (this.character && !objectsInViewArea.includes(this.character)) {
|
||||
objectsInViewArea.push(this.character);
|
||||
}
|
||||
|
||||
const newlyIntersecting = objectsInViewArea.filter(
|
||||
(o) => !this.objectsPreviouslyInViewArea.includes(o),
|
||||
);
|
||||
|
||||
const noLongerIntersecting = this.objectsPreviouslyInViewArea.filter(
|
||||
(o) => !this.objectsInViewArea.includes(o),
|
||||
(o) => !objectsInViewArea.includes(o),
|
||||
);
|
||||
this.objectsPreviouslyInViewArea = this.objectsInViewArea;
|
||||
|
||||
this.objectsPreviouslyInViewArea = objectsInViewArea;
|
||||
|
||||
if (noLongerIntersecting.length > 0) {
|
||||
this.socket.emit(
|
||||
TransportEvents.ServerToPlayer,
|
||||
serialize(
|
||||
new DeleteObjectsCommand([
|
||||
...new Set(noLongerIntersecting.map((p) => p.gameObject.id)),
|
||||
]),
|
||||
),
|
||||
this.queueCommandSend(
|
||||
new DeleteObjectsCommand(noLongerIntersecting.map((g) => g.id)),
|
||||
);
|
||||
}
|
||||
|
||||
if (newlyIntersecting.length > 0) {
|
||||
this.socket.emit(
|
||||
TransportEvents.ServerToPlayer,
|
||||
serialize(
|
||||
new CreateObjectsCommand([
|
||||
...new Set(newlyIntersecting.map((p) => p.gameObject)),
|
||||
]),
|
||||
this.queueCommandSend(new CreateObjectsCommand(newlyIntersecting));
|
||||
}
|
||||
|
||||
this.queueCommandSend(new UpdateMinimap(this.getMinimapPlayers()));
|
||||
|
||||
this.queueCommandSend(
|
||||
new PropertyUpdatesForObjects(
|
||||
this.objectsPreviouslyInViewArea
|
||||
.map((o) => o.getPropertyUpdates())
|
||||
.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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
this.socket.emit(
|
||||
TransportEvents.ServerToPlayer,
|
||||
serialize(
|
||||
new UpdateObjectsCommand([
|
||||
...new Set(
|
||||
this.objectsInViewArea.filter((p) => p.canMove).map((p) => p.gameObject),
|
||||
),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
public destroy() {
|
||||
this.isActive = false;
|
||||
this.character.destroy();
|
||||
this.objects.removeObject(this.character);
|
||||
// Every living player except this one, reported by absolute world position so
|
||||
// the client can plot the whole circular arena on its minimap.
|
||||
private getMinimapPlayers(): Array<MinimapPlayer> {
|
||||
return this.playerContainer.players
|
||||
.filter((p) => p !== this && p.character?.isAlive)
|
||||
.map(
|
||||
(p) =>
|
||||
new MinimapPlayer(p.character!.id, vec2.clone(p.character!.center), p.team),
|
||||
);
|
||||
}
|
||||
|
||||
private commandsToBeSent: Array<Command> = [];
|
||||
public queueCommandSend(command: Command) {
|
||||
this.commandsToBeSent.push(command);
|
||||
}
|
||||
|
||||
public stepCommunications(deltaTime: number) {
|
||||
const remoteCalls = this.objectsPreviouslyInViewArea
|
||||
.map((g) => new RemoteCallsForObject(g.id, g.getRemoteCalls()))
|
||||
.filter((c) => c.calls.length > 0);
|
||||
|
||||
if (remoteCalls.length > 0) {
|
||||
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) {
|
||||
this.handleAnnouncements();
|
||||
this.handleViewAreaUpdate();
|
||||
this.sendQueuedCommandsToClient();
|
||||
this.timeSinceLastMessage = 0;
|
||||
}
|
||||
}
|
||||
|
||||
public sendQueuedCommandsToClient() {
|
||||
this.socket.emit(TransportEvents.ServerToPlayer, serialize(this.commandsToBeSent));
|
||||
this.commandsToBeSent = [];
|
||||
}
|
||||
|
||||
private handleAnnouncements() {
|
||||
let announcement = '';
|
||||
if (this.winnerTeam) {
|
||||
announcement = `Team <span class="${this.winnerTeam}">${this.winnerTeam}</span> won 🎉`;
|
||||
} else if (!this.character) {
|
||||
announcement = `Reviving in ${Math.round(this.timeUntilRespawn)}…`;
|
||||
}
|
||||
|
||||
if (announcement) {
|
||||
this.queueCommandSend(new ServerAnnouncement(announcement));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
18
backend/tsconfig.json
Normal file
18
backend/tsconfig.json
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"skipLibCheck": true,
|
||||
"target": "es6",
|
||||
"esModuleInterop": true,
|
||||
"strict": true,
|
||||
"experimentalDecorators": true,
|
||||
"moduleResolution": "node",
|
||||
"ignoreDeprecations": "6.0",
|
||||
"module": "commonjs",
|
||||
"lib": ["dom", "es2017"],
|
||||
"typeRoots": ["./types", "./node_modules/@types"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "typings"]
|
||||
}
|
||||
1
backend/types/socket.io-msgpack-parser/index.d.ts
vendored
Normal file
1
backend/types/socket.io-msgpack-parser/index.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
declare module 'socket.io-msgpack-parser';
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
const path = require('path');
|
||||
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
|
||||
const nodeExternals = require('webpack-node-externals');
|
||||
const { ESBuildPlugin } = require('esbuild-loader');
|
||||
const TerserJSPlugin = require('terser-webpack-plugin');
|
||||
const webpack = require('webpack');
|
||||
|
||||
const PATHS = {
|
||||
entryPoint: path.resolve(__dirname, 'src/main.ts'),
|
||||
|
|
@ -13,6 +13,7 @@ module.exports = (env, argv) => ({
|
|||
entry: {
|
||||
main: [PATHS.entryPoint],
|
||||
},
|
||||
|
||||
externals: [
|
||||
nodeExternals({
|
||||
allowlist: [/(^shared)/],
|
||||
|
|
@ -31,9 +32,9 @@ module.exports = (env, argv) => ({
|
|||
minimize: argv.mode !== 'development',
|
||||
minimizer: [
|
||||
new TerserJSPlugin({
|
||||
sourceMap: false,
|
||||
test: /\.js$/,
|
||||
exclude: /node_modules/,
|
||||
// The custom serialization protocol keys on class names, so they must
|
||||
// survive minification (see shared/src/serialization).
|
||||
terserOptions: {
|
||||
keep_classnames: true,
|
||||
},
|
||||
|
|
@ -41,7 +42,7 @@ module.exports = (env, argv) => ({
|
|||
],
|
||||
},
|
||||
plugins: [
|
||||
new ESBuildPlugin(),
|
||||
new webpack.BannerPlugin({ banner: '#!/usr/bin/env node', raw: true }),
|
||||
new CleanWebpackPlugin({
|
||||
protectWebpackAssets: false,
|
||||
cleanAfterEveryBuildPatterns: [],
|
||||
|
|
@ -49,22 +50,10 @@ module.exports = (env, argv) => ({
|
|||
],
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.html$/,
|
||||
use: {
|
||||
loader: 'file-loader',
|
||||
query: {
|
||||
outputPath: '/',
|
||||
name: '[name].[ext]',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
test: /\.ts$/,
|
||||
loader: 'esbuild-loader',
|
||||
options: {
|
||||
loader: 'ts',
|
||||
target: 'es2015',
|
||||
use: {
|
||||
loader: 'ts-loader',
|
||||
},
|
||||
exclude: /node_modules/,
|
||||
},
|
||||
|
|
|
|||
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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
6758
frontend/package-lock.json
generated
Normal file
6758
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,46 +1,55 @@
|
|||
{
|
||||
"name": "decla.red-frontend",
|
||||
"private": true,
|
||||
"description": "",
|
||||
"name": "doppler-frontend",
|
||||
"version": "0.0.0",
|
||||
"description": "",
|
||||
"keywords": [],
|
||||
"author": "András Schmelczer <andras@schmelczer.dev> (https://schmelczer.dev/)",
|
||||
"sideEffects": [
|
||||
"*.scss"
|
||||
],
|
||||
"main": "index.js",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "npx webpack --mode production",
|
||||
"initialize": "npm install",
|
||||
"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"
|
||||
},
|
||||
"browserslist": [
|
||||
"defaults"
|
||||
],
|
||||
"devDependencies": {
|
||||
"clean-webpack-plugin": "^3.0.0",
|
||||
"esbuild-loader": "^2.4.0",
|
||||
"html-webpack-inline-source-plugin": "0.0.10",
|
||||
"html-webpack-plugin": "^3.2.0",
|
||||
"image-webpack-loader": "^6.0.0",
|
||||
"mini-css-extract-plugin": "^0.9.0",
|
||||
"optimize-css-assets-webpack-plugin": "^5.0.3",
|
||||
"postcss-loader": "^3.0.0",
|
||||
"raw-loader": "^4.0.1",
|
||||
"resolve-url-loader": "^3.1.1",
|
||||
"sass": "^1.26.3",
|
||||
"sass-loader": "^9.0.2",
|
||||
"svg-url-loader": "^6.0.0",
|
||||
"terser-webpack-plugin": "^4.2.2",
|
||||
"typescript": "^4.0.3",
|
||||
"webpack": "^4.43.0",
|
||||
"webpack-bundle-analyzer": "^3.9.0",
|
||||
"webpack-cli": "^3.3.11",
|
||||
"webpack-dev-server": "^3.10.3"
|
||||
},
|
||||
"postcss": {
|
||||
"plugins": {
|
||||
"autoprefixer": {}
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@plausible-analytics/tracker": "^0.4.5",
|
||||
"autoprefixer": "^10.5.0",
|
||||
"clean-webpack-plugin": "^4.0.0",
|
||||
"copy-webpack-plugin": "^14.0.0",
|
||||
"css-loader": "^7.1.4",
|
||||
"file-loader": "^6.2.0",
|
||||
"gl-matrix": "3.3.0",
|
||||
"html-inline-css-webpack-plugin": "^1.11.2",
|
||||
"html-webpack-plugin": "^5.6.7",
|
||||
"mini-css-extract-plugin": "^2.10.2",
|
||||
"postcss": "^8.5.15",
|
||||
"postcss-loader": "^8.2.1",
|
||||
"resize-observer-polyfill": "^1.5.1",
|
||||
"sass": "^1.100.0",
|
||||
"sass-loader": "^17.0.0",
|
||||
"sdf-2d": "^0.8.0",
|
||||
"shared": "file:../shared",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"socket.io-msgpack-parser": "^3.0.2",
|
||||
"source-map-loader": "^5.0.0",
|
||||
"terser-webpack-plugin": "^5.6.1",
|
||||
"ts-loader": "^9.6.0",
|
||||
"typescript": "^6.0.3",
|
||||
"webpack": "^5.107.2",
|
||||
"webpack-cli": "^7.0.3",
|
||||
"webpack-dev-server": "^5.2.4"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
15
frontend/src/custom.d.ts
vendored
Normal file
15
frontend/src/custom.d.ts
vendored
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
declare module '*.mp3' {
|
||||
const content: string;
|
||||
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 } };
|
||||
|
|
@ -1,21 +1,125 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0,
|
||||
user-scalable=0"
|
||||
/>
|
||||
<meta name="theme-color" content="#b7455e" />
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#b7455e" />
|
||||
<meta property="og:url" content="https://doppler.schmelczer.dev" />
|
||||
<meta property="og:image" content="https://doppler.schmelczer.dev/og-image.png" />
|
||||
<meta property="og:image:width" content="2086" />
|
||||
<meta property="og:image:height" content="940" />
|
||||
<meta
|
||||
property="og:image:alt"
|
||||
content="Dimly lit planets surrounding a text saying 'doppler'"
|
||||
/>
|
||||
<meta name="theme-color" content="#b33951" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Conquer the planets, work as a team, and play with your friends online through this browser game."
|
||||
/>
|
||||
|
||||
<link rel="shortcut icon" href="/favicon.ico" type="image/x-icon" />
|
||||
<link rel="icon" href="/favicon.ico" type="image/x-icon" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="static/apple-touch-icon.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" />
|
||||
|
||||
<title>decla.red</title>
|
||||
</head>
|
||||
<title>doppler</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<noscript>Javascript is required for this website.</noscript>
|
||||
<div id="overlay"></div>
|
||||
<canvas id="main"></canvas>
|
||||
</body>
|
||||
<body>
|
||||
<noscript>Javascript is required for this website.</noscript>
|
||||
|
||||
</html>
|
||||
<canvas></canvas>
|
||||
<div id="overlay"></div>
|
||||
|
||||
<section id="landing-ui">
|
||||
<h1>doppler</h1>
|
||||
<form id="join-game-form">
|
||||
<fieldset class="content">
|
||||
<legend>Join a game</legend>
|
||||
|
||||
<div class="name-group">
|
||||
<input
|
||||
required
|
||||
minlength="1"
|
||||
maxlength="20"
|
||||
placeholder=" "
|
||||
id="name"
|
||||
name="name"
|
||||
type="text"
|
||||
/>
|
||||
<label for="name">Choose a name</label>
|
||||
</div>
|
||||
|
||||
<div id="server-container"></div>
|
||||
<button id="join-game" type="submit">Join</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<div id="settings-container">
|
||||
<section id="settings">
|
||||
<label for="enable-sounds">
|
||||
<input id="enable-sounds" type="checkbox" />
|
||||
<img
|
||||
title="Enable sounds"
|
||||
alt="speaker with sound waves"
|
||||
src="static/volume.svg"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label for="enable-music">
|
||||
<input id="enable-music" type="checkbox" />
|
||||
<img title="Enable music" alt="music note" src="static/music.svg" />
|
||||
</label>
|
||||
|
||||
<label for="enable-vibration">
|
||||
<input id="enable-vibration" type="checkbox" />
|
||||
<img
|
||||
title="Enable vibration on mobile"
|
||||
alt="vibrating mobile"
|
||||
src="static/vibrate.svg"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<img
|
||||
title="Exit from current game"
|
||||
id="logout"
|
||||
alt="logout"
|
||||
src="static/logout.svg"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div id="toggle-settings-container">
|
||||
<img
|
||||
title="Toggle settings"
|
||||
id="toggle-settings"
|
||||
class="icon"
|
||||
alt="toggle-settings"
|
||||
src="static/settings.svg"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<img
|
||||
class="full-screen-controllers"
|
||||
id="minimize"
|
||||
alt="minimize-application"
|
||||
src="static/minimize.svg"
|
||||
/>
|
||||
<img
|
||||
class="full-screen-controllers"
|
||||
id="maximize"
|
||||
alt="maximize-application"
|
||||
src="static/maximize.svg"
|
||||
/>
|
||||
|
||||
<div id="spinner-container">
|
||||
<img id="spinner" alt="waiting" src="static/spinner.svg" />
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -1,20 +1,156 @@
|
|||
import { glMatrix } from 'gl-matrix';
|
||||
import { CharacterBase, LampBase, overrideDeserialization, TunnelBase } from 'shared';
|
||||
import {
|
||||
LampBase,
|
||||
overrideDeserialization,
|
||||
PlanetBase,
|
||||
CharacterBase,
|
||||
ProjectileBase,
|
||||
} from 'shared';
|
||||
import './main.scss';
|
||||
import './scripts/analytics';
|
||||
import '../static/og-image.png';
|
||||
import '../static/favicons/apple-touch-icon.png';
|
||||
import '../static/favicons/favicon-16x16.png';
|
||||
import '../static/favicons/favicon-32x32.png';
|
||||
import '../static/favicons/favicon.ico';
|
||||
import { LandingPageBackground } from './scripts/landing-page-background';
|
||||
import { JoinFormHandler } from './scripts/join-form-handler';
|
||||
import { handleFullScreen } from './scripts/helper/handle-full-screen';
|
||||
import { Game } from './scripts/game';
|
||||
import { CharacterView } from './scripts/objects/character-view';
|
||||
import { LampView } from './scripts/objects/lamp-view';
|
||||
import { TunnelView } from './scripts/objects/tunnel-view';
|
||||
import './styles/main.scss';
|
||||
import ResizeObserver from 'resize-observer-polyfill';
|
||||
import { OptionsHandler } from './scripts/options-handler';
|
||||
import { hide } from './scripts/helper/hide';
|
||||
import { show } from './scripts/helper/show';
|
||||
import { SoundHandler, Sounds } from './scripts/sound-handler';
|
||||
import { VibrationHandler } from './scripts/vibration-handler';
|
||||
import { CharacterView } from './scripts/objects/types/character-view';
|
||||
import { LampView } from './scripts/objects/types/lamp-view';
|
||||
import { PlanetView } from './scripts/objects/types/planet-view';
|
||||
import { ProjectileView } from './scripts/objects/types/projectile-view';
|
||||
|
||||
glMatrix.setMatrixArrayType(Array);
|
||||
|
||||
overrideDeserialization(CharacterBase, CharacterView);
|
||||
overrideDeserialization(TunnelBase, TunnelView);
|
||||
overrideDeserialization(PlanetBase, PlanetView);
|
||||
overrideDeserialization(LampBase, LampView);
|
||||
overrideDeserialization(ProjectileBase, ProjectileView);
|
||||
|
||||
const landingUI = document.querySelector('#landing-ui') as HTMLElement;
|
||||
const nameInput = document.querySelector('#name') as HTMLInputElement;
|
||||
const joinGameForm = document.querySelector('#join-game-form') as HTMLFormElement;
|
||||
const serverContainer = document.querySelector('#server-container') as HTMLElement;
|
||||
const canvas = document.querySelector('canvas') as HTMLCanvasElement;
|
||||
const overlay = document.querySelector('#overlay') as HTMLElement;
|
||||
const settings = document.querySelector('#settings') as HTMLElement;
|
||||
const toggleSettingsButton = document.querySelector(
|
||||
'#toggle-settings-container',
|
||||
) as HTMLElement;
|
||||
const minimize = document.querySelector('#minimize') as HTMLElement;
|
||||
const maximize = document.querySelector('#maximize') as HTMLElement;
|
||||
const logoutButton = document.querySelector('#logout') as HTMLElement;
|
||||
const enableSounds = document.querySelector('#enable-sounds') as HTMLInputElement;
|
||||
const enableMusic = document.querySelector('#enable-music') as HTMLInputElement;
|
||||
const enableVibration = document.querySelector('#enable-vibration') as HTMLInputElement;
|
||||
const spinner = document.querySelector('#spinner-container') as HTMLElement;
|
||||
|
||||
const toggleSettings = () => {
|
||||
settings.className = settings.className === 'open' ? '' : 'open';
|
||||
SoundHandler.play(Sounds.click);
|
||||
};
|
||||
|
||||
const applyServerContainerShadows = () => {
|
||||
const topShadow = 'inset 0 -8px 8px -8px rgba(0, 0, 0, 0.4)';
|
||||
const bottomShadow = 'inset 0 8px 8px -8px rgba(0, 0, 0, 0.4)';
|
||||
|
||||
const { scrollHeight, clientHeight, scrollTop } = serverContainer;
|
||||
if (scrollHeight > clientHeight) {
|
||||
if (scrollTop <= 0) {
|
||||
serverContainer.style.boxShadow = topShadow;
|
||||
} else if (scrollTop + clientHeight >= scrollHeight) {
|
||||
serverContainer.style.boxShadow = bottomShadow;
|
||||
} else {
|
||||
serverContainer.style.boxShadow = topShadow + ',' + bottomShadow;
|
||||
}
|
||||
} else {
|
||||
serverContainer.style.boxShadow = '';
|
||||
}
|
||||
};
|
||||
|
||||
const main = async () => {
|
||||
try {
|
||||
await new Game().start();
|
||||
let game: Game;
|
||||
|
||||
const storedUserName = localStorage?.getItem('userName');
|
||||
if (storedUserName) {
|
||||
nameInput.value = JSON.parse(storedUserName);
|
||||
}
|
||||
|
||||
const firstClickListener = () => {
|
||||
SoundHandler.initialize(
|
||||
() => {
|
||||
enableMusic.checked = true;
|
||||
enableMusic.dispatchEvent(new Event('change'));
|
||||
},
|
||||
() => {
|
||||
enableMusic.checked = false;
|
||||
enableMusic.dispatchEvent(new Event('change'));
|
||||
},
|
||||
);
|
||||
document.removeEventListener('click', firstClickListener);
|
||||
};
|
||||
document.addEventListener('click', firstClickListener);
|
||||
|
||||
if (!VibrationHandler.isVibrationEnabledHeuristics) {
|
||||
hide(document.querySelector("label[for='enable-vibration']") as HTMLElement, true);
|
||||
}
|
||||
|
||||
handleFullScreen(minimize, maximize);
|
||||
toggleSettingsButton.addEventListener('click', toggleSettings);
|
||||
|
||||
new ResizeObserver(applyServerContainerShadows).observe(serverContainer);
|
||||
serverContainer.addEventListener('scroll', applyServerContainerShadows);
|
||||
|
||||
OptionsHandler.initialize({
|
||||
soundsEnabled: enableSounds,
|
||||
vibrationEnabled: enableVibration,
|
||||
musicEnabled: enableMusic,
|
||||
});
|
||||
|
||||
logoutButton.addEventListener('click', () => {
|
||||
game.destroy();
|
||||
toggleSettings();
|
||||
});
|
||||
window.onpopstate = () => game.destroy();
|
||||
|
||||
for (;;) {
|
||||
show(spinner);
|
||||
hide(logoutButton, true);
|
||||
show(landingUI, true, 'flex');
|
||||
|
||||
const background = new LandingPageBackground(canvas);
|
||||
const joinHandler = new JoinFormHandler(joinGameForm, serverContainer);
|
||||
|
||||
await background.renderer;
|
||||
hide(spinner);
|
||||
|
||||
const playerDecision = await joinHandler.getPlayerDecision();
|
||||
|
||||
localStorage?.setItem('userName', JSON.stringify(playerDecision.name));
|
||||
|
||||
if (!history.state) {
|
||||
history.pushState(true, '');
|
||||
}
|
||||
|
||||
hide(landingUI, true);
|
||||
show(spinner);
|
||||
background.destroy();
|
||||
game = new Game(playerDecision, canvas, overlay);
|
||||
const gameOver = game.start();
|
||||
await game.started;
|
||||
hide(spinner);
|
||||
show(logoutButton, true, 'block');
|
||||
await gameOver;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert(e);
|
||||
|
|
|
|||
940
frontend/src/main.scss
Normal file
940
frontend/src/main.scss
Normal file
|
|
@ -0,0 +1,940 @@
|
|||
@use 'styles/vars' as *;
|
||||
@use 'styles/button';
|
||||
@use 'styles/form';
|
||||
@use 'styles/mixins' as *;
|
||||
@use 'styles/settings';
|
||||
|
||||
@import url('https://fonts.googleapis.com/css2?family=Comfortaa:wght@300&family=Open+Sans&display=swap');
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
box-sizing: border-box;
|
||||
color: white;
|
||||
font-family: 'Open Sans', 'Segoe UI Emoji', sans-serif;
|
||||
|
||||
&::selection {
|
||||
color: white;
|
||||
background-color: $accent;
|
||||
}
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: 0.85rem;
|
||||
|
||||
@media (max-width: $breakpoint) {
|
||||
font-size: 0.6rem;
|
||||
}
|
||||
}
|
||||
|
||||
img {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
h1 {
|
||||
|
||||
&,
|
||||
* {
|
||||
font-family: 'Comfortaa', sans-serif;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
font-size: 6rem;
|
||||
text-align: center;
|
||||
padding-bottom: $medium-padding;
|
||||
width: fit-content;
|
||||
margin-inline: auto;
|
||||
background: linear-gradient(90deg, $bright-blue, $bright-red);
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
color: transparent;
|
||||
|
||||
&::selection {
|
||||
color: white;
|
||||
-webkit-text-fill-color: white;
|
||||
background-color: $accent;
|
||||
}
|
||||
|
||||
@media (max-width: $height-breakpoint) {
|
||||
font-size: 4.5rem;
|
||||
}
|
||||
|
||||
@media (max-height: $height-breakpoint) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
canvas {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background: black;
|
||||
}
|
||||
|
||||
body {
|
||||
overflow: hidden;
|
||||
|
||||
#spinner-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
top: 0;
|
||||
|
||||
@include background;
|
||||
|
||||
#spinner {
|
||||
@include square(20vmax);
|
||||
|
||||
@media (max-width: $breakpoint) {
|
||||
@include square(20vmax);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#landing-ui {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#overlay {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
|
||||
overflow: hidden;
|
||||
|
||||
&>* {
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.player-tag {
|
||||
border-radius: 1000px;
|
||||
transition: transform 200ms;
|
||||
|
||||
&.blue {
|
||||
color: $bright-blue;
|
||||
|
||||
.health {
|
||||
background-color: $bright-blue;
|
||||
|
||||
&:before {
|
||||
background-color: $bright-blue;
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.red {
|
||||
color: $bright-red;
|
||||
|
||||
.health {
|
||||
background-color: $bright-red;
|
||||
|
||||
&:before {
|
||||
background-color: $bright-red;
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.health {
|
||||
position: relative;
|
||||
height: 5px;
|
||||
border-radius: 1000px;
|
||||
|
||||
&:before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
height: 5px;
|
||||
width: 50px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 1000px;
|
||||
}
|
||||
}
|
||||
|
||||
.charge {
|
||||
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-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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ownership {
|
||||
font-size: 0;
|
||||
box-shadow: inset 0 0 3px 0px rgba(0, 0, 0, 0.2);
|
||||
|
||||
@include square(50px);
|
||||
border-radius: 1000px;
|
||||
mask-image: url('../static/mask.svg');
|
||||
|
||||
&.contested {
|
||||
animation: contested-pulse 0.7s ease-in-out infinite;
|
||||
}
|
||||
|
||||
&.keystone {
|
||||
@include square(72px);
|
||||
}
|
||||
}
|
||||
|
||||
.falling-point {
|
||||
font-size: 1.2em;
|
||||
font-weight: 700;
|
||||
animation: falling-point 2s ease-out forwards;
|
||||
|
||||
&.blue {
|
||||
color: $bright-blue;
|
||||
}
|
||||
|
||||
&.red {
|
||||
color: $bright-red;
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
opacity: 0.9;
|
||||
|
||||
@include square($large-icon);
|
||||
|
||||
@media (max-width: $breakpoint) {
|
||||
@include square($small-icon);
|
||||
}
|
||||
|
||||
mask-image: url('../static/chevron.svg');
|
||||
mask-size: contain;
|
||||
background-color: $bright-neutral;
|
||||
|
||||
&.blue {
|
||||
background-color: $bright-blue;
|
||||
}
|
||||
|
||||
&.red {
|
||||
background-color: $bright-red;
|
||||
}
|
||||
|
||||
&.neutral {
|
||||
background-color: $bright-neutral;
|
||||
}
|
||||
}
|
||||
|
||||
.joystick {
|
||||
@include square($large-icon * 1.3);
|
||||
background-color: white;
|
||||
box-shadow: inset 0 0 8px 3px rgba(0, 0, 0, 0.33);
|
||||
opacity: 0.35;
|
||||
border-radius: 1000px;
|
||||
|
||||
div {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
background-color: #444;
|
||||
box-shadow: 0 0 8px 3px rgba(0, 0, 0, 0.33);
|
||||
|
||||
@include square($small-icon);
|
||||
border-radius: 1000px;
|
||||
}
|
||||
}
|
||||
|
||||
.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 {
|
||||
top: 25%;
|
||||
transform: translateX(calc(-50% + 50vw)) translateY(-50%);
|
||||
|
||||
font-size: 3rem;
|
||||
@include background;
|
||||
z-index: 1000;
|
||||
padding: $medium-padding;
|
||||
border-radius: 16px;
|
||||
|
||||
&:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.blue {
|
||||
color: $bright-blue;
|
||||
}
|
||||
|
||||
.red {
|
||||
color: $bright-red;
|
||||
}
|
||||
}
|
||||
|
||||
.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 {
|
||||
top: $small-padding;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 50%;
|
||||
$height: 20px;
|
||||
height: $height;
|
||||
|
||||
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 {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translateX(-50%) translateY(-50%);
|
||||
background-color: #fff;
|
||||
height: $height + 8px;
|
||||
width: 3px;
|
||||
border-radius: 1000px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.score {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
z-index: 3;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
color: #fff;
|
||||
text-shadow:
|
||||
0 0 3px rgba(0, 0, 0, 0.95),
|
||||
0 0 6px rgba(0, 0, 0, 0.8);
|
||||
opacity: 0.8;
|
||||
transition:
|
||||
opacity $animation-time,
|
||||
text-shadow $animation-time,
|
||||
font-size $animation-time;
|
||||
}
|
||||
|
||||
.score.blue {
|
||||
right: calc(50% + #{$small-padding});
|
||||
}
|
||||
|
||||
.score.red {
|
||||
left: calc(50% + #{$small-padding});
|
||||
}
|
||||
|
||||
.score.leading {
|
||||
opacity: 1;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.score.blue.leading {
|
||||
text-shadow:
|
||||
0 0 3px rgba(0, 0, 0, 0.95),
|
||||
0 0 8px $bright-blue;
|
||||
}
|
||||
|
||||
.score.red.leading {
|
||||
text-shadow:
|
||||
0 0 3px rgba(0, 0, 0, 0.95),
|
||||
0 0 8px $bright-red;
|
||||
}
|
||||
|
||||
.you-marker {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
margin-top: 4px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
white-space: nowrap;
|
||||
text-shadow: 0 0 4px rgba(0, 0, 0, 0.9);
|
||||
|
||||
&::after {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#server-container {
|
||||
max-height: 30vh;
|
||||
overflow-y: auto;
|
||||
|
||||
&::-webkit-scrollbar-track,
|
||||
&::-webkit-scrollbar {
|
||||
background-color: transparent;
|
||||
width: 3px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background-color: $accent;
|
||||
border-radius: $border-radius;
|
||||
}
|
||||
|
||||
transition: box-shadow $animation-time;
|
||||
}
|
||||
|
||||
.full-screen-controllers {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
box-sizing: content-box;
|
||||
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
|
||||
padding: $medium-padding;
|
||||
@include square($large-icon);
|
||||
|
||||
@media (max-width: $breakpoint) {
|
||||
@include square($small-icon);
|
||||
padding: $small-padding;
|
||||
}
|
||||
|
||||
&:not(:first-child) {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.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);
|
||||
};
|
||||
}
|
||||
20
frontend/src/scripts/commands/command-socket.ts
Normal file
20
frontend/src/scripts/commands/command-socket.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { Command, CommandReceiver, serialize, TransportEvents } from 'shared';
|
||||
import { Socket } from 'socket.io-client';
|
||||
|
||||
export class CommandSocket extends CommandReceiver {
|
||||
constructor(private readonly socket: Socket) {
|
||||
super();
|
||||
}
|
||||
|
||||
private commandQueue: Array<Command> = [];
|
||||
protected defaultCommandExecutor(command: Command) {
|
||||
this.commandQueue.push(command);
|
||||
}
|
||||
|
||||
public sendQueuedCommands() {
|
||||
if (this.commandQueue.length > 0) {
|
||||
this.socket.emit(TransportEvents.PlayerToServer, serialize(this.commandQueue));
|
||||
this.commandQueue = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
import { CommandGenerator, MoveActionCommand } from 'shared';
|
||||
|
||||
export class KeyboardListener extends CommandGenerator {
|
||||
private keysDown: Set<string> = new Set();
|
||||
|
||||
constructor(target: HTMLElement) {
|
||||
super();
|
||||
|
||||
target.addEventListener('keydown', (event: KeyboardEvent) => {
|
||||
const key = this.normalize(event.key);
|
||||
this.keysDown.add(key);
|
||||
this.generateCommands();
|
||||
});
|
||||
|
||||
target.addEventListener('keyup', (event: KeyboardEvent) => {
|
||||
const key = this.normalize(event.key);
|
||||
this.keysDown.delete(key);
|
||||
this.generateCommands();
|
||||
});
|
||||
}
|
||||
|
||||
private generateCommands() {
|
||||
const up = ~~(
|
||||
this.keysDown.has('w') ||
|
||||
this.keysDown.has('arrowup') ||
|
||||
this.keysDown.has(' ')
|
||||
);
|
||||
const down = ~~(this.keysDown.has('s') || this.keysDown.has('arrowdown'));
|
||||
const left = ~~(this.keysDown.has('a') || this.keysDown.has('arrowleft'));
|
||||
const right = ~~(this.keysDown.has('d') || this.keysDown.has('arrowright'));
|
||||
|
||||
const movement = vec2.fromValues(right - left, up - down);
|
||||
if (vec2.squaredLength(movement) > 0) {
|
||||
vec2.normalize(movement, movement);
|
||||
}
|
||||
this.sendCommandToSubcribers(new MoveActionCommand(movement));
|
||||
}
|
||||
|
||||
private normalize(key: string): string {
|
||||
return key.toLowerCase();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
import {
|
||||
CommandGenerator,
|
||||
PrimaryActionCommand,
|
||||
SecondaryActionCommand,
|
||||
TernaryActionCommand,
|
||||
} from 'shared';
|
||||
|
||||
export class MouseListener extends CommandGenerator {
|
||||
constructor(target: HTMLElement) {
|
||||
super();
|
||||
|
||||
target.addEventListener('mousemove', (event: MouseEvent) => {
|
||||
const position = this.positionFromEvent(event);
|
||||
this.sendCommandToSubcribers(new PrimaryActionCommand(position));
|
||||
});
|
||||
|
||||
target.addEventListener('mousedown', (event: MouseEvent) => {
|
||||
const position = this.positionFromEvent(event);
|
||||
|
||||
if (event.button == 0) {
|
||||
this.sendCommandToSubcribers(new SecondaryActionCommand(position));
|
||||
}
|
||||
});
|
||||
|
||||
target.addEventListener('contextmenu', (event: MouseEvent) => {
|
||||
event.preventDefault();
|
||||
const position = this.positionFromEvent(event);
|
||||
this.sendCommandToSubcribers(new TernaryActionCommand(position));
|
||||
});
|
||||
}
|
||||
|
||||
private positionFromEvent(event: MouseEvent): vec2 {
|
||||
return vec2.fromValues(event.clientX, event.clientY);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
import {
|
||||
CommandGenerator,
|
||||
PrimaryActionCommand,
|
||||
SecondaryActionCommand,
|
||||
TernaryActionCommand,
|
||||
MoveActionCommand,
|
||||
} from 'shared';
|
||||
|
||||
export class TouchListener extends CommandGenerator {
|
||||
private previousPosition = vec2.create();
|
||||
|
||||
constructor(target: HTMLElement) {
|
||||
super();
|
||||
|
||||
target.addEventListener('touchstart', (event: TouchEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
const touchCount = event.touches.length;
|
||||
const position = this.positionFromEvent(event);
|
||||
this.previousPosition = position;
|
||||
|
||||
if (touchCount == 1) {
|
||||
this.sendCommandToSubcribers(new PrimaryActionCommand(position));
|
||||
} else if (touchCount == 2) {
|
||||
this.sendCommandToSubcribers(new SecondaryActionCommand(position));
|
||||
} else {
|
||||
this.sendCommandToSubcribers(new TernaryActionCommand(position));
|
||||
}
|
||||
});
|
||||
|
||||
target.addEventListener('touchmove', (event: TouchEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
const position = this.positionFromEvent(event);
|
||||
const movement = vec2.subtract(vec2.create(), position, this.previousPosition);
|
||||
|
||||
if (vec2.squaredLength(movement) > 0) {
|
||||
vec2.normalize(movement, movement);
|
||||
this.sendCommandToSubcribers(new MoveActionCommand(movement));
|
||||
}
|
||||
|
||||
this.previousPosition = position;
|
||||
});
|
||||
|
||||
target.addEventListener('touchend', (event: TouchEvent) => {
|
||||
event.preventDefault();
|
||||
this.sendCommandToSubcribers(new MoveActionCommand(vec2.create()));
|
||||
});
|
||||
}
|
||||
|
||||
private positionFromEvent(event: TouchEvent): vec2 {
|
||||
const touches = Array.prototype.slice.call(event.touches);
|
||||
const center = touches.reduce(
|
||||
(center: vec2, touch: Touch) =>
|
||||
vec2.add(center, center, vec2.fromValues(-touch.clientX, touch.clientY)),
|
||||
vec2.create(),
|
||||
);
|
||||
|
||||
return vec2.scale(center, center, 1 / event.touches.length);
|
||||
}
|
||||
}
|
||||
58
frontend/src/scripts/commands/keyboard-listener.ts
Normal file
58
frontend/src/scripts/commands/keyboard-listener.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
import { CommandGenerator, LeapActionCommand, MoveActionCommand } from 'shared';
|
||||
import { localCharacterPredictor } from '../helper/prediction/local-character-predictor';
|
||||
|
||||
export class KeyboardListener extends CommandGenerator {
|
||||
private keysDown: Set<string> = new Set();
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
addEventListener('keydown', this.keyDownListener);
|
||||
addEventListener('keyup', this.keyUpListener);
|
||||
addEventListener('blur', this.blurListener);
|
||||
}
|
||||
|
||||
private keyDownListener = (event: KeyboardEvent) => {
|
||||
const key = event.key.toLowerCase();
|
||||
// Space leaps (W / ArrowUp already cover walking up). Edge-triggered so a
|
||||
// held key's auto-repeat doesn't spam leaps.
|
||||
if ((key === ' ' || key === 'shift') && !this.keysDown.has(key)) {
|
||||
const clientTimeMs = localCharacterPredictor.recordLeap();
|
||||
this.sendCommandToSubscribers(new LeapActionCommand(clientTimeMs));
|
||||
}
|
||||
this.keysDown.add(key);
|
||||
this.generateCommands();
|
||||
};
|
||||
|
||||
private keyUpListener = (event: KeyboardEvent) => {
|
||||
this.keysDown.delete(event.key.toLowerCase());
|
||||
this.generateCommands();
|
||||
};
|
||||
|
||||
private blurListener = () => {
|
||||
this.keysDown.clear();
|
||||
this.generateCommands();
|
||||
};
|
||||
|
||||
private generateCommands() {
|
||||
const up = ~~(this.keysDown.has('w') || this.keysDown.has('arrowup'));
|
||||
const down = ~~(this.keysDown.has('s') || this.keysDown.has('arrowdown'));
|
||||
const left = ~~(this.keysDown.has('a') || this.keysDown.has('arrowleft'));
|
||||
const right = ~~(this.keysDown.has('d') || this.keysDown.has('arrowright'));
|
||||
|
||||
const movement = vec2.fromValues(right - left, up - down);
|
||||
if (vec2.squaredLength(movement) > 0) {
|
||||
vec2.normalize(movement, movement);
|
||||
}
|
||||
|
||||
const clientTimeMs = localCharacterPredictor.recordInput(movement);
|
||||
this.sendCommandToSubscribers(new MoveActionCommand(movement, clientTimeMs));
|
||||
}
|
||||
|
||||
public destroy() {
|
||||
removeEventListener('keydown', this.keyDownListener);
|
||||
removeEventListener('keyup', this.keyUpListener);
|
||||
removeEventListener('blur', this.blurListener);
|
||||
}
|
||||
}
|
||||
71
frontend/src/scripts/commands/mouse-listener.ts
Normal file
71
frontend/src/scripts/commands/mouse-listener.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
import { CommandGenerator, PrimaryActionCommand, holdDurationToCharge } from 'shared';
|
||||
import { Game } from '../game';
|
||||
import { ChargeIndicator } from '../charge-indicator';
|
||||
import { Pointer } from '../helper/pointer';
|
||||
|
||||
export class MouseListener extends CommandGenerator {
|
||||
// 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();
|
||||
|
||||
target.addEventListener('mousedown', this.mouseDownListener);
|
||||
target.addEventListener('mouseup', this.mouseUpListener);
|
||||
target.addEventListener('mousemove', this.mouseMoveListener);
|
||||
target.addEventListener('contextmenu', this.contextMenuListener);
|
||||
}
|
||||
|
||||
// Only the screen position is stored; it is reprojected to world space each
|
||||
// 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) {
|
||||
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) => {
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
private positionFromEvent(event: MouseEvent): vec2 {
|
||||
return this.game.displayToWorldCoordinates(
|
||||
vec2.fromValues(event.clientX, event.clientY),
|
||||
);
|
||||
}
|
||||
|
||||
public destroy() {
|
||||
ChargeIndicator.end();
|
||||
this.target.removeEventListener('mousedown', this.mouseDownListener);
|
||||
this.target.removeEventListener('mouseup', this.mouseUpListener);
|
||||
this.target.removeEventListener('mousemove', this.mouseMoveListener);
|
||||
this.target.removeEventListener('contextmenu', this.contextMenuListener);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
import { Command, CommandReceiver, serialize, TransportEvents } from 'shared';
|
||||
|
||||
export class CommandReceiverSocket extends CommandReceiver {
|
||||
constructor(private readonly socket: SocketIOClient.Socket) {
|
||||
super();
|
||||
}
|
||||
|
||||
protected defaultCommandExecutor(command: Command) {
|
||||
this.socket.emit(TransportEvents.PlayerToServer, serialize(command));
|
||||
}
|
||||
}
|
||||
273
frontend/src/scripts/commands/touch-listener.ts
Normal file
273
frontend/src/scripts/commands/touch-listener.ts
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
import {
|
||||
CommandGenerator,
|
||||
MoveActionCommand,
|
||||
last,
|
||||
PrimaryActionCommand,
|
||||
LeapActionCommand,
|
||||
holdDurationToCharge,
|
||||
settings,
|
||||
} from 'shared';
|
||||
import { Game } from '../game';
|
||||
import { ChargeIndicator } from '../charge-indicator';
|
||||
import { localCharacterPredictor } from '../helper/prediction/local-character-predictor';
|
||||
|
||||
export class TouchListener extends CommandGenerator {
|
||||
private static readonly deadZone = 8;
|
||||
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 joystickButton: HTMLElement;
|
||||
private isJoystickActive = false;
|
||||
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(
|
||||
private target: HTMLElement,
|
||||
private overlay: HTMLElement,
|
||||
private readonly game: Game,
|
||||
) {
|
||||
super();
|
||||
|
||||
this.joystick = document.createElement('div');
|
||||
this.joystick.className = 'joystick';
|
||||
this.joystickButton = document.createElement('div');
|
||||
this.joystick.appendChild(this.joystickButton);
|
||||
|
||||
this.fireButton = document.createElement('div');
|
||||
this.fireButton.className = 'touch-button fire';
|
||||
this.fireStrengthRing = document.createElement('div');
|
||||
this.fireStrengthRing.className = 'strength-ring';
|
||||
this.fireButton.appendChild(this.fireStrengthRing);
|
||||
this.fireAimLine = document.createElement('div');
|
||||
this.fireAimLine.className = 'aim-line';
|
||||
this.fireButton.appendChild(this.fireAimLine);
|
||||
|
||||
this.fireButton.addEventListener('touchstart', this.fireButtonDownListener);
|
||||
this.fireButton.addEventListener('touchmove', this.fireButtonMoveListener);
|
||||
this.fireButton.addEventListener('touchend', this.fireButtonUpListener);
|
||||
|
||||
this.leapButton = document.createElement('div');
|
||||
this.leapButton.className = 'touch-button leap';
|
||||
this.leapButton.addEventListener('touchstart', this.leapButtonListener);
|
||||
|
||||
this.overlay.appendChild(this.fireButton);
|
||||
this.overlay.appendChild(this.leapButton);
|
||||
|
||||
target.addEventListener('touchstart', this.touchStartListener);
|
||||
target.addEventListener('touchmove', this.touchMoveListener);
|
||||
target.addEventListener('touchend', this.touchEndListener);
|
||||
}
|
||||
|
||||
private touchStartListener = (event: TouchEvent) => {
|
||||
event.preventDefault();
|
||||
if (this.isJoystickActive) {
|
||||
const center = vec2.fromValues(
|
||||
last(event.touches)!.clientX,
|
||||
last(event.touches)!.clientY,
|
||||
);
|
||||
this.sendCommandToSubscribers(
|
||||
new PrimaryActionCommand(this.game.displayToWorldCoordinates(center)),
|
||||
);
|
||||
} else {
|
||||
this.touchStartPosition = vec2.fromValues(
|
||||
event.touches[0].clientX,
|
||||
event.touches[0].clientY,
|
||||
);
|
||||
this.primaryDownAt = performance.now();
|
||||
ChargeIndicator.begin(this.touchStartPosition.x, this.touchStartPosition.y);
|
||||
}
|
||||
};
|
||||
|
||||
private touchMoveListener = (event: TouchEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
const touchPosition = vec2.fromValues(
|
||||
event.touches[0].clientX,
|
||||
event.touches[0].clientY,
|
||||
);
|
||||
|
||||
const delta = vec2.subtract(vec2.create(), touchPosition, this.touchStartPosition);
|
||||
vec2.scale(delta, delta, TouchListener.deltaScaling);
|
||||
const deltaLength = vec2.length(delta);
|
||||
|
||||
if (!this.isJoystickActive && deltaLength > TouchListener.deadZone) {
|
||||
this.isJoystickActive = true;
|
||||
this.primaryDownAt = null;
|
||||
ChargeIndicator.end();
|
||||
this.overlay.appendChild(this.joystick);
|
||||
this.joystickButton.style.transform = `translateX(-50%) translateY(-50%)`;
|
||||
this.joystick.style.transform = `translateX(${this.touchStartPosition.x}px) translateY(${this.touchStartPosition.y}px) translateX(-50%) translateY(-50%)`;
|
||||
}
|
||||
|
||||
const maxLength = 20;
|
||||
vec2.scale(delta, delta, Math.min(1, maxLength / deltaLength));
|
||||
this.joystickButton.style.transform = `translateX(${delta.x}px) translateY(${delta.y}px) translateX(-50%) translateY(-50%)`;
|
||||
|
||||
vec2.set(delta, delta.x, -delta.y);
|
||||
if (deltaLength > TouchListener.deadZone) {
|
||||
const direction = vec2.normalize(delta, delta);
|
||||
this.sendMove(direction);
|
||||
} else {
|
||||
this.sendMove(vec2.create());
|
||||
}
|
||||
};
|
||||
|
||||
private sendMove(direction: vec2) {
|
||||
const clientTimeMs = localCharacterPredictor.recordInput(direction);
|
||||
this.sendCommandToSubscribers(new MoveActionCommand(direction, clientTimeMs));
|
||||
}
|
||||
|
||||
private touchEndListener = (event: TouchEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (!this.isJoystickActive) {
|
||||
ChargeIndicator.end();
|
||||
const charge =
|
||||
this.primaryDownAt === null
|
||||
? 0
|
||||
: holdDurationToCharge((performance.now() - this.primaryDownAt) / 1000);
|
||||
this.primaryDownAt = null;
|
||||
const center = vec2.fromValues(
|
||||
event.changedTouches[0].clientX,
|
||||
event.changedTouches[0].clientY,
|
||||
);
|
||||
this.sendCommandToSubscribers(
|
||||
new PrimaryActionCommand(this.game.displayToWorldCoordinates(center), charge),
|
||||
);
|
||||
} else if (event.touches.length === 0) {
|
||||
this.isJoystickActive = false;
|
||||
this.joystick.parentElement?.removeChild(this.joystick);
|
||||
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() {
|
||||
ChargeIndicator.end();
|
||||
this.target.removeEventListener('touchstart', this.touchStartListener);
|
||||
this.target.removeEventListener('touchmove', this.touchMoveListener);
|
||||
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);
|
||||
}
|
||||
}
|
||||
7
frontend/src/scripts/commands/types/before-destroy.ts
Normal file
7
frontend/src/scripts/commands/types/before-destroy.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { Command } from 'shared';
|
||||
|
||||
export class BeforeDestroyCommand extends Command {
|
||||
public constructor() {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
import { Command } from 'shared';
|
||||
|
||||
export class MoveToCommand extends Command {
|
||||
public constructor(public readonly position: vec2) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
|
@ -2,7 +2,11 @@ import { Renderer } from 'sdf-2d';
|
|||
import { Command } from 'shared';
|
||||
|
||||
export class RenderCommand extends Command {
|
||||
public constructor(public readonly renderer: Renderer) {
|
||||
public constructor(
|
||||
public readonly renderer: Renderer,
|
||||
public readonly overlay: HTMLElement,
|
||||
public readonly shouldChangeLayout: boolean,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
7
frontend/src/scripts/commands/types/step.ts
Normal file
7
frontend/src/scripts/commands/types/step.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { Command } from 'shared';
|
||||
|
||||
export class StepCommand extends Command {
|
||||
public constructor(public readonly deltaTimeInSeconds: number) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
import firebase from 'firebase/app';
|
||||
import 'firebase/firebase-remote-config';
|
||||
|
||||
export abstract class Configuration {
|
||||
private static remoteConfig: firebase.remoteConfig.RemoteConfig;
|
||||
private static initialized = false;
|
||||
|
||||
public static async initialize(): Promise<void> {
|
||||
const firebaseConfig = {
|
||||
apiKey: 'AIzaSyBG85dp-AhaCW-qi_6mu77wDPSipzipIF4',
|
||||
authDomain: 'decla-red.firebaseapp.com',
|
||||
projectId: 'decla-red',
|
||||
appId: '1:635208271441:web:c910843ae7e0549dadda70',
|
||||
};
|
||||
|
||||
firebase.initializeApp(firebaseConfig);
|
||||
|
||||
this.remoteConfig = firebase.remoteConfig();
|
||||
|
||||
this.remoteConfig.settings = {
|
||||
minimumFetchIntervalMillis: 0, // todo: 3600 * 1000,
|
||||
fetchTimeoutMillis: 15 * 1000,
|
||||
} as any;
|
||||
|
||||
await this.remoteConfig.ensureInitialized();
|
||||
await this.remoteConfig.fetchAndActivate();
|
||||
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
public static get servers(): Array<string> {
|
||||
if (!this.initialized) {
|
||||
throw new Error('Configuration should be initialized');
|
||||
}
|
||||
|
||||
return JSON.parse(this.remoteConfig.getValue('online_servers').asString());
|
||||
}
|
||||
}
|
||||
35
frontend/src/scripts/configuration.ts
Normal file
35
frontend/src/scripts/configuration.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
/**
|
||||
* Hardcoded list of game servers the landing page offers to players.
|
||||
*
|
||||
* Each entry is the public origin of a dockerized `doppler-server` instance.
|
||||
* The join screen polls `<origin>/state` (see `serverInformationEndpoint`) and
|
||||
* only shows a server once it responds, so listing an offline origin here is
|
||||
* harmless. Add or remove origins as you deploy more server containers.
|
||||
*/
|
||||
// This origin predates the rebrand; update it once the game server is
|
||||
// redeployed under a doppler subdomain.
|
||||
const productionServers: Array<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 {
|
||||
public static async initialize(): Promise<void> {
|
||||
// Kept async for call-site compatibility; the server list is static now.
|
||||
}
|
||||
|
||||
public static get servers(): Array<string> {
|
||||
return servers;
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,165 +1,361 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
import {
|
||||
Circle,
|
||||
CircleLight,
|
||||
compile,
|
||||
FilteringOptions,
|
||||
Flashlight,
|
||||
InvertedTunnel,
|
||||
Renderer,
|
||||
renderNoise,
|
||||
runAnimation,
|
||||
WrapOptions,
|
||||
} from 'sdf-2d';
|
||||
import {
|
||||
broadcastCommands,
|
||||
deserialize,
|
||||
prettyPrint,
|
||||
serialize,
|
||||
settings,
|
||||
StepCommand,
|
||||
TransportEvents,
|
||||
SetAspectRatioActionCommand,
|
||||
UpdateMinimap,
|
||||
UpdateGameState,
|
||||
GameEndCommand,
|
||||
ServerAnnouncement,
|
||||
GameStartCommand,
|
||||
CommandReceiver,
|
||||
CommandExecutors,
|
||||
Command,
|
||||
settings,
|
||||
InputAcknowledgement,
|
||||
} from 'shared';
|
||||
import { SetAspectRatioActionCommand } from 'shared/src/main';
|
||||
import io from 'socket.io-client';
|
||||
import { KeyboardListener } from './commands/generators/keyboard-listener';
|
||||
import { MouseListener } from './commands/generators/mouse-listener';
|
||||
import { TouchListener } from './commands/generators/touch-listener';
|
||||
import { CommandReceiverSocket } from './commands/receivers/command-receiver-socket';
|
||||
import { RenderCommand } from './commands/types/render';
|
||||
import { Configuration } from './config/configuration';
|
||||
import { DeltaTimeCalculator } from './helper/delta-time-calculator';
|
||||
import { rgb } from './helper/rgb';
|
||||
|
||||
import { io, Socket } from 'socket.io-client';
|
||||
import { KeyboardListener } from './commands/keyboard-listener';
|
||||
import { MouseListener } from './commands/mouse-listener';
|
||||
import { TouchListener } from './commands/touch-listener';
|
||||
import { CommandSocket } from './commands/command-socket';
|
||||
import { PlayerDecision } from './join-form-handler';
|
||||
import { GameObjectContainer } from './objects/game-object-container';
|
||||
import { BlobShape } from './shapes/blob-shape';
|
||||
import parser from 'socket.io-msgpack-parser';
|
||||
import { CharacterShape } from './shapes/character-shape';
|
||||
import { PlanetShape } from './shapes/planet-shape';
|
||||
import { RenderCommand } from './commands/types/render';
|
||||
import { StepCommand } from './commands/types/step';
|
||||
import { serverTimeline } from './helper/server-timeline';
|
||||
import { localCharacterPredictor } 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 {
|
||||
public readonly gameObjects = new GameObjectContainer(this);
|
||||
private readonly canvas: HTMLCanvasElement = document.querySelector(
|
||||
'canvas#main',
|
||||
) as HTMLCanvasElement;
|
||||
private renderer!: Renderer;
|
||||
private socket!: SocketIOClient.Socket;
|
||||
private deltaTimeCalculator = new DeltaTimeCalculator();
|
||||
private overlay: HTMLElement = document.querySelector('#overlay') as HTMLDivElement;
|
||||
export class Game extends CommandReceiver {
|
||||
public gameObjects = new GameObjectContainer(this);
|
||||
public renderer?: Renderer;
|
||||
private socket!: Socket;
|
||||
private isBetweenGames = false;
|
||||
|
||||
private async setupCommunication(): Promise<void> {
|
||||
await Configuration.initialize();
|
||||
public started: Promise<void>;
|
||||
private resolveStarted!: () => unknown;
|
||||
|
||||
this.socket = io(Configuration.servers[0], {
|
||||
private keyboardListener: KeyboardListener;
|
||||
private mouseListener: MouseListener;
|
||||
private touchListener: TouchListener;
|
||||
|
||||
private scoreboard = new Scoreboard();
|
||||
private minimap = new Minimap();
|
||||
private announcementText = document.createElement('h2');
|
||||
private keystoneArrow?: HTMLElement;
|
||||
private socketReceiver!: CommandSocket;
|
||||
private tutorial!: Tutorial;
|
||||
|
||||
constructor(
|
||||
private readonly playerDecision: PlayerDecision,
|
||||
private readonly canvas: HTMLCanvasElement,
|
||||
private readonly overlay: HTMLElement,
|
||||
) {
|
||||
super();
|
||||
this.started = new Promise((r) => (this.resolveStarted = r));
|
||||
this.announcementText.className = 'announcement';
|
||||
|
||||
this.keyboardListener = new KeyboardListener();
|
||||
this.mouseListener = new MouseListener(this.canvas, this);
|
||||
this.touchListener = new TouchListener(this.canvas, this.overlay, this);
|
||||
}
|
||||
|
||||
private initialize() {
|
||||
this.isBetweenGames = true;
|
||||
|
||||
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.overlay.innerHTML = '';
|
||||
this.keystoneArrow = undefined;
|
||||
this.lastMinimap = undefined;
|
||||
this.isEnding = false;
|
||||
this.lastAnnouncementText = '';
|
||||
this.overlay.appendChild(this.scoreboard.element);
|
||||
this.overlay.appendChild(this.minimap.element);
|
||||
this.announcementText.innerText = '';
|
||||
this.timeScaling = 1;
|
||||
this.overlay.appendChild(this.announcementText);
|
||||
this.tutorial = new Tutorial(this.overlay);
|
||||
|
||||
this.socket = io(this.playerDecision.server, {
|
||||
reconnectionDelayMax: 10000,
|
||||
transports: ['websocket'],
|
||||
});
|
||||
forceNew: true,
|
||||
parser,
|
||||
} 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.on(TransportEvents.ServerToPlayer, (serialized: string) => {
|
||||
const command = deserialize(serialized);
|
||||
this.gameObjects.sendCommand(command);
|
||||
this.socket.on('disconnect', () => {
|
||||
if (!this.isBetweenGames) {
|
||||
this.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
this.socket.on(TransportEvents.Ping, () => {
|
||||
this.socket.emit(TransportEvents.Pong);
|
||||
});
|
||||
|
||||
this.socket.emit(TransportEvents.PlayerJoining, null);
|
||||
this.socket.on(TransportEvents.ServerToPlayer, (serializedCommands: string) => {
|
||||
const commands: Array<Command> = deserialize(serializedCommands);
|
||||
commands.forEach((c) => this.handleCommand(c));
|
||||
});
|
||||
|
||||
broadcastCommands(
|
||||
[
|
||||
new KeyboardListener(document.body),
|
||||
new MouseListener(this.canvas),
|
||||
new TouchListener(this.canvas),
|
||||
],
|
||||
[this.gameObjects, new CommandReceiverSocket(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.subscribe(this.socketReceiver);
|
||||
this.keyboardListener.subscribe(this.tutorial);
|
||||
this.mouseListener.clearSubscribers();
|
||||
this.mouseListener.subscribe(this.socketReceiver);
|
||||
this.mouseListener.subscribe(this.tutorial);
|
||||
this.touchListener.clearSubscribers();
|
||||
this.touchListener.subscribe(this.socketReceiver);
|
||||
this.touchListener.subscribe(this.tutorial);
|
||||
|
||||
this.isBetweenGames = false;
|
||||
|
||||
this.socket.emit(TransportEvents.PlayerJoining, this.playerDecision);
|
||||
}
|
||||
|
||||
private async setupRenderer(): Promise<void> {
|
||||
const noiseTexture = await renderNoise([512, 1], 50, 1 / 10);
|
||||
protected defaultCommandExecutor(c: Command) {
|
||||
this.gameObjects.handleCommand(c);
|
||||
}
|
||||
|
||||
this.renderer = await compile(
|
||||
private lastGameState?: UpdateGameState;
|
||||
private isEnding = false;
|
||||
private timeScaling = 1;
|
||||
|
||||
private lastAnnouncementText = '';
|
||||
protected commandExecutors: CommandExecutors = {
|
||||
[ServerAnnouncement.type]: (c: ServerAnnouncement) => {
|
||||
this.lastAnnouncementText = c.text;
|
||||
this.timeSinceLastAnnouncement = 0;
|
||||
},
|
||||
[UpdateGameState.type]: (c: UpdateGameState) => (this.lastGameState = c),
|
||||
[InputAcknowledgement.type]: (c: InputAcknowledgement) =>
|
||||
localCharacterPredictor.acknowledge(
|
||||
c.clientTimeMs,
|
||||
c.bodyVelocity,
|
||||
c.lastLeapClientTimeMs,
|
||||
),
|
||||
[GameEndCommand.type]: () => (this.isEnding = true),
|
||||
[UpdateMinimap.type]: (c: UpdateMinimap) => (this.lastMinimap = c),
|
||||
[GameStartCommand.type]: this.initialize.bind(this),
|
||||
};
|
||||
|
||||
private lastMinimap?: UpdateMinimap;
|
||||
|
||||
public async start(): Promise<void> {
|
||||
const noiseTexture = await renderNoise([256, 256], 2, 1);
|
||||
|
||||
this.initialize();
|
||||
|
||||
await runAnimation(
|
||||
this.canvas,
|
||||
[
|
||||
{
|
||||
...InvertedTunnel.descriptor,
|
||||
shaderCombinationSteps: [0, 2, 6, 16],
|
||||
},
|
||||
{
|
||||
...BlobShape.descriptor,
|
||||
shaderCombinationSteps: [0, 1, 2, 8],
|
||||
},
|
||||
{
|
||||
...Circle.descriptor,
|
||||
shaderCombinationSteps: [0, 2, 16],
|
||||
},
|
||||
PlanetShape.descriptor,
|
||||
CharacterShape.descriptor,
|
||||
{
|
||||
...CircleLight.descriptor,
|
||||
shaderCombinationSteps: [0, 1, 2, 4, 8],
|
||||
},
|
||||
{
|
||||
...Flashlight.descriptor,
|
||||
shaderCombinationSteps: [0, 1],
|
||||
shaderCombinationSteps: [0, 1, 2, 4, 8, 16],
|
||||
},
|
||||
],
|
||||
this.gameLoop.bind(this),
|
||||
{
|
||||
shadowTraceCount: 16,
|
||||
paletteSize: 10,
|
||||
enableStopwatch: true,
|
||||
},
|
||||
);
|
||||
|
||||
this.renderer.setRuntimeSettings({
|
||||
isWorldInverted: true,
|
||||
ambientLight: rgb(0.35, 0.1, 0.45),
|
||||
colorPalette: [
|
||||
rgb(0.4, 1, 0.6),
|
||||
rgb(1, 1, 0),
|
||||
rgb(0.3, 1, 1),
|
||||
rgb(0.3, 1, 1),
|
||||
rgb(0.3, 1, 1),
|
||||
rgb(0.3, 1, 1),
|
||||
rgb(0.3, 1, 1),
|
||||
],
|
||||
enableHighDpiRendering: false,
|
||||
lightCutoffDistance: settings.lightCutoffDistance,
|
||||
textures: {
|
||||
noiseTexture: {
|
||||
source: noiseTexture,
|
||||
overrides: {
|
||||
maxFilter: FilteringOptions.LINEAR,
|
||||
wrapS: WrapOptions.MIRRORED_REPEAT,
|
||||
paletteSize: settings.paletteDim.length,
|
||||
colorPalette: settings.paletteDim,
|
||||
enableHighDpiRendering: true,
|
||||
lightCutoffDistance: settings.lightCutoffDistance,
|
||||
lightOverlapReduction: settings.lightOverlapReduction,
|
||||
textures: {
|
||||
noiseTexture: {
|
||||
source: noiseTexture,
|
||||
overrides: {
|
||||
maxFilter: FilteringOptions.LINEAR,
|
||||
wrapS: WrapOptions.MIRRORED_REPEAT,
|
||||
wrapT: WrapOptions.MIRRORED_REPEAT,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
public async start(): Promise<void> {
|
||||
await Promise.all([this.setupCommunication(), this.setupRenderer()]);
|
||||
requestAnimationFrame(this.gameLoop.bind(this));
|
||||
);
|
||||
this.socket.close();
|
||||
this.overlay.innerHTML = '';
|
||||
this.keyboardListener.destroy();
|
||||
this.mouseListener.destroy();
|
||||
this.touchListener.destroy();
|
||||
}
|
||||
|
||||
public displayToWorldCoordinates(p: vec2): vec2 {
|
||||
return this.renderer.displayToWorldCoordinates(p);
|
||||
return this.renderer?.displayToWorldCoordinates(p) ?? vec2.create();
|
||||
}
|
||||
|
||||
public aspectRatioChanged(aspectRatio: number) {
|
||||
this.socket.emit(
|
||||
TransportEvents.PlayerToServer,
|
||||
serialize(new SetAspectRatioActionCommand(aspectRatio)),
|
||||
);
|
||||
this.socketReceiver.handleCommand(new SetAspectRatioActionCommand(aspectRatio));
|
||||
}
|
||||
|
||||
private gameLoop(time: DOMHighResTimeStamp) {
|
||||
const deltaTime = this.deltaTimeCalculator.getNextDeltaTimeInMilliseconds(time);
|
||||
private isActive = true;
|
||||
public destroy() {
|
||||
this.isActive = false;
|
||||
}
|
||||
|
||||
this.gameObjects.sendCommand(new StepCommand(deltaTime));
|
||||
this.gameObjects.sendCommand(new RenderCommand(this.renderer));
|
||||
this.renderer.renderDrawables();
|
||||
private timeSinceLastAnnouncement = 0;
|
||||
private framesSinceLastLayoutUpdate = 0;
|
||||
private gameLoop(
|
||||
renderer: Renderer,
|
||||
_: DOMHighResTimeStamp,
|
||||
deltaTime: DOMHighResTimeStamp,
|
||||
): boolean {
|
||||
this.resolveStarted();
|
||||
deltaTime /= 1000;
|
||||
|
||||
this.overlay.innerText = prettyPrint(this.renderer.insights);
|
||||
requestAnimationFrame(this.gameLoop.bind(this));
|
||||
// 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;
|
||||
if (++this.framesSinceLastLayoutUpdate > 1) {
|
||||
shouldChangeLayout = true;
|
||||
this.framesSinceLastLayoutUpdate = 0;
|
||||
this.draw();
|
||||
}
|
||||
|
||||
if (
|
||||
(this.timeSinceLastAnnouncement += deltaTime) > settings.announcementVisibleSeconds
|
||||
) {
|
||||
this.lastAnnouncementText = '';
|
||||
}
|
||||
|
||||
if (this.isEnding) {
|
||||
this.timeScaling *= Math.pow(settings.endGameDeltaScaling, deltaTime);
|
||||
deltaTime /= this.timeScaling;
|
||||
}
|
||||
|
||||
this.renderer = renderer;
|
||||
|
||||
this.gameObjects.handleCommand(new StepCommand(deltaTime));
|
||||
this.gameObjects.handleCommand(
|
||||
new RenderCommand(this.renderer, this.overlay, shouldChangeLayout),
|
||||
);
|
||||
|
||||
this.touchListener.update(deltaTime);
|
||||
|
||||
this.tutorial.step(this.gameObjects);
|
||||
|
||||
this.socketReceiver.sendQueuedCommands();
|
||||
|
||||
return this.isActive;
|
||||
}
|
||||
|
||||
private draw() {
|
||||
if (this.lastGameState) {
|
||||
// The local player's team is read off the main character once it exists.
|
||||
this.scoreboard.update(this.lastGameState, this.gameObjects.player?.team);
|
||||
}
|
||||
|
||||
this.minimap.update(
|
||||
this.gameObjects.localPlayerPosition,
|
||||
this.lastMinimap?.players ?? [],
|
||||
);
|
||||
|
||||
this.handleKeystoneArrow();
|
||||
|
||||
this.announcementText.innerHTML = this.lastAnnouncementText;
|
||||
}
|
||||
|
||||
// Points an off-screen chevron toward the keystone "Heart" planet, tinted by
|
||||
// who currently holds it, so the match's focal objective is always findable.
|
||||
private handleKeystoneArrow() {
|
||||
if (!this.renderer) {
|
||||
return;
|
||||
}
|
||||
const keystone = this.gameObjects.planets.find((p) => p.isKeystone);
|
||||
if (!keystone) {
|
||||
if (this.keystoneArrow) {
|
||||
this.keystoneArrow.style.display = 'none';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.keystoneArrow) {
|
||||
this.keystoneArrow = document.createElement('div');
|
||||
this.overlay.appendChild(this.keystoneArrow);
|
||||
}
|
||||
|
||||
const width = this.renderer.canvasSize.x;
|
||||
const height = this.renderer.canvasSize.y;
|
||||
const display = this.renderer.worldToDisplayCoordinates(keystone.center);
|
||||
const margin = 48;
|
||||
const onScreen =
|
||||
display.x >= margin &&
|
||||
display.x <= width - margin &&
|
||||
display.y >= margin &&
|
||||
display.y <= height - margin;
|
||||
|
||||
const control = Math.abs(keystone.ownership - 0.5);
|
||||
const team =
|
||||
control < settings.planetControlThreshold
|
||||
? 'neutral'
|
||||
: keystone.ownership < 0.5
|
||||
? 'blue'
|
||||
: 'red';
|
||||
this.keystoneArrow.className = 'keystone-arrow ' + team;
|
||||
|
||||
if (onScreen) {
|
||||
this.keystoneArrow.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
this.keystoneArrow.style.display = 'block';
|
||||
|
||||
const center = vec2.fromValues(width / 2, height / 2);
|
||||
const dir = vec2.fromValues(display.x - center.x, display.y - center.y);
|
||||
const angle = Math.atan2(dir.y, dir.x);
|
||||
|
||||
const aspectRatio = width / height;
|
||||
const directionRatio = dir.x / dir.y;
|
||||
let deltaX: number, deltaY: number;
|
||||
if (aspectRatio < Math.abs(directionRatio)) {
|
||||
deltaX = (width / 2 - margin) * Math.sign(dir.x);
|
||||
deltaY = deltaX / directionRatio;
|
||||
} else {
|
||||
deltaY = (height / 2 - margin) * Math.sign(dir.y);
|
||||
deltaX = deltaY * directionRatio;
|
||||
}
|
||||
|
||||
const p = vec2.add(center, center, vec2.fromValues(deltaX, deltaY));
|
||||
this.keystoneArrow.style.transform = `translateX(${p.x}px) translateY(${p.y}px) translateX(-50%) translateY(-50%) rotate(${angle + Math.PI / 2}rad)`;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,25 +0,0 @@
|
|||
export class DeltaTimeCalculator {
|
||||
private previousTime: DOMHighResTimeStamp | null = null;
|
||||
|
||||
constructor() {
|
||||
document.addEventListener('visibilitychange', this.handleVisibilityChange.bind(this));
|
||||
}
|
||||
|
||||
public getNextDeltaTimeInMilliseconds(
|
||||
currentTime: DOMHighResTimeStamp,
|
||||
): DOMHighResTimeStamp {
|
||||
if (this.previousTime === null) {
|
||||
this.previousTime = currentTime;
|
||||
}
|
||||
|
||||
const delta = currentTime - this.previousTime;
|
||||
this.previousTime = currentTime;
|
||||
return delta;
|
||||
}
|
||||
|
||||
private handleVisibilityChange() {
|
||||
if (!document.hidden) {
|
||||
this.previousTime = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
52
frontend/src/scripts/helper/handle-full-screen.ts
Normal file
52
frontend/src/scripts/helper/handle-full-screen.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { SoundHandler, Sounds } from '../sound-handler';
|
||||
|
||||
export const handleFullScreen = (
|
||||
minimizeButton: HTMLElement,
|
||||
maximizeButton: HTMLElement,
|
||||
) => {
|
||||
if (!document.fullscreenEnabled) {
|
||||
minimizeButton.style.visibility = 'hidden';
|
||||
maximizeButton.style.visibility = 'hidden';
|
||||
return;
|
||||
}
|
||||
|
||||
const isInFullScreen = (): boolean => document.fullscreenElement !== null;
|
||||
|
||||
const showButtons = () => {
|
||||
minimizeButton.style.visibility = isInFullScreen() ? 'visible' : 'hidden';
|
||||
maximizeButton.style.visibility = isInFullScreen() ? 'hidden' : 'visible';
|
||||
};
|
||||
|
||||
showButtons();
|
||||
|
||||
let currentWindowHeight = innerHeight;
|
||||
|
||||
const followToggle = () => {
|
||||
SoundHandler.play(Sounds.click);
|
||||
showButtons();
|
||||
currentWindowHeight = innerHeight;
|
||||
};
|
||||
|
||||
const triggerToggle = async () => {
|
||||
await (isInFullScreen()
|
||||
? document.exitFullscreen()
|
||||
: document.body.requestFullscreen());
|
||||
followToggle();
|
||||
};
|
||||
|
||||
addEventListener('keydown', (e) => {
|
||||
if (e.key === 'F11') {
|
||||
triggerToggle();
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
addEventListener('resize', () => {
|
||||
if (isInFullScreen() && currentWindowHeight > innerHeight) {
|
||||
followToggle();
|
||||
}
|
||||
});
|
||||
|
||||
maximizeButton.addEventListener('click', triggerToggle);
|
||||
minimizeButton.addEventListener('click', triggerToggle);
|
||||
};
|
||||
7
frontend/src/scripts/helper/hide.ts
Normal file
7
frontend/src/scripts/helper/hide.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
export const hide = (e: HTMLElement, useDisplay = false) => {
|
||||
if (useDisplay) {
|
||||
e.style.display = 'none';
|
||||
} else {
|
||||
e.style.visibility = 'hidden';
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
import { Circle } from 'shared';
|
||||
import { LinearInterpolator } from './linear-interpolator';
|
||||
import { Vec2Interpolator } from './vec2-interpolator';
|
||||
|
||||
export class CircleInterpolator {
|
||||
private center: Vec2Interpolator;
|
||||
private radius: LinearInterpolator;
|
||||
|
||||
constructor(currentValue: Circle) {
|
||||
this.center = new Vec2Interpolator(currentValue.center);
|
||||
this.radius = new LinearInterpolator(currentValue.radius);
|
||||
}
|
||||
|
||||
public addFrame(value: Circle, rateOfChange: Circle) {
|
||||
this.center.addFrame(value.center, rateOfChange.center);
|
||||
this.radius.addFrame(value.radius, rateOfChange.radius);
|
||||
}
|
||||
|
||||
public getValue(deltaTime: number): Circle {
|
||||
return new Circle(this.center.getValue(deltaTime), this.radius.getValue(deltaTime));
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
import { LinearInterpolator } from './linear-interpolator';
|
||||
|
||||
export class Vec2Interpolator {
|
||||
private x: LinearInterpolator;
|
||||
private y: LinearInterpolator;
|
||||
|
||||
constructor(currentValue: vec2) {
|
||||
this.x = new LinearInterpolator(currentValue.x);
|
||||
this.y = new LinearInterpolator(currentValue.y);
|
||||
}
|
||||
|
||||
public addFrame(value: vec2, rateOfChange: vec2) {
|
||||
this.x.addFrame(value.x, rateOfChange.x);
|
||||
this.y.addFrame(value.y, rateOfChange.y);
|
||||
}
|
||||
|
||||
public getValue(deltaTime: number): vec2 {
|
||||
return vec2.fromValues(this.x.getValue(deltaTime), this.y.getValue(deltaTime));
|
||||
}
|
||||
}
|
||||
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();
|
||||
7
frontend/src/scripts/helper/show.ts
Normal file
7
frontend/src/scripts/helper/show.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
export const show = (e: HTMLElement, useDisplay = false, normalDisplayValue?: string) => {
|
||||
if (useDisplay) {
|
||||
e.style.display = normalDisplayValue!;
|
||||
} else {
|
||||
e.style.visibility = 'inherit';
|
||||
}
|
||||
};
|
||||
201
frontend/src/scripts/join-form-handler.ts
Normal file
201
frontend/src/scripts/join-form-handler.ts
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
import { ServerInformation, serverInformationEndpoint, TransportEvents } from 'shared';
|
||||
import { io, Socket } from 'socket.io-client';
|
||||
import { Configuration } from './configuration';
|
||||
import parser from 'socket.io-msgpack-parser';
|
||||
import { SoundHandler, Sounds } from './sound-handler';
|
||||
|
||||
export type PlayerDecision = {
|
||||
name: string;
|
||||
server: string;
|
||||
};
|
||||
|
||||
const pollInterval = 8000;
|
||||
export class JoinFormHandler {
|
||||
private joinButton: HTMLButtonElement;
|
||||
private waitingForDecision: Promise<PlayerDecision>;
|
||||
private resolvePlayerDecision!: (d: PlayerDecision) => void;
|
||||
private pollServersTimer: any;
|
||||
private keyUpListener = (e: KeyboardEvent) => {
|
||||
// KeyboardEvent.key for Return is 'Enter' (capital E); the old lowercase
|
||||
// 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,
|
||||
) {
|
||||
this.joinButton = form.querySelector('button[type="submit"]') as HTMLButtonElement;
|
||||
this.joinButton.disabled = true;
|
||||
this.waitingForDecision = new Promise((r) => (this.resolvePlayerDecision = r));
|
||||
|
||||
new FormData(form);
|
||||
|
||||
addEventListener('keyup', this.keyUpListener);
|
||||
|
||||
form.onsubmit = (e) => {
|
||||
SoundHandler.play(Sounds.click);
|
||||
const result: PlayerDecision = (
|
||||
Array.from((new FormData(form) as any).entries()) as Array<[string, any]>
|
||||
).reduce((result, [name, value]) => {
|
||||
(result as any)[name] = value;
|
||||
return result;
|
||||
}, {}) as any;
|
||||
|
||||
this.resolvePlayerDecision(result);
|
||||
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
this.pollServersTimer = setInterval(this.loadServers.bind(this), pollInterval);
|
||||
this.loadServers();
|
||||
this.waitForFinish();
|
||||
}
|
||||
|
||||
private async waitForFinish() {
|
||||
await this.waitingForDecision;
|
||||
this.destroy();
|
||||
}
|
||||
|
||||
private destroy() {
|
||||
removeEventListener('keyup', this.keyUpListener);
|
||||
clearInterval(this.pollServersTimer);
|
||||
this.servers.forEach((s) => s.destroy());
|
||||
}
|
||||
|
||||
private servers: Array<ServerChooserOption> = [];
|
||||
private async loadServers() {
|
||||
await Configuration.initialize();
|
||||
|
||||
const serverList = Configuration.servers.filter(
|
||||
(u) => !this.servers.find((s) => s.url === u),
|
||||
);
|
||||
|
||||
serverList.map(async (url) => {
|
||||
const controller = new AbortController();
|
||||
const signal = controller.signal;
|
||||
setTimeout(() => controller.abort(), pollInterval * 0.8);
|
||||
|
||||
let response: Response | undefined;
|
||||
try {
|
||||
response = await fetch(url + serverInformationEndpoint, { signal });
|
||||
} catch {
|
||||
// it's okay
|
||||
}
|
||||
|
||||
if (response?.ok) {
|
||||
const content: ServerInformation = await response.json();
|
||||
if (!this.servers.find((s) => s.url === url)) {
|
||||
const server = new ServerChooserOption(
|
||||
content,
|
||||
url,
|
||||
this.removeServer.bind(this),
|
||||
this.servers.length === 0,
|
||||
);
|
||||
this.servers.push(server);
|
||||
this.joinButton.disabled = false;
|
||||
this.container.appendChild(server.element);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private removeServer(server: ServerChooserOption) {
|
||||
this.servers = this.servers.filter((s) => s !== server);
|
||||
if (!this.servers.length) {
|
||||
this.joinButton.disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
public async getPlayerDecision(): Promise<PlayerDecision> {
|
||||
return this.waitingForDecision;
|
||||
}
|
||||
}
|
||||
|
||||
class ServerChooserOption {
|
||||
private divElement = document.createElement('div');
|
||||
private inputElement = document.createElement('input');
|
||||
private labelElement = document.createElement('label');
|
||||
private serverNameElement = document.createElement('span');
|
||||
private completionElement = document.createElement('span');
|
||||
|
||||
private socket: Socket;
|
||||
|
||||
constructor(
|
||||
private content: ServerInformation,
|
||||
public readonly url: string,
|
||||
private onDestroy: (v: ServerChooserOption) => unknown,
|
||||
isFirst: boolean,
|
||||
) {
|
||||
this.inputElement.required = true;
|
||||
this.inputElement.type = 'radio';
|
||||
this.inputElement.id = this.inputElement.value = url;
|
||||
this.inputElement.name = 'server';
|
||||
this.inputElement.checked = isFirst;
|
||||
this.labelElement.htmlFor = url;
|
||||
this.labelElement.onclick = () => SoundHandler.play(Sounds.click);
|
||||
this.divElement.appendChild(this.inputElement);
|
||||
this.divElement.appendChild(this.labelElement);
|
||||
this.labelElement.appendChild(this.serverNameElement);
|
||||
this.labelElement.appendChild(document.createElement('br'));
|
||||
this.labelElement.appendChild(this.completionElement);
|
||||
this.completionElement.className = 'completion';
|
||||
this.setServerInfoLabelText();
|
||||
|
||||
this.socket = io(url, {
|
||||
reconnection: true,
|
||||
reconnectionAttempts: 5,
|
||||
timeout: 4000,
|
||||
parser,
|
||||
} as any);
|
||||
|
||||
this.socket.io.on('reconnect_failed', this.destroy.bind(this));
|
||||
|
||||
this.socket.on('connect', () =>
|
||||
this.socket.emit(TransportEvents.SubscribeForServerInfoUpdates),
|
||||
);
|
||||
this.socket.on(
|
||||
TransportEvents.ServerInfoUpdate,
|
||||
([playerCount, gameState]: [number, number]) => {
|
||||
this.content.playerCount = playerCount;
|
||||
this.content.gameStatePercent = gameState;
|
||||
this.setServerInfoLabelText();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
public destroy() {
|
||||
this.socket.close();
|
||||
this.divElement.parentElement?.removeChild(this.divElement);
|
||||
this.onDestroy(this);
|
||||
}
|
||||
|
||||
private getRoundCompletionText(percent: number): string {
|
||||
const texts = [
|
||||
'Just started',
|
||||
'Just started',
|
||||
'Ongoing',
|
||||
'Halfway through',
|
||||
'Nearly over',
|
||||
'About to finish',
|
||||
'Game is over',
|
||||
];
|
||||
|
||||
return texts[Math.floor((percent / 100) * (texts.length - 1))];
|
||||
}
|
||||
|
||||
private setServerInfoLabelText() {
|
||||
this.serverNameElement.innerText = `${this.content.serverName} - ${this.content.playerCount}/${this.content.playerLimit} players`;
|
||||
this.completionElement.innerText = this.getRoundCompletionText(
|
||||
this.content.gameStatePercent,
|
||||
);
|
||||
}
|
||||
|
||||
public get element(): HTMLElement {
|
||||
return this.divElement;
|
||||
}
|
||||
}
|
||||
174
frontend/src/scripts/landing-page-background.ts
Normal file
174
frontend/src/scripts/landing-page-background.ts
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
import {
|
||||
CircleLight,
|
||||
FilteringOptions,
|
||||
hsl,
|
||||
Renderer,
|
||||
renderNoise,
|
||||
runAnimation,
|
||||
WrapOptions,
|
||||
} from 'sdf-2d';
|
||||
import { settings, rgb, PlanetBase, Random } from 'shared';
|
||||
import { PlanetShape } from './shapes/planet-shape';
|
||||
|
||||
// PlanetShape colours by mixing blue (0) -> red (1). The two backdrop planets
|
||||
// read as the two in-game teams; the red planet is pulled a little off the
|
||||
// pure-red end so it shows as a more muted, less saturated red.
|
||||
const bluePlanet = 0;
|
||||
const redPlanet = 0.85;
|
||||
|
||||
export class LandingPageBackground {
|
||||
private isActive = true;
|
||||
public renderer: Promise<Renderer>;
|
||||
private resolveRenderer!: (r: Renderer) => unknown;
|
||||
|
||||
constructor(canvas: HTMLCanvasElement) {
|
||||
this.start(canvas);
|
||||
this.renderer = new Promise((r) => (this.resolveRenderer = r));
|
||||
}
|
||||
|
||||
private async start(canvas: HTMLCanvasElement): Promise<void> {
|
||||
const noiseTexture = await renderNoise([256, 256], 1.2, 2);
|
||||
|
||||
runAnimation(
|
||||
canvas,
|
||||
[
|
||||
{
|
||||
...PlanetShape.descriptor,
|
||||
shaderCombinationSteps: [0, 1, 2],
|
||||
},
|
||||
{
|
||||
...CircleLight.descriptor,
|
||||
shaderCombinationSteps: [0, 2],
|
||||
},
|
||||
],
|
||||
this.gameLoop.bind(this),
|
||||
{
|
||||
shadowTraceCount: 16,
|
||||
paletteSize: 1,
|
||||
ambientLight: rgb(0, 0, 0),
|
||||
lightCutoffDistance: settings.lightCutoffDistance,
|
||||
textures: {
|
||||
noiseTexture: {
|
||||
source: noiseTexture,
|
||||
overrides: {
|
||||
maxFilter: FilteringOptions.LINEAR,
|
||||
wrapS: WrapOptions.MIRRORED_REPEAT,
|
||||
wrapT: WrapOptions.MIRRORED_REPEAT,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private gameLoop(renderer: Renderer, time: DOMHighResTimeStamp, _: number): boolean {
|
||||
Random.seed = 42;
|
||||
|
||||
this.resolveRenderer(renderer);
|
||||
|
||||
renderer.setViewArea(vec2.fromValues(0, renderer.canvasSize.y), renderer.canvasSize);
|
||||
|
||||
const topPlanetPosition = vec2.fromValues(
|
||||
0.7 * renderer.canvasSize.x,
|
||||
0.7 * renderer.canvasSize.y,
|
||||
);
|
||||
|
||||
const topPlanet = new PlanetShape(
|
||||
PlanetBase.createPlanetVertices(
|
||||
topPlanetPosition,
|
||||
Random.getRandomInRange(150, 400),
|
||||
Random.getRandomInRange(150, 400),
|
||||
Random.getRandomInRange(10, 20),
|
||||
),
|
||||
redPlanet,
|
||||
);
|
||||
|
||||
// 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(
|
||||
0.3 * renderer.canvasSize.x,
|
||||
0.3 * renderer.canvasSize.y,
|
||||
);
|
||||
|
||||
const bottomPlanet = new PlanetShape(
|
||||
PlanetBase.createPlanetVertices(
|
||||
bottomPlanetPosition,
|
||||
Random.getRandomInRange(150, 800),
|
||||
Random.getRandomInRange(150, 400),
|
||||
Random.getRandomInRange(10, 40),
|
||||
),
|
||||
bluePlanet,
|
||||
);
|
||||
|
||||
bottomPlanet.randomOffset = Random.getRandom();
|
||||
bottomPlanet.rotation = (time / 1000) * -0.06;
|
||||
|
||||
const planetDistance = vec2.subtract(
|
||||
vec2.create(),
|
||||
topPlanetPosition,
|
||||
bottomPlanetPosition,
|
||||
);
|
||||
const planetDistanceLength = vec2.length(planetDistance);
|
||||
const planetDirection = vec2.normalize(planetDistance, planetDistance);
|
||||
const planetAngle = Math.atan2(planetDirection.y, planetDirection.x);
|
||||
|
||||
renderer.addDrawable(topPlanet);
|
||||
renderer.addDrawable(bottomPlanet);
|
||||
renderer.addDrawable(
|
||||
new CircleLight(
|
||||
this.calculateLightPosition(
|
||||
renderer,
|
||||
planetAngle,
|
||||
planetDistanceLength * 1.2,
|
||||
-time / 3000,
|
||||
),
|
||||
hsl(25, 75, 60),
|
||||
0.75,
|
||||
),
|
||||
);
|
||||
|
||||
renderer.addDrawable(
|
||||
new CircleLight(
|
||||
this.calculateLightPosition(
|
||||
renderer,
|
||||
planetAngle,
|
||||
planetDistanceLength * 1.2,
|
||||
time / 2000 + Math.PI,
|
||||
),
|
||||
hsl(249, 79, 70),
|
||||
0.25,
|
||||
),
|
||||
);
|
||||
|
||||
return this.isActive;
|
||||
}
|
||||
|
||||
private calculateLightPosition(
|
||||
renderer: Renderer,
|
||||
angle: number,
|
||||
length: number,
|
||||
t: number,
|
||||
): vec2 {
|
||||
const lightPosition = vec2.fromValues(
|
||||
length * Math.sin(t),
|
||||
length * Math.sin(t) * Math.cos(t),
|
||||
);
|
||||
|
||||
const canvasCenter = vec2.scale(vec2.create(), renderer.canvasSize, 0.5);
|
||||
|
||||
vec2.add(lightPosition, lightPosition, canvasCenter);
|
||||
vec2.rotate(lightPosition, lightPosition, canvasCenter, angle);
|
||||
return lightPosition;
|
||||
}
|
||||
|
||||
public destroy() {
|
||||
this.isActive = false;
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
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