Compare commits
No commits in common. "main" and "wip" have entirely different histories.
299 changed files with 3553 additions and 18389 deletions
|
|
@ -1,14 +0,0 @@
|
||||||
# The server image only needs `shared/` and `backend/`.
|
|
||||||
**/node_modules
|
|
||||||
**/dist
|
|
||||||
**/lib
|
|
||||||
.git
|
|
||||||
.github
|
|
||||||
.forgejo
|
|
||||||
.devcontainer
|
|
||||||
.vscode
|
|
||||||
frontend
|
|
||||||
media
|
|
||||||
*.log
|
|
||||||
Dockerfile
|
|
||||||
.dockerignore
|
|
||||||
|
|
@ -1,63 +0,0 @@
|
||||||
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 }}
|
|
||||||
50
.github/workflows/main.yaml
vendored
Normal file
50
.github/workflows/main.yaml
vendored
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
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
9
.gitignore
vendored
|
|
@ -1,8 +1 @@
|
||||||
dist
|
infrastructure/data/certbot
|
||||||
lib
|
|
||||||
node_modules
|
|
||||||
.firebase
|
|
||||||
yarm-error.log
|
|
||||||
yarn.lock
|
|
||||||
package-lock.json
|
|
||||||
.DS_Store
|
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
22
|
|
||||||
1
.nvmrc
1
.nvmrc
|
|
@ -1 +0,0 @@
|
||||||
22
|
|
||||||
16
.vscode/launch.json
vendored
16
.vscode/launch.json
vendored
|
|
@ -1,16 +0,0 @@
|
||||||
{
|
|
||||||
// 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
10
.vscode/settings.json
vendored
|
|
@ -1,10 +0,0 @@
|
||||||
{
|
|
||||||
"cSpell.words": [
|
|
||||||
"Deserializable",
|
|
||||||
"Deserialization",
|
|
||||||
"Respawn",
|
|
||||||
"doppler",
|
|
||||||
"overridable",
|
|
||||||
"serializable"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
50
Dockerfile
50
Dockerfile
|
|
@ -1,50 +0,0 @@
|
||||||
# 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"]
|
|
||||||
48
README.md
48
README.md
|
|
@ -1,35 +1,25 @@
|
||||||
# doppler
|
# decla.red
|
||||||
|
|
||||||
A 2-dimensional multiplayer game utilising ray tracing.
|

|
||||||
|

|
||||||
|
|
||||||
> **Available at [doppler.schmelczer.dev](https://doppler.schmelczer.dev).**
|
A good-looking 2D adventure.
|
||||||
|
|
||||||

|
## Development
|
||||||

|
|
||||||
|
|
||||||
For optimised 2D ray tracing, [SDF-2D](https://github.com/schmelczerandras/sdf-2d) is used.
|
- npm install
|
||||||
|
- npm run start
|
||||||
|
- npm run build
|
||||||
|
|
||||||
## Deployment
|
## Todo
|
||||||
|
|
||||||
CI/CD runs on Forgejo Actions (`.forgejo/workflows/deploy.yml`). On a push to
|
- Frontend nginx disable logging
|
||||||
`main` it:
|
- procedural piano
|
||||||
|
- subtract cameraPosition
|
||||||
- builds the static frontend and rsyncs `frontend/dist/` to the `/pages/declared`
|
- cross browser testing
|
||||||
mount on the runner host (the mount keeps its pre-rebrand name), and
|
- nginx log monitor
|
||||||
- builds the server image from the root `Dockerfile` and pushes it to the Forgejo
|
- lightweight object storage
|
||||||
container registry as `<registry>/<owner>/<repo>-server`.
|
- local dev env
|
||||||
|
- CI pipeline:
|
||||||
The registry job needs a `FORGEJO_PACKAGE_TOKEN` secret (with package write
|
- prettier script
|
||||||
scope) and, optionally, a `CONTAINER_REGISTRY_HOST` variable to override the
|
- tests?
|
||||||
registry host.
|
|
||||||
|
|
||||||
The website's server list is hardcoded in
|
|
||||||
[`frontend/src/scripts/configuration.ts`](frontend/src/scripts/configuration.ts) —
|
|
||||||
edit it to add or remove game-server origins.
|
|
||||||
|
|
||||||
Run the server image locally:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
docker build -t doppler-server .
|
|
||||||
docker run -p 3000:3000 doppler-server --name "My server" --playerLimit 16
|
|
||||||
```
|
|
||||||
|
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
import { Command } from 'shared';
|
|
||||||
|
|
||||||
// Internal request from a game object (e.g. a keystone planet flipping) asking
|
|
||||||
// the GameServer to broadcast a one-off announcement to every connected client.
|
|
||||||
export class AnnounceCommand extends Command {
|
|
||||||
public constructor(public readonly text: string) {
|
|
||||||
super();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
import { Command } from 'shared';
|
|
||||||
|
|
||||||
export class GeneratePointsCommand extends Command {
|
|
||||||
public constructor(
|
|
||||||
public readonly blue: number,
|
|
||||||
public readonly red: number,
|
|
||||||
) {
|
|
||||||
super();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
import { Command, GameObject } from 'shared';
|
|
||||||
|
|
||||||
export class ReactToCollisionCommand extends Command {
|
|
||||||
public constructor(public readonly other: GameObject) {
|
|
||||||
super();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
import { Command, CommandReceiver } from 'shared';
|
|
||||||
|
|
||||||
export class StepCommand extends Command {
|
|
||||||
public constructor(
|
|
||||||
public readonly deltaTimeInSeconds: number,
|
|
||||||
public readonly game: CommandReceiver,
|
|
||||||
) {
|
|
||||||
super();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,97 +0,0 @@
|
||||||
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));
|
|
||||||
};
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
import { Options } from './options';
|
|
||||||
|
|
||||||
export const defaultOptions: Options = {
|
|
||||||
port: 3000,
|
|
||||||
name: 'Test server',
|
|
||||||
playerLimit: 16,
|
|
||||||
npcCount: 8,
|
|
||||||
seed: Math.random(),
|
|
||||||
scoreLimit: 2500,
|
|
||||||
};
|
|
||||||
|
|
@ -1,289 +0,0 @@
|
||||||
import { PhysicalContainer } from './physics/containers/physical-container';
|
|
||||||
import { Server, Socket } from 'socket.io';
|
|
||||||
import {
|
|
||||||
TransportEvents,
|
|
||||||
deserialize,
|
|
||||||
settings,
|
|
||||||
ServerInformation,
|
|
||||||
PlayerInformation,
|
|
||||||
UpdateGameState,
|
|
||||||
CharacterTeam,
|
|
||||||
GameEndCommand,
|
|
||||||
GameStartCommand,
|
|
||||||
Command,
|
|
||||||
CommandReceiver,
|
|
||||||
CommandExecutors,
|
|
||||||
ServerAnnouncement,
|
|
||||||
} from 'shared';
|
|
||||||
import { createWorld } from './create-world';
|
|
||||||
import { DeltaTimeCalculator } from './helper/delta-time-calculator';
|
|
||||||
import { Options } from './options';
|
|
||||||
import { PlayerContainer } from './players/player-container';
|
|
||||||
import { StepCommand } from './commands/step';
|
|
||||||
import { GeneratePointsCommand } from './commands/generate-points';
|
|
||||||
import { AnnounceCommand } from './commands/announce';
|
|
||||||
|
|
||||||
const gameStateSubscribedRoom = 'gameStateSubscribedRoom';
|
|
||||||
|
|
||||||
export class GameServer extends CommandReceiver {
|
|
||||||
private objects!: PhysicalContainer;
|
|
||||||
private players!: PlayerContainer;
|
|
||||||
private deltaTimes!: Array<number>;
|
|
||||||
private deltaTimeCalculator!: DeltaTimeCalculator;
|
|
||||||
|
|
||||||
private bluePoints = 0;
|
|
||||||
private redPoints = 0;
|
|
||||||
private matchPointAnnounced: Partial<Record<CharacterTeam, boolean>> = {};
|
|
||||||
|
|
||||||
private isInEndGame = false;
|
|
||||||
private timeScaling = 1;
|
|
||||||
|
|
||||||
private serverName: string;
|
|
||||||
private playerLimit: number;
|
|
||||||
|
|
||||||
private initialize() {
|
|
||||||
const previousPlayers = this.players;
|
|
||||||
this.objects = new PhysicalContainer();
|
|
||||||
createWorld(this.objects);
|
|
||||||
this.objects.initialize();
|
|
||||||
this.players = new PlayerContainer(
|
|
||||||
this.objects,
|
|
||||||
this.options.playerLimit,
|
|
||||||
this.options.npcCount,
|
|
||||||
);
|
|
||||||
this.deltaTimeCalculator = new DeltaTimeCalculator();
|
|
||||||
this.deltaTimes = [];
|
|
||||||
this.bluePoints = 0;
|
|
||||||
this.redPoints = 0;
|
|
||||||
this.matchPointAnnounced = {};
|
|
||||||
this.isInEndGame = false;
|
|
||||||
this.timeScaling = 1;
|
|
||||||
previousPlayers?.queueCommandForEachClient(new GameStartCommand());
|
|
||||||
previousPlayers?.sendQueuedCommands();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected commandExecutors: CommandExecutors = {
|
|
||||||
[GeneratePointsCommand.type]: this.addPoints.bind(this),
|
|
||||||
[AnnounceCommand.type]: ({ text }: AnnounceCommand) =>
|
|
||||||
this.players.queueCommandForEachClient(new ServerAnnouncement(text)),
|
|
||||||
};
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly io: Server,
|
|
||||||
private options: Options,
|
|
||||||
) {
|
|
||||||
super();
|
|
||||||
|
|
||||||
this.serverName = options.name;
|
|
||||||
this.playerLimit = options.playerLimit;
|
|
||||||
|
|
||||||
this.initialize();
|
|
||||||
|
|
||||||
io.on('connection', (socket: Socket) => {
|
|
||||||
socket.on(TransportEvents.PlayerJoining, (playerInfo: PlayerInformation) => {
|
|
||||||
try {
|
|
||||||
const player = this.players.createPlayer(playerInfo, socket);
|
|
||||||
socket.on(TransportEvents.PlayerToServer, (json: string) => {
|
|
||||||
try {
|
|
||||||
const commands: Array<Command> = deserialize(json);
|
|
||||||
commands.forEach((c) => player.handleCommand(c));
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Error while processing command', e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
this.sendServerStateUpdate();
|
|
||||||
|
|
||||||
socket.on('disconnect', () => {
|
|
||||||
player.destroy();
|
|
||||||
this.players.deletePlayer(player);
|
|
||||||
this.sendServerStateUpdate();
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to register joining player; disconnecting socket', e);
|
|
||||||
socket.disconnect();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on(TransportEvents.SubscribeForServerInfoUpdates, () => {
|
|
||||||
socket.join(gameStateSubscribedRoom);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private timeSinceLastServerStateUpdate = 0;
|
|
||||||
public sendServerStateUpdate() {
|
|
||||||
this.io
|
|
||||||
.to(gameStateSubscribedRoom)
|
|
||||||
.emit(TransportEvents.ServerInfoUpdate, [this.players.count, this.gameProgress]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public start() {
|
|
||||||
this.handlePhysics();
|
|
||||||
}
|
|
||||||
|
|
||||||
private addPoints({ blue, red }: GeneratePointsCommand) {
|
|
||||||
if (this.isInEndGame) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.bluePoints += blue;
|
|
||||||
this.redPoints += red;
|
|
||||||
if (this.bluePoints >= this.options.scoreLimit) {
|
|
||||||
this.endGame(CharacterTeam.blue);
|
|
||||||
} else if (this.redPoints >= this.options.scoreLimit) {
|
|
||||||
this.endGame(CharacterTeam.red);
|
|
||||||
} else {
|
|
||||||
this.announceMatchPointOnce(CharacterTeam.blue, this.bluePoints);
|
|
||||||
this.announceMatchPointOnce(CharacterTeam.red, this.redPoints);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private announceMatchPointOnce(team: CharacterTeam, points: number) {
|
|
||||||
if (
|
|
||||||
!this.matchPointAnnounced[team] &&
|
|
||||||
points >= this.options.scoreLimit * settings.matchPointScoreRatio
|
|
||||||
) {
|
|
||||||
this.matchPointAnnounced[team] = true;
|
|
||||||
this.players.queueCommandForEachClient(
|
|
||||||
new ServerAnnouncement(
|
|
||||||
`Match point — team <span class="${team}">${team}</span>!`,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private endGame(winningTeam: CharacterTeam) {
|
|
||||||
this.isInEndGame = true;
|
|
||||||
const endTitleLength = 6;
|
|
||||||
this.players.endGame(winningTeam);
|
|
||||||
this.players.queueCommandForEachClient(
|
|
||||||
new GameEndCommand(winningTeam, endTitleLength, true),
|
|
||||||
);
|
|
||||||
setTimeout(() => this.destroy(), endTitleLength * 1000 * 1.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
private destroy() {
|
|
||||||
this.initialize();
|
|
||||||
}
|
|
||||||
|
|
||||||
private timeSinceLastPointUpdate = 0;
|
|
||||||
private physicsAccumulator = 0;
|
|
||||||
// Frames since the last stats report where physics ran over budget (more
|
|
||||||
// substeps than the cap). Surfaced by handleStats as a saturation signal.
|
|
||||||
private saturatedFrames = 0;
|
|
||||||
|
|
||||||
private handlePhysics() {
|
|
||||||
const delta = this.deltaTimeCalculator.getNextDeltaTimeInSeconds({ setAsBase: true });
|
|
||||||
this.deltaTimes.push(delta);
|
|
||||||
|
|
||||||
this.handleStats();
|
|
||||||
|
|
||||||
if ((this.timeSinceLastServerStateUpdate += delta) > 4) {
|
|
||||||
this.timeSinceLastServerStateUpdate = 0;
|
|
||||||
this.sendServerStateUpdate();
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((this.timeSinceLastPointUpdate += delta) > 0.5) {
|
|
||||||
this.timeSinceLastPointUpdate = 0;
|
|
||||||
this.players.queueCommandForEachClient(
|
|
||||||
new UpdateGameState(this.bluePoints, this.redPoints, this.options.scoreLimit),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const fixedDelta = settings.targetPhysicsDeltaTimeInSeconds;
|
|
||||||
const maxSubstepsPerFrame = 5;
|
|
||||||
// Cap on retained physics backlog when saturated, so a long stall can't
|
|
||||||
// accumulate an unrecoverable catch-up.
|
|
||||||
const maxBacklogSeconds = 0.25;
|
|
||||||
|
|
||||||
this.physicsAccumulator += delta;
|
|
||||||
let substeps = Math.floor(this.physicsAccumulator / fixedDelta);
|
|
||||||
if (substeps > maxSubstepsPerFrame) {
|
|
||||||
// Saturated: run the cap's worth of substeps but KEEP the remaining
|
|
||||||
// backlog (clamped) instead of zeroing it. Dropping it silently slowed
|
|
||||||
// simulated time for everyone — and diverged client prediction, whose
|
|
||||||
// wall-clock keeps running. Clamping bounds the catch-up so a transient
|
|
||||||
// spike recovers without a death spiral.
|
|
||||||
this.saturatedFrames++;
|
|
||||||
this.physicsAccumulator = Math.min(
|
|
||||||
this.physicsAccumulator - maxSubstepsPerFrame * fixedDelta,
|
|
||||||
maxBacklogSeconds,
|
|
||||||
);
|
|
||||||
substeps = maxSubstepsPerFrame;
|
|
||||||
} else {
|
|
||||||
this.physicsAccumulator -= substeps * fixedDelta;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 0; i < substeps; i++) {
|
|
||||||
let scaledDelta = fixedDelta;
|
|
||||||
if (this.isInEndGame) {
|
|
||||||
this.timeScaling *= Math.pow(settings.endGameDeltaScaling, fixedDelta);
|
|
||||||
scaledDelta /= this.timeScaling;
|
|
||||||
}
|
|
||||||
this.objects.handleCommand(new StepCommand(scaledDelta, this));
|
|
||||||
this.players.step(scaledDelta);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.players.stepCommunication(delta);
|
|
||||||
this.objects.resetRemoteCalls();
|
|
||||||
|
|
||||||
const physicsDelta = this.deltaTimeCalculator.getNextDeltaTimeInSeconds();
|
|
||||||
|
|
||||||
setTimeout(
|
|
||||||
this.handlePhysics.bind(this),
|
|
||||||
Math.max(0, fixedDelta - physicsDelta) * 1000,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleStats() {
|
|
||||||
const framesBetweenDeltaTimeCalculation = 10000;
|
|
||||||
|
|
||||||
if (this.deltaTimes.length > framesBetweenDeltaTimeCalculation) {
|
|
||||||
this.deltaTimes.sort((a, b) => a - b);
|
|
||||||
console.info(
|
|
||||||
`Median physics time: ${(
|
|
||||||
this.deltaTimes[Math.floor(framesBetweenDeltaTimeCalculation / 2)] * 1000
|
|
||||||
).toFixed(2)} ms`,
|
|
||||||
);
|
|
||||||
console.info(
|
|
||||||
'Tail times: ',
|
|
||||||
this.deltaTimes.slice(-20).map((v) => `${(v * 1000).toFixed(2)} ms`),
|
|
||||||
);
|
|
||||||
console.info(
|
|
||||||
`Memory used: ${(process.memoryUsage().rss / 1024 / 1024).toFixed(2)} MB`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const rtts = this.players.connectedPlayerRttsMs.filter((r) => r > 0);
|
|
||||||
if (rtts.length > 0) {
|
|
||||||
rtts.sort((a, b) => a - b);
|
|
||||||
console.info(
|
|
||||||
`Player RTT median ${rtts[Math.floor(rtts.length / 2)].toFixed(0)} ms ` +
|
|
||||||
`(min ${rtts[0].toFixed(0)}, max ${rtts[rtts.length - 1].toFixed(0)}, n=${rtts.length})`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.saturatedFrames > 0) {
|
|
||||||
console.warn(
|
|
||||||
`Physics saturated on ${this.saturatedFrames} frame(s) since last report — shedding backlog`,
|
|
||||||
);
|
|
||||||
this.saturatedFrames = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.deltaTimes = [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private get gameProgress(): number {
|
|
||||||
return (Math.max(this.bluePoints, this.redPoints) / this.options.scoreLimit) * 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get serverInfo(): ServerInformation {
|
|
||||||
return {
|
|
||||||
serverName: this.serverName,
|
|
||||||
playerCount: this.players.count,
|
|
||||||
playerLimit: this.playerLimit,
|
|
||||||
gameStatePercent: this.gameProgress,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
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();
|
|
||||||
|
|
@ -1,564 +0,0 @@
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,143 +0,0 @@
|
||||||
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];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,334 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,160 +0,0 @@
|
||||||
import { vec2 } from 'gl-matrix';
|
|
||||||
import {
|
|
||||||
id,
|
|
||||||
settings,
|
|
||||||
serializesTo,
|
|
||||||
ProjectileBase,
|
|
||||||
CharacterTeam,
|
|
||||||
PropertyUpdatesForObject,
|
|
||||||
UpdatePropertyCommand,
|
|
||||||
CommandExecutors,
|
|
||||||
Circle,
|
|
||||||
marchCircle,
|
|
||||||
} from 'shared';
|
|
||||||
import { ImmutableBoundingBox } from '../physics/bounding-boxes/immutable-bounding-box';
|
|
||||||
import { CirclePhysical } from './circle-physical';
|
|
||||||
import { DynamicPhysical } from '../physics/physicals/dynamic-physical';
|
|
||||||
import { PhysicalContainer } from '../physics/containers/physical-container';
|
|
||||||
import { CharacterPhysical } from './character-physical';
|
|
||||||
import { forceAtPosition } from '../physics/functions/force-at-position';
|
|
||||||
import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle';
|
|
||||||
import { StepCommand } from '../commands/step';
|
|
||||||
import { ReactToCollisionCommand } from '../commands/react-to-collision';
|
|
||||||
|
|
||||||
@serializesTo(ProjectileBase)
|
|
||||||
export class ProjectilePhysical extends ProjectileBase implements DynamicPhysical {
|
|
||||||
public readonly canCollide = true;
|
|
||||||
public readonly canMove = true;
|
|
||||||
|
|
||||||
private isDestroyed = false;
|
|
||||||
private bounceCount = 0;
|
|
||||||
private _boundingBox?: ImmutableBoundingBox;
|
|
||||||
|
|
||||||
public object: CirclePhysical;
|
|
||||||
|
|
||||||
protected commandExecutors: CommandExecutors = {
|
|
||||||
[StepCommand.type]: this.handleStep.bind(this),
|
|
||||||
[ReactToCollisionCommand.type]: this.onCollision.bind(this),
|
|
||||||
};
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
center: vec2,
|
|
||||||
radius: number,
|
|
||||||
public strength: number,
|
|
||||||
team: CharacterTeam,
|
|
||||||
private velocity: vec2,
|
|
||||||
public readonly originator: CharacterPhysical,
|
|
||||||
readonly container: PhysicalContainer,
|
|
||||||
// Normalised charge (0..1) of the shot that fired this, used by the victim
|
|
||||||
// to scale hit/kill feedback and the death fling.
|
|
||||||
public readonly charge: number = 0,
|
|
||||||
) {
|
|
||||||
super(id(), center, radius, team, strength);
|
|
||||||
this.object = new CirclePhysical(center, radius, this, container, 0.9);
|
|
||||||
|
|
||||||
this.moveOutsideOfObject();
|
|
||||||
}
|
|
||||||
|
|
||||||
public get isAlive(): boolean {
|
|
||||||
return !this.isDestroyed;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get direction(): vec2 {
|
|
||||||
const direction = vec2.clone(this.velocity);
|
|
||||||
return vec2.length(direction) > 0
|
|
||||||
? vec2.normalize(direction, direction)
|
|
||||||
: vec2.fromValues(0, -1);
|
|
||||||
}
|
|
||||||
|
|
||||||
private moveOutsideOfObject() {
|
|
||||||
let wasCollision = true;
|
|
||||||
const delta = vec2.scale(
|
|
||||||
vec2.create(),
|
|
||||||
vec2.normalize(vec2.create(), this.velocity),
|
|
||||||
10,
|
|
||||||
);
|
|
||||||
while (wasCollision) {
|
|
||||||
const intersecting = this.container
|
|
||||||
.findIntersecting(this.boundingBox)
|
|
||||||
.filter((g) => g instanceof CharacterPhysical && g.team === this.team);
|
|
||||||
const { hitSurface } = marchCircle(this.object, delta, intersecting, true);
|
|
||||||
wasCollision = hitSurface;
|
|
||||||
}
|
|
||||||
vec2.add(this.center, this.center, delta);
|
|
||||||
vec2.add(this.center, this.center, delta);
|
|
||||||
}
|
|
||||||
|
|
||||||
public get boundingBox(): ImmutableBoundingBox {
|
|
||||||
if (!this._boundingBox) {
|
|
||||||
this._boundingBox = (this.object as CirclePhysical).boundingBox;
|
|
||||||
}
|
|
||||||
|
|
||||||
return this._boundingBox;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get gameObject(): this {
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public distance(target: vec2): number {
|
|
||||||
return this.object.distance(target);
|
|
||||||
}
|
|
||||||
|
|
||||||
public destroy() {
|
|
||||||
if (!this.isDestroyed) {
|
|
||||||
this.isDestroyed = true;
|
|
||||||
this.container.removeObject(this);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public onCollision({ other }: ReactToCollisionCommand) {
|
|
||||||
if (
|
|
||||||
!(other instanceof CharacterPhysical && other.team === this.team) &&
|
|
||||||
this.bounceCount++ === settings.projectileMaxBounceCount
|
|
||||||
) {
|
|
||||||
this.destroy();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public getPropertyUpdates(): PropertyUpdatesForObject {
|
|
||||||
return new PropertyUpdatesForObject(this.id, [
|
|
||||||
new UpdatePropertyCommand('center', this.center, this.velocity),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleStep({ deltaTimeInSeconds }: StepCommand) {
|
|
||||||
super.step(deltaTimeInSeconds);
|
|
||||||
|
|
||||||
if (this.strength <= 0) {
|
|
||||||
this.destroy();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Curveball: the same planetary gravity that pulls on a free-falling
|
|
||||||
// character bends the shot, so slower (charged) shots arc and can be lobbed
|
|
||||||
// over a planet's horizon. Scale is tiny — near-surface gravity is huge.
|
|
||||||
// This single broadphase is reused for the step below: the gravity radius
|
|
||||||
// (maxGravityDistance) dwarfs one tick's travel, so the set is a superset of
|
|
||||||
// the swept-collision box and the step needn't query the container again.
|
|
||||||
const intersecting = settings.projectileGravityEnabled
|
|
||||||
? this.container.findIntersecting(
|
|
||||||
getBoundingBoxOfCircle(
|
|
||||||
new Circle(this.center, this.object.radius + settings.maxGravityDistance),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
if (intersecting) {
|
|
||||||
vec2.scaleAndAdd(
|
|
||||||
this.velocity,
|
|
||||||
this.velocity,
|
|
||||||
forceAtPosition(this.center, intersecting),
|
|
||||||
settings.projectileGravityScale * deltaTimeInSeconds,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
vec2.copy(this.object.velocity, this.velocity);
|
|
||||||
const { velocity } = this.object.stepManually(deltaTimeInSeconds, intersecting);
|
|
||||||
vec2.copy(this.velocity, velocity);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
export interface Options {
|
|
||||||
port: number;
|
|
||||||
name: string;
|
|
||||||
playerLimit: number;
|
|
||||||
scoreLimit: number;
|
|
||||||
npcCount: number;
|
|
||||||
seed: number;
|
|
||||||
}
|
|
||||||
|
|
@ -1,59 +0,0 @@
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
import { BoundingBoxBase } from './bounding-box-base';
|
|
||||||
|
|
||||||
export class ImmutableBoundingBox extends BoundingBoxBase {}
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,126 +0,0 @@
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
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),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
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(),
|
|
||||||
);
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
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,
|
|
||||||
);
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
import { Circle, evaluateSdf } from 'shared';
|
|
||||||
import { PhysicalBase } from '../physicals/physical-base';
|
|
||||||
|
|
||||||
export const isCircleIntersecting = (
|
|
||||||
circle: Circle,
|
|
||||||
intersectors: Array<PhysicalBase>,
|
|
||||||
): boolean => evaluateSdf(circle.center, intersectors) < circle.radius;
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
import { PhysicalBase } from './physical-base';
|
|
||||||
|
|
||||||
export interface DynamicPhysical extends PhysicalBase {
|
|
||||||
readonly canMove: true;
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
import { DynamicPhysical } from './dynamic-physical';
|
|
||||||
import { StaticPhysical } from './static-physical';
|
|
||||||
|
|
||||||
export type Physical = StaticPhysical | DynamicPhysical;
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
import { PhysicalBase } from './physical-base';
|
|
||||||
import { ImmutableBoundingBox } from '../bounding-boxes/immutable-bounding-box';
|
|
||||||
|
|
||||||
export interface StaticPhysical extends PhysicalBase {
|
|
||||||
readonly canMove: false;
|
|
||||||
readonly boundingBox: ImmutableBoundingBox;
|
|
||||||
}
|
|
||||||
|
|
@ -1,414 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,104 +0,0 @@
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,102 +0,0 @@
|
||||||
import { CharacterTeam, PlayerInformation, Random, settings, Command } from 'shared';
|
|
||||||
import { Socket } from 'socket.io';
|
|
||||||
import { PhysicalContainer } from '../physics/containers/physical-container';
|
|
||||||
import { NPC } from './npc';
|
|
||||||
import { Player } from './player';
|
|
||||||
import { PlayerBase } from './player-base';
|
|
||||||
|
|
||||||
export class PlayerContainer {
|
|
||||||
private _players: Array<Player> = [];
|
|
||||||
private _npcs: Array<NPC> = [];
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly objects: PhysicalContainer,
|
|
||||||
private readonly playerMaxCount: number,
|
|
||||||
private readonly npcMaxCount: number,
|
|
||||||
) {
|
|
||||||
this.createNPCs();
|
|
||||||
}
|
|
||||||
|
|
||||||
public createNPCs() {
|
|
||||||
const newNpcCount = Math.min(
|
|
||||||
this.playerMaxCount - this._players.length - this._npcs.length,
|
|
||||||
this.npcMaxCount - this._npcs.length,
|
|
||||||
);
|
|
||||||
for (let i = 0; i < newNpcCount; i++) {
|
|
||||||
const name = `🤖 ${Random.choose(settings.npcNames)}`;
|
|
||||||
this._npcs.push(
|
|
||||||
new NPC({ name }, this, this.objects, this.getTeamOfNextPlayer(true)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public createPlayer(playerInfo: PlayerInformation, socket: Socket): Player {
|
|
||||||
if (this._players.length === this.playerMaxCount) {
|
|
||||||
throw new Error('Too many players');
|
|
||||||
}
|
|
||||||
|
|
||||||
const team = this.getTeamOfNextPlayer();
|
|
||||||
let npcToReplace = this._npcs.find((n) => n.team === team);
|
|
||||||
if (!npcToReplace) {
|
|
||||||
npcToReplace = this._npcs.find((n) => n.team !== team);
|
|
||||||
}
|
|
||||||
npcToReplace?.destroy();
|
|
||||||
this._npcs = this._npcs.filter((n) => n !== npcToReplace);
|
|
||||||
|
|
||||||
const player = new Player(playerInfo, this, this.objects, team, socket);
|
|
||||||
this._players.push(player);
|
|
||||||
|
|
||||||
return player;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get players(): Array<PlayerBase> {
|
|
||||||
return [...this._players, ...this._npcs];
|
|
||||||
}
|
|
||||||
|
|
||||||
public get count(): number {
|
|
||||||
return this._players.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Measured round-trip times (ms) of the real connected players, for
|
|
||||||
// server-side latency stats. NPCs have no socket and are excluded.
|
|
||||||
public get connectedPlayerRttsMs(): Array<number> {
|
|
||||||
return this._players.map((p) => p.rttMs);
|
|
||||||
}
|
|
||||||
|
|
||||||
public step(deltaTimeInSeconds: number) {
|
|
||||||
this.players.forEach((p) => p.step(deltaTimeInSeconds));
|
|
||||||
}
|
|
||||||
|
|
||||||
public stepCommunication(deltaTimeInSeconds: number) {
|
|
||||||
this._players.forEach((p) => p.stepCommunications(deltaTimeInSeconds));
|
|
||||||
}
|
|
||||||
|
|
||||||
public endGame(winner: CharacterTeam) {
|
|
||||||
this._players.forEach((p) => p.onGameEnded(winner));
|
|
||||||
}
|
|
||||||
|
|
||||||
public queueCommandForEachClient(command: Command) {
|
|
||||||
this._players.forEach((p) => p.queueCommandSend(command));
|
|
||||||
}
|
|
||||||
|
|
||||||
public sendQueuedCommands() {
|
|
||||||
this._players.forEach((p) => p.sendQueuedCommandsToClient());
|
|
||||||
}
|
|
||||||
|
|
||||||
private getTeamOfNextPlayer(isNpc = false): CharacterTeam {
|
|
||||||
const players = isNpc ? this.players : this._players;
|
|
||||||
const blueCount = players.filter((p) => p.team === CharacterTeam.blue).length;
|
|
||||||
const redCount = players.filter((p) => p.team === CharacterTeam.red).length;
|
|
||||||
|
|
||||||
if ((blueCount === redCount && Random.getRandom() >= 0.5) || blueCount < redCount) {
|
|
||||||
return CharacterTeam.blue;
|
|
||||||
} else {
|
|
||||||
return CharacterTeam.red;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public deletePlayer(player: Player) {
|
|
||||||
this._players = this._players.filter((p) => p !== player);
|
|
||||||
this.createNPCs();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,243 +0,0 @@
|
||||||
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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"outDir": "dist",
|
|
||||||
"rootDir": "src",
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"target": "es6",
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"strict": true,
|
|
||||||
"experimentalDecorators": true,
|
|
||||||
"moduleResolution": "node",
|
|
||||||
"ignoreDeprecations": "6.0",
|
|
||||||
"module": "commonjs",
|
|
||||||
"lib": ["dom", "es2017"],
|
|
||||||
"typeRoots": ["./types", "./node_modules/@types"]
|
|
||||||
},
|
|
||||||
"include": ["src/**/*.ts"],
|
|
||||||
"exclude": ["node_modules", "typings"]
|
|
||||||
}
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
declare module 'socket.io-msgpack-parser';
|
|
||||||
|
|
@ -1,65 +0,0 @@
|
||||||
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'],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
25
config/ConfigurationServer/.dockerignore
Normal file
25
config/ConfigurationServer/.dockerignore
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
**/.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
|
||||||
4
config/ConfigurationServer/.gitignore
vendored
Normal file
4
config/ConfigurationServer/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
bin
|
||||||
|
obj
|
||||||
|
.vs
|
||||||
|
*.user
|
||||||
14
config/ConfigurationServer/ConfigurationServer.csproj
Normal file
14
config/ConfigurationServer/ConfigurationServer.csproj
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
<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>
|
||||||
25
config/ConfigurationServer/ConfigurationServer.sln
Normal file
25
config/ConfigurationServer/ConfigurationServer.sln
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
|
||||||
|
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
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
21
config/ConfigurationServer/Dockerfile
Normal file
21
config/ConfigurationServer/Dockerfile
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
#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"]
|
||||||
26
config/ConfigurationServer/Program.cs
Normal file
26
config/ConfigurationServer/Program.cs
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
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>();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
36
config/ConfigurationServer/Properties/launchSettings.json
Normal file
36
config/ConfigurationServer/Properties/launchSettings.json
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
{
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
48
config/ConfigurationServer/Startup.cs
Normal file
48
config/ConfigurationServer/Startup.cs
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
15
config/ConfigurationServer/WeatherForecast.cs
Normal file
15
config/ConfigurationServer/WeatherForecast.cs
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
9
config/ConfigurationServer/appsettings.Development.json
Normal file
9
config/ConfigurationServer/appsettings.Development.json
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft": "Warning",
|
||||||
|
"Microsoft.Hosting.Lifetime": "Information"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
config/ConfigurationServer/appsettings.json
Normal file
10
config/ConfigurationServer/appsettings.json
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft": "Warning",
|
||||||
|
"Microsoft.Hosting.Lifetime": "Information"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"AllowedHosts": "*"
|
||||||
|
}
|
||||||
31
docker-compose.yml
Normal file
31
docker-compose.yml
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
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
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
// 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',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
5
frontend/.dockerignore
Normal file
5
frontend/.dockerignore
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
Dockerfile
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
package-lock.json
|
||||||
|
.*
|
||||||
1
.gitattributes → frontend/.gitattributes
vendored
1
.gitattributes → frontend/.gitattributes
vendored
|
|
@ -1,2 +1 @@
|
||||||
*.psd filter=lfs diff=lfs merge=lfs -text
|
*.psd filter=lfs diff=lfs merge=lfs -text
|
||||||
* text=auto eol=lf
|
|
||||||
3
frontend/.gitignore
vendored
Normal file
3
frontend/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
dist
|
||||||
|
node_modules
|
||||||
|
package-lock.json
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
{
|
{
|
||||||
"trailingComma": "all",
|
"trailingComma": "es5",
|
||||||
"printWidth": 90,
|
|
||||||
"tabWidth": 2,
|
"tabWidth": 2,
|
||||||
"singleQuote": true,
|
"singleQuote": true,
|
||||||
"endOfLine": "lf"
|
"endOfLine": "lf"
|
||||||
8
frontend/.vscode/settings.json
vendored
Normal file
8
frontend/.vscode/settings.json
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"typescript.tsdk": "node_modules\\typescript\\lib",
|
||||||
|
"editor.tabSize": 2,
|
||||||
|
"editor.formatOnSave": true,
|
||||||
|
"editor.codeActionsOnSave": {
|
||||||
|
"source.organizeImports": true
|
||||||
|
}
|
||||||
|
}
|
||||||
26
frontend/Dockerfile
Normal file
26
frontend/Dockerfile
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
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
Normal file
24
frontend/custom.d.ts
vendored
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
*/
|
||||||
54
frontend/nginx-config/nginx.conf
Normal file
54
frontend/nginx-config/nginx.conf
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
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
6758
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,55 +1,59 @@
|
||||||
{
|
{
|
||||||
"name": "doppler-frontend",
|
"name": "decla.red",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"keywords": [],
|
"private": true,
|
||||||
"author": "András Schmelczer <andras@schmelczer.dev> (https://schmelczer.dev/)",
|
|
||||||
"sideEffects": [
|
|
||||||
"*.scss"
|
|
||||||
],
|
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"engines": {
|
|
||||||
"node": ">=20"
|
|
||||||
},
|
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "npx webpack --mode production",
|
"start": "webpack-dev-server --mode development",
|
||||||
"dev": "npx webpack-dev-server --mode development",
|
"start-unsafe": "webpack-dev-server --mode development --useLocalIp",
|
||||||
"try-build": "npm run build && cd dist && python3 -m http.server 8080"
|
"build": "webpack && find dist -type f -not -name '*.html' | xargs rm"
|
||||||
},
|
},
|
||||||
"browserslist": [
|
"keywords": [],
|
||||||
"defaults"
|
"author": "András Schmelczer",
|
||||||
],
|
|
||||||
"postcss": {
|
"postcss": {
|
||||||
"plugins": {
|
"plugins": {
|
||||||
"autoprefixer": {}
|
"autoprefixer": {}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"browserslist": [
|
||||||
|
"defaults"
|
||||||
|
],
|
||||||
|
"sideEffects": [
|
||||||
|
"*.scss"
|
||||||
|
],
|
||||||
|
"optimization": {
|
||||||
|
"usedExports": true
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@plausible-analytics/tracker": "^0.4.5",
|
"@types/gl-matrix": "^2.4.5",
|
||||||
"autoprefixer": "^10.5.0",
|
"@types/uuid": "^8.0.0",
|
||||||
"clean-webpack-plugin": "^4.0.0",
|
"autoprefixer": "^9.8.5",
|
||||||
"copy-webpack-plugin": "^14.0.0",
|
"clean-webpack-plugin": "^3.0.0",
|
||||||
"css-loader": "^7.1.4",
|
"css-loader": "^3.5.2",
|
||||||
"file-loader": "^6.2.0",
|
"cssnano": "^4.1.10",
|
||||||
"gl-matrix": "3.3.0",
|
"gl-matrix": "^3.3.0",
|
||||||
"html-inline-css-webpack-plugin": "^1.11.2",
|
"html-webpack-inline-source-plugin": "0.0.10",
|
||||||
"html-webpack-plugin": "^5.6.7",
|
"html-webpack-plugin": "^3.2.0",
|
||||||
"mini-css-extract-plugin": "^2.10.2",
|
"image-webpack-loader": "^6.0.0",
|
||||||
"postcss": "^8.5.15",
|
"mini-css-extract-plugin": "^0.9.0",
|
||||||
"postcss-loader": "^8.2.1",
|
"optimize-css-assets-webpack-plugin": "^5.0.3",
|
||||||
"resize-observer-polyfill": "^1.5.1",
|
"postcss-loader": "^3.0.0",
|
||||||
"sass": "^1.100.0",
|
"prettier": "^2.0.5",
|
||||||
"sass-loader": "^17.0.0",
|
"raw-loader": "^4.0.1",
|
||||||
"sdf-2d": "^0.8.0",
|
"resolve-url-loader": "^3.1.1",
|
||||||
"shared": "file:../shared",
|
"responsive-loader": "^1.2.0",
|
||||||
"socket.io-client": "^4.8.3",
|
"sass": "^1.26.3",
|
||||||
"socket.io-msgpack-parser": "^3.0.2",
|
"sass-loader": "^9.0.2",
|
||||||
"source-map-loader": "^5.0.0",
|
"sharp": "^0.25.4",
|
||||||
"terser-webpack-plugin": "^5.6.1",
|
"style-loader": "^1.1.4",
|
||||||
"ts-loader": "^9.6.0",
|
"svg-url-loader": "^6.0.0",
|
||||||
"typescript": "^6.0.3",
|
"terser-webpack-plugin": "^2.3.5",
|
||||||
"webpack": "^5.107.2",
|
"ts-loader": "^8.0.1",
|
||||||
"webpack-cli": "^7.0.3",
|
"typescript": "^3.8.3",
|
||||||
"webpack-dev-server": "^5.2.4"
|
"uuid": "^8.2.0",
|
||||||
|
"webpack": "^4.43.0",
|
||||||
|
"webpack-cli": "^3.3.11",
|
||||||
|
"webpack-dev-server": "^3.10.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
15
frontend/src/custom.d.ts
vendored
15
frontend/src/custom.d.ts
vendored
|
|
@ -1,15 +0,0 @@
|
||||||
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 } };
|
|
||||||
|
|
@ -2,124 +2,21 @@
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
|
|
||||||
<meta
|
<meta
|
||||||
name="viewport"
|
name="viewport"
|
||||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0,
|
content="width=device-width,initial-scale=1,viewport-fit=cover"
|
||||||
user-scalable=0"
|
|
||||||
/>
|
/>
|
||||||
<meta name="theme-color" content="#b7455e" />
|
<meta name="theme-color" content="#b7455e" />
|
||||||
|
|
||||||
<meta property="og:url" content="https://doppler.schmelczer.dev" />
|
<link rel="shortcut icon" href="/favicon.ico" type="image/x-icon" />
|
||||||
<meta property="og:image" content="https://doppler.schmelczer.dev/og-image.png" />
|
<link rel="icon" href="/favicon.ico" type="image/x-icon" />
|
||||||
<meta property="og:image:width" content="2086" />
|
|
||||||
<meta property="og:image:height" content="940" />
|
|
||||||
<meta
|
|
||||||
property="og:image:alt"
|
|
||||||
content="Dimly lit planets surrounding a text saying 'doppler'"
|
|
||||||
/>
|
|
||||||
<meta name="theme-color" content="#b33951" />
|
|
||||||
<meta
|
|
||||||
name="description"
|
|
||||||
content="Conquer the planets, work as a team, and play with your friends online through this browser game."
|
|
||||||
/>
|
|
||||||
|
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="static/apple-touch-icon.png" />
|
<title>Test</title>
|
||||||
<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>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<noscript>Javascript is required for this website.</noscript>
|
<noscript><h1>Javascript is required for this website.</h1></noscript>
|
||||||
|
|
||||||
<canvas></canvas>
|
|
||||||
<div id="overlay"></div>
|
<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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -1,160 +1,31 @@
|
||||||
import { glMatrix } from 'gl-matrix';
|
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 { Game } from './scripts/game';
|
||||||
import ResizeObserver from 'resize-observer-polyfill';
|
import { applyArrayPlugins } from './scripts/helper/array';
|
||||||
import { OptionsHandler } from './scripts/options-handler';
|
import './styles/main.scss';
|
||||||
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);
|
glMatrix.setMatrixArrayType(Array);
|
||||||
|
applyArrayPlugins();
|
||||||
|
|
||||||
overrideDeserialization(CharacterBase, CharacterView);
|
/*
|
||||||
overrideDeserialization(PlanetBase, PlanetView);
|
const tree = new BoundingBoxTree([
|
||||||
overrideDeserialization(LampBase, LampView);
|
new BoundingBox(300, 550, 150, 550, 'A'),
|
||||||
overrideDeserialization(ProjectileBase, ProjectileView);
|
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'),
|
||||||
|
]);
|
||||||
|
|
||||||
const landingUI = document.querySelector('#landing-ui') as HTMLElement;
|
tree.print();
|
||||||
const nameInput = document.querySelector('#name') as HTMLInputElement;
|
console.log(tree.findIntersecting(new BoundingBox(960, 1050, 50, 190, 'G')));
|
||||||
const joinGameForm = document.querySelector('#join-game-form') as HTMLFormElement;
|
*/
|
||||||
const serverContainer = document.querySelector('#server-container') as HTMLElement;
|
|
||||||
const canvas = document.querySelector('canvas') as HTMLCanvasElement;
|
|
||||||
const overlay = document.querySelector('#overlay') as HTMLElement;
|
|
||||||
const settings = document.querySelector('#settings') as HTMLElement;
|
|
||||||
const toggleSettingsButton = document.querySelector(
|
|
||||||
'#toggle-settings-container',
|
|
||||||
) as HTMLElement;
|
|
||||||
const minimize = document.querySelector('#minimize') as HTMLElement;
|
|
||||||
const maximize = document.querySelector('#maximize') as HTMLElement;
|
|
||||||
const logoutButton = document.querySelector('#logout') as HTMLElement;
|
|
||||||
const enableSounds = document.querySelector('#enable-sounds') as HTMLInputElement;
|
|
||||||
const enableMusic = document.querySelector('#enable-music') as HTMLInputElement;
|
|
||||||
const enableVibration = document.querySelector('#enable-vibration') as HTMLInputElement;
|
|
||||||
const spinner = document.querySelector('#spinner-container') as HTMLElement;
|
|
||||||
|
|
||||||
const toggleSettings = () => {
|
|
||||||
settings.className = settings.className === 'open' ? '' : 'open';
|
|
||||||
SoundHandler.play(Sounds.click);
|
|
||||||
};
|
|
||||||
|
|
||||||
const applyServerContainerShadows = () => {
|
|
||||||
const topShadow = 'inset 0 -8px 8px -8px rgba(0, 0, 0, 0.4)';
|
|
||||||
const bottomShadow = 'inset 0 8px 8px -8px rgba(0, 0, 0, 0.4)';
|
|
||||||
|
|
||||||
const { scrollHeight, clientHeight, scrollTop } = serverContainer;
|
|
||||||
if (scrollHeight > clientHeight) {
|
|
||||||
if (scrollTop <= 0) {
|
|
||||||
serverContainer.style.boxShadow = topShadow;
|
|
||||||
} else if (scrollTop + clientHeight >= scrollHeight) {
|
|
||||||
serverContainer.style.boxShadow = bottomShadow;
|
|
||||||
} else {
|
|
||||||
serverContainer.style.boxShadow = topShadow + ',' + bottomShadow;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
serverContainer.style.boxShadow = '';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const main = async () => {
|
|
||||||
try {
|
try {
|
||||||
let game: Game;
|
new 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) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
alert(e);
|
alert(e);
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
main();
|
|
||||||
|
|
|
||||||
|
|
@ -1,940 +0,0 @@
|
||||||
@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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
@ -1,58 +0,0 @@
|
||||||
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);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
13
frontend/src/scripts/commands/command-broadcaster.ts
Normal file
13
frontend/src/scripts/commands/command-broadcaster.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
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))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,18 +1,14 @@
|
||||||
import { CommandReceiver } from './command-receiver';
|
import { CommandReceiver } from './command-receiver';
|
||||||
import { Command } from './command';
|
import { Command } from './command';
|
||||||
|
|
||||||
export abstract class CommandGenerator {
|
export class CommandGenerator {
|
||||||
private subscribers: Array<CommandReceiver> = [];
|
private subscribers: Array<CommandReceiver> = [];
|
||||||
|
|
||||||
public subscribe(subscriber: CommandReceiver): void {
|
public subscribe(subscriber: CommandReceiver): void {
|
||||||
this.subscribers.push(subscriber);
|
this.subscribers.push(subscriber);
|
||||||
}
|
}
|
||||||
|
|
||||||
public clearSubscribers(): void {
|
protected sendCommand(command: Command): void {
|
||||||
this.subscribers = [];
|
this.subscribers.forEach((s) => s.sendCommand(command));
|
||||||
}
|
|
||||||
|
|
||||||
protected sendCommandToSubscribers(command: Command): void {
|
|
||||||
this.subscribers.forEach((s) => s.handleCommand(command));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
5
frontend/src/scripts/commands/command-receiver.ts
Normal file
5
frontend/src/scripts/commands/command-receiver.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { Command } from './command';
|
||||||
|
|
||||||
|
export interface CommandReceiver {
|
||||||
|
sendCommand(command: Command): void;
|
||||||
|
}
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
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 = [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
6
frontend/src/scripts/commands/command.ts
Normal file
6
frontend/src/scripts/commands/command.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { Typed } from '../transport/serializable';
|
||||||
|
import { Id } from '../identity/identity';
|
||||||
|
|
||||||
|
export abstract class Command extends Typed {
|
||||||
|
target?: Id;
|
||||||
|
}
|
||||||
|
|
@ -1,58 +0,0 @@
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,71 +0,0 @@
|
||||||
import { vec2 } from 'gl-matrix';
|
|
||||||
import { CommandGenerator, PrimaryActionCommand, holdDurationToCharge } from 'shared';
|
|
||||||
import { Game } from '../game';
|
|
||||||
import { ChargeIndicator } from '../charge-indicator';
|
|
||||||
import { Pointer } from '../helper/pointer';
|
|
||||||
|
|
||||||
export class MouseListener extends CommandGenerator {
|
|
||||||
// Timestamp (ms) of the primary press, or null when not held. On release the
|
|
||||||
// held duration is mapped to the charge scalar; a quick tap reads as ~0.
|
|
||||||
private primaryDownAt: number | null = null;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private target: HTMLElement,
|
|
||||||
private readonly game: Game,
|
|
||||||
) {
|
|
||||||
super();
|
|
||||||
|
|
||||||
target.addEventListener('mousedown', this.mouseDownListener);
|
|
||||||
target.addEventListener('mouseup', this.mouseUpListener);
|
|
||||||
target.addEventListener('mousemove', this.mouseMoveListener);
|
|
||||||
target.addEventListener('contextmenu', this.contextMenuListener);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only the screen position is stored; it is reprojected to world space each
|
|
||||||
// frame so the gaze stays correct even while the camera pans under a still
|
|
||||||
// cursor.
|
|
||||||
private mouseMoveListener = (event: MouseEvent) => {
|
|
||||||
Pointer.setDisplayPosition(event.clientX, event.clientY);
|
|
||||||
};
|
|
||||||
|
|
||||||
private mouseDownListener = (event: MouseEvent) => {
|
|
||||||
if (event.button === 0) {
|
|
||||||
this.primaryDownAt = performance.now();
|
|
||||||
// The ring follows the cursor and only fades in once this press has
|
|
||||||
// clearly become a hold.
|
|
||||||
ChargeIndicator.begin(event.clientX, event.clientY, true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
private mouseUpListener = (event: MouseEvent) => {
|
|
||||||
if (event.button !== 0 || this.primaryDownAt === null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ChargeIndicator.end();
|
|
||||||
const charge = holdDurationToCharge((performance.now() - this.primaryDownAt) / 1000);
|
|
||||||
this.primaryDownAt = null;
|
|
||||||
this.sendCommandToSubscribers(
|
|
||||||
new PrimaryActionCommand(this.positionFromEvent(event), charge),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Suppress the browser context menu on the canvas; right-click has no action.
|
|
||||||
private contextMenuListener = (event: MouseEvent) => {
|
|
||||||
event.preventDefault();
|
|
||||||
};
|
|
||||||
|
|
||||||
private positionFromEvent(event: MouseEvent): vec2 {
|
|
||||||
return this.game.displayToWorldCoordinates(
|
|
||||||
vec2.fromValues(event.clientX, event.clientY),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public destroy() {
|
|
||||||
ChargeIndicator.end();
|
|
||||||
this.target.removeEventListener('mousedown', this.mouseDownListener);
|
|
||||||
this.target.removeEventListener('mouseup', this.mouseUpListener);
|
|
||||||
this.target.removeEventListener('mousemove', this.mouseMoveListener);
|
|
||||||
this.target.removeEventListener('contextmenu', this.contextMenuListener);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,273 +0,0 @@
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
import { Command } from 'shared';
|
|
||||||
|
|
||||||
export class BeforeDestroyCommand extends Command {
|
|
||||||
public constructor() {
|
|
||||||
super();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
import { Command } from 'shared';
|
|
||||||
|
|
||||||
export class StepCommand extends Command {
|
|
||||||
public constructor(public readonly deltaTimeInSeconds: number) {
|
|
||||||
super();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
/**
|
|
||||||
* 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
12
frontend/src/scripts/drawing/commands/before-render.ts
Normal file
12
frontend/src/scripts/drawing/commands/before-render.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
}
|
||||||
12
frontend/src/scripts/drawing/commands/render.ts
Normal file
12
frontend/src/scripts/drawing/commands/render.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
export interface IDrawableDescriptor {
|
||||||
|
uniformName: string;
|
||||||
|
countMacroName: string;
|
||||||
|
shaderCombinationSteps: Array<number>;
|
||||||
|
}
|
||||||
11
frontend/src/scripts/drawing/drawables/i-drawable.ts
Normal file
11
frontend/src/scripts/drawing/drawables/i-drawable.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
3
frontend/src/scripts/drawing/drawables/lights/i-light.ts
Normal file
3
frontend/src/scripts/drawing/drawables/lights/i-light.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
import { IDrawable } from '../i-drawable';
|
||||||
|
|
||||||
|
export interface ILight extends IDrawable {}
|
||||||
50
frontend/src/scripts/drawing/drawables/lights/point-light.ts
Normal file
50
frontend/src/scripts/drawing/drawables/lights/point-light.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
47
frontend/src/scripts/drawing/drawables/primitives/circle.ts
Normal file
47
frontend/src/scripts/drawing/drawables/primitives/circle.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { IDrawable } from '../i-drawable';
|
||||||
|
|
||||||
|
export interface IPrimitive extends IDrawable {
|
||||||
|
clone(): IPrimitive
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,99 @@
|
||||||
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
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
Loading…
Add table
Add a link
Reference in a new issue