Improve netcode and simplify (#4)
- Simplify the logic wherever possible to remove overengineering and simplify the netcode - Actually improve the netcode - The new lines are mainly the package locks and tests Reviewed-on: https://home.schmelczer.dev/git/andras/decla-red/pulls/4
This commit is contained in:
parent
5d854440ec
commit
93a7fe01ab
176 changed files with 15073 additions and 4754 deletions
|
|
@ -1,4 +1,3 @@
|
||||||
# The server image only needs `shared/` and `backend/`.
|
|
||||||
**/node_modules
|
**/node_modules
|
||||||
**/dist
|
**/dist
|
||||||
**/lib
|
**/lib
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,6 @@ concurrency:
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# ---- Static website -> /pages mount on the runner host --------------------
|
|
||||||
website:
|
website:
|
||||||
name: Build & deploy website
|
name: Build & deploy website
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
|
|
@ -26,8 +25,11 @@ jobs:
|
||||||
node-version-file: .nvmrc
|
node-version-file: .nvmrc
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
# No lockfiles are committed (see .gitignore), so use `npm install`.
|
run: |
|
||||||
run: npm install && npm run init
|
npm ci
|
||||||
|
npm --prefix shared ci
|
||||||
|
npm --prefix frontend ci
|
||||||
|
npm --prefix backend ci
|
||||||
|
|
||||||
- name: Build (shared -> frontend -> backend)
|
- name: Build (shared -> frontend -> backend)
|
||||||
run: npm run build
|
run: npm run build
|
||||||
|
|
@ -45,11 +47,9 @@ jobs:
|
||||||
source: frontend/dist
|
source: frontend/dist
|
||||||
target: declared
|
target: declared
|
||||||
|
|
||||||
# ---- Server Docker image -> Forgejo container registry --------------------
|
|
||||||
server-image:
|
server-image:
|
||||||
name: Build & publish server image
|
name: Build & publish server image
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
# No registry push on PRs; build validation still happens in the website job.
|
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request'
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
|
|
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -4,5 +4,4 @@ node_modules
|
||||||
.firebase
|
.firebase
|
||||||
yarm-error.log
|
yarm-error.log
|
||||||
yarn.lock
|
yarn.lock
|
||||||
package-lock.json
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
|
||||||
3
.vscode/launch.json
vendored
3
.vscode/launch.json
vendored
|
|
@ -1,7 +1,4 @@
|
||||||
{
|
{
|
||||||
// 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",
|
"version": "0.2.0",
|
||||||
"configurations": [
|
"configurations": [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
26
Dockerfile
26
Dockerfile
|
|
@ -1,38 +1,29 @@
|
||||||
# syntax=docker/dockerfile:1
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
# doppler game server (the `doppler-server` package).
|
# The full image carries the build tools for socket.io's optional native dependencies.
|
||||||
# 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
|
FROM node:22-bookworm AS build
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# `shared` is consumed by the backend as `file:../shared` and is bundled into
|
# `shared` is consumed by the backend as `file:../shared` and is bundled into
|
||||||
# the server bundle by webpack, so it must be installed and built first.
|
# the server bundle by webpack, so it must be installed and built first.
|
||||||
COPY shared/package.json shared/
|
COPY shared/package.json shared/package-lock.json shared/
|
||||||
RUN cd shared && npm install
|
RUN npm --prefix shared ci
|
||||||
COPY shared/ shared/
|
COPY shared/ shared/
|
||||||
RUN cd shared && npm run build
|
RUN npm --prefix shared run build
|
||||||
|
|
||||||
# Install the backend deps. `file:../shared` now resolves to the built /app/shared.
|
COPY backend/package.json backend/package-lock.json backend/
|
||||||
COPY backend/package.json backend/
|
RUN npm --prefix backend ci
|
||||||
RUN cd backend && npm install
|
|
||||||
COPY backend/ backend/
|
COPY backend/ backend/
|
||||||
RUN cd backend && npm run build
|
RUN npm --prefix backend run build
|
||||||
|
|
||||||
# Drop devDependencies; the runtime only needs the production deps that webpack
|
# Drop devDependencies; the runtime only needs the production deps that webpack
|
||||||
# left external (express, socket.io, cors, gl-matrix, minimist, msgpack parser).
|
# left external (express, socket.io, cors, gl-matrix, minimist, msgpack parser).
|
||||||
RUN cd backend && npm prune --production
|
RUN npm --prefix backend prune --production
|
||||||
|
|
||||||
# ---- Stage 2: minimal runtime ----------------------------------------------
|
|
||||||
FROM node:22-bookworm-slim
|
FROM node:22-bookworm-slim
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
# Run as an unprivileged user.
|
|
||||||
RUN groupadd -r app && useradd -r -g app -d /app app
|
RUN groupadd -r app && useradd -r -g app -d /app app
|
||||||
|
|
||||||
COPY --from=build /app/backend/dist ./dist
|
COPY --from=build /app/backend/dist ./dist
|
||||||
|
|
@ -41,7 +32,6 @@ COPY --from=build /app/backend/node_modules ./node_modules
|
||||||
USER app
|
USER app
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
# Hits the same /state endpoint the website polls (see serverInformationEndpoint).
|
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
||||||
CMD node -e "require('http').get('http://localhost:3000/state',r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))"
|
CMD node -e "require('http').get('http://localhost:3000/state',r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))"
|
||||||
|
|
||||||
|
|
|
||||||
16
README.md
16
README.md
|
|
@ -9,19 +9,11 @@ A 2-dimensional multiplayer game utilising ray tracing.
|
||||||
|
|
||||||
For optimised 2D ray tracing, [SDF-2D](https://github.com/schmelczerandras/sdf-2d) is used.
|
For optimised 2D ray tracing, [SDF-2D](https://github.com/schmelczerandras/sdf-2d) is used.
|
||||||
|
|
||||||
## Deployment
|
## Development
|
||||||
|
|
||||||
CI/CD runs on Forgejo Actions (`.forgejo/workflows/deploy.yml`). On a push to
|
Run `npm install && npm run init` once, then `npm run dev`. Run `npm test` for
|
||||||
`main` it:
|
regressions, `npm run lint:check` for formatting and lint, and `npm run build` for
|
||||||
|
all production bundles. Tests rebuild the shared library before running.
|
||||||
- builds the static frontend and rsyncs `frontend/dist/` to the `/pages/declared`
|
|
||||||
mount on the runner host (the mount keeps its pre-rebrand name), and
|
|
||||||
- builds the server image from the root `Dockerfile` and pushes it to the Forgejo
|
|
||||||
container registry as `<registry>/<owner>/<repo>-server`.
|
|
||||||
|
|
||||||
The registry job needs a `FORGEJO_PACKAGE_TOKEN` secret (with package write
|
|
||||||
scope) and, optionally, a `CONTAINER_REGISTRY_HOST` variable to override the
|
|
||||||
registry host.
|
|
||||||
|
|
||||||
The website's server list is hardcoded in
|
The website's server list is hardcoded in
|
||||||
[`frontend/src/scripts/configuration.ts`](frontend/src/scripts/configuration.ts) —
|
[`frontend/src/scripts/configuration.ts`](frontend/src/scripts/configuration.ts) —
|
||||||
|
|
|
||||||
3635
backend/package-lock.json
generated
Normal file
3635
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -13,7 +13,7 @@
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "npx webpack --mode production",
|
"build": "npx webpack --mode production",
|
||||||
"dev": "concurrently --kill-others-on-fail \"webpack --mode development -w\" \"nodemon --legacy-watch dist/main.js\"",
|
"dev": "concurrently --kill-others-on-fail \"webpack --mode development -w\" \"nodemon --legacy-watch --delay 500ms dist/main.js\"",
|
||||||
"try-build": "npm run build && cd dist && node main.js && cd -"
|
"try-build": "npm run build && cd dist && node main.js && cd -"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
|
||||||
|
|
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -2,16 +2,14 @@ import { vec2 } from 'gl-matrix';
|
||||||
import { Random, PlanetBase, hsl, settings, evaluateSdf } from 'shared';
|
import { Random, PlanetBase, hsl, settings, evaluateSdf } from 'shared';
|
||||||
import { LampPhysical } from './objects/lamp-physical';
|
import { LampPhysical } from './objects/lamp-physical';
|
||||||
import { PlanetPhysical } from './objects/planet-physical';
|
import { PlanetPhysical } from './objects/planet-physical';
|
||||||
import { PhysicalContainer } from './physics/containers/physical-container';
|
import { PhysicalContainer } from './physics/physical-container';
|
||||||
import { Physical } from './physics/physicals/physical';
|
|
||||||
|
|
||||||
export const createWorld = (objectContainer: PhysicalContainer) => {
|
export const createWorld = (objectContainer: PhysicalContainer) => {
|
||||||
const objects: Array<Physical> = [];
|
const planets: Array<PlanetPhysical> = [];
|
||||||
const lights: Array<Physical> = [];
|
const lamps: Array<LampPhysical> = [];
|
||||||
|
|
||||||
for (let r = 0; r < settings.worldRadius; r += settings.radiusSteps) {
|
for (let r = 0; r < settings.worldRadius; r += settings.radiusSteps) {
|
||||||
const circumference = 2 * Math.PI * r;
|
const stepCount = 2 * Math.PI * r * settings.objectsOnCircleLength;
|
||||||
const stepCount = circumference * settings.objectsOnCircleLength;
|
|
||||||
for (let rad = 0; rad < 2 * Math.PI; rad += (2 * Math.PI) / stepCount) {
|
for (let rad = 0; rad < 2 * Math.PI; rad += (2 * Math.PI) / stepCount) {
|
||||||
const position = vec2.rotate(
|
const position = vec2.rotate(
|
||||||
vec2.create(),
|
vec2.create(),
|
||||||
|
|
@ -20,12 +18,12 @@ export const createWorld = (objectContainer: PhysicalContainer) => {
|
||||||
rad,
|
rad,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (objects.length !== 0 && Random.getRandom() > 0.5) {
|
if (planets.length !== 0 && Random.getRandom() > 0.5) {
|
||||||
if (
|
if (
|
||||||
evaluateSdf(position, objects) > 200 &&
|
evaluateSdf(position, planets) > 200 &&
|
||||||
!lights.find((l) => l.distance(position) < 2500)
|
!lamps.find((l) => l.distance(position) < 2500)
|
||||||
) {
|
) {
|
||||||
lights.push(
|
lamps.push(
|
||||||
new LampPhysical(
|
new LampPhysical(
|
||||||
position,
|
position,
|
||||||
hsl(
|
hsl(
|
||||||
|
|
@ -37,61 +35,43 @@ export const createWorld = (objectContainer: PhysicalContainer) => {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else if (
|
||||||
if (
|
evaluateSdf(position, planets) > 1400 &&
|
||||||
evaluateSdf(position, objects) > 1400 &&
|
!lamps.find((l) => l.distance(position) < 1700)
|
||||||
!lights.find((l) => l.distance(position) < 1700)
|
) {
|
||||||
) {
|
const isKeystone = planets.length === 0;
|
||||||
const planet =
|
const [sizeMin, sizeMax, roughMin, roughMax] = isKeystone
|
||||||
objects.length === 0
|
? [1600, 2400, 80, 300]
|
||||||
? // The first, central giant is the keystone "Heart": a named,
|
: [300, 1600, 20, 100];
|
||||||
// always-contested focal objective the whole match orbits.
|
planets.push(
|
||||||
new PlanetPhysical(
|
new PlanetPhysical(
|
||||||
PlanetBase.createPlanetVertices(
|
PlanetBase.createPlanetVertices(
|
||||||
position,
|
position,
|
||||||
Random.getRandomInRange(1600, 2400),
|
Random.getRandomInRange(sizeMin, sizeMax),
|
||||||
Random.getRandomInRange(1600, 2400),
|
Random.getRandomInRange(sizeMin, sizeMax),
|
||||||
Random.getRandomInRange(80, 300),
|
Random.getRandomInRange(roughMin, roughMax),
|
||||||
),
|
),
|
||||||
true,
|
isKeystone,
|
||||||
)
|
objectContainer.game,
|
||||||
: 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 ${planets.length} planets and ${lamps.length} lamps`);
|
||||||
console.info(`Generated ${lights.length} light`);
|
|
||||||
|
|
||||||
// Associate each lamp with its NEAREST planet, so a planet can repaint "its"
|
for (const lamp of lamps) {
|
||||||
// lamps to the owning team's colour when it flips. Lamps are already placed by
|
let nearest: PlanetPhysical | undefined;
|
||||||
// proximity during world-gen, so the nearest planet is the one whose capture
|
let nearestDistance = Infinity;
|
||||||
// they should advertise. Distances use the planet SDF (negative inside), which
|
for (const planet of planets) {
|
||||||
// is exactly the "closest planet" metric we want.
|
const distance = planet.distance(lamp.center);
|
||||||
const planets = objects.filter((o): o is PlanetPhysical => o instanceof PlanetPhysical);
|
if (distance < nearestDistance) {
|
||||||
lights
|
nearestDistance = distance;
|
||||||
.filter((l): l is LampPhysical => l instanceof LampPhysical)
|
nearest = planet;
|
||||||
.forEach((lamp) => {
|
}
|
||||||
let nearest: PlanetPhysical | undefined;
|
}
|
||||||
let nearestDistance = Infinity;
|
nearest?.addLamp(lamp);
|
||||||
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));
|
[...planets, ...lamps].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,
|
|
||||||
};
|
|
||||||
17
backend/src/game-events.ts
Normal file
17
backend/src/game-events.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import { CharacterTeam } from 'shared';
|
||||||
|
|
||||||
|
export interface GameEvents {
|
||||||
|
addPoints(blue: number, red: number): void;
|
||||||
|
announce(text: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const addPointsForTeam = (
|
||||||
|
game: GameEvents,
|
||||||
|
team: CharacterTeam,
|
||||||
|
points: number,
|
||||||
|
): void => {
|
||||||
|
game.addPoints(
|
||||||
|
team === CharacterTeam.blue ? points : 0,
|
||||||
|
team === CharacterTeam.red ? points : 0,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { PhysicalContainer } from './physics/containers/physical-container';
|
|
||||||
import { Server, Socket } from 'socket.io';
|
import { Server, Socket } from 'socket.io';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { performance } from 'node:perf_hooks';
|
||||||
import {
|
import {
|
||||||
TransportEvents,
|
TransportEvents,
|
||||||
deserialize,
|
deserialize,
|
||||||
|
|
@ -11,25 +12,36 @@ import {
|
||||||
GameEndCommand,
|
GameEndCommand,
|
||||||
GameStartCommand,
|
GameStartCommand,
|
||||||
Command,
|
Command,
|
||||||
CommandReceiver,
|
|
||||||
CommandExecutors,
|
|
||||||
ServerAnnouncement,
|
ServerAnnouncement,
|
||||||
|
JoinRejectionReason,
|
||||||
|
GameObject,
|
||||||
|
PropertyUpdatesForObject,
|
||||||
} from 'shared';
|
} from 'shared';
|
||||||
|
import { PhysicalContainer } from './physics/physical-container';
|
||||||
import { createWorld } from './create-world';
|
import { createWorld } from './create-world';
|
||||||
import { DeltaTimeCalculator } from './helper/delta-time-calculator';
|
|
||||||
import { Options } from './options';
|
import { Options } from './options';
|
||||||
import { PlayerContainer } from './players/player-container';
|
import { GameEvents } from './game-events';
|
||||||
import { StepCommand } from './commands/step';
|
import { CarriedScore, PlayerContainer } from './players/player-container';
|
||||||
import { GeneratePointsCommand } from './commands/generate-points';
|
import { Player } from './players/player';
|
||||||
import { AnnounceCommand } from './commands/announce';
|
|
||||||
|
|
||||||
const gameStateSubscribedRoom = 'gameStateSubscribedRoom';
|
const gameStateSubscribedRoom = 'gameStateSubscribedRoom';
|
||||||
|
const endTitleSeconds = 6;
|
||||||
|
|
||||||
export class GameServer extends CommandReceiver {
|
const asPlayerInformation = (payload: unknown): PlayerInformation | undefined => {
|
||||||
|
if (!payload || typeof payload !== 'object') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const { name, reconnectToken } = payload as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
name: name as string,
|
||||||
|
reconnectToken: typeof reconnectToken === 'string' ? reconnectToken : undefined,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export class GameServer implements GameEvents {
|
||||||
private objects!: PhysicalContainer;
|
private objects!: PhysicalContainer;
|
||||||
private players!: PlayerContainer;
|
private players!: PlayerContainer;
|
||||||
private deltaTimes!: Array<number>;
|
private lastPhysicsBase = process.hrtime.bigint();
|
||||||
private deltaTimeCalculator!: DeltaTimeCalculator;
|
|
||||||
|
|
||||||
private bluePoints = 0;
|
private bluePoints = 0;
|
||||||
private redPoints = 0;
|
private redPoints = 0;
|
||||||
|
|
@ -37,92 +49,202 @@ export class GameServer extends CommandReceiver {
|
||||||
|
|
||||||
private isInEndGame = false;
|
private isInEndGame = false;
|
||||||
private timeScaling = 1;
|
private timeScaling = 1;
|
||||||
|
private statReportMs = Date.now();
|
||||||
|
private timeSinceLastServerStateUpdate = 0;
|
||||||
|
private timeSinceLastPointUpdate = 0;
|
||||||
|
private physicsAccumulator = 0;
|
||||||
|
private saturatedFrames = 0;
|
||||||
|
private droppedInboundMessages = 0;
|
||||||
|
|
||||||
private serverName: string;
|
private readonly joinedSockets = new Map<
|
||||||
private playerLimit: number;
|
Socket,
|
||||||
|
{ player: Player; release: () => void }
|
||||||
private initialize() {
|
>();
|
||||||
const previousPlayers = this.players;
|
private inboundBudget = new WeakMap<Socket, { tokens: number; lastMs: number }>();
|
||||||
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(
|
constructor(
|
||||||
private readonly io: Server,
|
private readonly io: Server,
|
||||||
private options: Options,
|
private options: Options,
|
||||||
) {
|
) {
|
||||||
super();
|
|
||||||
|
|
||||||
this.serverName = options.name;
|
|
||||||
this.playerLimit = options.playerLimit;
|
|
||||||
|
|
||||||
this.initialize();
|
this.initialize();
|
||||||
|
|
||||||
io.on('connection', (socket: Socket) => {
|
io.on('connection', (socket: Socket) => {
|
||||||
socket.on(TransportEvents.PlayerJoining, (playerInfo: PlayerInformation) => {
|
socket.on(TransportEvents.PlayerJoining, (payload: unknown) => {
|
||||||
|
if (!this.allowInboundMessage(socket)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const player = this.players.createPlayer(playerInfo, socket);
|
this.handleJoin(socket, payload);
|
||||||
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) {
|
} catch (e) {
|
||||||
console.error('Failed to register joining player; disconnecting socket', e);
|
console.error('Failed to handle a join request', e);
|
||||||
|
socket.emit(TransportEvents.JoinRejected, JoinRejectionReason.InvalidRequest);
|
||||||
socket.disconnect();
|
socket.disconnect();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on(TransportEvents.SubscribeForServerInfoUpdates, () => {
|
socket.on(TransportEvents.SubscribeForServerInfoUpdates, () => {
|
||||||
socket.join(gameStateSubscribedRoom);
|
if (this.allowInboundMessage(socket)) {
|
||||||
|
socket.join(gameStateSubscribedRoom);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private timeSinceLastServerStateUpdate = 0;
|
private initialize() {
|
||||||
|
const previousPlayers = this.players;
|
||||||
|
this.joinedSockets.forEach(({ release }) => release());
|
||||||
|
this.joinedSockets.clear();
|
||||||
|
this.inboundBudget = new WeakMap();
|
||||||
|
|
||||||
|
this.objects = new PhysicalContainer(this);
|
||||||
|
createWorld(this.objects);
|
||||||
|
this.players = new PlayerContainer(
|
||||||
|
this.objects,
|
||||||
|
this.options.playerLimit,
|
||||||
|
this.options.npcCount,
|
||||||
|
);
|
||||||
|
this.lastPhysicsBase = process.hrtime.bigint();
|
||||||
|
this.bluePoints = 0;
|
||||||
|
this.redPoints = 0;
|
||||||
|
this.matchPointAnnounced = {};
|
||||||
|
this.isInEndGame = false;
|
||||||
|
this.timeScaling = 1;
|
||||||
|
this.physicsAccumulator = 0;
|
||||||
|
this.timeSinceLastServerStateUpdate = 0;
|
||||||
|
this.timeSinceLastPointUpdate = 0;
|
||||||
|
previousPlayers?.queueCommandForEachClient(new GameStartCommand());
|
||||||
|
previousPlayers?.sendQueuedCommands();
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleJoin(socket: Socket, payload: unknown) {
|
||||||
|
const playerInfo = asPlayerInformation(payload);
|
||||||
|
if (!playerInfo) {
|
||||||
|
socket.emit(TransportEvents.JoinRejected, JoinRejectionReason.InvalidRequest);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.joinedSockets.has(socket)) {
|
||||||
|
socket.emit(TransportEvents.JoinRejected, JoinRejectionReason.AlreadyJoined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isInEndGame) {
|
||||||
|
socket.emit(TransportEvents.JoinRejected, JoinRejectionReason.RoundEnding);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const carried = this.retireGhost(playerInfo.reconnectToken);
|
||||||
|
|
||||||
|
if (this.players.isFull) {
|
||||||
|
socket.emit(TransportEvents.JoinRejected, JoinRejectionReason.ServerFull);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const player = this.players.createPlayer(playerInfo, socket, carried);
|
||||||
|
|
||||||
|
const onPlayerToServer = (json: unknown) => {
|
||||||
|
try {
|
||||||
|
if (typeof json !== 'string' || json.length > settings.maxInboundMessageBytes) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.allowInboundMessage(socket)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const commands: Array<Command> = deserialize(json);
|
||||||
|
if (!Array.isArray(commands)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
commands.forEach((c) => player.handleCommand(c));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error while processing command', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDisconnect = () => {
|
||||||
|
this.removeJoined(socket);
|
||||||
|
this.sendServerStateUpdate();
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.on(TransportEvents.PlayerToServer, onPlayerToServer);
|
||||||
|
socket.on('disconnect', onDisconnect);
|
||||||
|
this.joinedSockets.set(socket, {
|
||||||
|
player,
|
||||||
|
release: () => {
|
||||||
|
socket.off(TransportEvents.PlayerToServer, onPlayerToServer);
|
||||||
|
socket.off('disconnect', onDisconnect);
|
||||||
|
player.detachFromSocket();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
player.reconnectToken = randomUUID();
|
||||||
|
socket.emit(TransportEvents.PlayerJoined, player.reconnectToken);
|
||||||
|
|
||||||
|
this.sendServerStateUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
private allowInboundMessage(socket: Socket): boolean {
|
||||||
|
// performance.now(), not Date.now(): a backwards wall-clock step would lock the client out.
|
||||||
|
const nowMs = performance.now();
|
||||||
|
const burst = settings.maxInboundMessageBurst;
|
||||||
|
const perSecond = settings.maxInboundMessagesPerSecond;
|
||||||
|
|
||||||
|
const budget = this.inboundBudget.get(socket) ?? { tokens: burst, lastMs: nowMs };
|
||||||
|
budget.tokens = Math.min(
|
||||||
|
burst,
|
||||||
|
budget.tokens + (Math.max(0, nowMs - budget.lastMs) / 1000) * perSecond,
|
||||||
|
);
|
||||||
|
budget.lastMs = nowMs;
|
||||||
|
this.inboundBudget.set(socket, budget);
|
||||||
|
|
||||||
|
if (budget.tokens < 1) {
|
||||||
|
this.droppedInboundMessages++;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
budget.tokens -= 1;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private retireGhost(token?: string): CarriedScore | undefined {
|
||||||
|
if (!token) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
for (const [socket, { player }] of this.joinedSockets) {
|
||||||
|
if (player.reconnectToken === token) {
|
||||||
|
const carried = { team: player.team, ...player.score };
|
||||||
|
this.removeJoined(socket);
|
||||||
|
return carried;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private removeJoined(socket: Socket) {
|
||||||
|
const joined = this.joinedSockets.get(socket);
|
||||||
|
if (!joined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.joinedSockets.delete(socket);
|
||||||
|
this.inboundBudget.delete(socket);
|
||||||
|
joined.release();
|
||||||
|
joined.player.destroy();
|
||||||
|
this.players.deletePlayer(joined.player);
|
||||||
|
}
|
||||||
|
|
||||||
public sendServerStateUpdate() {
|
public sendServerStateUpdate() {
|
||||||
this.io
|
this.io
|
||||||
.to(gameStateSubscribedRoom)
|
.to(gameStateSubscribedRoom)
|
||||||
.emit(TransportEvents.ServerInfoUpdate, [this.players.count, this.gameProgress]);
|
.emit(TransportEvents.ServerInfoUpdate, [
|
||||||
|
this.players.count,
|
||||||
|
this.serverInfo.gameStatePercent,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public start() {
|
public start() {
|
||||||
this.handlePhysics();
|
this.handlePhysics();
|
||||||
}
|
}
|
||||||
|
|
||||||
private addPoints({ blue, red }: GeneratePointsCommand) {
|
public addPoints(blue: number, red: number) {
|
||||||
if (this.isInEndGame) {
|
if (this.isInEndGame) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -139,45 +261,60 @@ export class GameServer extends CommandReceiver {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public announce(text: string) {
|
||||||
|
this.players.queueCommandForEachClient(new ServerAnnouncement(text));
|
||||||
|
}
|
||||||
|
|
||||||
private announceMatchPointOnce(team: CharacterTeam, points: number) {
|
private announceMatchPointOnce(team: CharacterTeam, points: number) {
|
||||||
if (
|
if (
|
||||||
!this.matchPointAnnounced[team] &&
|
!this.matchPointAnnounced[team] &&
|
||||||
points >= this.options.scoreLimit * settings.matchPointScoreRatio
|
points >= this.options.scoreLimit * settings.matchPointScoreRatio
|
||||||
) {
|
) {
|
||||||
this.matchPointAnnounced[team] = true;
|
this.matchPointAnnounced[team] = true;
|
||||||
this.players.queueCommandForEachClient(
|
this.announce(`Match point — team <span class="${team}">${team}</span>!`);
|
||||||
new ServerAnnouncement(
|
|
||||||
`Match point — team <span class="${team}">${team}</span>!`,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private endGame(winningTeam: CharacterTeam) {
|
private endGame(winningTeam: CharacterTeam) {
|
||||||
this.isInEndGame = true;
|
this.isInEndGame = true;
|
||||||
const endTitleLength = 6;
|
|
||||||
this.players.endGame(winningTeam);
|
this.players.endGame(winningTeam);
|
||||||
this.players.queueCommandForEachClient(
|
this.players.queueCommandForEachClient(new GameEndCommand());
|
||||||
new GameEndCommand(winningTeam, endTitleLength, true),
|
setTimeout(() => this.initialize(), endTitleSeconds * 1000 * 1.1);
|
||||||
);
|
|
||||||
setTimeout(() => this.destroy(), endTitleLength * 1000 * 1.1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private destroy() {
|
private reportStats() {
|
||||||
this.initialize();
|
const mem = `${(process.memoryUsage().rss / 1024 / 1024).toFixed(2)} MB`;
|
||||||
}
|
console.info(`Memory: ${mem}, Players: ${this.players.count}`);
|
||||||
|
|
||||||
private timeSinceLastPointUpdate = 0;
|
const rtts = this.players.connectedPlayerRttsMs.filter((r) => r > 0);
|
||||||
private physicsAccumulator = 0;
|
if (rtts.length > 0) {
|
||||||
// Frames since the last stats report where physics ran over budget (more
|
rtts.sort((a, b) => a - b);
|
||||||
// substeps than the cap). Surfaced by handleStats as a saturation signal.
|
console.info(
|
||||||
private saturatedFrames = 0;
|
`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.droppedInboundMessages > 0) {
|
||||||
|
console.warn(`Rate limited ${this.droppedInboundMessages} inbound msg(s)`);
|
||||||
|
this.droppedInboundMessages = 0;
|
||||||
|
}
|
||||||
|
if (this.saturatedFrames > 0) {
|
||||||
|
console.warn(`Physics saturated on ${this.saturatedFrames} frame(s)`);
|
||||||
|
this.saturatedFrames = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private handlePhysics() {
|
private handlePhysics() {
|
||||||
const delta = this.deltaTimeCalculator.getNextDeltaTimeInSeconds({ setAsBase: true });
|
const frameStart = process.hrtime.bigint();
|
||||||
this.deltaTimes.push(delta);
|
const frameStartMs = performance.now();
|
||||||
|
const delta = Number(frameStart - this.lastPhysicsBase) / 1e9;
|
||||||
|
this.lastPhysicsBase = frameStart;
|
||||||
|
|
||||||
this.handleStats();
|
if (Date.now() - this.statReportMs > 30000) {
|
||||||
|
this.statReportMs = Date.now();
|
||||||
|
this.reportStats();
|
||||||
|
}
|
||||||
|
|
||||||
if ((this.timeSinceLastServerStateUpdate += delta) > 4) {
|
if ((this.timeSinceLastServerStateUpdate += delta) > 4) {
|
||||||
this.timeSinceLastServerStateUpdate = 0;
|
this.timeSinceLastServerStateUpdate = 0;
|
||||||
|
|
@ -193,18 +330,11 @@ export class GameServer extends CommandReceiver {
|
||||||
|
|
||||||
const fixedDelta = settings.targetPhysicsDeltaTimeInSeconds;
|
const fixedDelta = settings.targetPhysicsDeltaTimeInSeconds;
|
||||||
const maxSubstepsPerFrame = 5;
|
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;
|
const maxBacklogSeconds = 0.25;
|
||||||
|
|
||||||
this.physicsAccumulator += delta;
|
this.physicsAccumulator += delta;
|
||||||
let substeps = Math.floor(this.physicsAccumulator / fixedDelta);
|
let substeps = Math.floor(this.physicsAccumulator / fixedDelta);
|
||||||
if (substeps > maxSubstepsPerFrame) {
|
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.saturatedFrames++;
|
||||||
this.physicsAccumulator = Math.min(
|
this.physicsAccumulator = Math.min(
|
||||||
this.physicsAccumulator - maxSubstepsPerFrame * fixedDelta,
|
this.physicsAccumulator - maxSubstepsPerFrame * fixedDelta,
|
||||||
|
|
@ -221,69 +351,41 @@ export class GameServer extends CommandReceiver {
|
||||||
this.timeScaling *= Math.pow(settings.endGameDeltaScaling, fixedDelta);
|
this.timeScaling *= Math.pow(settings.endGameDeltaScaling, fixedDelta);
|
||||||
scaledDelta /= this.timeScaling;
|
scaledDelta /= this.timeScaling;
|
||||||
}
|
}
|
||||||
this.objects.handleCommand(new StepCommand(scaledDelta, this));
|
this.objects.step(scaledDelta);
|
||||||
this.players.step(scaledDelta);
|
this.players.step(scaledDelta);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.players.stepCommunication(delta);
|
// Physics has only advanced to the start of this frame less whatever is still in the
|
||||||
this.objects.resetRemoteCalls();
|
// accumulator. Timing anything off the send time instead would put up to one physics step
|
||||||
|
// of noise on every interpolated position and on the client's replay anchor.
|
||||||
|
const simulatedThroughMs = frameStartMs - this.physicsAccumulator * 1000;
|
||||||
|
|
||||||
const physicsDelta = this.deltaTimeCalculator.getNextDeltaTimeInSeconds();
|
if (substeps > 0) {
|
||||||
|
const propertyUpdates = new Map<GameObject, PropertyUpdatesForObject | undefined>();
|
||||||
setTimeout(
|
this.players.stepCommunication(
|
||||||
this.handlePhysics.bind(this),
|
substeps * fixedDelta,
|
||||||
Math.max(0, fixedDelta - physicsDelta) * 1000,
|
simulatedThroughMs,
|
||||||
);
|
(object) => {
|
||||||
}
|
if (!propertyUpdates.has(object)) {
|
||||||
|
propertyUpdates.set(object, object.getPropertyUpdates(1 / this.timeScaling));
|
||||||
private handleStats() {
|
}
|
||||||
const framesBetweenDeltaTimeCalculation = 10000;
|
return propertyUpdates.get(object);
|
||||||
|
},
|
||||||
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(
|
this.objects.resetRemoteCalls();
|
||||||
'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 {
|
const elapsed = Number(process.hrtime.bigint() - frameStart) / 1e9;
|
||||||
return (Math.max(this.bluePoints, this.redPoints) / this.options.scoreLimit) * 100;
|
setTimeout(() => this.handlePhysics(), Math.max(0, fixedDelta - elapsed) * 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
public get serverInfo(): ServerInformation {
|
public get serverInfo(): ServerInformation {
|
||||||
return {
|
return {
|
||||||
serverName: this.serverName,
|
serverName: this.options.name,
|
||||||
playerCount: this.players.count,
|
playerCount: this.players.count,
|
||||||
playerLimit: this.playerLimit,
|
playerLimit: this.options.playerLimit,
|
||||||
gameStatePercent: this.gameProgress,
|
gameStatePercent:
|
||||||
|
(Math.max(this.bluePoints, this.redPoints) / this.options.scoreLimit) * 100,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -2,15 +2,14 @@ import { Server as IoServer } from 'socket.io';
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import { Server } from 'http';
|
import { Server } from 'http';
|
||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
import { applyArrayPlugins, Random, serverInformationEndpoint } from 'shared';
|
import { Random, serverInformationEndpoint, settings } from 'shared';
|
||||||
import minimist from 'minimist';
|
import minimist from 'minimist';
|
||||||
import { glMatrix } from 'gl-matrix';
|
import { glMatrix } from 'gl-matrix';
|
||||||
import { GameServer } from './game-server';
|
import { GameServer } from './game-server';
|
||||||
import { defaultOptions } from './default-options';
|
import { defaultOptions } from './options';
|
||||||
import parser from 'socket.io-msgpack-parser';
|
import parser from 'socket.io-msgpack-parser';
|
||||||
|
|
||||||
glMatrix.setMatrixArrayType(Array);
|
glMatrix.setMatrixArrayType(Array);
|
||||||
applyArrayPlugins();
|
|
||||||
|
|
||||||
const optionOverrides = minimist(process.argv.slice(2));
|
const optionOverrides = minimist(process.argv.slice(2));
|
||||||
const options = {
|
const options = {
|
||||||
|
|
@ -24,6 +23,7 @@ const app = express();
|
||||||
const server = new Server(app);
|
const server = new Server(app);
|
||||||
const io = new IoServer(server, {
|
const io = new IoServer(server, {
|
||||||
parser,
|
parser,
|
||||||
|
maxHttpBufferSize: settings.maxInboundMessageBytes,
|
||||||
cors: {
|
cors: {
|
||||||
origin: true,
|
origin: true,
|
||||||
credentials: true,
|
credentials: true,
|
||||||
|
|
@ -32,14 +32,7 @@ const io = new IoServer(server, {
|
||||||
|
|
||||||
const gameServer = new GameServer(io, options);
|
const gameServer = new GameServer(io, options);
|
||||||
|
|
||||||
app.use(
|
app.use(cors({ origin: true, credentials: true }));
|
||||||
cors({
|
|
||||||
origin: (_, callback) => {
|
|
||||||
callback(null, true);
|
|
||||||
},
|
|
||||||
credentials: true,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
app.get(serverInformationEndpoint, (_, res) => {
|
app.get(serverInformationEndpoint, (_, res) => {
|
||||||
res.json(gameServer.serverInfo);
|
res.json(gameServer.serverInfo);
|
||||||
|
|
|
||||||
|
|
@ -2,22 +2,23 @@ import { vec2 } from 'gl-matrix';
|
||||||
import {
|
import {
|
||||||
id,
|
id,
|
||||||
settings,
|
settings,
|
||||||
MoveActionCommand,
|
|
||||||
serializesTo,
|
|
||||||
last,
|
|
||||||
Circle,
|
Circle,
|
||||||
CharacterBase,
|
CharacterBase,
|
||||||
CharacterTeam,
|
CharacterTeam,
|
||||||
PropertyUpdatesForObject,
|
PropertyUpdatesForObject,
|
||||||
UpdatePropertyCommand,
|
UpdatePropertyCommand,
|
||||||
CommandExecutors,
|
CharacterMovementSnapshot,
|
||||||
CommandReceiver,
|
GameObject,
|
||||||
mix,
|
mix,
|
||||||
clamp01,
|
clamp01,
|
||||||
|
strengthToCharge,
|
||||||
stepCharacterMovement,
|
stepCharacterMovement,
|
||||||
|
applyForce,
|
||||||
applyLeapImpulse,
|
applyLeapImpulse,
|
||||||
decayMomentum,
|
decayMomentum,
|
||||||
CharacterMovementState,
|
tickPlanetDetachment,
|
||||||
|
characterCenter,
|
||||||
|
sumGravity,
|
||||||
CharacterWorld,
|
CharacterWorld,
|
||||||
GroundSurface,
|
GroundSurface,
|
||||||
headRadius,
|
headRadius,
|
||||||
|
|
@ -27,69 +28,45 @@ import {
|
||||||
rightFootOffset,
|
rightFootOffset,
|
||||||
boundRadius,
|
boundRadius,
|
||||||
} from 'shared';
|
} from 'shared';
|
||||||
import { DynamicPhysical } from '../physics/physicals/dynamic-physical';
|
import { addPointsForTeam } from '../game-events';
|
||||||
|
import { Physical } from '../physics/physical';
|
||||||
import { CirclePhysical } from './circle-physical';
|
import { CirclePhysical } from './circle-physical';
|
||||||
import { PhysicalContainer } from '../physics/containers/physical-container';
|
import { PhysicalContainer } from '../physics/physical-container';
|
||||||
import { BoundingBoxBase } from '../physics/bounding-boxes/bounding-box-base';
|
import { BoundingBox } from '../physics/bounding-box';
|
||||||
import { ProjectilePhysical } from './projectile-physical';
|
import { ProjectilePhysical } from './projectile-physical';
|
||||||
import { forceAtPosition } from '../physics/functions/force-at-position';
|
import { PlanetPhysical, planetsNear } from './planet-physical';
|
||||||
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)
|
const placeholder = new Circle(vec2.create(), 0);
|
||||||
export class CharacterPhysical extends CharacterBase implements DynamicPhysical {
|
|
||||||
|
export class CharacterPhysical extends CharacterBase implements Physical {
|
||||||
public readonly canCollide = true;
|
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 head: CirclePhysical;
|
||||||
public leftFoot: CirclePhysical;
|
public leftFoot: CirclePhysical;
|
||||||
public rightFoot: CirclePhysical;
|
public rightFoot: CirclePhysical;
|
||||||
|
|
||||||
private movementState!: CharacterMovementState;
|
public direction = 0;
|
||||||
private movementWorld!: CharacterWorld;
|
public currentPlanet: GroundSurface | undefined;
|
||||||
|
public secondsSinceOnSurface = settings.planetDetachmentSeconds;
|
||||||
|
public readonly bodyVelocity = vec2.create();
|
||||||
|
|
||||||
private movementActions: Array<MoveActionCommand> = [];
|
private projectileStrength = settings.playerMaxStrength;
|
||||||
private lastMovementAction: MoveActionCommand = new MoveActionCommand(vec2.create());
|
private timeAlive = 0;
|
||||||
|
private timeSinceDying = 0;
|
||||||
|
private isDestroyed = false;
|
||||||
|
private hasJustBorn = true;
|
||||||
|
private hasFiredSinceSpawn = false;
|
||||||
|
private timeSinceLastShot = settings.projectileCreationInterval;
|
||||||
|
private timeSinceLastLeap = settings.leapCooldownSeconds;
|
||||||
|
private timeSinceLastDamage = settings.playerOutOfCombatDelaySeconds;
|
||||||
|
private lastSyncedHealth = settings.playerMaxHealth;
|
||||||
|
private killStreak = 0;
|
||||||
|
|
||||||
private headVelocity = new Circle(vec2.create(), 0);
|
private readonly movementDirection = vec2.create();
|
||||||
private leftFootVelocity = new Circle(vec2.create(), 0);
|
|
||||||
private rightFootVelocity = new Circle(vec2.create(), 0);
|
|
||||||
|
|
||||||
protected commandExecutors: CommandExecutors = {
|
private readonly previousPose = [0, 1, 2].map(() => new Circle(vec2.create(), 0));
|
||||||
[StepCommand.type]: this.step.bind(this),
|
private readonly velocities = [0, 1, 2].map(() => new Circle(vec2.create(), 0));
|
||||||
[ReactToCollisionCommand.type]: this.onCollision.bind(this),
|
private readonly box = new BoundingBox();
|
||||||
};
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
name: string,
|
name: string,
|
||||||
|
|
@ -99,112 +76,93 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
|
||||||
private readonly container: PhysicalContainer,
|
private readonly container: PhysicalContainer,
|
||||||
startPosition: vec2,
|
startPosition: vec2,
|
||||||
) {
|
) {
|
||||||
super(id(), name, killCount, deathCount, team, settings.playerMaxHealth);
|
super(
|
||||||
this.head = new CirclePhysical(
|
id(),
|
||||||
vec2.add(vec2.create(), startPosition, headOffset),
|
name,
|
||||||
headRadius,
|
killCount,
|
||||||
this,
|
deathCount,
|
||||||
container,
|
team,
|
||||||
|
settings.playerMaxHealth,
|
||||||
|
placeholder,
|
||||||
|
placeholder,
|
||||||
|
placeholder,
|
||||||
);
|
);
|
||||||
this.leftFoot = new CirclePhysical(
|
const part = (offset: vec2, radius: number) =>
|
||||||
vec2.add(vec2.create(), startPosition, leftFootOffset),
|
new CirclePhysical(
|
||||||
feetRadius,
|
vec2.add(vec2.create(), startPosition, offset),
|
||||||
this,
|
radius,
|
||||||
container,
|
this,
|
||||||
);
|
container,
|
||||||
this.rightFoot = new CirclePhysical(
|
);
|
||||||
vec2.add(vec2.create(), startPosition, rightFootOffset),
|
this.head = part(headOffset, headRadius);
|
||||||
feetRadius,
|
this.leftFoot = part(leftFootOffset, feetRadius);
|
||||||
this,
|
this.rightFoot = part(rightFootOffset, feetRadius);
|
||||||
container,
|
|
||||||
);
|
|
||||||
container.addObject(this.head);
|
|
||||||
container.addObject(this.leftFoot);
|
|
||||||
container.addObject(this.rightFoot);
|
|
||||||
|
|
||||||
this.initMovementBridge();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private initMovementBridge() {
|
private readonly movementWorld: CharacterWorld = {
|
||||||
// The movementState object-literal getters/setters below can't use `this`
|
groundsNear: (center, radius) => planetsNear(this.container, center, radius),
|
||||||
// (it would bind to the literal), so alias the character instance.
|
stepBody: (body, deltaTimeInSeconds) => {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
const hit = (body as CirclePhysical).stepManually(deltaTimeInSeconds);
|
||||||
const self = this;
|
return hit instanceof PlanetPhysical ? hit : undefined;
|
||||||
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 = {
|
private get parts(): Array<CirclePhysical> {
|
||||||
// Same set and order forceAtPosition used: planets in the force field,
|
return [this.head, this.leftFoot, this.rightFoot];
|
||||||
// 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 {
|
private get isSpawnProtected(): boolean {
|
||||||
return (
|
return (
|
||||||
|
!this.hasFiredSinceSpawn &&
|
||||||
this.timeAlive <
|
this.timeAlive <
|
||||||
settings.spawnDespawnTime + settings.spawnInvulnerabilityExtraSeconds
|
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 {
|
public get isAlive(): boolean {
|
||||||
return !this.isDestroyed;
|
return !this.isDestroyed;
|
||||||
}
|
}
|
||||||
|
|
||||||
public handleMovementAction(c: MoveActionCommand) {
|
|
||||||
this.movementActions.push(c);
|
|
||||||
}
|
|
||||||
|
|
||||||
public get groundPlanet(): PlanetPhysical | undefined {
|
public get groundPlanet(): PlanetPhysical | undefined {
|
||||||
return this.currentPlanet;
|
return this.currentPlanet as PlanetPhysical | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Persistent launch momentum, streamed to the owning client so its predictor
|
public get movementSnapshot(): CharacterMovementSnapshot {
|
||||||
// can reproduce a leap/slingshot/recoil flight instead of only snapping to it.
|
return new CharacterMovementSnapshot(
|
||||||
public get launchMomentum(): vec2 {
|
this.direction,
|
||||||
return this.bodyVelocity;
|
vec2.clone(this.bodyVelocity),
|
||||||
|
vec2.clone(this.leftFoot.lastNormal),
|
||||||
|
vec2.clone(this.rightFoot.lastNormal),
|
||||||
|
this.groundPlanet?.id ?? null,
|
||||||
|
this.secondsSinceOnSurface,
|
||||||
|
Math.max(0, settings.leapCooldownSeconds - this.timeSinceLastLeap),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public get boundingBox(): BoundingBox {
|
||||||
|
return this.box.setCircle(this.center, boundRadius);
|
||||||
|
}
|
||||||
|
|
||||||
|
public get gameObject(): this {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get center(): vec2 {
|
||||||
|
return characterCenter(this.head, this.leftFoot, this.rightFoot);
|
||||||
|
}
|
||||||
|
|
||||||
|
public distance(target: vec2): number {
|
||||||
|
return (
|
||||||
|
Math.min(
|
||||||
|
this.head.distance(target),
|
||||||
|
this.leftFoot.distance(target),
|
||||||
|
this.rightFoot.distance(target),
|
||||||
|
) - 5
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public setMoveDirection(direction: vec2) {
|
||||||
|
vec2.normalize(this.movementDirection, direction);
|
||||||
}
|
}
|
||||||
|
|
||||||
public addKill(victimName: string, charge = 0) {
|
public addKill(victimName: string, charge = 0) {
|
||||||
|
|
@ -212,11 +170,13 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
|
||||||
this.killStreak++;
|
this.killStreak++;
|
||||||
this.remoteCall('setKillCount', this.killCount);
|
this.remoteCall('setKillCount', this.killCount);
|
||||||
|
|
||||||
this.health = Math.min(
|
if (this.isAlive) {
|
||||||
settings.playerMaxHealth,
|
this.health = Math.min(
|
||||||
this.health + settings.playerKillHealthReward,
|
settings.playerMaxHealth,
|
||||||
);
|
this.health + settings.playerKillHealthReward,
|
||||||
this.syncHealth();
|
);
|
||||||
|
this.syncHealth();
|
||||||
|
}
|
||||||
this.remoteCall('onKillConfirmed', victimName, this.killStreak, charge);
|
this.remoteCall('onKillConfirmed', victimName, this.killStreak, charge);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -232,39 +192,38 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public onCollision({ other }: ReactToCollisionCommand) {
|
public onCollision(other: GameObject) {
|
||||||
if (
|
if (
|
||||||
// A corpse keeps its collidable circles for the despawn animation; the
|
!this.isAlive ||
|
||||||
// isAlive guard stops a flying corpse from eating shots aimed past it.
|
!(other instanceof ProjectilePhysical) ||
|
||||||
this.isAlive &&
|
other.team === this.team ||
|
||||||
other instanceof ProjectilePhysical &&
|
!other.isAlive
|
||||||
other.team !== this.team &&
|
|
||||||
other.isAlive
|
|
||||||
) {
|
) {
|
||||||
other.destroy();
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (this.isSpawnProtected) {
|
other.destroy();
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.timeSinceLastDamage = 0;
|
if (this.isSpawnProtected) {
|
||||||
this.health -= other.strength;
|
return;
|
||||||
this.lastSyncedHealth = Math.round(this.health);
|
}
|
||||||
this.remoteCall('setHealth', this.health);
|
|
||||||
|
|
||||||
if (this.health <= 0 && this.isAlive) {
|
this.timeSinceLastDamage = 0;
|
||||||
// Throw the corpse along the killing shot, harder for charged hits.
|
this.health -= other.strength;
|
||||||
vec2.scaleAndAdd(
|
this.lastSyncedHealth = Math.round(this.health);
|
||||||
this.bodyVelocity,
|
this.remoteCall('setHealth', this.health);
|
||||||
this.bodyVelocity,
|
|
||||||
other.direction,
|
if (this.health <= 0) {
|
||||||
mix(settings.deathImpulseMin, settings.deathImpulseMax, other.charge),
|
vec2.scaleAndAdd(
|
||||||
);
|
this.bodyVelocity,
|
||||||
this.onDie();
|
this.bodyVelocity,
|
||||||
other.originator.addKill(this.name, other.charge);
|
other.direction,
|
||||||
} else {
|
mix(settings.deathImpulseMin, settings.deathImpulseMax, other.charge),
|
||||||
other.originator.registerHit(other.charge);
|
);
|
||||||
}
|
this.onDie(true);
|
||||||
|
other.originator.addKill(this.name, other.charge);
|
||||||
|
} else {
|
||||||
|
other.originator.registerHit(other.charge);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -277,42 +236,45 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const direction = vec2.subtract(vec2.create(), position, this.center);
|
||||||
|
if (vec2.length(direction) === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.timeSinceLastShot = 0;
|
this.timeSinceLastShot = 0;
|
||||||
|
this.hasFiredSinceSpawn = true;
|
||||||
|
|
||||||
const c = clamp01(charge);
|
|
||||||
const desiredStrength = mix(
|
const desiredStrength = mix(
|
||||||
settings.chargeShotStrengthMin,
|
settings.chargeShotStrengthMin,
|
||||||
settings.chargeShotStrengthMax,
|
settings.chargeShotStrengthMax,
|
||||||
c,
|
clamp01(charge),
|
||||||
);
|
);
|
||||||
const strength = Math.min(desiredStrength, this.projectileStrength);
|
const strength = Math.min(desiredStrength, this.projectileStrength);
|
||||||
this.projectileStrength -= strength;
|
this.projectileStrength -= strength;
|
||||||
|
|
||||||
|
const c = strengthToCharge(strength);
|
||||||
const radius = mix(settings.chargeShotRadiusMin, settings.chargeShotRadiusMax, c);
|
const radius = mix(settings.chargeShotRadiusMin, settings.chargeShotRadiusMax, c);
|
||||||
const speed = mix(settings.chargeShotSpeedMin, settings.chargeShotSpeedMax, c);
|
const speed = mix(settings.chargeShotSpeedMin, settings.chargeShotSpeedMax, c);
|
||||||
|
|
||||||
const direction = vec2.subtract(vec2.create(), position, this.center);
|
|
||||||
vec2.normalize(direction, direction);
|
vec2.normalize(direction, direction);
|
||||||
// Keep the unit direction before vec2.scale repurposes it as the velocity.
|
this.container.addObject(
|
||||||
const shotDirection = vec2.clone(direction);
|
new ProjectilePhysical(
|
||||||
const velocity = vec2.scale(direction, direction, speed);
|
vec2.clone(this.center),
|
||||||
const projectile = new ProjectilePhysical(
|
radius,
|
||||||
vec2.clone(this.center),
|
strength,
|
||||||
radius,
|
this.team,
|
||||||
strength,
|
vec2.scale(vec2.create(), direction, speed),
|
||||||
this.team,
|
this,
|
||||||
velocity,
|
this.container,
|
||||||
this,
|
c,
|
||||||
this.container,
|
),
|
||||||
c,
|
|
||||||
);
|
);
|
||||||
this.container.addObject(projectile);
|
|
||||||
|
|
||||||
if (c > 0) {
|
if (c > 0) {
|
||||||
vec2.scaleAndAdd(
|
vec2.scaleAndAdd(
|
||||||
this.bodyVelocity,
|
this.bodyVelocity,
|
||||||
this.bodyVelocity,
|
this.bodyVelocity,
|
||||||
shotDirection,
|
direction,
|
||||||
-settings.chargeShotRecoilMax * c,
|
-settings.chargeShotRecoilMax * c,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -333,210 +295,89 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
|
||||||
|
|
||||||
this.timeSinceLastLeap = 0;
|
this.timeSinceLastLeap = 0;
|
||||||
this.projectileStrength -= settings.leapStrengthCost;
|
this.projectileStrength -= settings.leapStrengthCost;
|
||||||
|
applyLeapImpulse(this, this.movementDirection);
|
||||||
// Same impulse the client predicts with (shared), so a leap launches
|
|
||||||
// identically on both sides.
|
|
||||||
applyLeapImpulse(this.movementState, this.lastMovementAction.direction);
|
|
||||||
this.remoteCall('onLeap');
|
this.remoteCall('onLeap');
|
||||||
}
|
}
|
||||||
|
|
||||||
public get boundingBox(): BoundingBoxBase {
|
public onDie(killedInCombat = false) {
|
||||||
return getBoundingBoxOfCircle(new Circle(this.center, CharacterPhysical.boundRadius));
|
if (this.isDestroyed) {
|
||||||
}
|
return;
|
||||||
|
|
||||||
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 = [];
|
|
||||||
}
|
}
|
||||||
|
this.isDestroyed = true;
|
||||||
|
this.remoteCall('onDie');
|
||||||
|
|
||||||
return vec2.length(direction) > 0
|
if (killedInCombat) {
|
||||||
? vec2.normalize(direction, direction)
|
const opposingTeam =
|
||||||
: vec2.create();
|
this.team === CharacterTeam.blue ? CharacterTeam.red : CharacterTeam.blue;
|
||||||
|
addPointsForTeam(this.container.game, opposingTeam, settings.playerKillPoint);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private animateScaling(q: number) {
|
public getPropertyUpdates(timeScale = 1): PropertyUpdatesForObject {
|
||||||
this.head.radius = headRadius * q;
|
const [headVelocity, leftFootVelocity, rightFootVelocity] = this.velocities.map(
|
||||||
this.leftFoot.radius = this.rightFoot.radius = feetRadius * q;
|
(velocity) =>
|
||||||
}
|
new Circle(
|
||||||
|
vec2.scale(vec2.create(), velocity.center, timeScale),
|
||||||
public getPropertyUpdates(): PropertyUpdatesForObject {
|
velocity.radius * timeScale,
|
||||||
|
),
|
||||||
|
);
|
||||||
return new PropertyUpdatesForObject(this.id, [
|
return new PropertyUpdatesForObject(this.id, [
|
||||||
new UpdatePropertyCommand('head', this.head, this.headVelocity),
|
new UpdatePropertyCommand('head', this.head, headVelocity),
|
||||||
new UpdatePropertyCommand('leftFoot', this.leftFoot, this.leftFootVelocity),
|
new UpdatePropertyCommand('leftFoot', this.leftFoot, leftFootVelocity),
|
||||||
new UpdatePropertyCommand('rightFoot', this.rightFoot, this.rightFootVelocity),
|
new UpdatePropertyCommand('rightFoot', this.rightFoot, rightFootVelocity),
|
||||||
new UpdatePropertyCommand(
|
new UpdatePropertyCommand(
|
||||||
'strength',
|
'strength',
|
||||||
this.projectileStrength,
|
this.projectileStrength,
|
||||||
settings.playerStrengthRegenerationPerSeconds,
|
settings.playerStrengthRegenerationPerSeconds * timeScale,
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private setPropertyUpdates(
|
public step(deltaTimeInSeconds: number) {
|
||||||
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.timeAlive += deltaTimeInSeconds;
|
||||||
this.timeSinceLastLeap += deltaTimeInSeconds;
|
this.timeSinceLastLeap += deltaTimeInSeconds;
|
||||||
const oldHead = new Circle(vec2.clone(this.head.center), this.head.radius);
|
this.parts.forEach((part, i) => {
|
||||||
const oldLeftFoot = new Circle(
|
vec2.copy(this.previousPose[i].center, part.center);
|
||||||
vec2.clone(this.leftFoot.center),
|
this.previousPose[i].radius = part.radius;
|
||||||
this.leftFoot.radius,
|
});
|
||||||
);
|
|
||||||
const oldRightFoot = new Circle(
|
|
||||||
vec2.clone(this.rightFoot.center),
|
|
||||||
this.rightFoot.radius,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (this.isDestroyed) {
|
if (this.isDestroyed) {
|
||||||
if ((this.timeSinceDying += deltaTimeInSeconds) > settings.spawnDespawnTime) {
|
if ((this.timeSinceDying += deltaTimeInSeconds) > settings.spawnDespawnTime) {
|
||||||
this.destroy();
|
this.container.removeObject(this);
|
||||||
} else {
|
} else {
|
||||||
this.freeFallCorpse(deltaTimeInSeconds);
|
this.freeFallCorpse(deltaTimeInSeconds);
|
||||||
this.animateScaling(1 - this.timeSinceDying / settings.spawnDespawnTime);
|
this.animateScaling(1 - this.timeSinceDying / settings.spawnDespawnTime);
|
||||||
}
|
}
|
||||||
this.setPropertyUpdates(oldHead, oldLeftFoot, oldRightFoot, deltaTimeInSeconds);
|
} else if (this.hasJustBorn) {
|
||||||
return;
|
if (this.timeAlive > settings.spawnDespawnTime) {
|
||||||
}
|
|
||||||
|
|
||||||
if (this.hasJustBorn) {
|
|
||||||
if ((this.timeSinceBorn += deltaTimeInSeconds) > settings.spawnDespawnTime) {
|
|
||||||
this.hasJustBorn = false;
|
this.hasJustBorn = false;
|
||||||
|
this.animateScaling(1);
|
||||||
} else {
|
} else {
|
||||||
this.animateScaling(this.timeSinceBorn / settings.spawnDespawnTime);
|
this.animateScaling(this.timeAlive / settings.spawnDespawnTime);
|
||||||
}
|
}
|
||||||
this.setPropertyUpdates(oldHead, oldLeftFoot, oldRightFoot, deltaTimeInSeconds);
|
} else {
|
||||||
return;
|
this.stepAlive(deltaTimeInSeconds);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
this.parts.forEach((part, i) => {
|
||||||
(this.secondsSinceOnSurface += deltaTimeInSeconds) >
|
const velocity = this.velocities[i];
|
||||||
settings.planetDetachmentSeconds
|
const previous = this.previousPose[i];
|
||||||
) {
|
vec2.subtract(velocity.center, part.center, previous.center);
|
||||||
this.currentPlanet = undefined;
|
vec2.scale(velocity.center, velocity.center, 1 / deltaTimeInSeconds);
|
||||||
}
|
velocity.radius = (part.radius - previous.radius) / deltaTimeInSeconds;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private stepAlive(deltaTimeInSeconds: number) {
|
||||||
|
tickPlanetDetachment(this, deltaTimeInSeconds);
|
||||||
|
|
||||||
this.timeSinceLastShot += deltaTimeInSeconds;
|
this.timeSinceLastShot += deltaTimeInSeconds;
|
||||||
|
|
||||||
this.projectileStrength = Math.min(
|
this.projectileStrength = Math.min(
|
||||||
settings.playerMaxStrength,
|
settings.playerMaxStrength,
|
||||||
this.projectileStrength +
|
this.projectileStrength +
|
||||||
settings.playerStrengthRegenerationPerSeconds * deltaTimeInSeconds,
|
settings.playerStrengthRegenerationPerSeconds * deltaTimeInSeconds,
|
||||||
);
|
);
|
||||||
|
|
||||||
this.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;
|
this.timeSinceLastDamage += deltaTimeInSeconds;
|
||||||
if (
|
if (
|
||||||
this.timeSinceLastDamage > settings.playerOutOfCombatDelaySeconds &&
|
this.timeSinceLastDamage > settings.playerOutOfCombatDelaySeconds &&
|
||||||
|
|
@ -548,17 +389,35 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
|
||||||
);
|
);
|
||||||
this.syncHealth();
|
this.syncHealth();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.groundPlanet?.registerPresence(this);
|
||||||
|
|
||||||
|
stepCharacterMovement(
|
||||||
|
this,
|
||||||
|
this.movementWorld,
|
||||||
|
this.movementDirection,
|
||||||
|
deltaTimeInSeconds,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public onDie() {
|
private animateScaling(q: number) {
|
||||||
this.isDestroyed = true;
|
this.head.radius = headRadius * q;
|
||||||
this.remoteCall('onDie');
|
this.leftFoot.radius = this.rightFoot.radius = feetRadius * q;
|
||||||
}
|
}
|
||||||
|
|
||||||
private destroy() {
|
private freeFallCorpse(deltaTime: number) {
|
||||||
this.container.removeObject(this);
|
const planets = this.movementWorld.groundsNear(
|
||||||
this.container.removeObject(this.head);
|
this.center,
|
||||||
this.container.removeObject(this.leftFoot);
|
boundRadius + settings.maxGravityDistance,
|
||||||
this.container.removeObject(this.rightFoot);
|
);
|
||||||
|
let grounded = false;
|
||||||
|
for (const part of this.parts) {
|
||||||
|
vec2.copy(part.velocity, this.bodyVelocity);
|
||||||
|
applyForce(part, sumGravity(planets, part.center), deltaTime);
|
||||||
|
if (part.stepManually(deltaTime) instanceof PlanetPhysical) {
|
||||||
|
grounded = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
decayMomentum(this.bodyVelocity, grounded, deltaTime);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,143 +1,64 @@
|
||||||
import { vec2 } from 'gl-matrix';
|
import { vec2 } from 'gl-matrix';
|
||||||
import {
|
import { Circle, GameObject, resolveCircleMovement } from 'shared';
|
||||||
Circle,
|
import { BoundingBox } from '../physics/bounding-box';
|
||||||
CommandExecutors,
|
import { PhysicalContainer } from '../physics/physical-container';
|
||||||
CommandReceiver,
|
import { Physical } from '../physics/physical';
|
||||||
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;
|
|
||||||
|
|
||||||
|
export class CirclePhysical extends Circle implements Physical {
|
||||||
|
public readonly canCollide = true;
|
||||||
public velocity = vec2.create();
|
public velocity = vec2.create();
|
||||||
public lastNormal = vec2.fromValues(0, 1);
|
public lastNormal = vec2.fromValues(0, 1);
|
||||||
|
|
||||||
private _boundingBox: BoundingBox;
|
private readonly box = new BoundingBox();
|
||||||
|
|
||||||
protected commandExecutors: CommandExecutors = {
|
|
||||||
[ReactToCollisionCommand.type]: this.onCollision.bind(this),
|
|
||||||
};
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private _center: vec2,
|
center: vec2,
|
||||||
private _radius: number,
|
radius: number,
|
||||||
public owner: GameObject,
|
public readonly owner: Physical,
|
||||||
private readonly container: PhysicalContainer,
|
private readonly container: PhysicalContainer,
|
||||||
// Public + readonly so a CirclePhysical structurally satisfies the shared
|
|
||||||
// PhysicsBody interface the movement simulation operates on.
|
|
||||||
public readonly restitution = 0,
|
public readonly restitution = 0,
|
||||||
) {
|
) {
|
||||||
super();
|
super(center, radius);
|
||||||
this._boundingBox = new BoundingBox();
|
|
||||||
this.recalculateBoundingBox();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public get boundingBox(): BoundingBoxBase {
|
public get boundingBox(): BoundingBox {
|
||||||
return this._boundingBox;
|
return this.box.setCircle(this.center, this.radius);
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
public get gameObject(): GameObject {
|
||||||
return this.owner;
|
return this.owner.gameObject;
|
||||||
}
|
}
|
||||||
|
|
||||||
public get radius(): number {
|
public onCollision(other: GameObject) {
|
||||||
return this._radius;
|
this.owner.onCollision?.(other);
|
||||||
}
|
}
|
||||||
|
|
||||||
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(
|
public stepManually(
|
||||||
deltaTimeInSeconds: number,
|
deltaTimeInSeconds: number,
|
||||||
possibleIntersectors?: Array<Physical>,
|
possibleIntersectors?: Array<Physical>,
|
||||||
): {
|
): GameObject | undefined {
|
||||||
hitObject: GameObject | undefined;
|
|
||||||
velocity: vec2;
|
|
||||||
} {
|
|
||||||
const intersecting = (
|
const intersecting = (
|
||||||
possibleIntersectors ?? this.sweptBroadphase(deltaTimeInSeconds)
|
possibleIntersectors ?? this.sweptBroadphase(deltaTimeInSeconds)
|
||||||
).filter((b) => b.gameObject !== this.gameObject && b.canCollide);
|
).filter((b) => b.gameObject !== this.gameObject && b.canCollide);
|
||||||
|
|
||||||
const { hitObject, velocity } = resolveCircleMovement(
|
const hit = resolveCircleMovement(
|
||||||
this,
|
this,
|
||||||
deltaTimeInSeconds,
|
deltaTimeInSeconds,
|
||||||
intersecting,
|
intersecting,
|
||||||
(intersected) => {
|
(intersected) => {
|
||||||
const physical = intersected as Physical;
|
const physical = intersected as Physical;
|
||||||
physical.handleCommand(new ReactToCollisionCommand(this.gameObject));
|
physical.onCollision?.(this.gameObject);
|
||||||
this.handleCommand(new ReactToCollisionCommand(physical.gameObject));
|
this.onCollision(physical.gameObject);
|
||||||
},
|
},
|
||||||
);
|
) as Physical | undefined;
|
||||||
|
|
||||||
return { hitObject: (hitObject as Physical | undefined)?.gameObject, velocity };
|
return hit?.gameObject;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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> {
|
private sweptBroadphase(deltaTimeInSeconds: number): Array<Physical> {
|
||||||
const sweep = vec2.length(
|
const sweep = vec2.length(this.velocity) * deltaTimeInSeconds;
|
||||||
vec2.scale(vec2.create(), this.velocity, deltaTimeInSeconds),
|
return this.container.findIntersecting(
|
||||||
|
BoundingBox.ofCircle(this.center, this.radius + sweep),
|
||||||
);
|
);
|
||||||
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];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue