Compare commits

...
Sign in to create a new pull request.

154 commits
wip ... main

Author SHA1 Message Date
5d854440ec Merge pull request 'Modernise & make fun' (#3) from asch/modernise into main
All checks were successful
Build & deploy / Build & publish server image (push) Successful in 1m26s
Build & deploy / Build & deploy website (push) Successful in 1m49s
Reviewed-on: https://home.schmelczer.dev/git/git/andras/decla-red/pulls/3
2026-06-21 10:43:50 +01:00
614db16e41 Fix lint
All checks were successful
Build & deploy / Build & publish server image (pull_request) Has been skipped
Build & deploy / Build & deploy website (pull_request) Successful in 1m22s
2026-06-21 10:36:15 +01:00
f19ada7a1c Fix
Some checks failed
Build & deploy / Build & publish server image (pull_request) Has been skipped
Build & deploy / Build & deploy website (pull_request) Failing after 2m20s
2026-06-20 11:47:06 +01:00
d9b80b92ca ai
Some checks failed
Build & deploy / Build & publish server image (pull_request) Has been skipped
Build & deploy / Build & deploy website (pull_request) Failing after 4m40s
2026-06-20 11:30:49 +01:00
b6db7e8dc7 Add tests 2026-06-16 07:54:06 +01:00
d0265ad90e Fix ci
Some checks failed
Build & deploy / Build & publish server image (pull_request) Has been skipped
Build & deploy / Build & deploy website (pull_request) Failing after 1m23s
2026-06-15 21:46:03 +01:00
73f5f45322 Add minimap
Some checks failed
Build & deploy / Build & publish server image (pull_request) Has been skipped
Build & deploy / Build & deploy website (pull_request) Failing after 52s
2026-06-15 08:10:54 +01:00
fc9df09ee1 More fun 2026-06-14 20:58:42 +01:00
a1fb6755c7 Fix net code 2026-06-14 15:01:36 +01:00
1f10b9c750 Rebrand 2026-06-12 21:46:47 +01:00
ec579650d7 Even more improvements 2026-06-11 21:43:49 +01:00
e3c44f775b More improvements 2026-06-11 21:33:08 +01:00
3848e460cd Improve UI 2026-06-11 21:02:06 +01:00
793d9a81e8 Improve gameplay 2026-06-11 20:26:44 +01:00
e50cd2a79a Merge pull request 'Adopt shared ci-actions for publishing' (#2) from ci/shared-actions into main
All checks were successful
Build & deploy / Build & deploy website (push) Successful in 1m9s
Build & deploy / Build & publish server image (push) Successful in 1m38s
Reviewed-on: https://home.schmelczer.dev/git/git/andras/decla-red/pulls/2
2026-06-06 15:19:13 +01:00
10d4f73562 Adopt shared ci-actions for publishing
All checks were successful
Build & deploy / Build & publish server image (pull_request) Has been skipped
Build & deploy / Build & deploy website (pull_request) Successful in 1m6s
Replace inline publish step(s) with the canonical shell-only composite
actions in andras/ci-actions. Switches the publish jobs to the registered
'docker' runner.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 15:00:38 +01:00
7412bc8af5 Fix dev 2026-06-04 17:46:42 +01:00
913abb7642 Bump dependencies 2026-06-04 17:46:28 +01:00
52ca1b8844 Introduce plausible 2026-06-04 17:20:56 +01:00
018df228df Rename start to dev 2026-06-04 17:19:10 +01:00
7a285549b5 Remove devcontainer 2026-06-04 17:18:08 +01:00
a5d1ed6e29 Fix CI
All checks were successful
Build & deploy / Build & deploy website (push) Successful in 2m24s
Build & deploy / Build & publish server image (push) Successful in 2m56s
2026-06-03 22:45:33 +01:00
0ed480777e Deploy
Some checks failed
Build & deploy / Build & publish server image (push) Failing after 17s
Build & deploy / Build & deploy website (push) Successful in 1m28s
2026-06-03 21:03:13 +01:00
379ee14739
Fix delta time calculation 2023-07-15 13:29:06 +01:00
bde0ad8f25
Update gitignore 2022-09-20 13:39:12 +02:00
4e231821c7
Fix typo 2022-09-20 13:39:01 +02:00
schmelczerandras
00d8e7ae81 Add readme 2020-11-17 10:42:49 +01:00
schmelczerandras
e6376ad059 Fix linting 2020-11-17 10:13:31 +01:00
schmelczerandras
e5e928d99a Move deployment folder 2020-11-17 10:13:19 +01:00
schmelczerandras
561a66c196 Refactor frontend 2020-11-09 12:24:40 +01:00
schmelczerandras
a4bab29d84 Fix issues 2020-11-09 10:33:20 +01:00
Schmelczer András
7cf33b9f1a Refactor backend to use commands 2020-11-06 21:10:08 +01:00
Schmelczer András
503c99cb1f Refactor frontend to use commands 2020-11-06 19:56:09 +01:00
András Schmelczer
be26ab422c
Update README.md 2020-11-05 21:05:04 +01:00
schmelczerandras
f0fc6135b4 Fix missing sound 2020-11-05 21:04:06 +01:00
schmelczerandras
42e4de28b5 Fix issues 2020-11-05 19:27:27 +01:00
schmelczerandras
57d7009342 Refactor 2020-11-04 22:05:21 +01:00
schmelczerandras
b774357807 Fix issues 2020-11-04 18:54:57 +01:00
schmelczerandras
99cdb62928 Refactor 2020-11-04 16:01:17 +01:00
schmelczerandras
1ce961d6c2 Fix some issues 2020-11-03 23:40:14 +01:00
schmelczerandras
c0a588fb73 Fix typo 2020-11-03 23:19:20 +01:00
schmelczerandras
27df7e628c Update sdf2d 2020-11-03 23:19:04 +01:00
schmelczerandras
fd26ed7d59 Fix typo 2020-11-03 23:17:17 +01:00
schmelczerandras
b48bf076ad Fix vibration 2020-11-03 23:14:29 +01:00
schmelczerandras
f7420777f0 Fix touch shooting and typo 2020-11-02 11:12:03 +01:00
schmelczerandras
1c4b7a3483 Disable join when there are no servers 2020-10-30 20:47:55 +01:00
schmelczerandras
45b6588701 Fix vibration on iphone 2020-10-28 16:34:11 +01:00
schmelczerandras
7162dc69b9 Fix fullscreen - f11 2020-10-28 14:39:24 +01:00
schmelczerandras
db2c4579b6 Fix ambient not playing 2020-10-28 13:55:56 +01:00
schmelczerandras
d746854307 Tweak settings & fix bug 2020-10-27 15:01:56 +01:00
schmelczerandras
99d923c221 Update sdf-2d 2020-10-27 11:11:21 +01:00
schmelczerandras
c9eae3adeb Improve movement 2020-10-27 10:32:37 +01:00
schmelczerandras
7e188c2b9a Saturate colors 2020-10-26 16:09:28 +01:00
schmelczerandras
3dd3e21bb8 Fix showing win text 2020-10-26 16:09:16 +01:00
schmelczerandras
271d4ba171 Fix npcs 2020-10-26 16:09:05 +01:00
schmelczerandras
4dd90b9860 Fix vibration bug 2020-10-26 16:08:47 +01:00
schmelczerandras
b8ef90c100 Optimize performance 2020-10-26 14:18:36 +01:00
schmelczerandras
e4cd3284b7 Release server 2020-10-25 17:56:35 +01:00
schmelczerandras
efa838a2ad Add npcs 2020-10-25 17:54:45 +01:00
schmelczerandras
a66fa63b4b Change default options 2020-10-25 10:34:10 +01:00
schmelczerandras
0e22f9b33d Put planet in center of map 2020-10-25 10:33:57 +01:00
schmelczerandras
e7f9007b23 Minor changes and add favicon 2020-10-25 09:12:23 +01:00
schmelczerandras
34dae300da Change gameplay 2020-10-24 22:24:27 +02:00
schmelczerandras
d79900e3ea Add sounds 2020-10-24 13:26:03 +02:00
schmelczerandras
f6f54483db Add spinner and fix og-image 2020-10-24 11:02:44 +02:00
schmelczerandras
b55e927a34 Add player container 2020-10-24 10:01:54 +02:00
schmelczerandras
1cd8f5fbe6 Add KD stats and joystick deadzone 2020-10-23 22:10:39 +02:00
schmelczerandras
c74b2f14a0 Optimize css 2020-10-23 18:35:18 +02:00
schmelczerandras
8b49b44ebf Add touch joystick movement 2020-10-23 18:06:56 +02:00
schmelczerandras
23fa7646d6 Update gameplay 2020-10-23 15:19:04 +02:00
schmelczerandras
49c410d8ff Update build 2020-10-23 15:16:52 +02:00
schmelczerandras
ffc89e5590 Update DO config 2020-10-23 13:23:29 +02:00
schmelczerandras
4a520d52f8 Update colors 2020-10-22 23:47:56 +02:00
schmelczerandras
938e7f3c36 Fix docker build 2020-10-22 22:22:05 +02:00
schmelczerandras
0058f40f1f Add og-image 2020-10-22 22:11:41 +02:00
schmelczerandras
1d1bc6655c Improve gameplay and design 2020-10-22 21:50:39 +02:00
schmelczerandras
af616042f3 Fix build issues 2020-10-22 21:49:39 +02:00
schmelczerandras
dd21c20b3a Improve world creation 2020-10-22 21:49:18 +02:00
schmelczerandras
dee72440bb Fix docker build 2020-10-22 21:46:40 +02:00
schmelczerandras
6694e353ad Fix mismatching function names 2020-10-22 16:58:10 +02:00
schmelczerandras
f1a2131540 Upgrade webpack config and inline svg-s 2020-10-22 16:39:04 +02:00
schmelczerandras
81a8834c4d Improve settings 2020-10-22 10:53:31 +02:00
schmelczerandras
2509199abc Add landing page player count updates 2020-10-21 20:27:03 +02:00
schmelczerandras
c844e6c52b FIx cross-browser incompatibility 2020-10-21 20:26:28 +02:00
schmelczerandras
fd64100dc9 Add insights 2020-10-21 10:32:19 +02:00
schmelczerandras
eb7240b40d Add vibration 2020-10-21 10:32:11 +02:00
schmelczerandras
91079c0420 Fix docker build 2020-10-20 10:00:28 +02:00
schmelczerandras
909b69392d Remove console logs 2020-10-20 10:00:20 +02:00
schmelczerandras
021cac655c Fix built assets 2020-10-20 09:24:00 +02:00
schmelczerandras
7c76b16d13 Improve gameplay 2020-10-20 08:56:38 +02:00
schmelczerandras
e02a5b264c Change ts compile error 2020-10-18 10:36:16 +02:00
schmelczerandras
ec0520698e Fix build 2020-10-17 19:06:41 +02:00
schmelczerandras
e83c58e1a5 Add better object updates 2020-10-17 18:35:09 +02:00
schmelczerandras
fd80a299b6 Fix monorepo build 2020-10-17 17:24:24 +02:00
schmelczerandras
2d67b7d939 Fix failing build 2020-10-17 10:18:58 +02:00
schmelczerandras
f7e7522379 Make server script work 2020-10-16 23:38:56 +02:00
schmelczerandras
e2129bbb26 Add server screen 2020-10-16 23:26:03 +02:00
schmelczerandras
89fafeafd3 Make physics more fun 2020-10-16 11:48:52 +02:00
schmelczerandras
155e360c8e Add source map loader to frontend 2020-10-16 11:16:15 +02:00
schmelczerandras
cd65cb5b6c Fix player spawn overlap 2020-10-16 11:15:53 +02:00
schmelczerandras
ba4dd5c470 Fix bugs and add improvements 2020-10-13 12:47:25 +02:00
schmelczerandras
555be9d602 Add projectile damage 2020-10-12 21:46:23 +02:00
schmelczerandras
f9f6825776 Fix physics 2020-10-12 20:49:17 +02:00
schmelczerandras
37954e2ef1 Improve multiplayer 2020-10-10 10:55:08 +02:00
schmelczerandras
220b20476f WIP ts 2020-10-08 10:27:07 +02:00
schmelczerandras
b498090d7b Test gcloud 2020-10-07 22:47:42 +02:00
András Schmelczer
849543d8be
Update main.yaml 2020-10-07 21:45:06 +02:00
András Schmelczer
8e87c62ab1
Update main.yaml 2020-10-07 21:44:21 +02:00
András Schmelczer
82d02198c7
Update main.yaml 2020-10-07 21:36:21 +02:00
András Schmelczer
b5b643d4a9
Update main.yaml 2020-10-07 21:29:07 +02:00
schmelczerandras
19e51639b4 Dockerize server 2020-10-07 21:19:38 +02:00
schmelczerandras
9e35538b79 Improve movement 2020-10-07 21:05:24 +02:00
schmelczerandras
e01400058d Fix ide support 2020-10-07 18:35:32 +02:00
schmelczerandras
1a3afe4dd0 Add IDE sytles 2020-10-07 16:44:20 +02:00
schmelczerandras
dd850d11d1 Setup formatting 2020-10-07 16:40:21 +02:00
schmelczerandras
d34f25295c Add minification 2020-10-07 14:13:22 +02:00
schmelczerandras
b3da27e73b Refactor serialization 2020-10-07 10:04:05 +02:00
schmelczerandras
8e44dd3733 Solve serialization 2020-10-06 21:33:04 +02:00
schmelczerandras
ba8b1a29fd Simplify types 2020-10-06 14:08:43 +02:00
schmelczerandras
6eddb4834b Fix bootstraping 2020-10-06 13:06:19 +02:00
schmelczerandras
51a8e2a629 Add lerna 2020-10-06 11:47:07 +02:00
schmelczerandras
d40c538ac5 Add devcontainer 2020-10-06 09:27:14 +02:00
schmelczerandras
ec0b700313 Improve physics 2020-10-06 09:27:02 +02:00
schmelczerandras
8b87b68dae Merge gitignores 2020-10-06 08:28:50 +02:00
schmelczerandras
c5d97eeea6 Refactor physics 2020-10-05 20:30:42 +02:00
schmelczerandras
46a48e7c15 Add basic multiplayer 2020-10-05 19:07:17 +02:00
schmelczerandras
0f0a1eaf67 Add backend 2020-10-04 19:05:32 +02:00
schmelczerandras
4ad60813c9 Improve physics 2020-10-03 17:57:38 +02:00
schmelczerandras
c21025caf6 Remove clutter 2020-10-03 09:17:43 +02:00
schmelczerandras
78b1d648ee Fix argument 2020-10-02 15:45:08 +02:00
schmelczerandras
ad73596967 Add firebase 2020-10-02 15:41:21 +02:00
schmelczerandras
d9d8d03c36 Update physics 2020-10-02 15:12:30 +02:00
schmelczerandras
1b7dee4be0 Start using the sdf-2d library 2020-09-21 09:42:00 +02:00
schmelczerandras
99f47e2961 Some refactoring 2020-09-14 19:18:59 +02:00
schmelczerandras
a51c06ae7b Try fixing invisible wall bug 2020-09-14 15:35:51 +02:00
schmelczerandras
94817d6e67 Enhance lights 2020-09-14 15:01:47 +02:00
schmelczerandras
4f75ce7065 Some improvements 2020-09-12 12:18:11 +02:00
schmelczerandras
d078d67d58 Add healthcheck 2020-09-12 12:17:51 +02:00
schmelczerandras
c362032aea Merge branch 'main' of https://github.com/schmelczerandras/decla.red into main 2020-09-11 21:06:55 +02:00
schmelczerandras
b1b3ca685a Some improvements 2020-09-11 21:06:40 +02:00
schmelczerandras
23a6b473ae Disable logging and server tokens 2020-09-11 20:41:12 +02:00
schmelczerandras
39a6ee9a20 Add parallel shader compiling 2020-09-10 11:32:05 +02:00
András Schmelczer
1eeccd909d
Update README.md 2020-08-21 12:17:27 +02:00
András Schmelczer
89986a749b
Update README.md 2020-08-21 12:16:50 +02:00
schmelczerandras
a6efbc02b9 Work on various issues 2020-08-20 09:56:23 +02:00
schmelczerandras
b7afa274f2 Add source maps 2020-08-19 08:09:39 +02:00
schmelczerandras
05682dd2d5 Render in NDC 2020-08-18 22:36:30 +02:00
schmelczerandras
40a660b7cb Add linting 2020-08-18 16:52:11 +02:00
schmelczerandras
76282a4cf7 Refactor rendering 2020-08-18 15:49:15 +02:00
schmelczerandras
edca93fcfe Add blob and fix some issues 2020-08-18 09:40:51 +02:00
schmelczerandras
5b4e67cbf0 Fix collision detection 2020-08-17 18:55:47 +02:00
schmelczerandras
006ab3c4e6 Refactor shapes 2020-08-16 10:24:12 +02:00
schmelczerandras
eb39846b75 Fix rendering issues 2020-08-16 09:15:09 +02:00
schmelczerandras
56dca9fa60 Fix workflow 2020-08-14 10:22:30 +02:00
299 changed files with 18390 additions and 3554 deletions

14
.dockerignore Normal file
View 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

View 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 }}

View file

@ -1 +1,2 @@
*.psd filter=lfs diff=lfs merge=lfs -text
* text=auto eol=lf

View file

@ -1,50 +0,0 @@
name: Deploy everything
on:
push:
branches:
- main
env:
CONTAINER_REGISTRY: schmelczera
DOMAIN: decla.red
jobs:
build-frontend:
runs-on: ubuntu-latest
steps:
- name: Checkout current branch with lfs
uses: actions/checkout@main
with:
lfs: true
- name: Setup auth tokens
run: |
docker login -u ${{ secrets.DOCKER_USER }} -p ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push frontend
run: |
docker build . -t $CONTAINER_REGISTRY/declared-frontend
docker push $CONTAINER_REGISTRY/declared-frontend
working-directory: frontend
deploy:
runs-on: ubuntu-latest
needs:
- build-frontend
steps:
- name: Checkout current branch with lfs
uses: actions/checkout@main
with:
lfs: true
- name: Setup auth tokens
run: |
# SSH key
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_TOKEN }} -p ${{ secrets.DOCKER_TOKEN }}
DOCKER_HOST=ssh://root@$DOMAIN docker stack deploy declared -c docker-compose.yml --with-registry-auth

9
.gitignore vendored
View file

@ -1 +1,8 @@
infrastructure/data/certbot
dist
lib
node_modules
.firebase
yarm-error.log
yarn.lock
package-lock.json
.DS_Store

1
.node-version Normal file
View file

@ -0,0 +1 @@
22

1
.nvmrc Normal file
View file

@ -0,0 +1 @@
22

View file

@ -1,5 +1,6 @@
{
"trailingComma": "es5",
"trailingComma": "all",
"printWidth": 90,
"tabWidth": 2,
"singleQuote": true,
"endOfLine": "lf"

16
.vscode/launch.json vendored Normal file
View 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
View file

@ -0,0 +1,10 @@
{
"cSpell.words": [
"Deserializable",
"Deserialization",
"Respawn",
"doppler",
"overridable",
"serializable"
]
}

50
Dockerfile Normal file
View file

@ -0,0 +1,50 @@
# syntax=docker/dockerfile:1
# doppler game server (the `doppler-server` package).
# The frontend is a static site and is NOT built here — see .forgejo/workflows.
# ---- Stage 1: build the shared lib, then bundle the server -------------------
# 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
# `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
# 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
# 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
# ---- 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
# 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"]

View file

@ -1,25 +1,35 @@
# decla.red
# doppler
![logo](frontend/static/declared.png)
![Deploy everything](https://github.com/schmelczerandras/decla.red/workflows/Deploy%20everything/badge.svg)
A 2-dimensional multiplayer game utilising ray tracing.
A good-looking 2D adventure.
> **Available at [doppler.schmelczer.dev](https://doppler.schmelczer.dev).**
## Development
![screenshot of in-game fight](media/game-pc.png)
![three screenshots taken at iPhone SE screen size](media/collage-iphone.png)
- npm install
- npm run start
- npm run build
For optimised 2D ray tracing, [SDF-2D](https://github.com/schmelczerandras/sdf-2d) is used.
## Todo
## Deployment
- Frontend nginx disable logging
- procedural piano
- subtract cameraPosition
- cross browser testing
- nginx log monitor
- lightweight object storage
- local dev env
- CI pipeline:
- prettier script
- tests?
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
```

43
backend/package.json Normal file
View file

@ -0,0 +1,43 @@
{
"name": "doppler-server",
"version": "0.1.0",
"description": "Game server for doppler",
"keywords": [],
"author": "András Schmelczer <andras@schmelczer.dev> (https://schmelczer.dev/)",
"main": "dist/main.js",
"bin": {
"doppler-server": "dist/main.js"
},
"engines": {
"node": ">=20"
},
"scripts": {
"build": "npx webpack --mode production",
"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.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": {
"@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"
}
}

View file

@ -0,0 +1,9 @@
import { Command } from 'shared';
// Internal request from a game object (e.g. a keystone planet flipping) asking
// the GameServer to broadcast a one-off announcement to every connected client.
export class AnnounceCommand extends Command {
public constructor(public readonly text: string) {
super();
}
}

View file

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

View file

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

View 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();
}
}

View 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));
};

View 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
View 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,
};
}
}

View file

@ -0,0 +1,13 @@
export class DeltaTimeCalculator {
private previousTime: [number, number] = process.hrtime();
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;
}
}

52
backend/src/main.ts Normal file
View file

@ -0,0 +1,52 @@
import { Server as IoServer } from 'socket.io';
import express from 'express';
import { Server } from 'http';
import cors from 'cors';
import { applyArrayPlugins, Random, serverInformationEndpoint } from 'shared';
import minimist from 'minimist';
import { glMatrix } from 'gl-matrix';
import { GameServer } from './game-server';
import { defaultOptions } from './default-options';
import parser from 'socket.io-msgpack-parser';
glMatrix.setMatrixArrayType(Array);
applyArrayPlugins();
const optionOverrides = minimist(process.argv.slice(2));
const options = {
...defaultOptions,
...optionOverrides,
};
Random.seed = options.seed;
const app = express();
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: (_, callback) => {
callback(null, true);
},
credentials: true,
}),
);
app.get(serverInformationEndpoint, (_, res) => {
res.json(gameServer.serverInfo);
});
server.listen(options.port, () => {
console.info(`Server started on port ${options.port}`);
});
gameServer.start();

View file

@ -0,0 +1,564 @@
import { vec2 } from 'gl-matrix';
import {
id,
settings,
MoveActionCommand,
serializesTo,
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 { DynamicPhysical } from '../physics/physicals/dynamic-physical';
import { CirclePhysical } from './circle-physical';
import { PhysicalContainer } from '../physics/containers/physical-container';
import { BoundingBoxBase } from '../physics/bounding-boxes/bounding-box-base';
import { ProjectilePhysical } from './projectile-physical';
import { 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 DynamicPhysical {
public readonly canCollide = true;
public readonly canMove = true;
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),
[ReactToCollisionCommand.type]: this.onCollision.bind(this),
};
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.add(vec2.create(), startPosition, headOffset),
headRadius,
this,
container,
);
this.leftFoot = new CirclePhysical(
vec2.add(vec2.create(), startPosition, leftFootOffset),
feetRadius,
this,
container,
);
this.rightFoot = new CirclePhysical(
vec2.add(vec2.create(), startPosition, rightFootOffset),
feetRadius,
this,
container,
);
container.addObject(this.head);
container.addObject(this.leftFoot);
container.addObject(this.rightFoot);
this.initMovementBridge();
}
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,
};
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;
}
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 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 {
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 {
return (
Math.min(
this.head.distance(target),
this.leftFoot.distance(target),
this.rightFoot.distance(target),
) - 5
);
}
private averageAndResetMovementActions(): vec2 {
let direction: vec2;
if (this.movementActions.length === 0) {
direction = vec2.clone(this.lastMovementAction.direction);
} else {
direction = this.movementActions.reduce(
(sum, current) => vec2.add(sum, sum, current.direction),
vec2.create(),
);
vec2.scale(direction, direction, 1 / this.movementActions.length);
this.lastMovementAction = last(this.movementActions)!;
this.movementActions = [];
}
return vec2.length(direction) > 0
? vec2.normalize(direction, direction)
: vec2.create();
}
private animateScaling(q: number) {
this.head.radius = headRadius * q;
this.leftFoot.radius = this.rightFoot.radius = feetRadius * q;
}
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);
}
}

View file

@ -0,0 +1,143 @@
import { vec2 } from 'gl-matrix';
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 { 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 extends CommandReceiver implements Circle, DynamicPhysical {
readonly canCollide = true;
readonly canMove = true;
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();
}
public get boundingBox(): BoundingBoxBase {
return this._boundingBox;
}
public get center(): vec2 {
return this._center;
}
public set center(value: vec2) {
this._center = value;
this.recalculateBoundingBox();
}
public onCollision(c: ReactToCollisionCommand) {
this.owner.handleCommand(c);
}
public get gameObject(): GameObject {
return this.owner;
}
public get radius(): number {
return this._radius;
}
public set radius(value: number) {
this._radius = value;
this.recalculateBoundingBox();
}
public distance(target: vec2): number {
return vec2.distance(target, this.center) - this.radius;
}
private recalculateBoundingBox() {
this._boundingBox.xMin = this.center.x - this._radius;
this._boundingBox.xMax = this.center.x + this._radius;
this._boundingBox.yMin = this.center.y - this._radius;
this._boundingBox.yMax = this.center.y + this._radius;
}
public applyForce(force: vec2, timeInSeconds: number) {
vec2.add(
this.velocity,
this.velocity,
vec2.scale(vec2.create(), force, timeInSeconds),
);
}
// 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);
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));
},
);
return { hitObject: (hitObject as Physical | undefined)?.gameObject, velocity };
}
// 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> {
const { center, radius } = this;
return [center, radius];
}
}

View file

@ -0,0 +1,42 @@
import { vec2, vec3 } from 'gl-matrix';
import { LampBase, settings, id, serializesTo } from 'shared';
import { ImmutableBoundingBox } from '../physics/bounding-boxes/immutable-bounding-box';
import { StaticPhysical } from '../physics/physicals/static-physical';
@serializesTo(LampBase)
export class LampPhysical extends LampBase implements StaticPhysical {
public readonly canCollide = false;
public readonly canMove = false;
constructor(center: vec2, color: vec3, lightness: number) {
super(id(), center, color, lightness);
}
private _boundingBox?: ImmutableBoundingBox;
public get boundingBox(): ImmutableBoundingBox {
if (!this._boundingBox) {
this._boundingBox = new ImmutableBoundingBox(
this.center.x - settings.lightCutoffDistance,
this.center.x + settings.lightCutoffDistance,
this.center.y - settings.lightCutoffDistance,
this.center.y + settings.lightCutoffDistance,
);
}
return this._boundingBox;
}
public get gameObject(): this {
return this;
}
public queueSetLight(color: vec3, lightness: number) {
this.remoteCall('setLight', color, lightness);
}
public distance(target: vec2): number {
return vec2.distance(this.center, target);
}
}

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

View 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);
}
}

8
backend/src/options.ts Normal file
View file

@ -0,0 +1,8 @@
export interface Options {
port: number;
name: string;
playerLimit: number;
scoreLimit: number;
npcCount: number;
seed: number;
}

View file

@ -1,15 +1,16 @@
import { vec2 } from 'gl-matrix';
import { IPrimitive } from '../../drawing/drawables/primitives/i-primitive';
// axis-aligned
export abstract class BoundingBoxBase {
constructor(
public readonly value: IPrimitive,
protected _xMin: number,
protected _xMax: number,
protected _yMin: number,
protected _yMax: number
protected _xMin: number = 0,
protected _xMax: number = 0,
protected _yMin: number = 0,
protected _yMax: number = 0,
) {}
[key: number]: number | undefined;
public get 0(): number {
return this._xMin;
}

View file

@ -0,0 +1,59 @@
import { vec2 } from 'gl-matrix';
import { BoundingBoxBase } from './bounding-box-base';
import { ImmutableBoundingBox } from './immutable-bounding-box';
export class BoundingBox extends BoundingBoxBase {
public get xMin(): number {
return this._xMin;
}
public set xMin(value: number) {
this._xMin = value;
}
public set xMax(value: number) {
this._xMax = value;
}
public get xMax(): number {
return this._xMax;
}
public set yMin(value: number) {
this._yMin = value;
}
public get yMin(): number {
return this._yMin;
}
public set yMax(value: number) {
this._yMax = value;
}
public get yMax(): number {
return this._yMax;
}
public get topLeft(): vec2 {
return vec2.fromValues(this._xMin, this._yMax);
}
public set topLeft(value: vec2) {
this._xMin = value.x;
this._yMax = value.y;
}
public set size(value: vec2) {
this._xMax = this.xMin + value.x;
this._yMin = this.yMax - value.y;
}
public get size(): vec2 {
return vec2.fromValues(this._xMax - this._xMin, this._yMax - this._yMin);
}
public cloneAsImmutable(): ImmutableBoundingBox {
return new ImmutableBoundingBox(this.xMin, this.xMax, this.yMin, this.yMax);
}
}

View file

@ -0,0 +1,3 @@
import { BoundingBoxBase } from './bounding-box-base';
export class ImmutableBoundingBox extends BoundingBoxBase {}

View file

@ -0,0 +1,25 @@
import { DynamicPhysical } from '../physicals/dynamic-physical';
import { BoundingBoxBase } from '../bounding-boxes/bounding-box-base';
export class BoundingBoxList {
constructor(private objects: Array<DynamicPhysical> = []) {}
public insert(object: DynamicPhysical) {
this.objects.push(object);
}
public remove(object: DynamicPhysical) {
this.objects.splice(
this.objects.findIndex((i) => i === object),
1,
);
}
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));
}
}

View file

@ -0,0 +1,126 @@
import { BoundingBoxBase } from '../bounding-boxes/bounding-box-base';
import { StaticPhysical } from '../physicals/static-physical';
// source: https://github.com/ubilabs/kd-tree-javascript/blob/master/kdTree.js
class Node {
public left: Node | null = null;
public right: Node | null = null;
constructor(
public object: StaticPhysical,
public parent: Node | null,
) {}
}
export class BoundingBoxTree {
root?: Node | null;
constructor(objects: Array<StaticPhysical> = []) {
this.build(objects);
}
public build(objects: Array<StaticPhysical>) {
this.root = this.buildRecursive(objects, 0, null);
}
private buildRecursive(
objects: Array<StaticPhysical>,
depth: number,
parent: Node | null,
): Node | null {
if (objects.length === 0) {
return null;
}
if (objects.length === 1) {
return new Node(objects[0], parent);
}
const dimension = depth % 4;
objects.sort((a, b) => a.boundingBox[dimension]! - b.boundingBox[dimension]!);
const median = Math.floor(objects.length / 2);
const node = new Node(objects[median], parent);
node.left = this.buildRecursive(objects.slice(0, median), depth + 1, node);
node.right = this.buildRecursive(objects.slice(median + 1), depth + 1, node);
return node;
}
public insert(object: StaticPhysical) {
const [insertPosition, depth] = this.findParent(object, this.root, 0, null);
if (insertPosition === null) {
this.root = new Node(object, null);
} else {
const node = new Node(object, insertPosition);
const dimension = depth % 4;
if (
object.boundingBox[dimension]! < insertPosition.object.boundingBox[dimension]!
) {
insertPosition.left = node;
} else {
insertPosition.right = node;
}
}
}
public findIntersecting(boundingBox: BoundingBoxBase): Array<StaticPhysical> {
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 | null,
depth: number,
): Array<StaticPhysical> {
if (node === null) {
return [];
}
if (depth % 4 == 0 && boundingBox.xMax < node.object.boundingBox.xMin) {
return this.findMaybeIntersecting(boundingBox, node.left, depth + 1);
}
if (depth % 4 == 1 && boundingBox.xMin > node.object.boundingBox.xMax) {
return this.findMaybeIntersecting(boundingBox, node.right, depth + 1);
}
if (depth % 4 == 2 && boundingBox.yMax < node.object.boundingBox.yMin) {
return this.findMaybeIntersecting(boundingBox, node.left, depth + 1);
}
if (depth % 4 == 3 && boundingBox.yMin > node.object.boundingBox.yMax) {
return this.findMaybeIntersecting(boundingBox, node.right, depth + 1);
}
return [
node.object,
...this.findMaybeIntersecting(boundingBox, node.left, depth + 1),
...this.findMaybeIntersecting(boundingBox, node.right, depth + 1),
];
}
private findParent(
object: StaticPhysical,
node: Node | null | undefined,
depth: number,
parent: Node | null,
): [Node | null, number] {
if (!node) {
return [parent, depth - 1];
}
const dimension = depth % 4;
if (object.boundingBox[dimension]! < node.object.boundingBox[dimension]!) {
return this.findParent(object, node.left, depth + 1, node);
}
return this.findParent(object, node.right, depth + 1, node);
}
}

View file

@ -0,0 +1,54 @@
import { BoundingBoxBase } from '../bounding-boxes/bounding-box-base';
import { BoundingBoxList } from './bounding-box-list';
import { BoundingBoxTree } from './bounding-box-tree';
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 extends CommandReceiver {
private isTreeInitialized = false;
private staticBoundingBoxesWaitList: Array<StaticPhysical> = [];
private staticBoundingBoxes = new BoundingBoxTree();
private dynamicBoundingBoxes = new BoundingBoxList();
private objects: Array<Physical> = [];
protected defaultCommandExecutor(c: Command) {
this.objects.forEach((o) => o.handleCommand(c));
}
public initialize() {
this.staticBoundingBoxes.build(this.staticBoundingBoxesWaitList);
this.isTreeInitialized = true;
}
public addObject(physical: Physical) {
this.objects.push(physical);
if (physical.canMove) {
this.dynamicBoundingBoxes.insert(physical);
} else {
if (!this.isTreeInitialized) {
this.staticBoundingBoxesWaitList.push(physical);
} else {
this.staticBoundingBoxes.insert(physical);
}
}
}
public removeObject(object: DynamicPhysical) {
this.objects = this.objects.filter((p) => p !== object);
this.dynamicBoundingBoxes.remove(object);
}
public resetRemoteCalls() {
this.objects.forEach((o) => o.gameObject.resetRemoteCalls());
}
public findIntersecting(box: BoundingBoxBase): Array<Physical> {
return [
...this.staticBoundingBoxes.findIntersecting(box),
...this.dynamicBoundingBoxes.findIntersecting(box),
];
}
}

View 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(),
);

View 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,
);

View 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;

View file

@ -0,0 +1,5 @@
import { PhysicalBase } from './physical-base';
export interface DynamicPhysical extends PhysicalBase {
readonly canMove: true;
}

View file

@ -0,0 +1,12 @@
import { vec2 } from 'gl-matrix';
import { CommandReceiver, GameObject } from 'shared';
import { BoundingBoxBase } from '../bounding-boxes/bounding-box-base';
export interface PhysicalBase extends CommandReceiver {
readonly canCollide: boolean;
readonly canMove: boolean;
readonly boundingBox: BoundingBoxBase;
readonly gameObject: GameObject;
distance(target: vec2): number;
}

View file

@ -0,0 +1,4 @@
import { DynamicPhysical } from './dynamic-physical';
import { StaticPhysical } from './static-physical';
export type Physical = StaticPhysical | DynamicPhysical;

View file

@ -0,0 +1,7 @@
import { PhysicalBase } from './physical-base';
import { ImmutableBoundingBox } from '../bounding-boxes/immutable-bounding-box';
export interface StaticPhysical extends PhysicalBase {
readonly canMove: false;
readonly boundingBox: ImmutableBoundingBox;
}

414
backend/src/players/npc.ts Normal file
View 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;
}
}

View 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();
}
}

View 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();
}
}

View file

@ -0,0 +1,243 @@
import { performance } from 'perf_hooks';
import { vec2 } from 'gl-matrix';
import {
CommandExecutors,
CreateObjectsCommand,
CreatePlayerCommand,
DeleteObjectsCommand,
MoveActionCommand,
serialize,
TransportEvents,
SetAspectRatioActionCommand,
calculateViewArea,
settings,
PlayerInformation,
CharacterTeam,
UpdateMinimap,
GameObject,
Command,
MinimapPlayer,
RemoteCallsForObject,
RemoteCallsForObjects,
ServerAnnouncement,
PropertyUpdatesForObjects,
PropertyUpdatesForObject,
PrimaryActionCommand,
LeapActionCommand,
InputAcknowledgement,
} from 'shared';
import { Socket } from 'socket.io';
import { BoundingBox } from '../physics/bounding-boxes/bounding-box';
import { PhysicalContainer } from '../physics/containers/physical-container';
import { PlayerContainer } from './player-container';
import { PlayerBase } from './player-base';
// How often the server pings each client to measure round-trip time.
const pingIntervalSeconds = 1;
export class Player extends PlayerBase {
// default, until the clients sends its real value
private aspectRatio: number = 16 / 9;
private timeUntilRespawn = 0;
private timeSinceLastMessage = 0;
private objectsPreviouslyInViewArea: Array<GameObject> = [];
private lastInputClientTimeMs = 0;
private lastLeapClientTimeMs = 0;
// Measured round-trip time to this client (ms) — the latency primitive that
// lag compensation, a latency HUD, and adaptive interpolation build on.
public rttMs = 0;
private timeSinceLastPing = 0;
private lastPingSentMs = 0;
protected commandExecutors: CommandExecutors = {
[SetAspectRatioActionCommand.type]: (v: SetAspectRatioActionCommand) =>
(this.aspectRatio = v.aspectRatio),
[MoveActionCommand.type]: (c: MoveActionCommand) => {
// Remember how far into this client's input timeline we've consumed, to
// echo back for client-side prediction reconciliation.
this.lastInputClientTimeMs = c.clientTimeMs;
this.character?.handleMovementAction(c);
},
[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(
playerInfo: PlayerInformation,
playerContainer: PlayerContainer,
objectContainer: PhysicalContainer,
team: CharacterTeam,
private readonly socket: Socket,
) {
super(playerInfo, playerContainer, objectContainer, team);
this.createCharacter();
this.step(0);
// The client already echoes a Pong for every Ping (see game.ts). Only one
// ping is ever in flight, so RTT is simply now send-time; no payload
// needed and no client change required.
this.socket.on(TransportEvents.Pong, () => {
if (this.lastPingSentMs > 0) {
this.rttMs = performance.now() - this.lastPingSentMs;
}
});
}
protected createCharacter() {
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;
const objectsInViewArea = Array.from(
new Set(this.objectContainer.findIntersecting(bb).map((o) => o.gameObject)),
);
// The owning character must always be in its own snapshot, regardless of the
// view-area query, so the client predictor never loses its authoritative
// anchor (the body can ride a fast spinner to the very edge of the box).
if (this.character && !objectsInViewArea.includes(this.character)) {
objectsInViewArea.push(this.character);
}
const newlyIntersecting = objectsInViewArea.filter(
(o) => !this.objectsPreviouslyInViewArea.includes(o),
);
const noLongerIntersecting = this.objectsPreviouslyInViewArea.filter(
(o) => !objectsInViewArea.includes(o),
);
this.objectsPreviouslyInViewArea = objectsInViewArea;
if (noLongerIntersecting.length > 0) {
this.queueCommandSend(
new DeleteObjectsCommand(noLongerIntersecting.map((g) => g.id)),
);
}
if (newlyIntersecting.length > 0) {
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,
),
);
}
}
// 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
View 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"]
}

View file

@ -0,0 +1 @@
declare module 'socket.io-msgpack-parser';

65
backend/webpack.config.js Normal file
View file

@ -0,0 +1,65 @@
const path = require('path');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const nodeExternals = require('webpack-node-externals');
const TerserJSPlugin = require('terser-webpack-plugin');
const webpack = require('webpack');
const PATHS = {
entryPoint: path.resolve(__dirname, 'src/main.ts'),
bundles: path.resolve(__dirname, 'dist'),
};
module.exports = (env, argv) => ({
entry: {
main: [PATHS.entryPoint],
},
externals: [
nodeExternals({
allowlist: [/(^shared)/],
}),
],
target: 'node',
output: {
filename: '[name].js',
path: PATHS.bundles,
},
devtool: argv.mode === 'development' ? 'source-map' : false,
watchOptions: {
poll: true,
},
optimization: {
minimize: argv.mode !== 'development',
minimizer: [
new TerserJSPlugin({
exclude: /node_modules/,
// The custom serialization protocol keys on class names, so they must
// survive minification (see shared/src/serialization).
terserOptions: {
keep_classnames: true,
},
}),
],
},
plugins: [
new webpack.BannerPlugin({ banner: '#!/usr/bin/env node', raw: true }),
new CleanWebpackPlugin({
protectWebpackAssets: false,
cleanAfterEveryBuildPatterns: [],
}),
],
module: {
rules: [
{
test: /\.ts$/,
use: {
loader: 'ts-loader',
},
exclude: /node_modules/,
},
],
},
resolve: {
extensions: ['.ts', '.js'],
},
});

View file

@ -1,25 +0,0 @@
**/.classpath
**/.dockerignore
**/.env
**/.git
**/.gitignore
**/.project
**/.settings
**/.toolstarget
**/.vs
**/.vscode
**/*.*proj.user
**/*.dbmdl
**/*.jfm
**/azds.yaml
**/bin
**/charts
**/docker-compose*
**/Dockerfile*
**/node_modules
**/npm-debug.log
**/obj
**/secrets.dev.yaml
**/values.dev.yaml
LICENSE
README.md

View file

@ -1,4 +0,0 @@
bin
obj
.vs
*.user

View file

@ -1,14 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>netcoreapp3.1</TargetFramework>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
<DockerfileContext>.</DockerfileContext>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.10.9" />
</ItemGroup>
</Project>

View file

@ -1,25 +0,0 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 16
VisualStudioVersion = 16.0.30309.148
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ConfigurationServer", "ConfigurationServer.csproj", "{CD78C1FA-1A88-4299-961B-2B5424DBEEDF}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{CD78C1FA-1A88-4299-961B-2B5424DBEEDF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{CD78C1FA-1A88-4299-961B-2B5424DBEEDF}.Debug|Any CPU.Build.0 = Debug|Any CPU
{CD78C1FA-1A88-4299-961B-2B5424DBEEDF}.Release|Any CPU.ActiveCfg = Release|Any CPU
{CD78C1FA-1A88-4299-961B-2B5424DBEEDF}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {5CE906F0-BAD5-4BD3-B3D7-1D26E4D6385C}
EndGlobalSection
EndGlobal

View file

@ -1,39 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace ConfigurationServer.Controllers
{
[ApiController]
[Route("[controller]")]
public class WeatherForecastController : ControllerBase
{
private static readonly string[] Summaries = new[]
{
"Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
};
private readonly ILogger<WeatherForecastController> _logger;
public WeatherForecastController(ILogger<WeatherForecastController> logger)
{
_logger = logger;
}
[HttpGet]
public IEnumerable<WeatherForecast> Get()
{
var rng = new Random();
return Enumerable.Range(1, 5).Select(index => new WeatherForecast
{
Date = DateTime.Now.AddDays(index),
TemperatureC = rng.Next(-20, 55),
Summary = Summaries[rng.Next(Summaries.Length)]
})
.ToArray();
}
}
}

View file

@ -1,21 +0,0 @@
#See https://aka.ms/containerfastmode to understand how Visual Studio uses this Dockerfile to build your images for faster debugging.
FROM mcr.microsoft.com/dotnet/core/aspnet:3.1-buster-slim AS base
WORKDIR /app
EXPOSE 80
FROM mcr.microsoft.com/dotnet/core/sdk:3.1-buster AS build
WORKDIR /src
COPY ["ConfigurationServer.csproj", ""]
RUN dotnet restore "./ConfigurationServer.csproj"
COPY . .
WORKDIR "/src/."
RUN dotnet build "ConfigurationServer.csproj" -c Release -o /app/build
FROM build AS publish
RUN dotnet publish "ConfigurationServer.csproj" -c Release -o /app/publish
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "ConfigurationServer.dll"]

View file

@ -1,26 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace ConfigurationServer
{
public class Program
{
public static void Main(string[] args)
{
CreateHostBuilder(args).Build().Run();
}
public static IHostBuilder CreateHostBuilder(string[] args) =>
Host.CreateDefaultBuilder(args)
.ConfigureWebHostDefaults(webBuilder =>
{
webBuilder.UseStartup<Startup>();
});
}
}

View file

@ -1,36 +0,0 @@
{
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:63198",
"sslPort": 0
}
},
"$schema": "http://json.schemastore.org/launchsettings.json",
"profiles": {
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"launchUrl": "weatherforecast",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"ConfigurationServer": {
"commandName": "Project",
"launchBrowser": true,
"launchUrl": "weatherforecast",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "http://localhost:5000"
},
"Docker": {
"commandName": "Docker",
"launchBrowser": true,
"launchUrl": "{Scheme}://{ServiceHost}:{ServicePort}/weatherforecast",
"publishAllPorts": true
}
}
}

View file

@ -1,48 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace ConfigurationServer
{
public class Startup
{
public Startup(IConfiguration configuration)
{
Configuration = configuration;
}
public IConfiguration Configuration { get; }
// This method gets called by the runtime. Use this method to add services to the container.
public void ConfigureServices(IServiceCollection services)
{
services.AddControllers();
}
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
app.UseRouting();
app.UseAuthorization();
app.UseEndpoints(endpoints =>
{
endpoints.MapControllers();
});
}
}
}

View file

@ -1,15 +0,0 @@
using System;
namespace ConfigurationServer
{
public class WeatherForecast
{
public DateTime Date { get; set; }
public int TemperatureC { get; set; }
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
public string Summary { get; set; }
}
}

View file

@ -1,9 +0,0 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information"
}
}
}

View file

@ -1,10 +0,0 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information"
}
},
"AllowedHosts": "*"
}

View file

@ -1,31 +0,0 @@
version: "3.8"
services:
declared-frontend:
init: true
image: schmelczera/declared-frontend
ports:
- "80"
networks:
- network
deploy:
replicas: 2
resources:
limits:
cpus: "0.1"
memory: 16M
reservations:
cpus: "0.1"
memory: 16M
update_config:
parallelism: 1
failure_action: rollback
delay: 10s
monitor: 10s
restart_policy:
condition: on-failure
window: 30s
networks:
network:
driver: overlay

32
eslint.config.js Normal file
View file

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

View file

@ -1,5 +0,0 @@
Dockerfile
node_modules
dist
package-lock.json
.*

3
frontend/.gitignore vendored
View file

@ -1,3 +0,0 @@
dist
node_modules
package-lock.json

View file

@ -1,8 +0,0 @@
{
"typescript.tsdk": "node_modules\\typescript\\lib",
"editor.tabSize": 2,
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.organizeImports": true
}
}

View file

@ -1,26 +0,0 @@
FROM schmelczera/error-pages as build-error-pages
RUN python build.py 403 404 50x
FROM node:latest as build-webpage
WORKDIR /home/node
COPY src src
COPY static static
COPY package.json custom.d.ts tsconfig.json webpack.config.js ./
RUN npm install
RUN npm run build
FROM nginx:alpine
WORKDIR /usr/share/nginx/html
RUN rm -rf *
COPY --from=build-webpage /home/node/dist .
COPY --from=build-error-pages /home/python/built errors
RUN find . -type f | xargs gzip -k9 &&\
chmod -R 555 .
COPY nginx-config /etc/nginx/

24
frontend/custom.d.ts vendored
View file

@ -1,24 +0,0 @@
declare module '*.glsl' {
const content: string;
export default content;
}
/*
declare module '*.png' {
import { ResponsiveImage } from 'src/framework/model/misc';
const content: ResponsiveImage;
export default content;
}
declare module '*.jpg' {
import { ResponsiveImage } from 'src/framework/model/misc';
const content: ResponsiveImage;
export default content;
}
declare module '*.jpeg' {
import { ResponsiveImage } from 'src/framework/model/misc';
const content: ResponsiveImage;
export default content;
}
*/

View file

@ -1,54 +0,0 @@
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile on;
sendfile_max_chunk 1m;
tcp_nopush on;
keepalive_timeout 65;
gzip on;
gzip_static on;
gzip_vary on;
gzip_min_length 10240;
gzip_proxied any;
gzip_disable "MSIE [1-6]\.(?!.*SV1)";
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
location ~* \.(jpg|jpeg|png|ico)$ {
expires 30d;
}
error_page 403 /403.html;
error_page 404 /404.html;
error_page 500 501 502 503 504 /50x.html;
location ~ ^/(403|404|50x).html$ {
root /usr/share/nginx/html/errors;
internal;
}
}
}

6758
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,59 +1,55 @@
{
"name": "decla.red",
"name": "doppler-frontend",
"version": "0.0.0",
"description": "![logo](media/declared.png)",
"private": true,
"main": "index.js",
"scripts": {
"start": "webpack-dev-server --mode development",
"start-unsafe": "webpack-dev-server --mode development --useLocalIp",
"build": "webpack && find dist -type f -not -name '*.html' | xargs rm"
},
"description": "",
"keywords": [],
"author": "András Schmelczer",
"author": "András Schmelczer <andras@schmelczer.dev> (https://schmelczer.dev/)",
"sideEffects": [
"*.scss"
],
"main": "index.js",
"engines": {
"node": ">=20"
},
"scripts": {
"build": "npx webpack --mode production",
"dev": "npx webpack-dev-server --mode development",
"try-build": "npm run build && cd dist && python3 -m http.server 8080"
},
"browserslist": [
"defaults"
],
"postcss": {
"plugins": {
"autoprefixer": {}
}
},
"browserslist": [
"defaults"
],
"sideEffects": [
"*.scss"
],
"optimization": {
"usedExports": true
},
"devDependencies": {
"@types/gl-matrix": "^2.4.5",
"@types/uuid": "^8.0.0",
"autoprefixer": "^9.8.5",
"clean-webpack-plugin": "^3.0.0",
"css-loader": "^3.5.2",
"cssnano": "^4.1.10",
"gl-matrix": "^3.3.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",
"prettier": "^2.0.5",
"raw-loader": "^4.0.1",
"resolve-url-loader": "^3.1.1",
"responsive-loader": "^1.2.0",
"sass": "^1.26.3",
"sass-loader": "^9.0.2",
"sharp": "^0.25.4",
"style-loader": "^1.1.4",
"svg-url-loader": "^6.0.0",
"terser-webpack-plugin": "^2.3.5",
"ts-loader": "^8.0.1",
"typescript": "^3.8.3",
"uuid": "^8.2.0",
"webpack": "^4.43.0",
"webpack-cli": "^3.3.11",
"webpack-dev-server": "^3.10.3"
"@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
View 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 } };

View file

@ -2,21 +2,124 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width,initial-scale=1,viewport-fit=cover"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0,
user-scalable=0"
/>
<meta name="theme-color" content="#b7455e" />
<link rel="shortcut icon" href="/favicon.ico" type="image/x-icon" />
<link rel="icon" href="/favicon.ico" type="image/x-icon" />
<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."
/>
<title>Test</title>
<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>doppler</title>
</head>
<body>
<noscript><h1>Javascript is required for this website.</h1></noscript>
<noscript>Javascript is required for this website.</noscript>
<canvas></canvas>
<div id="overlay"></div>
<canvas id="main"></canvas>
<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>

View file

@ -1,31 +1,160 @@
import { glMatrix } from 'gl-matrix';
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 { applyArrayPlugins } from './scripts/helper/array';
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);
applyArrayPlugins();
/*
const tree = new BoundingBoxTree([
new BoundingBox(300, 550, 150, 550, 'A'),
new BoundingBox(400, 800, 50, 200, 'B'),
new BoundingBox(450, 500, 175, 185, 'C'),
new BoundingBox(100, 200, 100, 500, 'D'),
new BoundingBox(750, 950, 450, 600, 'E'),
new BoundingBox(940, 1000, -2, 180, 'F'),
new BoundingBox(960, 1050, 50, 190, 'G'),
new BoundingBox(150, 900, 0, 575, 'H'),
new BoundingBox(-10000, 10000, -10000, 10000, 'I'),
]);
overrideDeserialization(CharacterBase, CharacterView);
overrideDeserialization(PlanetBase, PlanetView);
overrideDeserialization(LampBase, LampView);
overrideDeserialization(ProjectileBase, ProjectileView);
tree.print();
console.log(tree.findIntersecting(new BoundingBox(960, 1050, 50, 190, 'G')));
*/
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;
try {
new Game();
} catch (e) {
console.error(e);
alert(e);
}
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 {
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);
}
};
main();

940
frontend/src/main.scss Normal file
View 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);
}
}

View file

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

View file

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

View file

@ -1,13 +0,0 @@
import { CommandReceiver } from './command-receiver';
import { CommandGenerator } from './command-generator';
export class CommandBroadcaster {
constructor(
commandGenerators: Array<CommandGenerator>,
commandReceivers: Array<CommandReceiver>
) {
commandReceivers.forEach((r) =>
commandGenerators.forEach((g) => g.subscribe(r))
);
}
}

View file

@ -1,5 +0,0 @@
import { Command } from './command';
export interface CommandReceiver {
sendCommand(command: Command): void;
}

View 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 = [];
}
}
}

View file

@ -1,6 +0,0 @@
import { Typed } from '../transport/serializable';
import { Id } from '../identity/identity';
export abstract class Command extends Typed {
target?: Id;
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View file

@ -0,0 +1,7 @@
import { Command } from 'shared';
export class BeforeDestroyCommand extends Command {
public constructor() {
super();
}
}

View file

@ -0,0 +1,12 @@
import { Renderer } from 'sdf-2d';
import { Command } from 'shared';
export class RenderCommand extends Command {
public constructor(
public readonly renderer: Renderer,
public readonly overlay: HTMLElement,
public readonly shouldChangeLayout: boolean,
) {
super();
}
}

View file

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

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

View file

@ -1,12 +0,0 @@
import { Command } from '../../commands/command';
import { IRenderer } from '../i-renderer';
export class BeforeRenderCommand extends Command {
public constructor(public readonly renderer?: IRenderer) {
super();
}
public get type(): string {
return 'BeforeRenderCommand';
}
}

View file

@ -1,12 +0,0 @@
import { IRenderer } from '../../drawing/i-renderer';
import { Command } from '../../commands/command';
export class RenderCommand extends Command {
public constructor(public readonly renderer?: IRenderer) {
super();
}
public get type(): string {
return 'RenderCommand';
}
}

View file

@ -1,5 +0,0 @@
export interface IDrawableDescriptor {
uniformName: string;
countMacroName: string;
shaderCombinationSteps: Array<number>;
}

View file

@ -1,11 +0,0 @@
import { vec2 } from 'gl-matrix';
import { ImmutableBoundingBox } from '../../physics/containers/immutable-bounding-box';
import { GameObject } from '../../objects/game-object';
export interface IDrawable {
serializeToUniforms(uniforms: any): void;
distance(target: vec2): number;
minimumDistance(target: vec2): number;
readonly owner: GameObject;
readonly boundingBox: ImmutableBoundingBox;
}

View file

@ -1,54 +0,0 @@
import { vec2, vec3 } from 'gl-matrix';
import { GameObject } from '../../../objects/game-object';
import { ImmutableBoundingBox } from '../../../physics/containers/immutable-bounding-box';
import { settings } from '../../settings';
import { IDrawableDescriptor } from '../i-drawable-descriptor';
import { ILight } from './i-light';
export class CircleLight implements ILight {
public static descriptor: IDrawableDescriptor = {
uniformName: 'circleLights',
countMacroName: 'circleLightCount',
shaderCombinationSteps: settings.shaderCombinations.circleLightSteps,
};
constructor(
public readonly owner: GameObject,
public center: vec2,
public radius: number,
public color: vec3,
public lightness: number
) { }
boundingBox: ImmutableBoundingBox;
public distance(target: vec2): number {
return 0;
}
public minimumDistance(target: vec2): number {
return 0;
}
public serializeToUniforms(uniforms: any): void {
const listName = CircleLight.descriptor.uniformName;
if (!uniforms.hasOwnProperty(listName)) {
uniforms[listName] = [];
}
uniforms[listName].push({
center: this.center,
radius: this.radius,
value: this.value,
});
}
public get value(): vec3 {
return vec3.scale(
vec3.create(),
vec3.normalize(this.color, this.color),
this.lightness
);
}
}

View file

@ -1,3 +0,0 @@
import { IDrawable } from '../i-drawable';
export interface ILight extends IDrawable {}

View file

@ -1,50 +0,0 @@
import { ILight } from './i-light';
import { vec2, vec3 } from 'gl-matrix';
import { IDrawableDescriptor } from '../i-drawable-descriptor';
import { settings } from '../../settings';
import { ImmutableBoundingBox } from '../../../physics/containers/immutable-bounding-box';
import { GameObject } from '../../../objects/game-object';
export class PointLight implements ILight {
public static descriptor: IDrawableDescriptor = {
uniformName: 'pointLights',
countMacroName: 'pointLightCount',
shaderCombinationSteps: settings.shaderCombinations.pointLightSteps,
};
public constructor(
public readonly owner: GameObject,
public center: vec2,
public radius: number,
public color: vec3,
public lightness: number
) {}
boundingBox: ImmutableBoundingBox;
public distance(target: vec2): number {
return vec2.distance(this.center, target) - this.radius;
}
public minimumDistance(target: vec2): number {
return vec2.distance(this.center, target) - this.radius;
}
public serializeToUniforms(uniforms: any): void {
const listName = PointLight.descriptor.uniformName;
if (!uniforms.hasOwnProperty(listName)) {
uniforms[listName] = [];
}
uniforms[listName].push({
center: this.center,
radius: this.radius,
value: this.value,
});
}
public get value(): vec3 {
return vec3.scale(vec3.create(), this.color, this.lightness);
}
}

View file

@ -1,47 +0,0 @@
import { vec2 } from 'gl-matrix';
import { GameObject } from '../../../objects/game-object';
import { ImmutableBoundingBox } from '../../../physics/containers/immutable-bounding-box';
import { IPrimitive } from './i-primitive';
export class Circle implements IPrimitive {
public constructor(
public readonly owner: GameObject,
public center = vec2.create(),
public radius = 0
) { }
public serializeToUniforms(uniforms: any): void {
throw new Error('Method not implemented.');
}
public distance(target: vec2): number {
return vec2.distance(this.center, target) - this.radius;
}
public minimumDistance(target: vec2): number {
return vec2.distance(this.center, target) - this.radius;
}
public get boundingBox(): ImmutableBoundingBox {
return new ImmutableBoundingBox(
this,
this.center.x - this.radius,
this.center.x + this.radius,
this.center.y - this.radius,
this.center.y + this.radius
);
}
public isInside(target: vec2): boolean {
return this.distance(target) < 0;
}
public areIntersecting(other: Circle): boolean {
const distance = vec2.distance(this.center, other.center);
return distance < this.radius + other.radius;
}
public clone(): Circle {
return new Circle(this.owner, this.center, this.radius);
}
}

View file

@ -1,5 +0,0 @@
import { IDrawable } from '../i-drawable';
export interface IPrimitive extends IDrawable {
clone(): IPrimitive
}

View file

@ -1,99 +0,0 @@
import { vec2 } from 'gl-matrix';
import { clamp01 } from '../../../helper/clamp';
import { mix } from '../../../helper/mix';
import { GameObject } from '../../../objects/game-object';
import { ImmutableBoundingBox } from '../../../physics/containers/immutable-bounding-box';
import { settings } from '../../settings';
import { IDrawableDescriptor } from '../i-drawable-descriptor';
import { Circle } from './circle';
import { IPrimitive } from './i-primitive';
export class TunnelShape implements IPrimitive {
public static descriptor: IDrawableDescriptor = {
uniformName: 'lines',
countMacroName: 'lineCount',
shaderCombinationSteps: settings.shaderCombinations.lineSteps,
};
public readonly toFromDelta: vec2;
private boundingCircle: Circle;
constructor(
public readonly owner: GameObject,
public readonly from: vec2,
public readonly to: vec2,
public readonly fromRadius: number,
public readonly toRadius: number
) {
this.toFromDelta = vec2.subtract(vec2.create(), to, from);
this.boundingCircle = new Circle(
this.owner,
vec2.fromValues(from.x / 2 + to.x / 2, from.y / 2 + to.y / 2),
Math.max(fromRadius, toRadius) + vec2.distance(from, to)
);
}
public serializeToUniforms(uniforms: any): void {
if (!uniforms.hasOwnProperty(TunnelShape.descriptor.uniformName)) {
uniforms[TunnelShape.descriptor.uniformName] = [];
}
uniforms[TunnelShape.descriptor.uniformName].push({
from: this.from,
toFromDelta: this.toFromDelta,
fromRadius: this.fromRadius,
toRadius: this.toRadius,
});
}
public get boundingBox(): ImmutableBoundingBox {
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
);
return new ImmutableBoundingBox(this, xMin, xMax, yMin, yMax);
}
public distance(target: vec2): number {
const targetFromDelta = vec2.subtract(vec2.create(), target, this.from);
const h = clamp01(
vec2.dot(targetFromDelta, this.toFromDelta) /
vec2.dot(this.toFromDelta, this.toFromDelta)
);
return (
vec2.distance(
targetFromDelta,
vec2.scale(vec2.create(), this.toFromDelta, h)
) - mix(this.fromRadius, this.toRadius, h)
);
}
public minimumDistance(target: vec2): number {
return this.boundingCircle.distance(target);
}
public clone(): TunnelShape {
return new TunnelShape(
this.owner,
this.from,
this.to,
this.fromRadius,
this.toRadius
);
}
}

View file

@ -1,22 +0,0 @@
import { FrameBuffer } from './frame-buffer';
export class DefaultFrameBuffer extends FrameBuffer {
constructor(gl: WebGL2RenderingContext) {
super(gl);
this.frameBuffer = null;
this.setSize();
}
public setSize() {
super.setSize();
if (
this.gl.canvas.width !== this.size.x ||
this.gl.canvas.height !== this.size.y
) {
this.gl.canvas.width = this.size.x;
this.gl.canvas.height = this.size.y;
}
}
}

View file

@ -1,38 +0,0 @@
import { vec2 } from 'gl-matrix';
export abstract class FrameBuffer {
public renderScale = 1;
public enableHighDpiRendering = false;
protected size: vec2;
protected frameBuffer: WebGLFramebuffer;
constructor(protected gl: WebGL2RenderingContext) {}
public bindAndClear(colorInput?: WebGLTexture) {
this.gl.bindFramebuffer(this.gl.FRAMEBUFFER, this.frameBuffer);
if (colorInput !== null) {
this.gl.bindTexture(this.gl.TEXTURE_2D, colorInput);
}
this.gl.viewport(0, 0, this.size.x, this.size.y);
this.gl.clearColor(0, 0, 0, 0);
this.gl.clear(this.gl.COLOR_BUFFER_BIT | this.gl.DEPTH_BUFFER_BIT);
}
public setSize() {
const realToCssPixels = window.devicePixelRatio * this.renderScale;
const canvasWidth = (this.gl.canvas as HTMLCanvasElement).clientWidth;
const canvasHeight = (this.gl.canvas as HTMLCanvasElement).clientHeight;
const displayWidth = Math.floor(canvasWidth * realToCssPixels);
const displayHeight = Math.floor(canvasHeight * realToCssPixels);
this.size = vec2.fromValues(displayWidth, displayHeight);
}
public getSize(): vec2 {
return this.size;
}
}

View file

@ -1,75 +0,0 @@
import { FrameBuffer } from './frame-buffer';
export class IntermediateFrameBuffer extends FrameBuffer {
private frameTexture: WebGLTexture;
constructor(gl: WebGL2RenderingContext) {
super(gl);
this.frameTexture = this.gl.createTexture();
this.configureTexture();
this.frameBuffer = this.gl.createFramebuffer();
this.configureFrameBuffer();
this.setSize();
}
public get colorTexture(): WebGLTexture {
return this.frameTexture;
}
public setSize() {
super.setSize();
this.gl.bindTexture(this.gl.TEXTURE_2D, this.frameTexture);
this.gl.texImage2D(
this.gl.TEXTURE_2D,
0,
this.gl.RGBA,
this.size.x,
this.size.y,
0,
this.gl.RGBA,
this.gl.UNSIGNED_BYTE,
null
);
}
private configureTexture() {
this.gl.bindTexture(this.gl.TEXTURE_2D, this.frameTexture);
this.gl.texParameteri(
this.gl.TEXTURE_2D,
this.gl.TEXTURE_MAG_FILTER,
this.gl.LINEAR
);
this.gl.texParameteri(
this.gl.TEXTURE_2D,
this.gl.TEXTURE_MIN_FILTER,
this.gl.LINEAR
);
this.gl.texParameteri(
this.gl.TEXTURE_2D,
this.gl.TEXTURE_WRAP_S,
this.gl.CLAMP_TO_EDGE
);
this.gl.texParameteri(
this.gl.TEXTURE_2D,
this.gl.TEXTURE_WRAP_T,
this.gl.CLAMP_TO_EDGE
);
}
private configureFrameBuffer() {
this.gl.bindFramebuffer(this.gl.FRAMEBUFFER, this.frameBuffer);
const attachmentPoint = this.gl.COLOR_ATTACHMENT0;
this.gl.framebufferTexture2D(
this.gl.FRAMEBUFFER,
attachmentPoint,
this.gl.TEXTURE_2D,
this.frameTexture,
0
);
}
}

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