Compare commits

..

No commits in common. "main" and "wip" have entirely different histories.
main ... wip

299 changed files with 3553 additions and 18389 deletions

View file

@ -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

View file

@ -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
View 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
View file

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

View file

@ -1 +0,0 @@
22

1
.nvmrc
View file

@ -1 +0,0 @@
22

16
.vscode/launch.json vendored
View file

@ -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
View file

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

View file

@ -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"]

View file

@ -1,35 +1,25 @@
# doppler
# decla.red
A 2-dimensional multiplayer game utilising ray tracing.
![logo](frontend/static/declared.png)
![Deploy everything](https://github.com/schmelczerandras/decla.red/workflows/Deploy%20everything/badge.svg)
> **Available at [doppler.schmelczer.dev](https://doppler.schmelczer.dev).**
A good-looking 2D adventure.
![screenshot of in-game fight](media/game-pc.png)
![three screenshots taken at iPhone SE screen size](media/collage-iphone.png)
## 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
`main` it:
- builds the static frontend and rsyncs `frontend/dist/` to the `/pages/declared`
mount on the runner host (the mount keeps its pre-rebrand name), and
- builds the server image from the root `Dockerfile` and pushes it to the Forgejo
container registry as `<registry>/<owner>/<repo>-server`.
The registry job needs a `FORGEJO_PACKAGE_TOKEN` secret (with package write
scope) and, optionally, a `CONTAINER_REGISTRY_HOST` variable to override the
registry host.
The website's server list is hardcoded in
[`frontend/src/scripts/configuration.ts`](frontend/src/scripts/configuration.ts) —
edit it to add or remove game-server origins.
Run the server image locally:
```sh
docker build -t doppler-server .
docker run -p 3000:3000 doppler-server --name "My server" --playerLimit 16
```
- Frontend nginx disable logging
- procedural piano
- subtract cameraPosition
- cross browser testing
- nginx log monitor
- lightweight object storage
- local dev env
- CI pipeline:
- prettier script
- tests?

View file

@ -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"
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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();

View file

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

View file

@ -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];
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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),
];
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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"]
}

View file

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

View file

@ -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'],
},
});

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

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

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

View 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

View file

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

View 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"]

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

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

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

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

View file

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

View file

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

31
docker-compose.yml Normal file
View 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

View file

@ -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
View file

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

View file

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

3
frontend/.gitignore vendored Normal file
View file

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

View file

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

8
frontend/.vscode/settings.json vendored Normal file
View 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
View 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
View 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;
}
*/

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

File diff suppressed because it is too large Load diff

View file

@ -1,55 +1,59 @@
{
"name": "doppler-frontend",
"name": "decla.red",
"version": "0.0.0",
"description": "",
"keywords": [],
"author": "András Schmelczer <andras@schmelczer.dev> (https://schmelczer.dev/)",
"sideEffects": [
"*.scss"
],
"description": "![logo](media/declared.png)",
"private": true,
"main": "index.js",
"engines": {
"node": ">=20"
},
"scripts": {
"build": "npx webpack --mode production",
"dev": "npx webpack-dev-server --mode development",
"try-build": "npm run build && cd dist && python3 -m http.server 8080"
"start": "webpack-dev-server --mode development",
"start-unsafe": "webpack-dev-server --mode development --useLocalIp",
"build": "webpack && find dist -type f -not -name '*.html' | xargs rm"
},
"browserslist": [
"defaults"
],
"keywords": [],
"author": "András Schmelczer",
"postcss": {
"plugins": {
"autoprefixer": {}
}
},
"browserslist": [
"defaults"
],
"sideEffects": [
"*.scss"
],
"optimization": {
"usedExports": true
},
"devDependencies": {
"@plausible-analytics/tracker": "^0.4.5",
"autoprefixer": "^10.5.0",
"clean-webpack-plugin": "^4.0.0",
"copy-webpack-plugin": "^14.0.0",
"css-loader": "^7.1.4",
"file-loader": "^6.2.0",
"gl-matrix": "3.3.0",
"html-inline-css-webpack-plugin": "^1.11.2",
"html-webpack-plugin": "^5.6.7",
"mini-css-extract-plugin": "^2.10.2",
"postcss": "^8.5.15",
"postcss-loader": "^8.2.1",
"resize-observer-polyfill": "^1.5.1",
"sass": "^1.100.0",
"sass-loader": "^17.0.0",
"sdf-2d": "^0.8.0",
"shared": "file:../shared",
"socket.io-client": "^4.8.3",
"socket.io-msgpack-parser": "^3.0.2",
"source-map-loader": "^5.0.0",
"terser-webpack-plugin": "^5.6.1",
"ts-loader": "^9.6.0",
"typescript": "^6.0.3",
"webpack": "^5.107.2",
"webpack-cli": "^7.0.3",
"webpack-dev-server": "^5.2.4"
"@types/gl-matrix": "^2.4.5",
"@types/uuid": "^8.0.0",
"autoprefixer": "^9.8.5",
"clean-webpack-plugin": "^3.0.0",
"css-loader": "^3.5.2",
"cssnano": "^4.1.10",
"gl-matrix": "^3.3.0",
"html-webpack-inline-source-plugin": "0.0.10",
"html-webpack-plugin": "^3.2.0",
"image-webpack-loader": "^6.0.0",
"mini-css-extract-plugin": "^0.9.0",
"optimize-css-assets-webpack-plugin": "^5.0.3",
"postcss-loader": "^3.0.0",
"prettier": "^2.0.5",
"raw-loader": "^4.0.1",
"resolve-url-loader": "^3.1.1",
"responsive-loader": "^1.2.0",
"sass": "^1.26.3",
"sass-loader": "^9.0.2",
"sharp": "^0.25.4",
"style-loader": "^1.1.4",
"svg-url-loader": "^6.0.0",
"terser-webpack-plugin": "^2.3.5",
"ts-loader": "^8.0.1",
"typescript": "^3.8.3",
"uuid": "^8.2.0",
"webpack": "^4.43.0",
"webpack-cli": "^3.3.11",
"webpack-dev-server": "^3.10.3"
}
}

View file

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

View file

@ -2,124 +2,21 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0,
user-scalable=0"
content="width=device-width,initial-scale=1,viewport-fit=cover"
/>
<meta name="theme-color" content="#b7455e" />
<meta property="og:url" content="https://doppler.schmelczer.dev" />
<meta property="og:image" content="https://doppler.schmelczer.dev/og-image.png" />
<meta property="og:image:width" content="2086" />
<meta property="og:image:height" content="940" />
<meta
property="og:image:alt"
content="Dimly lit planets surrounding a text saying 'doppler'"
/>
<meta name="theme-color" content="#b33951" />
<meta
name="description"
content="Conquer the planets, work as a team, and play with your friends online through this browser game."
/>
<link rel="shortcut icon" href="/favicon.ico" type="image/x-icon" />
<link rel="icon" href="/favicon.ico" type="image/x-icon" />
<link rel="apple-touch-icon" sizes="180x180" href="static/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="static/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="static/favicon-16x16.png" />
<title>doppler</title>
<title>Test</title>
</head>
<body>
<noscript>Javascript is required for this website.</noscript>
<canvas></canvas>
<noscript><h1>Javascript is required for this website.</h1></noscript>
<div id="overlay"></div>
<section id="landing-ui">
<h1>doppler</h1>
<form id="join-game-form">
<fieldset class="content">
<legend>Join a game</legend>
<div class="name-group">
<input
required
minlength="1"
maxlength="20"
placeholder=" "
id="name"
name="name"
type="text"
/>
<label for="name">Choose a name</label>
</div>
<div id="server-container"></div>
<button id="join-game" type="submit">Join</button>
</fieldset>
</form>
</section>
<div id="settings-container">
<section id="settings">
<label for="enable-sounds">
<input id="enable-sounds" type="checkbox" />
<img
title="Enable sounds"
alt="speaker with sound waves"
src="static/volume.svg"
/>
</label>
<label for="enable-music">
<input id="enable-music" type="checkbox" />
<img title="Enable music" alt="music note" src="static/music.svg" />
</label>
<label for="enable-vibration">
<input id="enable-vibration" type="checkbox" />
<img
title="Enable vibration on mobile"
alt="vibrating mobile"
src="static/vibrate.svg"
/>
</label>
<img
title="Exit from current game"
id="logout"
alt="logout"
src="static/logout.svg"
/>
</section>
</div>
<div id="toggle-settings-container">
<img
title="Toggle settings"
id="toggle-settings"
class="icon"
alt="toggle-settings"
src="static/settings.svg"
/>
</div>
<img
class="full-screen-controllers"
id="minimize"
alt="minimize-application"
src="static/minimize.svg"
/>
<img
class="full-screen-controllers"
id="maximize"
alt="maximize-application"
src="static/maximize.svg"
/>
<div id="spinner-container">
<img id="spinner" alt="waiting" src="static/spinner.svg" />
</div>
<canvas id="main"></canvas>
</body>
</html>

View file

@ -1,160 +1,31 @@
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 ResizeObserver from 'resize-observer-polyfill';
import { OptionsHandler } from './scripts/options-handler';
import { hide } from './scripts/helper/hide';
import { show } from './scripts/helper/show';
import { SoundHandler, Sounds } from './scripts/sound-handler';
import { VibrationHandler } from './scripts/vibration-handler';
import { CharacterView } from './scripts/objects/types/character-view';
import { LampView } from './scripts/objects/types/lamp-view';
import { PlanetView } from './scripts/objects/types/planet-view';
import { ProjectileView } from './scripts/objects/types/projectile-view';
import { applyArrayPlugins } from './scripts/helper/array';
import './styles/main.scss';
glMatrix.setMatrixArrayType(Array);
applyArrayPlugins();
overrideDeserialization(CharacterBase, CharacterView);
overrideDeserialization(PlanetBase, PlanetView);
overrideDeserialization(LampBase, LampView);
overrideDeserialization(ProjectileBase, ProjectileView);
/*
const tree = new BoundingBoxTree([
new BoundingBox(300, 550, 150, 550, 'A'),
new BoundingBox(400, 800, 50, 200, 'B'),
new BoundingBox(450, 500, 175, 185, 'C'),
new BoundingBox(100, 200, 100, 500, 'D'),
new BoundingBox(750, 950, 450, 600, 'E'),
new BoundingBox(940, 1000, -2, 180, 'F'),
new BoundingBox(960, 1050, 50, 190, 'G'),
new BoundingBox(150, 900, 0, 575, 'H'),
new BoundingBox(-10000, 10000, -10000, 10000, 'I'),
]);
const landingUI = document.querySelector('#landing-ui') as HTMLElement;
const nameInput = document.querySelector('#name') as HTMLInputElement;
const joinGameForm = document.querySelector('#join-game-form') as HTMLFormElement;
const serverContainer = document.querySelector('#server-container') as HTMLElement;
const canvas = document.querySelector('canvas') as HTMLCanvasElement;
const overlay = document.querySelector('#overlay') as HTMLElement;
const settings = document.querySelector('#settings') as HTMLElement;
const toggleSettingsButton = document.querySelector(
'#toggle-settings-container',
) as HTMLElement;
const minimize = document.querySelector('#minimize') as HTMLElement;
const maximize = document.querySelector('#maximize') as HTMLElement;
const logoutButton = document.querySelector('#logout') as HTMLElement;
const enableSounds = document.querySelector('#enable-sounds') as HTMLInputElement;
const enableMusic = document.querySelector('#enable-music') as HTMLInputElement;
const enableVibration = document.querySelector('#enable-vibration') as HTMLInputElement;
const spinner = document.querySelector('#spinner-container') as HTMLElement;
tree.print();
console.log(tree.findIntersecting(new BoundingBox(960, 1050, 50, 190, 'G')));
*/
const toggleSettings = () => {
settings.className = settings.className === 'open' ? '' : 'open';
SoundHandler.play(Sounds.click);
};
const applyServerContainerShadows = () => {
const topShadow = 'inset 0 -8px 8px -8px rgba(0, 0, 0, 0.4)';
const bottomShadow = 'inset 0 8px 8px -8px rgba(0, 0, 0, 0.4)';
const { scrollHeight, clientHeight, scrollTop } = serverContainer;
if (scrollHeight > clientHeight) {
if (scrollTop <= 0) {
serverContainer.style.boxShadow = topShadow;
} else if (scrollTop + clientHeight >= scrollHeight) {
serverContainer.style.boxShadow = bottomShadow;
} else {
serverContainer.style.boxShadow = topShadow + ',' + bottomShadow;
}
} else {
serverContainer.style.boxShadow = '';
}
};
const main = async () => {
try {
let game: Game;
const storedUserName = localStorage?.getItem('userName');
if (storedUserName) {
nameInput.value = JSON.parse(storedUserName);
}
const firstClickListener = () => {
SoundHandler.initialize(
() => {
enableMusic.checked = true;
enableMusic.dispatchEvent(new Event('change'));
},
() => {
enableMusic.checked = false;
enableMusic.dispatchEvent(new Event('change'));
},
);
document.removeEventListener('click', firstClickListener);
};
document.addEventListener('click', firstClickListener);
if (!VibrationHandler.isVibrationEnabledHeuristics) {
hide(document.querySelector("label[for='enable-vibration']") as HTMLElement, true);
}
handleFullScreen(minimize, maximize);
toggleSettingsButton.addEventListener('click', toggleSettings);
new ResizeObserver(applyServerContainerShadows).observe(serverContainer);
serverContainer.addEventListener('scroll', applyServerContainerShadows);
OptionsHandler.initialize({
soundsEnabled: enableSounds,
vibrationEnabled: enableVibration,
musicEnabled: enableMusic,
});
logoutButton.addEventListener('click', () => {
game.destroy();
toggleSettings();
});
window.onpopstate = () => game.destroy();
for (;;) {
show(spinner);
hide(logoutButton, true);
show(landingUI, true, 'flex');
const background = new LandingPageBackground(canvas);
const joinHandler = new JoinFormHandler(joinGameForm, serverContainer);
await background.renderer;
hide(spinner);
const playerDecision = await joinHandler.getPlayerDecision();
localStorage?.setItem('userName', JSON.stringify(playerDecision.name));
if (!history.state) {
history.pushState(true, '');
}
hide(landingUI, true);
show(spinner);
background.destroy();
game = new Game(playerDecision, canvas, overlay);
const gameOver = game.start();
await game.started;
hide(spinner);
show(logoutButton, true, 'block');
await gameOver;
}
} catch (e) {
console.error(e);
alert(e);
}
};
main();
try {
new Game();
} catch (e) {
console.error(e);
alert(e);
}

View file

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

View file

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

View file

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

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

View file

@ -1,18 +1,14 @@
import { CommandReceiver } from './command-receiver';
import { Command } from './command';
export abstract class CommandGenerator {
export class CommandGenerator {
private subscribers: Array<CommandReceiver> = [];
public subscribe(subscriber: CommandReceiver): void {
this.subscribers.push(subscriber);
}
public clearSubscribers(): void {
this.subscribers = [];
}
protected sendCommandToSubscribers(command: Command): void {
this.subscribers.forEach((s) => s.handleCommand(command));
protected sendCommand(command: Command): void {
this.subscribers.forEach((s) => s.sendCommand(command));
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

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

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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