diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..2bd8264 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +# The server image only needs `shared/` and `backend/`. +**/node_modules +**/dist +**/lib +.git +.github +.forgejo +.devcontainer +.vscode +frontend +media +*.log +Dockerfile +.dockerignore diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml new file mode 100644 index 0000000..1136a06 --- /dev/null +++ b/.forgejo/workflows/deploy.yml @@ -0,0 +1,63 @@ +name: Build & deploy + +on: + push: + branches: [main] + tags: ['v*'] + pull_request: + branches: [main] + workflow_dispatch: + +concurrency: + group: ${{ gitea.workflow }}-${{ gitea.ref }} + cancel-in-progress: true + +jobs: + # ---- Static website -> /pages mount on the runner host -------------------- + website: + name: Build & deploy website + runs-on: docker + steps: + - uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + + - name: Install dependencies + # No lockfiles are committed (see .gitignore), so use `npm install`. + run: npm install && npm run init + + - name: Build (shared -> frontend -> backend) + run: npm run build + + - name: Test + run: npm test + + - name: Lint + run: npm run lint:check + + - name: Deploy to host pages mount + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + uses: http://forgejo:3000/andras/ci-actions/deploy-pages@main + with: + source: frontend/dist + target: declared + + # ---- Server Docker image -> Forgejo container registry -------------------- + server-image: + name: Build & publish server image + runs-on: docker + # No registry push on PRs; build validation still happens in the website job. + if: github.event_name != 'pull_request' + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Build and publish server image + uses: http://forgejo:3000/andras/ci-actions/docker-publish@main + with: + context: . + image-suffix: -server + token: ${{ secrets.FORGEJO_PACKAGE_TOKEN }} diff --git a/frontend/.gitattributes b/.gitattributes similarity index 68% rename from frontend/.gitattributes rename to .gitattributes index ea544b2..32bea41 100644 --- a/frontend/.gitattributes +++ b/.gitattributes @@ -1 +1,2 @@ *.psd filter=lfs diff=lfs merge=lfs -text +* text=auto eol=lf diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml deleted file mode 100644 index 4450321..0000000 --- a/.github/workflows/main.yaml +++ /dev/null @@ -1,50 +0,0 @@ -name: Deploy everything -on: - push: - branches: - - main -env: - CONTAINER_REGISTRY: schmelczera - DOMAIN: decla.red - -jobs: - build-frontend: - runs-on: ubuntu-latest - steps: - - name: Checkout current branch with lfs - uses: actions/checkout@main - with: - lfs: true - - - name: Setup auth tokens - run: | - docker login -u ${{ secrets.DOCKER_USER }} -p ${{ secrets.DOCKER_PASSWORD }} - - - name: Build and push frontend - run: | - docker build . -t $CONTAINER_REGISTRY/declared-frontend - docker push $CONTAINER_REGISTRY/declared-frontend - working-directory: frontend - - deploy: - runs-on: ubuntu-latest - needs: - - build-frontend - steps: - - name: Checkout current branch with lfs - uses: actions/checkout@main - with: - lfs: true - - - name: Setup auth tokens - run: | - # SSH key - mkdir ~/.ssh - echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519 - chmod 400 ~/.ssh/id_ed25519 - ssh -o StrictHostKeyChecking=no root@$DOMAIN uptime - - - name: Stack deploy - run: | - DOCKER_HOST=ssh://root@$DOMAIN docker login -u ${{ secrets.DOCKER_TOKEN }} -p ${{ secrets.DOCKER_TOKEN }} - DOCKER_HOST=ssh://root@$DOMAIN docker stack deploy declared -c docker-compose.yml --with-registry-auth diff --git a/.gitignore b/.gitignore index c5d417c..6e9e967 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,8 @@ -infrastructure/data/certbot +dist +lib +node_modules +.firebase +yarm-error.log +yarn.lock +package-lock.json +.DS_Store diff --git a/.node-version b/.node-version new file mode 100644 index 0000000..2bd5a0a --- /dev/null +++ b/.node-version @@ -0,0 +1 @@ +22 diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..2bd5a0a --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/frontend/.prettierrc b/.prettierrc similarity index 58% rename from frontend/.prettierrc rename to .prettierrc index ea2afbe..84d3cf1 100644 --- a/frontend/.prettierrc +++ b/.prettierrc @@ -1,5 +1,6 @@ { - "trailingComma": "es5", + "trailingComma": "all", + "printWidth": 90, "tabWidth": 2, "singleQuote": true, "endOfLine": "lf" diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..3b0b653 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,16 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "type": "node", + "request": "launch", + "name": "Launch Program", + "skipFiles": ["/**"], + "program": "${workspaceFolder}/backend/dist/main.js", + "outFiles": ["${workspaceFolder}/**/*.js"] + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..57b1058 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,10 @@ +{ + "cSpell.words": [ + "Deserializable", + "Deserialization", + "Respawn", + "doppler", + "overridable", + "serializable" + ] +} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..327d1f2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,50 @@ +# syntax=docker/dockerfile:1 + +# doppler game server (the `doppler-server` package). +# The frontend is a static site and is NOT built here — see .forgejo/workflows. + +# ---- Stage 1: build the shared lib, then bundle the server ------------------- +# Node 22 (current LTS) matches the project toolchain (webpack 5 / TypeScript 6, +# see .devcontainer). The full (non-slim) image carries the build tools that +# socket.io's optional native deps (bufferutil/utf-8-validate) compile against. +FROM node:22-bookworm AS build +WORKDIR /app + +# `shared` is consumed by the backend as `file:../shared` and is bundled into +# the server bundle by webpack, so it must be installed and built first. +COPY shared/package.json shared/ +RUN cd shared && npm install +COPY shared/ shared/ +RUN cd shared && npm run build + +# Install the backend deps. `file:../shared` now resolves to the built /app/shared. +COPY backend/package.json backend/ +RUN cd backend && npm install +COPY backend/ backend/ +RUN cd backend && npm run build + +# Drop devDependencies; the runtime only needs the production deps that webpack +# left external (express, socket.io, cors, gl-matrix, minimist, msgpack parser). +RUN cd backend && npm prune --production + +# ---- Stage 2: minimal runtime ---------------------------------------------- +FROM node:22-bookworm-slim +WORKDIR /app +ENV NODE_ENV=production + +# Run as an unprivileged user. +RUN groupadd -r app && useradd -r -g app -d /app app + +COPY --from=build /app/backend/dist ./dist +COPY --from=build /app/backend/node_modules ./node_modules + +USER app +EXPOSE 3000 + +# Hits the same /state endpoint the website polls (see serverInformationEndpoint). +HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ + CMD node -e "require('http').get('http://localhost:3000/state',r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))" + +ENTRYPOINT ["node", "dist/main.js"] +# Override these to tune the server, e.g. `--name`, `--playerLimit`, `--scoreLimit`. +CMD ["--port", "3000"] diff --git a/README.md b/README.md index 826b7dc..ce57285 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,35 @@ -# decla.red +# doppler -![logo](frontend/static/declared.png) -![Deploy everything](https://github.com/schmelczerandras/decla.red/workflows/Deploy%20everything/badge.svg) +A 2-dimensional multiplayer game utilising ray tracing. -A good-looking 2D adventure. +> **Available at [doppler.schmelczer.dev](https://doppler.schmelczer.dev).** -## Development +![screenshot of in-game fight](media/game-pc.png) +![three screenshots taken at iPhone SE screen size](media/collage-iphone.png) -- npm install -- npm run start -- npm run build +For optimised 2D ray tracing, [SDF-2D](https://github.com/schmelczerandras/sdf-2d) is used. -## Todo +## Deployment -- Frontend nginx disable logging -- procedural piano -- subtract cameraPosition -- cross browser testing -- nginx log monitor -- lightweight object storage -- local dev env -- CI pipeline: - - prettier script - - tests? +CI/CD runs on Forgejo Actions (`.forgejo/workflows/deploy.yml`). On a push to +`main` it: + +- builds the static frontend and rsyncs `frontend/dist/` to the `/pages/declared` + mount on the runner host (the mount keeps its pre-rebrand name), and +- builds the server image from the root `Dockerfile` and pushes it to the Forgejo + container registry as `//-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 +``` diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..d655b4c --- /dev/null +++ b/backend/package.json @@ -0,0 +1,43 @@ +{ + "name": "doppler-server", + "version": "0.1.0", + "description": "Game server for doppler", + "keywords": [], + "author": "András Schmelczer (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" + } +} diff --git a/backend/src/commands/announce.ts b/backend/src/commands/announce.ts new file mode 100644 index 0000000..2e8aadf --- /dev/null +++ b/backend/src/commands/announce.ts @@ -0,0 +1,9 @@ +import { Command } from 'shared'; + +// Internal request from a game object (e.g. a keystone planet flipping) asking +// the GameServer to broadcast a one-off announcement to every connected client. +export class AnnounceCommand extends Command { + public constructor(public readonly text: string) { + super(); + } +} diff --git a/backend/src/commands/generate-points.ts b/backend/src/commands/generate-points.ts new file mode 100644 index 0000000..3f1ac98 --- /dev/null +++ b/backend/src/commands/generate-points.ts @@ -0,0 +1,10 @@ +import { Command } from 'shared'; + +export class GeneratePointsCommand extends Command { + public constructor( + public readonly blue: number, + public readonly red: number, + ) { + super(); + } +} diff --git a/backend/src/commands/react-to-collision.ts b/backend/src/commands/react-to-collision.ts new file mode 100644 index 0000000..fdcd492 --- /dev/null +++ b/backend/src/commands/react-to-collision.ts @@ -0,0 +1,7 @@ +import { Command, GameObject } from 'shared'; + +export class ReactToCollisionCommand extends Command { + public constructor(public readonly other: GameObject) { + super(); + } +} diff --git a/backend/src/commands/step.ts b/backend/src/commands/step.ts new file mode 100644 index 0000000..ee0f6f7 --- /dev/null +++ b/backend/src/commands/step.ts @@ -0,0 +1,10 @@ +import { Command, CommandReceiver } from 'shared'; + +export class StepCommand extends Command { + public constructor( + public readonly deltaTimeInSeconds: number, + public readonly game: CommandReceiver, + ) { + super(); + } +} diff --git a/backend/src/create-world.ts b/backend/src/create-world.ts new file mode 100644 index 0000000..a1039fc --- /dev/null +++ b/backend/src/create-world.ts @@ -0,0 +1,97 @@ +import { vec2 } from 'gl-matrix'; +import { Random, PlanetBase, hsl, settings, evaluateSdf } from 'shared'; +import { LampPhysical } from './objects/lamp-physical'; +import { PlanetPhysical } from './objects/planet-physical'; +import { PhysicalContainer } from './physics/containers/physical-container'; +import { Physical } from './physics/physicals/physical'; + +export const createWorld = (objectContainer: PhysicalContainer) => { + const objects: Array = []; + const lights: Array = []; + + 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)); +}; diff --git a/backend/src/default-options.ts b/backend/src/default-options.ts new file mode 100644 index 0000000..1580103 --- /dev/null +++ b/backend/src/default-options.ts @@ -0,0 +1,10 @@ +import { Options } from './options'; + +export const defaultOptions: Options = { + port: 3000, + name: 'Test server', + playerLimit: 16, + npcCount: 8, + seed: Math.random(), + scoreLimit: 2500, +}; diff --git a/backend/src/game-server.ts b/backend/src/game-server.ts new file mode 100644 index 0000000..5e92f3b --- /dev/null +++ b/backend/src/game-server.ts @@ -0,0 +1,289 @@ +import { PhysicalContainer } from './physics/containers/physical-container'; +import { Server, Socket } from 'socket.io'; +import { + TransportEvents, + deserialize, + settings, + ServerInformation, + PlayerInformation, + UpdateGameState, + CharacterTeam, + GameEndCommand, + GameStartCommand, + Command, + CommandReceiver, + CommandExecutors, + ServerAnnouncement, +} from 'shared'; +import { createWorld } from './create-world'; +import { DeltaTimeCalculator } from './helper/delta-time-calculator'; +import { Options } from './options'; +import { PlayerContainer } from './players/player-container'; +import { StepCommand } from './commands/step'; +import { GeneratePointsCommand } from './commands/generate-points'; +import { AnnounceCommand } from './commands/announce'; + +const gameStateSubscribedRoom = 'gameStateSubscribedRoom'; + +export class GameServer extends CommandReceiver { + private objects!: PhysicalContainer; + private players!: PlayerContainer; + private deltaTimes!: Array; + private deltaTimeCalculator!: DeltaTimeCalculator; + + private bluePoints = 0; + private redPoints = 0; + private matchPointAnnounced: Partial> = {}; + + 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 = 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 ${team}!`, + ), + ); + } + } + + 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, + }; + } +} diff --git a/backend/src/helper/delta-time-calculator.ts b/backend/src/helper/delta-time-calculator.ts new file mode 100644 index 0000000..4d3723e --- /dev/null +++ b/backend/src/helper/delta-time-calculator.ts @@ -0,0 +1,13 @@ +export class DeltaTimeCalculator { + private previousTime: [number, number] = process.hrtime(); + + public getNextDeltaTimeInSeconds( + { setAsBase = false }: { setAsBase: boolean } = { setAsBase: false }, + ): number { + const [seconds, nanoSeconds] = process.hrtime(this.previousTime); + if (setAsBase) { + this.previousTime = process.hrtime(); + } + return seconds + nanoSeconds / 1e9; + } +} diff --git a/backend/src/main.ts b/backend/src/main.ts new file mode 100644 index 0000000..c492c7a --- /dev/null +++ b/backend/src/main.ts @@ -0,0 +1,52 @@ +import { Server as IoServer } from 'socket.io'; +import express from 'express'; +import { Server } from 'http'; +import cors from 'cors'; +import { applyArrayPlugins, Random, serverInformationEndpoint } from 'shared'; +import minimist from 'minimist'; +import { glMatrix } from 'gl-matrix'; +import { GameServer } from './game-server'; +import { defaultOptions } from './default-options'; +import parser from 'socket.io-msgpack-parser'; + +glMatrix.setMatrixArrayType(Array); +applyArrayPlugins(); + +const optionOverrides = minimist(process.argv.slice(2)); +const options = { + ...defaultOptions, + ...optionOverrides, +}; + +Random.seed = options.seed; + +const app = express(); +const server = new Server(app); +const io = new IoServer(server, { + parser, + cors: { + origin: true, + credentials: true, + }, +} as any); + +const gameServer = new GameServer(io, options); + +app.use( + cors({ + origin: (_, callback) => { + callback(null, true); + }, + credentials: true, + }), +); + +app.get(serverInformationEndpoint, (_, res) => { + res.json(gameServer.serverInfo); +}); + +server.listen(options.port, () => { + console.info(`Server started on port ${options.port}`); +}); + +gameServer.start(); diff --git a/backend/src/objects/character-physical.ts b/backend/src/objects/character-physical.ts new file mode 100644 index 0000000..bdc8873 --- /dev/null +++ b/backend/src/objects/character-physical.ts @@ -0,0 +1,564 @@ +import { vec2 } from 'gl-matrix'; +import { + id, + settings, + MoveActionCommand, + serializesTo, + last, + Circle, + CharacterBase, + CharacterTeam, + PropertyUpdatesForObject, + UpdatePropertyCommand, + CommandExecutors, + CommandReceiver, + mix, + clamp01, + stepCharacterMovement, + applyLeapImpulse, + decayMomentum, + CharacterMovementState, + CharacterWorld, + GroundSurface, + headRadius, + feetRadius, + headOffset, + leftFootOffset, + rightFootOffset, + boundRadius, +} from 'shared'; +import { DynamicPhysical } from '../physics/physicals/dynamic-physical'; +import { CirclePhysical } from './circle-physical'; +import { PhysicalContainer } from '../physics/containers/physical-container'; +import { BoundingBoxBase } from '../physics/bounding-boxes/bounding-box-base'; +import { ProjectilePhysical } from './projectile-physical'; +import { forceAtPosition } from '../physics/functions/force-at-position'; +import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle'; +import { PlanetPhysical } from './planet-physical'; +import { StepCommand } from '../commands/step'; +import { ReactToCollisionCommand } from '../commands/react-to-collision'; +import { GeneratePointsCommand } from '../commands/generate-points'; + +@serializesTo(CharacterBase) +export class CharacterPhysical extends CharacterBase implements DynamicPhysical { + public readonly canCollide = true; + public readonly canMove = true; + + private projectileStrength = settings.playerMaxStrength; + + // Body geometry (head/foot radii, posture offsets, bound radius) is defined + // once in the shared movement module and imported here, so the authoritative + // body and the client's predicted body are bit-identical by construction + // instead of by a hand-synced "copied verbatim" duplicate. Re-exposed as a + // static only because external callers reference CharacterPhysical.boundRadius. + public static readonly boundRadius = boundRadius; + + private timeSinceDying = 0; + private isDestroyed = false; + private timeSinceBorn = 0; + private hasJustBorn = true; + private timeAlive = 0; + + private timeSinceLastShot = settings.projectileCreationInterval; + private timeSinceLastDamage = settings.playerOutOfCombatDelaySeconds; + private lastSyncedHealth = settings.playerMaxHealth; + + private killStreak = 0; + + private direction = 0; + private currentPlanet?: PlanetPhysical; + private secondsSinceOnSurface = settings.planetDetachmentSeconds; + + private bodyVelocity = vec2.create(); + private timeSinceLastLeap = settings.leapCooldownSeconds; + + public head: CirclePhysical; + public leftFoot: CirclePhysical; + public rightFoot: CirclePhysical; + + private movementState!: CharacterMovementState; + private movementWorld!: CharacterWorld; + + private movementActions: Array = []; + 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); + } +} diff --git a/backend/src/objects/circle-physical.ts b/backend/src/objects/circle-physical.ts new file mode 100644 index 0000000..e1a6abf --- /dev/null +++ b/backend/src/objects/circle-physical.ts @@ -0,0 +1,143 @@ +import { vec2 } from 'gl-matrix'; +import { + Circle, + CommandExecutors, + CommandReceiver, + GameObject, + resolveCircleMovement, + serializesTo, +} from 'shared'; +import { BoundingBox } from '../physics/bounding-boxes/bounding-box'; +import { BoundingBoxBase } from '../physics/bounding-boxes/bounding-box-base'; +import { PhysicalContainer } from '../physics/containers/physical-container'; +import { DynamicPhysical } from '../physics/physicals/dynamic-physical'; +import { Physical } from '../physics/physicals/physical'; +import { ReactToCollisionCommand } from '../commands/react-to-collision'; + +@serializesTo(Circle) +export class CirclePhysical extends CommandReceiver implements Circle, DynamicPhysical { + readonly canCollide = true; + readonly canMove = true; + + public velocity = vec2.create(); + public lastNormal = vec2.fromValues(0, 1); + + private _boundingBox: BoundingBox; + + protected commandExecutors: CommandExecutors = { + [ReactToCollisionCommand.type]: this.onCollision.bind(this), + }; + + constructor( + private _center: vec2, + private _radius: number, + public owner: GameObject, + private readonly container: PhysicalContainer, + // Public + readonly so a CirclePhysical structurally satisfies the shared + // PhysicsBody interface the movement simulation operates on. + public readonly restitution = 0, + ) { + super(); + this._boundingBox = new BoundingBox(); + this.recalculateBoundingBox(); + } + + public get boundingBox(): BoundingBoxBase { + return this._boundingBox; + } + + public get center(): vec2 { + return this._center; + } + + public set center(value: vec2) { + this._center = value; + this.recalculateBoundingBox(); + } + + public onCollision(c: ReactToCollisionCommand) { + this.owner.handleCommand(c); + } + + public get gameObject(): GameObject { + return this.owner; + } + + public get radius(): number { + return this._radius; + } + + public set radius(value: number) { + this._radius = value; + this.recalculateBoundingBox(); + } + + public distance(target: vec2): number { + return vec2.distance(target, this.center) - this.radius; + } + + private recalculateBoundingBox() { + this._boundingBox.xMin = this.center.x - this._radius; + this._boundingBox.xMax = this.center.x + this._radius; + this._boundingBox.yMin = this.center.y - this._radius; + this._boundingBox.yMax = this.center.y + this._radius; + } + + public applyForce(force: vec2, timeInSeconds: number) { + vec2.add( + this.velocity, + this.velocity, + vec2.scale(vec2.create(), force, timeInSeconds), + ); + } + + // Position-resolution for one tick. Delegates to the shared + // resolveCircleMovement so the server integrates a body with the exact same + // geometry the client predictor runs (shared/physics) — no parallel copy to + // keep in sync. The onHit callback dispatches the collision reactions at the + // same points the old inline move-circle did (both the initial march and the + // post-bounce slide). `possibleIntersectors`, when supplied, lets a caller + // that already broadphased (e.g. a projectile's gravity query) avoid a second + // container query; otherwise it is self-gathered from the swept bounding box. + public stepManually( + deltaTimeInSeconds: number, + possibleIntersectors?: Array, + ): { + 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 { + 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 { + const { center, radius } = this; + return [center, radius]; + } +} diff --git a/backend/src/objects/lamp-physical.ts b/backend/src/objects/lamp-physical.ts new file mode 100644 index 0000000..591b17c --- /dev/null +++ b/backend/src/objects/lamp-physical.ts @@ -0,0 +1,42 @@ +import { vec2, vec3 } from 'gl-matrix'; +import { LampBase, settings, id, serializesTo } from 'shared'; + +import { ImmutableBoundingBox } from '../physics/bounding-boxes/immutable-bounding-box'; +import { StaticPhysical } from '../physics/physicals/static-physical'; + +@serializesTo(LampBase) +export class LampPhysical extends LampBase implements StaticPhysical { + public readonly canCollide = false; + public readonly canMove = false; + + constructor(center: vec2, color: vec3, lightness: number) { + super(id(), center, color, lightness); + } + + private _boundingBox?: ImmutableBoundingBox; + + public get boundingBox(): ImmutableBoundingBox { + if (!this._boundingBox) { + this._boundingBox = new ImmutableBoundingBox( + this.center.x - settings.lightCutoffDistance, + this.center.x + settings.lightCutoffDistance, + this.center.y - settings.lightCutoffDistance, + this.center.y + settings.lightCutoffDistance, + ); + } + + return this._boundingBox; + } + + public get gameObject(): this { + return this; + } + + public queueSetLight(color: vec3, lightness: number) { + this.remoteCall('setLight', color, lightness); + } + + public distance(target: vec2): number { + return vec2.distance(this.center, target); + } +} diff --git a/backend/src/objects/planet-physical.ts b/backend/src/objects/planet-physical.ts new file mode 100644 index 0000000..4305b18 --- /dev/null +++ b/backend/src/objects/planet-physical.ts @@ -0,0 +1,334 @@ +import { vec2 } from 'gl-matrix'; + +import { + clamp, + clamp01, + id, + mix, + Random, + serializesTo, + settings, + PlanetBase, + CharacterTeam, + PropertyUpdatesForObject, + UpdatePropertyCommand, + CommandExecutors, + CommandReceiver, +} from 'shared'; +import { GeneratePointsCommand } from '../commands/generate-points'; +import { AnnounceCommand } from '../commands/announce'; +import { StepCommand } from '../commands/step'; + +import { ImmutableBoundingBox } from '../physics/bounding-boxes/immutable-bounding-box'; +import { StaticPhysical } from '../physics/physicals/static-physical'; +import { LampPhysical } from './lamp-physical'; +import type { CharacterPhysical } from './character-physical'; + +@serializesTo(PlanetBase) +export class PlanetPhysical extends PlanetBase implements StaticPhysical { + public readonly canCollide = true; + public readonly canMove = false; + // Marks this as standable ground for the shared movement simulation (a body + // landing on it latches it as currentPlanet). See shared GroundSurface. + public readonly isGround = true; + + public readonly sizePointMultiplier: number; + + // Planets slowly spin. The angle is authoritative here and streamed to the + // client (see getPropertyUpdates), so the rendered outline and this collision + // polygon turn as one rigid body. cos/sin are memoised per angle because + // distance() is called many times per tick by the raymarcher and SDF sampling. + private rotation = 0; + private readonly rotationSpeed: number; + private cachedRotation = Number.NaN; + private cosRotation = 1; + private sinRotation = 0; + + private _boundingBox?: ImmutableBoundingBox; + + private readonly lamps: Array = []; + + 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 = []; + private isContested = false; + + protected commandExecutors: CommandExecutors = { + [StepCommand.type]: this.step.bind(this), + }; + + public addLamp(lamp: LampPhysical) { + this.lamps.push(lamp); + } + + constructor(vertices: Array, 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 ${currentTeam} 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; + } +} diff --git a/backend/src/objects/projectile-physical.ts b/backend/src/objects/projectile-physical.ts new file mode 100644 index 0000000..8a967f1 --- /dev/null +++ b/backend/src/objects/projectile-physical.ts @@ -0,0 +1,160 @@ +import { vec2 } from 'gl-matrix'; +import { + id, + settings, + serializesTo, + ProjectileBase, + CharacterTeam, + PropertyUpdatesForObject, + UpdatePropertyCommand, + CommandExecutors, + Circle, + marchCircle, +} from 'shared'; +import { ImmutableBoundingBox } from '../physics/bounding-boxes/immutable-bounding-box'; +import { CirclePhysical } from './circle-physical'; +import { DynamicPhysical } from '../physics/physicals/dynamic-physical'; +import { PhysicalContainer } from '../physics/containers/physical-container'; +import { CharacterPhysical } from './character-physical'; +import { forceAtPosition } from '../physics/functions/force-at-position'; +import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle'; +import { StepCommand } from '../commands/step'; +import { ReactToCollisionCommand } from '../commands/react-to-collision'; + +@serializesTo(ProjectileBase) +export class ProjectilePhysical extends ProjectileBase implements DynamicPhysical { + public readonly canCollide = true; + public readonly canMove = true; + + private isDestroyed = false; + private bounceCount = 0; + private _boundingBox?: ImmutableBoundingBox; + + public object: CirclePhysical; + + protected commandExecutors: CommandExecutors = { + [StepCommand.type]: this.handleStep.bind(this), + [ReactToCollisionCommand.type]: this.onCollision.bind(this), + }; + + constructor( + center: vec2, + radius: number, + public strength: number, + team: CharacterTeam, + private velocity: vec2, + public readonly originator: CharacterPhysical, + readonly container: PhysicalContainer, + // Normalised charge (0..1) of the shot that fired this, used by the victim + // to scale hit/kill feedback and the death fling. + public readonly charge: number = 0, + ) { + super(id(), center, radius, team, strength); + this.object = new CirclePhysical(center, radius, this, container, 0.9); + + this.moveOutsideOfObject(); + } + + public get isAlive(): boolean { + return !this.isDestroyed; + } + + public get direction(): vec2 { + const direction = vec2.clone(this.velocity); + return vec2.length(direction) > 0 + ? vec2.normalize(direction, direction) + : vec2.fromValues(0, -1); + } + + private moveOutsideOfObject() { + let wasCollision = true; + const delta = vec2.scale( + vec2.create(), + vec2.normalize(vec2.create(), this.velocity), + 10, + ); + while (wasCollision) { + const intersecting = this.container + .findIntersecting(this.boundingBox) + .filter((g) => g instanceof CharacterPhysical && g.team === this.team); + const { hitSurface } = marchCircle(this.object, delta, intersecting, true); + wasCollision = hitSurface; + } + vec2.add(this.center, this.center, delta); + vec2.add(this.center, this.center, delta); + } + + public get boundingBox(): ImmutableBoundingBox { + if (!this._boundingBox) { + this._boundingBox = (this.object as CirclePhysical).boundingBox; + } + + return this._boundingBox; + } + + public get gameObject(): this { + return this; + } + + public distance(target: vec2): number { + return this.object.distance(target); + } + + public destroy() { + if (!this.isDestroyed) { + this.isDestroyed = true; + this.container.removeObject(this); + } + } + + public onCollision({ other }: ReactToCollisionCommand) { + if ( + !(other instanceof CharacterPhysical && other.team === this.team) && + this.bounceCount++ === settings.projectileMaxBounceCount + ) { + this.destroy(); + } + } + + public getPropertyUpdates(): PropertyUpdatesForObject { + return new PropertyUpdatesForObject(this.id, [ + new UpdatePropertyCommand('center', this.center, this.velocity), + ]); + } + + private handleStep({ deltaTimeInSeconds }: StepCommand) { + super.step(deltaTimeInSeconds); + + if (this.strength <= 0) { + this.destroy(); + return; + } + + // Curveball: the same planetary gravity that pulls on a free-falling + // character bends the shot, so slower (charged) shots arc and can be lobbed + // over a planet's horizon. Scale is tiny — near-surface gravity is huge. + // This single broadphase is reused for the step below: the gravity radius + // (maxGravityDistance) dwarfs one tick's travel, so the set is a superset of + // the swept-collision box and the step needn't query the container again. + const intersecting = settings.projectileGravityEnabled + ? this.container.findIntersecting( + getBoundingBoxOfCircle( + new Circle(this.center, this.object.radius + settings.maxGravityDistance), + ), + ) + : undefined; + + if (intersecting) { + vec2.scaleAndAdd( + this.velocity, + this.velocity, + forceAtPosition(this.center, intersecting), + settings.projectileGravityScale * deltaTimeInSeconds, + ); + } + + vec2.copy(this.object.velocity, this.velocity); + const { velocity } = this.object.stepManually(deltaTimeInSeconds, intersecting); + vec2.copy(this.velocity, velocity); + } +} diff --git a/backend/src/options.ts b/backend/src/options.ts new file mode 100644 index 0000000..ba28c16 --- /dev/null +++ b/backend/src/options.ts @@ -0,0 +1,8 @@ +export interface Options { + port: number; + name: string; + playerLimit: number; + scoreLimit: number; + npcCount: number; + seed: number; +} diff --git a/frontend/src/scripts/physics/containers/bounding-box-base.ts b/backend/src/physics/bounding-boxes/bounding-box-base.ts similarity index 80% rename from frontend/src/scripts/physics/containers/bounding-box-base.ts rename to backend/src/physics/bounding-boxes/bounding-box-base.ts index b674561..91946c9 100644 --- a/frontend/src/scripts/physics/containers/bounding-box-base.ts +++ b/backend/src/physics/bounding-boxes/bounding-box-base.ts @@ -1,15 +1,16 @@ import { vec2 } from 'gl-matrix'; -import { IPrimitive } from '../../drawing/drawables/primitives/i-primitive'; +// axis-aligned export abstract class BoundingBoxBase { constructor( - public readonly value: IPrimitive, - protected _xMin: number, - protected _xMax: number, - protected _yMin: number, - protected _yMax: number + protected _xMin: number = 0, + protected _xMax: number = 0, + protected _yMin: number = 0, + protected _yMax: number = 0, ) {} + [key: number]: number | undefined; + public get 0(): number { return this._xMin; } diff --git a/backend/src/physics/bounding-boxes/bounding-box.ts b/backend/src/physics/bounding-boxes/bounding-box.ts new file mode 100644 index 0000000..562794f --- /dev/null +++ b/backend/src/physics/bounding-boxes/bounding-box.ts @@ -0,0 +1,59 @@ +import { vec2 } from 'gl-matrix'; +import { BoundingBoxBase } from './bounding-box-base'; +import { ImmutableBoundingBox } from './immutable-bounding-box'; + +export class BoundingBox extends BoundingBoxBase { + public get xMin(): number { + return this._xMin; + } + + public set xMin(value: number) { + this._xMin = value; + } + + public set xMax(value: number) { + this._xMax = value; + } + + public get xMax(): number { + return this._xMax; + } + + public set yMin(value: number) { + this._yMin = value; + } + + public get yMin(): number { + return this._yMin; + } + + public set yMax(value: number) { + this._yMax = value; + } + + public get yMax(): number { + return this._yMax; + } + + public get topLeft(): vec2 { + return vec2.fromValues(this._xMin, this._yMax); + } + + public set topLeft(value: vec2) { + this._xMin = value.x; + this._yMax = value.y; + } + + public set size(value: vec2) { + this._xMax = this.xMin + value.x; + this._yMin = this.yMax - value.y; + } + + public get size(): vec2 { + return vec2.fromValues(this._xMax - this._xMin, this._yMax - this._yMin); + } + + public cloneAsImmutable(): ImmutableBoundingBox { + return new ImmutableBoundingBox(this.xMin, this.xMax, this.yMin, this.yMax); + } +} diff --git a/backend/src/physics/bounding-boxes/immutable-bounding-box.ts b/backend/src/physics/bounding-boxes/immutable-bounding-box.ts new file mode 100644 index 0000000..43b87e1 --- /dev/null +++ b/backend/src/physics/bounding-boxes/immutable-bounding-box.ts @@ -0,0 +1,3 @@ +import { BoundingBoxBase } from './bounding-box-base'; + +export class ImmutableBoundingBox extends BoundingBoxBase {} diff --git a/backend/src/physics/containers/bounding-box-list.ts b/backend/src/physics/containers/bounding-box-list.ts new file mode 100644 index 0000000..4311f62 --- /dev/null +++ b/backend/src/physics/containers/bounding-box-list.ts @@ -0,0 +1,25 @@ +import { DynamicPhysical } from '../physicals/dynamic-physical'; +import { BoundingBoxBase } from '../bounding-boxes/bounding-box-base'; + +export class BoundingBoxList { + constructor(private objects: Array = []) {} + + 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 { + return this.objects.filter((b) => b.boundingBox.intersects(boundingBox)); + } +} diff --git a/backend/src/physics/containers/bounding-box-tree.ts b/backend/src/physics/containers/bounding-box-tree.ts new file mode 100644 index 0000000..39981ff --- /dev/null +++ b/backend/src/physics/containers/bounding-box-tree.ts @@ -0,0 +1,126 @@ +import { BoundingBoxBase } from '../bounding-boxes/bounding-box-base'; +import { StaticPhysical } from '../physicals/static-physical'; + +// source: https://github.com/ubilabs/kd-tree-javascript/blob/master/kdTree.js +class Node { + public left: Node | null = null; + public right: Node | null = null; + constructor( + public object: StaticPhysical, + public parent: Node | null, + ) {} +} + +export class BoundingBoxTree { + root?: Node | null; + + constructor(objects: Array = []) { + this.build(objects); + } + + public build(objects: Array) { + this.root = this.buildRecursive(objects, 0, null); + } + + private buildRecursive( + objects: Array, + 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 { + 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 { + 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); + } +} diff --git a/backend/src/physics/containers/physical-container.ts b/backend/src/physics/containers/physical-container.ts new file mode 100644 index 0000000..604672e --- /dev/null +++ b/backend/src/physics/containers/physical-container.ts @@ -0,0 +1,54 @@ +import { BoundingBoxBase } from '../bounding-boxes/bounding-box-base'; +import { BoundingBoxList } from './bounding-box-list'; +import { BoundingBoxTree } from './bounding-box-tree'; +import { Physical } from '../physicals/physical'; +import { StaticPhysical } from '../physicals/static-physical'; +import { DynamicPhysical } from '../physicals/dynamic-physical'; +import { Command, CommandReceiver } from 'shared'; + +export class PhysicalContainer extends CommandReceiver { + private isTreeInitialized = false; + private staticBoundingBoxesWaitList: Array = []; + private staticBoundingBoxes = new BoundingBoxTree(); + private dynamicBoundingBoxes = new BoundingBoxList(); + private objects: Array = []; + + 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 { + return [ + ...this.staticBoundingBoxes.findIntersecting(box), + ...this.dynamicBoundingBoxes.findIntersecting(box), + ]; + } +} diff --git a/backend/src/physics/functions/force-at-position.ts b/backend/src/physics/functions/force-at-position.ts new file mode 100644 index 0000000..85bf2e3 --- /dev/null +++ b/backend/src/physics/functions/force-at-position.ts @@ -0,0 +1,12 @@ +import { vec2 } from 'gl-matrix'; +import { PlanetPhysical } from '../../objects/planet-physical'; +import { Physical } from '../physicals/physical'; + +export const forceAtPosition = (position: vec2, objects: Array) => + objects + .filter((o) => o instanceof PlanetPhysical) + .reduce( + (sum: vec2, o: Physical) => + vec2.add(sum, sum, (o as PlanetPhysical).getForce(position)), + vec2.create(), + ); diff --git a/backend/src/physics/functions/get-bounding-box-of-circle.ts b/backend/src/physics/functions/get-bounding-box-of-circle.ts new file mode 100644 index 0000000..9c5b2eb --- /dev/null +++ b/backend/src/physics/functions/get-bounding-box-of-circle.ts @@ -0,0 +1,11 @@ +import { Circle } from 'shared'; +import { BoundingBoxBase } from '../bounding-boxes/bounding-box-base'; +import { ImmutableBoundingBox } from '../bounding-boxes/immutable-bounding-box'; + +export const getBoundingBoxOfCircle = (circle: Circle): BoundingBoxBase => + new ImmutableBoundingBox( + circle.center.x - circle.radius, + circle.center.x + circle.radius, + circle.center.y - circle.radius, + circle.center.y + circle.radius, + ); diff --git a/backend/src/physics/functions/is-circle-intersecting.ts b/backend/src/physics/functions/is-circle-intersecting.ts new file mode 100644 index 0000000..203eb1d --- /dev/null +++ b/backend/src/physics/functions/is-circle-intersecting.ts @@ -0,0 +1,7 @@ +import { Circle, evaluateSdf } from 'shared'; +import { PhysicalBase } from '../physicals/physical-base'; + +export const isCircleIntersecting = ( + circle: Circle, + intersectors: Array, +): boolean => evaluateSdf(circle.center, intersectors) < circle.radius; diff --git a/backend/src/physics/physicals/dynamic-physical.ts b/backend/src/physics/physicals/dynamic-physical.ts new file mode 100644 index 0000000..d72cd9d --- /dev/null +++ b/backend/src/physics/physicals/dynamic-physical.ts @@ -0,0 +1,5 @@ +import { PhysicalBase } from './physical-base'; + +export interface DynamicPhysical extends PhysicalBase { + readonly canMove: true; +} diff --git a/backend/src/physics/physicals/physical-base.ts b/backend/src/physics/physicals/physical-base.ts new file mode 100644 index 0000000..ab41eac --- /dev/null +++ b/backend/src/physics/physicals/physical-base.ts @@ -0,0 +1,12 @@ +import { vec2 } from 'gl-matrix'; +import { CommandReceiver, GameObject } from 'shared'; +import { BoundingBoxBase } from '../bounding-boxes/bounding-box-base'; + +export interface PhysicalBase extends CommandReceiver { + readonly canCollide: boolean; + readonly canMove: boolean; + readonly boundingBox: BoundingBoxBase; + readonly gameObject: GameObject; + + distance(target: vec2): number; +} diff --git a/backend/src/physics/physicals/physical.ts b/backend/src/physics/physicals/physical.ts new file mode 100644 index 0000000..2e3189b --- /dev/null +++ b/backend/src/physics/physicals/physical.ts @@ -0,0 +1,4 @@ +import { DynamicPhysical } from './dynamic-physical'; +import { StaticPhysical } from './static-physical'; + +export type Physical = StaticPhysical | DynamicPhysical; diff --git a/backend/src/physics/physicals/static-physical.ts b/backend/src/physics/physicals/static-physical.ts new file mode 100644 index 0000000..eef168b --- /dev/null +++ b/backend/src/physics/physicals/static-physical.ts @@ -0,0 +1,7 @@ +import { PhysicalBase } from './physical-base'; +import { ImmutableBoundingBox } from '../bounding-boxes/immutable-bounding-box'; + +export interface StaticPhysical extends PhysicalBase { + readonly canMove: false; + readonly boundingBox: ImmutableBoundingBox; +} diff --git a/backend/src/players/npc.ts b/backend/src/players/npc.ts new file mode 100644 index 0000000..655ade7 --- /dev/null +++ b/backend/src/players/npc.ts @@ -0,0 +1,414 @@ +import { vec2 } from 'gl-matrix'; +import { + PlayerInformation, + settings, + Circle, + Random, + MoveActionCommand, + CharacterTeam, + Id, +} from 'shared'; +import { PhysicalContainer } from '../physics/containers/physical-container'; +import { PlayerContainer } from './player-container'; +import { PlayerBase } from './player-base'; +import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle'; +import { CharacterPhysical } from '../objects/character-physical'; +import { PlanetPhysical } from '../objects/planet-physical'; +import { ProjectilePhysical } from '../objects/projectile-physical'; +import { Physical } from '../physics/physicals/physical'; + +const npcTuning = { + planIntervalSeconds: 1, + shootIntervalSeconds: 1.5, + reactionIntervalSeconds: 1 / 10, + reactionObserveRadius: 1400, + planScanRadius: 3500, + + aggressionMin: 0.25, + aggressionMax: 1, + + wanderReconsiderSeconds: 5, + wanderProbability: 0.4, + wanderTurn: 0.3, + + fleeBaseRange: 240, + fleeAggressionFalloff: 1.4, + + chaseBaseRange: 700, + chaseAggressionRange: 1600, + captureHoldEnemyDistance: 500, + + dodgeThreatRange: 450, + dodgeApproachDot: 0.6, + + dodgeBaseChance: 0.25, + dodgeAggressionChance: 0.35, + dodgeCommitSeconds: 0.35, + dodgeCooldownSeconds: 0.5, + + lineOfSightClearance: 20, + lineOfSightStartOffset: 100, + + fireBaseChance: 0.45, + fireAggressionChance: 0.45, + + chargeRangeThreshold: 500, + chargeBaseChance: 0.3, + chargeAggressionChance: 0.4, + chargeMin: 0.6, + + spreadBase: 60, + spreadPerDistance: 0.08, + spreadAggressionFalloff: 1.3, + + // Per-second chance to hop while grounded and on the move, so bots use the + // leap verb too instead of being grounded targets. + leapChancePerSecond: 0.35, +}; + +export class NPC extends PlayerBase { + private direction = vec2.fromValues(Random.getRandom() - 0.5, Random.getRandom() - 0.5); + private timeSinceLastPlan = 10000; + private timeSinceLastShoot = 10000; + private isWandering = false; + private timeSinceLastWanderingConsideration = 0; + private isComingBack = false; + + private readonly aggression = Random.getRandomInRange( + npcTuning.aggressionMin, + npcTuning.aggressionMax, + ); + + private aimTargetId: Id = null; + private readonly aimTargetLastPosition = vec2.create(); + + private readonly dodgeDirection = vec2.create(); + private dodgeCommitRemaining = 0; + private dodgeCooldownRemaining = 0; + + private timeSinceObserve = npcTuning.reactionIntervalSeconds; + private nearObjects: Array = []; + + 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): 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): 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) { + 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): boolean { + const planets: Array = []; + 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 { + return this.objectContainer.findIntersecting( + getBoundingBoxOfCircle(new Circle(this.center, radius)), + ); + } + + private enemiesByDistance( + nearObjects: Array, + ): Array<{ character: CharacterPhysical; distance: number }> { + const seen = new Set(); + 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, + ): 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; + } +} diff --git a/backend/src/players/player-base.ts b/backend/src/players/player-base.ts new file mode 100644 index 0000000..6de34cd --- /dev/null +++ b/backend/src/players/player-base.ts @@ -0,0 +1,104 @@ +import { vec2 } from 'gl-matrix'; +import { + CommandReceiver, + Circle, + PlayerInformation, + CharacterTeam, + Random, + settings, +} from 'shared'; +import { PhysicalContainer } from '../physics/containers/physical-container'; +import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle'; +import { isCircleIntersecting } from '../physics/functions/is-circle-intersecting'; +import { CharacterPhysical } from '../objects/character-physical'; +import { PlanetPhysical } from '../objects/planet-physical'; +import { PlayerContainer } from './player-container'; + +export abstract class PlayerBase extends CommandReceiver { + public character?: CharacterPhysical | null; + public center: vec2 = vec2.create(); + + protected sumKills = 0; + protected sumDeaths = 0; + + constructor( + protected readonly playerInfo: PlayerInformation, + protected readonly playerContainer: PlayerContainer, + protected readonly objectContainer: PhysicalContainer, + public readonly team: CharacterTeam, + ) { + super(); + } + + protected createCharacter() { + this.character = new CharacterPhysical( + this.playerInfo.name.slice(0, 20), + this.sumKills, + this.sumDeaths, + this.team, + this.objectContainer, + this.findEmptyPositionForPlayer(this.findSpawnCenter()), + ); + + this.objectContainer.addObject(this.character); + } + + public abstract step(deltaTimeInSeconds: number): void; + + private findSpawnCenter(): vec2 { + const planets = this.objectContainer + .findIntersecting( + getBoundingBoxOfCircle(new Circle(vec2.create(), settings.worldRadius * 2)), + ) + .filter((o): o is PlanetPhysical => o instanceof PlanetPhysical); + + const friendly = planets.filter((p) => p.team === this.team); + const neutral = planets.filter((p) => p.team === CharacterTeam.neutral); + const candidates = friendly.length ? friendly : neutral.length ? neutral : planets; + if (candidates.length === 0) { + return vec2.create(); + } + + const isContested = (planet: PlanetPhysical) => + this.playerContainer.players.some( + (p) => + p.team !== this.team && + p.character?.isAlive && + vec2.distance(p.center, planet.center) < settings.spawnSafetyDistance, + ); + const safe = candidates.filter((p) => !isContested(p)); + + // candidates is non-empty here, so choose() always returns a planet. + return vec2.clone(Random.choose(safe.length ? safe : candidates)!.center); + } + + protected findEmptyPositionForPlayer(preferredCenter: vec2): vec2 { + let rotation = 0; + let radius = 0; + for (;;) { + const playerPosition = vec2.fromValues( + radius * Math.cos(rotation) + preferredCenter.x, + radius * Math.sin(rotation) + preferredCenter.y, + ); + + const playerBoundingCircle = new Circle( + playerPosition, + CharacterPhysical.boundRadius, + ); + + const playerBoundingBox = getBoundingBoxOfCircle(playerBoundingCircle); + const possibleIntersectors = + this.objectContainer.findIntersecting(playerBoundingBox); + if (!isCircleIntersecting(playerBoundingCircle, possibleIntersectors)) { + return playerPosition; + } + + rotation += Math.PI / 8; + radius += 30; + } + } + + public destroy() { + this.character?.onDie(); + } +} diff --git a/backend/src/players/player-container.ts b/backend/src/players/player-container.ts new file mode 100644 index 0000000..01a1c96 --- /dev/null +++ b/backend/src/players/player-container.ts @@ -0,0 +1,102 @@ +import { CharacterTeam, PlayerInformation, Random, settings, Command } from 'shared'; +import { Socket } from 'socket.io'; +import { PhysicalContainer } from '../physics/containers/physical-container'; +import { NPC } from './npc'; +import { Player } from './player'; +import { PlayerBase } from './player-base'; + +export class PlayerContainer { + private _players: Array = []; + private _npcs: Array = []; + + 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 { + 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 { + 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(); + } +} diff --git a/backend/src/players/player.ts b/backend/src/players/player.ts new file mode 100644 index 0000000..923be65 --- /dev/null +++ b/backend/src/players/player.ts @@ -0,0 +1,243 @@ +import { performance } from 'perf_hooks'; +import { vec2 } from 'gl-matrix'; +import { + CommandExecutors, + CreateObjectsCommand, + CreatePlayerCommand, + DeleteObjectsCommand, + MoveActionCommand, + serialize, + TransportEvents, + SetAspectRatioActionCommand, + calculateViewArea, + settings, + PlayerInformation, + CharacterTeam, + UpdateMinimap, + GameObject, + Command, + MinimapPlayer, + RemoteCallsForObject, + RemoteCallsForObjects, + ServerAnnouncement, + PropertyUpdatesForObjects, + PropertyUpdatesForObject, + PrimaryActionCommand, + LeapActionCommand, + InputAcknowledgement, +} from 'shared'; +import { Socket } from 'socket.io'; +import { BoundingBox } from '../physics/bounding-boxes/bounding-box'; +import { PhysicalContainer } from '../physics/containers/physical-container'; +import { PlayerContainer } from './player-container'; +import { PlayerBase } from './player-base'; + +// How often the server pings each client to measure round-trip time. +const pingIntervalSeconds = 1; + +export class Player extends PlayerBase { + // default, until the clients sends its real value + private aspectRatio: number = 16 / 9; + private timeUntilRespawn = 0; + private timeSinceLastMessage = 0; + private objectsPreviouslyInViewArea: Array = []; + 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, + 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 { + 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 = []; + 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 ${this.winnerTeam} won 🎉`; + } else if (!this.character) { + announcement = `Reviving in ${Math.round(this.timeUntilRespawn)}…`; + } + + if (announcement) { + this.queueCommandSend(new ServerAnnouncement(announcement)); + } + } +} diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..e9e0884 --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "skipLibCheck": true, + "target": "es6", + "esModuleInterop": true, + "strict": true, + "experimentalDecorators": true, + "moduleResolution": "node", + "ignoreDeprecations": "6.0", + "module": "commonjs", + "lib": ["dom", "es2017"], + "typeRoots": ["./types", "./node_modules/@types"] + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "typings"] +} diff --git a/backend/types/socket.io-msgpack-parser/index.d.ts b/backend/types/socket.io-msgpack-parser/index.d.ts new file mode 100644 index 0000000..4065a1e --- /dev/null +++ b/backend/types/socket.io-msgpack-parser/index.d.ts @@ -0,0 +1 @@ +declare module 'socket.io-msgpack-parser'; diff --git a/backend/webpack.config.js b/backend/webpack.config.js new file mode 100644 index 0000000..20e8a57 --- /dev/null +++ b/backend/webpack.config.js @@ -0,0 +1,65 @@ +const path = require('path'); +const { CleanWebpackPlugin } = require('clean-webpack-plugin'); +const nodeExternals = require('webpack-node-externals'); +const TerserJSPlugin = require('terser-webpack-plugin'); +const webpack = require('webpack'); + +const PATHS = { + entryPoint: path.resolve(__dirname, 'src/main.ts'), + bundles: path.resolve(__dirname, 'dist'), +}; + +module.exports = (env, argv) => ({ + entry: { + main: [PATHS.entryPoint], + }, + + externals: [ + nodeExternals({ + allowlist: [/(^shared)/], + }), + ], + target: 'node', + output: { + filename: '[name].js', + path: PATHS.bundles, + }, + devtool: argv.mode === 'development' ? 'source-map' : false, + watchOptions: { + poll: true, + }, + optimization: { + minimize: argv.mode !== 'development', + minimizer: [ + new TerserJSPlugin({ + exclude: /node_modules/, + // The custom serialization protocol keys on class names, so they must + // survive minification (see shared/src/serialization). + terserOptions: { + keep_classnames: true, + }, + }), + ], + }, + plugins: [ + new webpack.BannerPlugin({ banner: '#!/usr/bin/env node', raw: true }), + new CleanWebpackPlugin({ + protectWebpackAssets: false, + cleanAfterEveryBuildPatterns: [], + }), + ], + module: { + rules: [ + { + test: /\.ts$/, + use: { + loader: 'ts-loader', + }, + exclude: /node_modules/, + }, + ], + }, + resolve: { + extensions: ['.ts', '.js'], + }, +}); diff --git a/config/ConfigurationServer/.dockerignore b/config/ConfigurationServer/.dockerignore deleted file mode 100644 index 3729ff0..0000000 --- a/config/ConfigurationServer/.dockerignore +++ /dev/null @@ -1,25 +0,0 @@ -**/.classpath -**/.dockerignore -**/.env -**/.git -**/.gitignore -**/.project -**/.settings -**/.toolstarget -**/.vs -**/.vscode -**/*.*proj.user -**/*.dbmdl -**/*.jfm -**/azds.yaml -**/bin -**/charts -**/docker-compose* -**/Dockerfile* -**/node_modules -**/npm-debug.log -**/obj -**/secrets.dev.yaml -**/values.dev.yaml -LICENSE -README.md \ No newline at end of file diff --git a/config/ConfigurationServer/.gitignore b/config/ConfigurationServer/.gitignore deleted file mode 100644 index bd7da14..0000000 --- a/config/ConfigurationServer/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -bin -obj -.vs -*.user diff --git a/config/ConfigurationServer/ConfigurationServer.csproj b/config/ConfigurationServer/ConfigurationServer.csproj deleted file mode 100644 index f5db55a..0000000 --- a/config/ConfigurationServer/ConfigurationServer.csproj +++ /dev/null @@ -1,14 +0,0 @@ - - - - netcoreapp3.1 - Linux - . - - - - - - - - diff --git a/config/ConfigurationServer/ConfigurationServer.sln b/config/ConfigurationServer/ConfigurationServer.sln deleted file mode 100644 index 3715c7a..0000000 --- a/config/ConfigurationServer/ConfigurationServer.sln +++ /dev/null @@ -1,25 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 16 -VisualStudioVersion = 16.0.30309.148 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ConfigurationServer", "ConfigurationServer.csproj", "{CD78C1FA-1A88-4299-961B-2B5424DBEEDF}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {CD78C1FA-1A88-4299-961B-2B5424DBEEDF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {CD78C1FA-1A88-4299-961B-2B5424DBEEDF}.Debug|Any CPU.Build.0 = Debug|Any CPU - {CD78C1FA-1A88-4299-961B-2B5424DBEEDF}.Release|Any CPU.ActiveCfg = Release|Any CPU - {CD78C1FA-1A88-4299-961B-2B5424DBEEDF}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {5CE906F0-BAD5-4BD3-B3D7-1D26E4D6385C} - EndGlobalSection -EndGlobal diff --git a/config/ConfigurationServer/Controllers/WeatherForecastController.cs b/config/ConfigurationServer/Controllers/WeatherForecastController.cs deleted file mode 100644 index 3ea0014..0000000 --- a/config/ConfigurationServer/Controllers/WeatherForecastController.cs +++ /dev/null @@ -1,39 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; - -namespace ConfigurationServer.Controllers -{ - [ApiController] - [Route("[controller]")] - public class WeatherForecastController : ControllerBase - { - private static readonly string[] Summaries = new[] - { - "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" - }; - - private readonly ILogger _logger; - - public WeatherForecastController(ILogger logger) - { - _logger = logger; - } - - [HttpGet] - public IEnumerable 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(); - } - } -} diff --git a/config/ConfigurationServer/Dockerfile b/config/ConfigurationServer/Dockerfile deleted file mode 100644 index 26db67a..0000000 --- a/config/ConfigurationServer/Dockerfile +++ /dev/null @@ -1,21 +0,0 @@ -#See https://aka.ms/containerfastmode to understand how Visual Studio uses this Dockerfile to build your images for faster debugging. - -FROM mcr.microsoft.com/dotnet/core/aspnet:3.1-buster-slim AS base -WORKDIR /app -EXPOSE 80 - -FROM mcr.microsoft.com/dotnet/core/sdk:3.1-buster AS build -WORKDIR /src -COPY ["ConfigurationServer.csproj", ""] -RUN dotnet restore "./ConfigurationServer.csproj" -COPY . . -WORKDIR "/src/." -RUN dotnet build "ConfigurationServer.csproj" -c Release -o /app/build - -FROM build AS publish -RUN dotnet publish "ConfigurationServer.csproj" -c Release -o /app/publish - -FROM base AS final -WORKDIR /app -COPY --from=publish /app/publish . -ENTRYPOINT ["dotnet", "ConfigurationServer.dll"] \ No newline at end of file diff --git a/config/ConfigurationServer/Program.cs b/config/ConfigurationServer/Program.cs deleted file mode 100644 index 7459de3..0000000 --- a/config/ConfigurationServer/Program.cs +++ /dev/null @@ -1,26 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Hosting; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; - -namespace ConfigurationServer -{ - public class Program - { - public static void Main(string[] args) - { - CreateHostBuilder(args).Build().Run(); - } - - public static IHostBuilder CreateHostBuilder(string[] args) => - Host.CreateDefaultBuilder(args) - .ConfigureWebHostDefaults(webBuilder => - { - webBuilder.UseStartup(); - }); - } -} diff --git a/config/ConfigurationServer/Properties/launchSettings.json b/config/ConfigurationServer/Properties/launchSettings.json deleted file mode 100644 index e54db27..0000000 --- a/config/ConfigurationServer/Properties/launchSettings.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "iisSettings": { - "windowsAuthentication": false, - "anonymousAuthentication": true, - "iisExpress": { - "applicationUrl": "http://localhost:63198", - "sslPort": 0 - } - }, - "$schema": "http://json.schemastore.org/launchsettings.json", - "profiles": { - "IIS Express": { - "commandName": "IISExpress", - "launchBrowser": true, - "launchUrl": "weatherforecast", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - }, - "ConfigurationServer": { - "commandName": "Project", - "launchBrowser": true, - "launchUrl": "weatherforecast", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - }, - "applicationUrl": "http://localhost:5000" - }, - "Docker": { - "commandName": "Docker", - "launchBrowser": true, - "launchUrl": "{Scheme}://{ServiceHost}:{ServicePort}/weatherforecast", - "publishAllPorts": true - } - } -} \ No newline at end of file diff --git a/config/ConfigurationServer/Startup.cs b/config/ConfigurationServer/Startup.cs deleted file mode 100644 index 5268c56..0000000 --- a/config/ConfigurationServer/Startup.cs +++ /dev/null @@ -1,48 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; - -namespace ConfigurationServer -{ - public class Startup - { - public Startup(IConfiguration configuration) - { - Configuration = configuration; - } - - public IConfiguration Configuration { get; } - - // This method gets called by the runtime. Use this method to add services to the container. - public void ConfigureServices(IServiceCollection services) - { - services.AddControllers(); - } - - // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. - public void Configure(IApplicationBuilder app, IWebHostEnvironment env) - { - if (env.IsDevelopment()) - { - app.UseDeveloperExceptionPage(); - } - - app.UseRouting(); - - app.UseAuthorization(); - - app.UseEndpoints(endpoints => - { - endpoints.MapControllers(); - }); - } - } -} diff --git a/config/ConfigurationServer/WeatherForecast.cs b/config/ConfigurationServer/WeatherForecast.cs deleted file mode 100644 index e4664f1..0000000 --- a/config/ConfigurationServer/WeatherForecast.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System; - -namespace ConfigurationServer -{ - public class WeatherForecast - { - public DateTime Date { get; set; } - - public int TemperatureC { get; set; } - - public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); - - public string Summary { get; set; } - } -} diff --git a/config/ConfigurationServer/appsettings.Development.json b/config/ConfigurationServer/appsettings.Development.json deleted file mode 100644 index 8983e0f..0000000 --- a/config/ConfigurationServer/appsettings.Development.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft": "Warning", - "Microsoft.Hosting.Lifetime": "Information" - } - } -} diff --git a/config/ConfigurationServer/appsettings.json b/config/ConfigurationServer/appsettings.json deleted file mode 100644 index d9d9a9b..0000000 --- a/config/ConfigurationServer/appsettings.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft": "Warning", - "Microsoft.Hosting.Lifetime": "Information" - } - }, - "AllowedHosts": "*" -} diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index a37d649..0000000 --- a/docker-compose.yml +++ /dev/null @@ -1,31 +0,0 @@ -version: "3.8" - -services: - declared-frontend: - init: true - image: schmelczera/declared-frontend - ports: - - "80" - networks: - - network - deploy: - replicas: 2 - resources: - limits: - cpus: "0.1" - memory: 16M - reservations: - cpus: "0.1" - memory: 16M - update_config: - parallelism: 1 - failure_action: rollback - delay: 10s - monitor: 10s - restart_policy: - condition: on-failure - window: 30s - -networks: - network: - driver: overlay diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..84a6892 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,32 @@ +// Flat config (ESLint 9+/10). Ported from the former .eslintrc.json. +const tseslint = require('typescript-eslint'); +const prettierRecommended = require('eslint-plugin-prettier/recommended'); +const unusedImports = require('eslint-plugin-unused-imports'); + +module.exports = tseslint.config( + { + ignores: ['**/node_modules/**', '**/dist/**', '**/lib/**'], + }, + ...tseslint.configs.recommended, + prettierRecommended, + { + files: ['**/*.ts'], + plugins: { + 'unused-imports': unusedImports, + }, + languageOptions: { + sourceType: 'module', + }, + rules: { + 'no-unused-vars': 'off', + 'unused-imports/no-unused-imports': 'error', + '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }], + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/explicit-module-boundary-types': 'off', + '@typescript-eslint/no-non-null-assertion': 'off', + '@typescript-eslint/no-empty-function': 'off', + // Renamed from the former no-var-requires; keep require() allowed. + '@typescript-eslint/no-require-imports': 'off', + }, + }, +); diff --git a/frontend/.dockerignore b/frontend/.dockerignore deleted file mode 100644 index 2044e20..0000000 --- a/frontend/.dockerignore +++ /dev/null @@ -1,5 +0,0 @@ -Dockerfile -node_modules -dist -package-lock.json -.* diff --git a/frontend/.gitignore b/frontend/.gitignore deleted file mode 100644 index 91a3983..0000000 --- a/frontend/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -dist -node_modules -package-lock.json diff --git a/frontend/.vscode/settings.json b/frontend/.vscode/settings.json deleted file mode 100644 index 20b972e..0000000 --- a/frontend/.vscode/settings.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "typescript.tsdk": "node_modules\\typescript\\lib", - "editor.tabSize": 2, - "editor.formatOnSave": true, - "editor.codeActionsOnSave": { - "source.organizeImports": true - } -} diff --git a/frontend/Dockerfile b/frontend/Dockerfile deleted file mode 100644 index e3b4563..0000000 --- a/frontend/Dockerfile +++ /dev/null @@ -1,26 +0,0 @@ -FROM schmelczera/error-pages as build-error-pages -RUN python build.py 403 404 50x - - -FROM node:latest as build-webpage -WORKDIR /home/node - -COPY src src -COPY static static -COPY package.json custom.d.ts tsconfig.json webpack.config.js ./ - -RUN npm install -RUN npm run build - - -FROM nginx:alpine - -WORKDIR /usr/share/nginx/html - -RUN rm -rf * -COPY --from=build-webpage /home/node/dist . -COPY --from=build-error-pages /home/python/built errors -RUN find . -type f | xargs gzip -k9 &&\ - chmod -R 555 . - -COPY nginx-config /etc/nginx/ diff --git a/frontend/custom.d.ts b/frontend/custom.d.ts deleted file mode 100644 index 7c29b64..0000000 --- a/frontend/custom.d.ts +++ /dev/null @@ -1,24 +0,0 @@ -declare module '*.glsl' { - const content: string; - export default content; -} - -/* -declare module '*.png' { - import { ResponsiveImage } from 'src/framework/model/misc'; - const content: ResponsiveImage; - export default content; -} - -declare module '*.jpg' { - import { ResponsiveImage } from 'src/framework/model/misc'; - const content: ResponsiveImage; - export default content; -} - -declare module '*.jpeg' { - import { ResponsiveImage } from 'src/framework/model/misc'; - const content: ResponsiveImage; - export default content; -} -*/ diff --git a/frontend/nginx-config/nginx.conf b/frontend/nginx-config/nginx.conf deleted file mode 100644 index 901dada..0000000 --- a/frontend/nginx-config/nginx.conf +++ /dev/null @@ -1,54 +0,0 @@ -user nginx; -worker_processes auto; - -error_log /var/log/nginx/error.log warn; -pid /var/run/nginx.pid; - -events { - worker_connections 1024; -} - -http { - include /etc/nginx/mime.types; - default_type application/octet-stream; - - log_format main '$remote_addr - $remote_user [$time_local] "$request" ' - '$status $body_bytes_sent "$http_referer" ' - '"$http_user_agent" "$http_x_forwarded_for"'; - - access_log /var/log/nginx/access.log main; - - sendfile on; - sendfile_max_chunk 1m; - tcp_nopush on; - - keepalive_timeout 65; - - gzip on; - gzip_static on; - gzip_vary on; - gzip_min_length 10240; - gzip_proxied any; - gzip_disable "MSIE [1-6]\.(?!.*SV1)"; - - server { - listen 80; - server_name localhost; - - root /usr/share/nginx/html; - index index.html; - - location ~* \.(jpg|jpeg|png|ico)$ { - expires 30d; - } - - error_page 403 /403.html; - error_page 404 /404.html; - error_page 500 501 502 503 504 /50x.html; - - location ~ ^/(403|404|50x).html$ { - root /usr/share/nginx/html/errors; - internal; - } - } -} diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..bfa9f49 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,6758 @@ +{ + "name": "doppler-frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "doppler-frontend", + "version": "0.0.0", + "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": "file:../../sdf-2d", + "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" + } + }, + "../../sdf-2d": { + "version": "0.7.6", + "dev": true, + "license": "ISC", + "dependencies": { + "gl-matrix": "^3.4.4", + "resize-observer-polyfill": "^1.5.1" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@typescript-eslint/eslint-plugin": "^8.60.1", + "@typescript-eslint/parser": "^8.60.1", + "eslint": "^10.4.1", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-prettier": "^5.5.6", + "eslint-plugin-unused-imports": "^4.4.1", + "globals": "^17.6.0", + "prettier": "^3.8.3", + "raw-loader": "^4.0.2", + "terser-webpack-plugin": "^5.6.1", + "ts-loader": "^9.6.0", + "typedoc": "^0.28.19", + "typedoc-plugin-extras": "^4.0.1", + "typescript": "^6.0.3", + "webpack": "^5.107.2", + "webpack-cli": "^7.0.3" + } + }, + "../../sdf-2d/dist": { + "extraneous": true + }, + "../../sdf-2d/lib": { + "extraneous": true + }, + "../shared": { + "version": "0.0.0", + "dev": true, + "devDependencies": { + "clean-webpack-plugin": "^4.0.0", + "file-loader": "^6.2.0", + "gl-matrix": "^3.3.0", + "prettier": "^3.8.3", + "terser-webpack-plugin": "^5.6.1", + "ts-loader": "^9.6.0", + "typescript": "^6.0.3", + "webpack": "^5.107.2", + "webpack-cli": "^7.0.3" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@discoveryjs/json-ext": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-1.1.0.tgz", + "integrity": "sha512-Xc3VhU02wqZ1HvHRJUwL09HkZSTvidqY5Ya0NXBSYOxAp+Ln9dcJr9fySI+CkONzP3PekQo9WdzCv0PGER/mOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.17.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@jsonjoy.com/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-q6XAnWQDIMA3+FTiOYajoYqySkO+JSat0ytXGSuRdq9uXE7o92gzuQwQM14xaCRlBLGq3v5miDGC4vkVTn54xA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/buffers": { + "version": "17.67.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/buffers/-/buffers-17.67.0.tgz", + "integrity": "sha512-tfExRpYxBvi32vPs9ZHaTjSP4fHAfzSmcahOfNxtvGHcyJel+aibkPlGeBB+7AoC6hL7lXIE++8okecBxx7lcw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/codegen": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/codegen/-/codegen-1.0.0.tgz", + "integrity": "sha512-E8Oy+08cmCf0EK/NMxpaJZmOxPqM+6iSe2S4nlSBrPZOORoDJILxtbSUEDKQyTamm/BVAhIGllOBNU79/dwf0g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-core": { + "version": "4.57.6", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-core/-/fs-core-4.57.6.tgz", + "integrity": "sha512-uI++Wx6VkBJqVmkb4ZeExwAVpZiA2Do5NrEtXoDk0Pdvce3ytFXJoviT1sLOj16+qDIMnD5nWPfOhVpnDmRJKg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/fs-node-builtins": "4.57.6", + "@jsonjoy.com/fs-node-utils": "4.57.6", + "thingies": "^2.5.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-fsa": { + "version": "4.57.6", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-fsa/-/fs-fsa-4.57.6.tgz", + "integrity": "sha512-pKkw/yC5CzSZKhIIUIsH1przOa+K5jGmZIg1sWaSF24JojyrUFbjcQv7QrcGAudriei6HQ6R0BFj+V8NbQinJw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/fs-core": "4.57.6", + "@jsonjoy.com/fs-node-builtins": "4.57.6", + "@jsonjoy.com/fs-node-utils": "4.57.6", + "thingies": "^2.5.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-node": { + "version": "4.57.6", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node/-/fs-node-4.57.6.tgz", + "integrity": "sha512-Kbn1jdkvDN4F2+BhoB6mMu7NCbhP0bgA5NcI1aJj/Q5UcU+I1JLLW+dEQean33iV4tXv35AzBVKPICnDltBpxw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/fs-core": "4.57.6", + "@jsonjoy.com/fs-node-builtins": "4.57.6", + "@jsonjoy.com/fs-node-utils": "4.57.6", + "@jsonjoy.com/fs-print": "4.57.6", + "@jsonjoy.com/fs-snapshot": "4.57.6", + "glob-to-regex.js": "^1.0.0", + "thingies": "^2.5.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-node-builtins": { + "version": "4.57.6", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-builtins/-/fs-node-builtins-4.57.6.tgz", + "integrity": "sha512-V4DgEFT3Cg5S9fCMOZSCVdTxdJWWLBO0WnAazV7hnCM96u5zXHyW/ubDAfcSVwqjkMJ50W1Y44IXtxRoIwaCVg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-node-to-fsa": { + "version": "4.57.6", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-to-fsa/-/fs-node-to-fsa-4.57.6.tgz", + "integrity": "sha512-+JptNw3iifihxH2rEXrninDzX4FFVW8JD/wPR8GbJPAeL9CQUSblrlumOPB5gZuS7tYRX+PJPLtT7XzKoRhv/Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/fs-fsa": "4.57.6", + "@jsonjoy.com/fs-node-builtins": "4.57.6", + "@jsonjoy.com/fs-node-utils": "4.57.6" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-node-utils": { + "version": "4.57.6", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-utils/-/fs-node-utils-4.57.6.tgz", + "integrity": "sha512-foyUrfS7WmYEUzqYXSNxmJBcSj04TABrkpFabwO9SCDCpVCfJ+qG+2sk5FjfiflG2n0SDFZDCJ6vYlJAEpxJFg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/fs-node-builtins": "4.57.6" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-print": { + "version": "4.57.6", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-print/-/fs-print-4.57.6.tgz", + "integrity": "sha512-96eAn4Dudtt67LTeuU47yUD+pg9/G/oKpI10zei9ljk3X3WK4lYKc+n3cpaPCAbKPzoyfxl0mXm8f8Y7BOSFXw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/fs-node-utils": "4.57.6", + "tree-dump": "^1.1.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-snapshot": { + "version": "4.57.6", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-snapshot/-/fs-snapshot-4.57.6.tgz", + "integrity": "sha512-V57CMzbOgTzUWGOWQ8GzHQdpJP6JnrYVNCtTBNxVYEnlVRvo4uEJqHhtAT8vhDFrIuJOXLrTL1Fki4h5oI7xxg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/buffers": "^17.65.0", + "@jsonjoy.com/fs-node-utils": "4.57.6", + "@jsonjoy.com/json-pack": "^17.65.0", + "@jsonjoy.com/util": "^17.65.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-snapshot/node_modules/@jsonjoy.com/base64": { + "version": "17.67.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/base64/-/base64-17.67.0.tgz", + "integrity": "sha512-5SEsJGsm15aP8TQGkDfJvz9axgPwAEm98S5DxOuYe8e1EbfajcDmgeXXzccEjh+mLnjqEKrkBdjHWS5vFNwDdw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-snapshot/node_modules/@jsonjoy.com/codegen": { + "version": "17.67.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/codegen/-/codegen-17.67.0.tgz", + "integrity": "sha512-idnkUplROpdBOV0HMcwhsCUS5TRUi9poagdGs70A6S4ux9+/aPuKbh8+UYRTLYQHtXvAdNfQWXDqZEx5k4Dj2Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-snapshot/node_modules/@jsonjoy.com/json-pack": { + "version": "17.67.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pack/-/json-pack-17.67.0.tgz", + "integrity": "sha512-t0ejURcGaZsn1ClbJ/3kFqSOjlryd92eQY465IYrezsXmPcfHPE/av4twRSxf6WE+TkZgLY+71vCZbiIiFKA/w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/base64": "17.67.0", + "@jsonjoy.com/buffers": "17.67.0", + "@jsonjoy.com/codegen": "17.67.0", + "@jsonjoy.com/json-pointer": "17.67.0", + "@jsonjoy.com/util": "17.67.0", + "hyperdyperid": "^1.2.0", + "thingies": "^2.5.0", + "tree-dump": "^1.1.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-snapshot/node_modules/@jsonjoy.com/json-pointer": { + "version": "17.67.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pointer/-/json-pointer-17.67.0.tgz", + "integrity": "sha512-+iqOFInH+QZGmSuaybBUNdh7yvNrXvqR+h3wjXm0N/3JK1EyyFAeGJvqnmQL61d1ARLlk/wJdFKSL+LHJ1eaUA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/util": "17.67.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-snapshot/node_modules/@jsonjoy.com/util": { + "version": "17.67.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/util/-/util-17.67.0.tgz", + "integrity": "sha512-6+8xBaz1rLSohlGh68D1pdw3AwDi9xydm8QNlAFkvnavCJYSze+pxoW2VKP8p308jtlMRLs5NTHfPlZLd4w7ew==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/buffers": "17.67.0", + "@jsonjoy.com/codegen": "17.67.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/json-pack": { + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pack/-/json-pack-1.21.0.tgz", + "integrity": "sha512-+AKG+R2cfZMShzrF2uQw34v3zbeDYUqnQ+jg7ORic3BGtfw9p/+N6RJbq/kkV8JmYZaINknaEQ2m0/f693ZPpg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/base64": "^1.1.2", + "@jsonjoy.com/buffers": "^1.2.0", + "@jsonjoy.com/codegen": "^1.0.0", + "@jsonjoy.com/json-pointer": "^1.0.2", + "@jsonjoy.com/util": "^1.9.0", + "hyperdyperid": "^1.2.0", + "thingies": "^2.5.0", + "tree-dump": "^1.1.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/json-pack/node_modules/@jsonjoy.com/buffers": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/buffers/-/buffers-1.2.1.tgz", + "integrity": "sha512-12cdlDwX4RUM3QxmUbVJWqZ/mrK6dFQH4Zxq6+r1YXKXYBNgZXndx2qbCJwh3+WWkCSn67IjnlG3XYTvmvYtgA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/json-pointer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pointer/-/json-pointer-1.0.2.tgz", + "integrity": "sha512-Fsn6wM2zlDzY1U+v4Nc8bo3bVqgfNTGcn6dMgs6FjrEnt4ZCe60o6ByKRjOGlI2gow0aE/Q41QOigdTqkyK5fg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/codegen": "^1.0.0", + "@jsonjoy.com/util": "^1.9.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/util": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/util/-/util-1.9.0.tgz", + "integrity": "sha512-pLuQo+VPRnN8hfPqUTLTHk126wuYdXVxE6aDmjSeV4NCAgyxWbiOIeNJVtID3h1Vzpoi9m4jXezf73I6LgabgQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/buffers": "^1.0.0", + "@jsonjoy.com/codegen": "^1.0.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/util/node_modules/@jsonjoy.com/buffers": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/buffers/-/buffers-1.2.1.tgz", + "integrity": "sha512-12cdlDwX4RUM3QxmUbVJWqZ/mrK6dFQH4Zxq6+r1YXKXYBNgZXndx2qbCJwh3+WWkCSn67IjnlG3XYTvmvYtgA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@leichtgewicht/ip-codec": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz", + "integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@noble/hashes": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz", + "integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@parcel/watcher": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", + "integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.3", + "is-glob": "^4.0.3", + "node-addon-api": "^7.0.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.6", + "@parcel/watcher-darwin-arm64": "2.5.6", + "@parcel/watcher-darwin-x64": "2.5.6", + "@parcel/watcher-freebsd-x64": "2.5.6", + "@parcel/watcher-linux-arm-glibc": "2.5.6", + "@parcel/watcher-linux-arm-musl": "2.5.6", + "@parcel/watcher-linux-arm64-glibc": "2.5.6", + "@parcel/watcher-linux-arm64-musl": "2.5.6", + "@parcel/watcher-linux-x64-glibc": "2.5.6", + "@parcel/watcher-linux-x64-musl": "2.5.6", + "@parcel/watcher-win32-arm64": "2.5.6", + "@parcel/watcher-win32-ia32": "2.5.6", + "@parcel/watcher-win32-x64": "2.5.6" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz", + "integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz", + "integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz", + "integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz", + "integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz", + "integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz", + "integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz", + "integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz", + "integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz", + "integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz", + "integrity": "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz", + "integrity": "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz", + "integrity": "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz", + "integrity": "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@peculiar/asn1-cms": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.7.0.tgz", + "integrity": "sha512-hew63shtzzvBcSHbhm+cyAmKe6AIfinT9hzEqSPjDC6opTTMKmTkQ0gHuN2KsWlvqiKw1S/fS94fhag/FJkioQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/asn1-x509": "^2.7.0", + "@peculiar/asn1-x509-attr": "^2.7.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-csr": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.7.0.tgz", + "integrity": "sha512-VVsAyGqErT9D1SY4aEqozThXMVI+ssVRiv2DDeYuvpBKLIgZ3hYs3Ay3u/VSoKq6ESFi9cf6rf3IOOzfwh7oMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/asn1-x509": "^2.7.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-ecc": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.7.0.tgz", + "integrity": "sha512-n7KEs/Q/wrB415cxy4fHOBhegp4NdJ15fkJPwcB/3/8iNBQC2L/N7SChJPKDJPZGYH0jD4Tg4/0vnHmwghnbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/asn1-x509": "^2.7.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pfx": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.7.0.tgz", + "integrity": "sha512-V/nrlQVmhg7lYAsM7E13UDL5erAwFv6kCIVFqNaMIHSVi7dngcT839JkRTkQBqznMG98l2XjxYk74ZztAohZzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.7.0", + "@peculiar/asn1-pkcs8": "^2.7.0", + "@peculiar/asn1-rsa": "^2.7.0", + "@peculiar/asn1-schema": "^2.7.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pkcs8": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.7.0.tgz", + "integrity": "sha512-9GTl1nE8Mx1kTZ+7QyYatDyKsm34QcWRBFkY1iPvWC3X4Dona5s/tlLiQsx5WzVdZqiMBZNYT0buyw4/vbhnjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/asn1-x509": "^2.7.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pkcs9": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.7.0.tgz", + "integrity": "sha512-Bh7m+OuIaSEllPQcSd9OSp93F4ROWH7sbITWV8MI+8dwsjE5111/87VxiWVvYFKyww3vp39geLv9ENqhwWHcew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.7.0", + "@peculiar/asn1-pfx": "^2.7.0", + "@peculiar/asn1-pkcs8": "^2.7.0", + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/asn1-x509": "^2.7.0", + "@peculiar/asn1-x509-attr": "^2.7.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-rsa": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.7.0.tgz", + "integrity": "sha512-/qvENQrXyTZURjMqSeofHul0JJt2sNSzSwk36pl2olkHbaioMQgrASDZAlHXl0xUlnVbHj0uGgOrBMTb5x2aJQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/asn1-x509": "^2.7.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.7.0.tgz", + "integrity": "sha512-W8ZfWzLmQnrcky+eh3tni4IozMdqBDiHWU0N+vve/UGjMaUs8c0L7A2oEdkBXS8rTpWDpK/aoI3DG/L/hxmxPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-x509": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.7.0.tgz", + "integrity": "sha512-mUn9RRrkGDnG4ALfunDmzyRW5dg+sWCj/pfnCCqEHYbkGxEpvUt6iVJv8Yw1cyp6SWZ26ZE5oSmI5SqEaen15g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-x509-attr": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.7.0.tgz", + "integrity": "sha512-NS8e7SOgXipkzUPLF/sce7ukpMpWjhxYsH0n6Y+bHYo4TTxOb95Zv7hqwSuL212mj5YxovjdOKQOgH1As3E94w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/asn1-x509": "^2.7.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz", + "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/x509": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.3.tgz", + "integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.6.0", + "@peculiar/asn1-csr": "^2.6.0", + "@peculiar/asn1-ecc": "^2.6.0", + "@peculiar/asn1-pkcs9": "^2.6.0", + "@peculiar/asn1-rsa": "^2.6.0", + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.0", + "pvtsutils": "^1.3.6", + "reflect-metadata": "^0.2.2", + "tslib": "^2.8.1", + "tsyringe": "^4.10.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@plausible-analytics/tracker": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@plausible-analytics/tracker/-/tracker-0.4.5.tgz", + "integrity": "sha512-6BfAGejXY+YA3Cw6LYT2Zpn4hTxDtPQAawFsYUsQCOg78wIS5C4deAGXTfJffa5VleMWITv5lpJ/EYuQBl1tPA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/bonjour": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@types/bonjour/-/bonjour-3.5.13.tgz", + "integrity": "sha512-z9fJ5Im06zvUL548KvYNecEVlA7cVDkGUi6kZusb04mpyEFKCIZJvloCcmpmLaIahDpOQGHaHmG6imtPMmPXGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/connect-history-api-fallback": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@types/connect-history-api-fallback/-/connect-history-api-fallback-1.5.4.tgz", + "integrity": "sha512-n6Cr2xS1h4uAulPRdlw6Jl6s1oG8KrVilPN2yUITEs+K48EzMJJ3W1xy8K5eWuFvjp3R74AOIGSmp2UfBJ8HFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express-serve-static-core": "*", + "@types/node": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/express": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.8", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz", + "integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/glob": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@types/glob/-/glob-7.2.0.tgz", + "integrity": "sha512-ZUxbzKl0IfJILTS6t7ip5fQQM/J3TJYubDm3nMbgubNNYS62eXeUpoLUC8/7fJNiFYHTrGPQn7hspDUzIHX3UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/minimatch": "*", + "@types/node": "*" + } + }, + "node_modules/@types/html-minifier-terser": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz", + "integrity": "sha512-oh/6byDPnL1zeNXFrDXFLyZjkr1MsBG667IM792caf1L2UPOOMf65NFzjUH/ltyfwjAGfs1rsX1eftK0jC/KIg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/http-proxy": { + "version": "1.17.17", + "resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.17.tgz", + "integrity": "sha512-ED6LB+Z1AVylNTu7hdzuBqOgMnvG/ld6wGCG8wFnAzKX5uyW2K3WD52v0gnLCTK/VLpXtKckgWuyScYK6cSPaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/minimatch": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-5.1.2.tgz", + "integrity": "sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.9.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz", + "integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/retry": { + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.2.tgz", + "integrity": "sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-index": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@types/serve-index/-/serve-index-1.9.4.tgz", + "integrity": "sha512-qLpGZ/c2fhSs5gnYsQxtDEq3Oy8SXPClIXkW5ghvAvsNuVSA8k+gCONcUCS/UjLEYvYps+e8uBtfgXgvhwfNug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@types/sockjs": { + "version": "0.3.36", + "resolved": "https://registry.npmjs.org/@types/sockjs/-/sockjs-0.3.36.tgz", + "integrity": "sha512-MK9V6NzAS1+Ud7JV9lJLFqW85VbC9dq3LmwZCuBe4wBDgKC0Kj/jd8Xl+nSviU+Qc3+m7umHHyHg//2KSa0a0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@webassemblyjs/ast": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", + "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/helper-numbers": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2" + } + }, + "node_modules/@webassemblyjs/floating-point-hex-parser": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", + "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-api-error": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", + "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-buffer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", + "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-numbers": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", + "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/floating-point-hex-parser": "1.13.2", + "@webassemblyjs/helper-api-error": "1.13.2", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/helper-wasm-bytecode": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", + "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-wasm-section": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", + "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/wasm-gen": "1.14.1" + } + }, + "node_modules/@webassemblyjs/ieee754": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", + "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "node_modules/@webassemblyjs/leb128": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", + "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/utf8": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", + "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/wasm-edit": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", + "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/helper-wasm-section": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-opt": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1", + "@webassemblyjs/wast-printer": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-gen": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", + "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wasm-opt": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", + "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-parser": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", + "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-api-error": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wast-printer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", + "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@xtuc/ieee754": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@xtuc/long": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-import-phases": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", + "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + }, + "peerDependencies": { + "acorn": "^8.14.0" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/ansi-html-community": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/ansi-html-community/-/ansi-html-community-0.0.8.tgz", + "integrity": "sha512-1APHAyr3+PCamwNw3bXCPp4HFLONZt/yIH0sZp0/469KWNTEy+qN5jQ3GVX6DMZ1UXAi34yVwtTeaG/HpBuuzw==", + "dev": true, + "engines": [ + "node >= 0.8.0" + ], + "license": "Apache-2.0", + "bin": { + "ansi-html": "bin/ansi-html" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/array-union": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-1.0.2.tgz", + "integrity": "sha512-Dxr6QJj/RdU/hCaBjOfxW+q6lyuVE6JFWIrAUpuOOhoJJoQ99cUn3igRaHVB5P9WrgFVN0FfArM3x0cueOU8ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-uniq": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/array-uniq": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/array-uniq/-/array-uniq-1.0.3.tgz", + "integrity": "sha512-MNha4BWQ6JbwhFhj03YK552f7cb3AzoE8SzeljgChvL1dl3IcvggXVz1DilzySZkCja+CXuZbdW7yATchWn8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/asn1js": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", + "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.5", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/autoprefixer": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", + "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.2", + "caniuse-lite": "^1.0.30001787", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.33", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.33.tgz", + "integrity": "sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/batch": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/batch/-/batch-0.6.1.tgz", + "integrity": "sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw==", + "dev": true, + "license": "MIT" + }, + "node_modules/big.js": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", + "integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/body-parser": { + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/body-parser/node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/body-parser/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, + "node_modules/bonjour-service": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/bonjour-service/-/bonjour-service-1.4.0.tgz", + "integrity": "sha512-fGQtj1qdR9vIKjFiWPQd52qIqwjaYqhcI40JEiDuvlZ86E7ZBPBwY9fPgHy9r2rYGIjiRfctNPYz6OQU73ww2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "multicast-dns": "^7.2.5" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true, + "license": "ISC" + }, + "node_modules/brace-expansion": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/bytestreamjs": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/bytestreamjs/-/bytestreamjs-2.0.1.tgz", + "integrity": "sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camel-case": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/camel-case/-/camel-case-4.1.2.tgz", + "integrity": "sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "pascal-case": "^3.1.2", + "tslib": "^2.0.3" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001793", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", + "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/chrome-trace-event": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", + "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0" + } + }, + "node_modules/clean-css": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-5.3.3.tgz", + "integrity": "sha512-D5J+kHaVb/wKSFcyyV75uCn8fiY4sV38XJoe4CUyGQ+mOU/fMVYUdH1hJC+CJQ5uY3EnW27SbJYS4X8BiLrAFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "source-map": "~0.6.0" + }, + "engines": { + "node": ">= 10.0" + } + }, + "node_modules/clean-webpack-plugin": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/clean-webpack-plugin/-/clean-webpack-plugin-4.0.0.tgz", + "integrity": "sha512-WuWE1nyTNAyW5T7oNyys2EN0cfP2fdRxhxnIQWiAp0bMabPdHhoGxM8A6YL2GhqwgrPnnaemVE7nv5XJ2Fhh2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "del": "^4.1.1" + }, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "webpack": ">=4.0.0 <6.0.0" + } + }, + "node_modules/clone-deep": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz", + "integrity": "sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-plain-object": "^2.0.4", + "kind-of": "^6.0.2", + "shallow-clone": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/compression/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/compression/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/connect-history-api-fallback": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/connect-history-api-fallback/-/connect-history-api-fallback-2.0.0.tgz", + "integrity": "sha512-U73+6lQFmfiNPrYbXqr6kZ1i1wiRqXnp2nhMsINseWXO8lDau0LGEffJ8kQi4EjLZympVgRdvqjAgiZ1tgzDDA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "dev": true, + "license": "MIT" + }, + "node_modules/copy-webpack-plugin": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-14.0.0.tgz", + "integrity": "sha512-3JLW90aBGeaTLpM7mYQKpnVdgsUZRExY55giiZgLuX/xTQRUs1dOCwbBnWnvY6Q6rfZoXMNwzOQJCSZPppfqXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "glob-parent": "^6.0.1", + "normalize-path": "^3.0.0", + "schema-utils": "^4.2.0", + "serialize-javascript": "^7.0.3", + "tinyglobby": "^0.2.12" + }, + "engines": { + "node": ">= 20.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + } + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cosmiconfig": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.1.tgz", + "integrity": "sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.1", + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-loader": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-7.1.4.tgz", + "integrity": "sha512-vv3J9tlOl04WjiMvHQI/9tmIrCxVrj6PFbHemBB1iihpeRbi/I4h033eoFIhwxBBqLhI0KYFS7yvynBFhIZfTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "icss-utils": "^5.1.0", + "postcss": "^8.4.40", + "postcss-modules-extract-imports": "^3.1.0", + "postcss-modules-local-by-default": "^4.0.5", + "postcss-modules-scope": "^3.2.0", + "postcss-modules-values": "^4.0.0", + "postcss-value-parser": "^4.2.0", + "semver": "^7.6.3" + }, + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "@rspack/core": "0.x || ^1.0.0 || ^2.0.0-0", + "webpack": "^5.27.0" + }, + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/css-select": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-4.3.0.tgz", + "integrity": "sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.0.1", + "domhandler": "^4.3.1", + "domutils": "^2.8.0", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/default-browser": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", + "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", + "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/del": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/del/-/del-4.1.1.tgz", + "integrity": "sha512-QwGuEUouP2kVwQenAsOof5Fv8K9t3D8Ca8NxcXKrIpEHjTXK5J2nXLdP+ALI1cgv8wj7KuwBhTwBkOZSJKM5XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/glob": "^7.1.1", + "globby": "^6.1.0", + "is-path-cwd": "^2.0.0", + "is-path-in-cwd": "^2.0.0", + "p-map": "^2.0.0", + "pify": "^4.0.1", + "rimraf": "^2.6.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "dev": true, + "license": "MIT" + }, + "node_modules/dns-packet": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz", + "integrity": "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@leichtgewicht/ip-codec": "^2.0.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/dom-converter": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/dom-converter/-/dom-converter-0.2.0.tgz", + "integrity": "sha512-gd3ypIPfOMr9h5jIKq8E3sHOTCjeirnl0WK5ZdS1AW0Odt0b1PaWaHdJ4Qk4klv+YB9aJBS7mESXjFoDQPu6DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "utila": "~0.4" + } + }, + "node_modules/dom-serializer": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz", + "integrity": "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^2.0.1", + "domhandler": "^4.2.0", + "entities": "^2.0.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.3.1.tgz", + "integrity": "sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.2.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz", + "integrity": "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^1.0.1", + "domelementtype": "^2.2.0", + "domhandler": "^4.2.0" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dot-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", + "integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.366", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.366.tgz", + "integrity": "sha512-OlRuhb688YTCzzU3gXPLn6nGyd+F+53INE1qaKKlu6kETErE8FYsyDh0XqXEU+uBRn0MpCzz2vfNwORhkap8qg==", + "dev": true, + "license": "ISC" + }, + "node_modules/emojis-list": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz", + "integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/engine.io-client": { + "version": "6.6.5", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.5.tgz", + "integrity": "sha512-QCwxUDULPlXv8F6tqMMKx5dNkTe6OaBYRMPYeXKBlyOoKvAmE0ac6pW7fFhSscJ/5SI7666/U/B+MElbsrJlIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.20.1", + "xmlhttprequest-ssl": "~2.1.1" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.22.2", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.22.2.tgz", + "integrity": "sha512-0rxICaFZ7NQho/sHely2bvOPRP0Eu2B0NZ9zM54YvRvWMn7jfz3DmnOZDR9LlXDdDcqntAVc6Hfy4gr/tdH/Ag==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz", + "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==", + "dev": true, + "license": "BSD-2-Clause", + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/envinfo": { + "version": "7.21.0", + "resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.21.0.tgz", + "integrity": "sha512-Lw7I8Zp5YKHFCXL7+Dz95g4CcbMEpgvqZNNq3AmlT5XAV6CgAAk6gyAMqn2zjw08K9BHfcNuKrMiCPLByGafow==", + "dev": true, + "license": "MIT", + "bin": { + "envinfo": "dist/cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esrecurse/node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "dev": true, + "license": "MIT" + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/faye-websocket": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", + "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "websocket-driver": ">=0.5.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-loader": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/file-loader/-/file-loader-6.2.0.tgz", + "integrity": "sha512-qo3glqyTa61Ytg4u73GultjHGjdRyig3tG6lPtyX/jOEJvHif9uB0/OCI2Kif6ctF3caQTW2G5gym21oAsI4pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "loader-utils": "^2.0.0", + "schema-utils": "^3.0.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^4.0.0 || ^5.0.0" + } + }, + "node_modules/file-loader/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/file-loader/node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/file-loader/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/file-loader/node_modules/schema-utils": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", + "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/flat": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", + "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", + "dev": true, + "license": "BSD-3-Clause", + "bin": { + "flat": "cli.js" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gl-matrix": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.3.0.tgz", + "integrity": "sha512-COb7LDz+SXaHtl/h4LeaFcNdJdAQSDeVqjiIihSXNrkWObZLhDI4hIkZC11Aeqp7bcE72clzB0BnDXr2SmslRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob-to-regex.js": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/glob-to-regex.js/-/glob-to-regex.js-1.2.0.tgz", + "integrity": "sha512-QMwlOQKU/IzqMUOAZWubUOT8Qft+Y0KQWnX9nK3ch0CJg0tTp4TvGZsTfudYKv2NzoQSyPcnA6TYeIQ3jGichQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/globby": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-6.1.0.tgz", + "integrity": "sha512-KVbFv2TQtbzCoxAnfD6JcHZTYCzyliEaaeM/gH8qQdkKr5s0OP9scEgvdcngyk7AVdY6YVW/TJHd+lQ/Df3Daw==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-union": "^1.0.1", + "glob": "^7.0.3", + "object-assign": "^4.0.1", + "pify": "^2.0.0", + "pinkie-promise": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/globby/node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/handle-thing": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.1.tgz", + "integrity": "sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg==", + "dev": true, + "license": "MIT" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/hpack.js": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/hpack.js/-/hpack.js-2.1.6.tgz", + "integrity": "sha512-zJxVehUdMGIKsRaNt7apO2Gqp0BdqW5yaiGHXXmbpvxgBYVZnAql+BJb4RO5ad2MgpbZKn5G6nMnegrH1FcNYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.1", + "obuf": "^1.0.0", + "readable-stream": "^2.0.1", + "wbuf": "^1.1.0" + } + }, + "node_modules/hpack.js/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/hpack.js/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/hpack.js/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/html-inline-css-webpack-plugin": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/html-inline-css-webpack-plugin/-/html-inline-css-webpack-plugin-1.11.2.tgz", + "integrity": "sha512-jzJptIAOrYQh9ITxiDeNWQvPwk1nHZIo0P9/ZlLsfSYKqggMENJdO+Hcw67xSHoxtL2t+X9QO00eHu6W/c8V0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "lodash": "^4.17.21", + "tslib": "^2.6.0" + }, + "peerDependencies": { + "html-webpack-plugin": "^3.0.0 || ^4.0.0 || ^5.0.0" + } + }, + "node_modules/html-minifier-terser": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz", + "integrity": "sha512-YXxSlJBZTP7RS3tWnQw74ooKa6L9b9i9QYXY21eUEvhZ3u9XLfv6OnFsQq6RxkhHygsaUMvYsZRV5rU/OVNZxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "camel-case": "^4.1.2", + "clean-css": "^5.2.2", + "commander": "^8.3.0", + "he": "^1.2.0", + "param-case": "^3.0.4", + "relateurl": "^0.2.7", + "terser": "^5.10.0" + }, + "bin": { + "html-minifier-terser": "cli.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/html-webpack-plugin": { + "version": "5.6.7", + "resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-5.6.7.tgz", + "integrity": "sha512-md+vXtdCAe60s1k6AU3dUyMJnDxUyQAwfwPKoLisvgUF1IXjtlLsk2se54+qfL9Mdm26bbwvjJybpNx48NKRLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/html-minifier-terser": "^6.0.0", + "html-minifier-terser": "^6.0.2", + "lodash": "^4.17.21", + "pretty-error": "^4.0.0", + "tapable": "^2.0.0" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/html-webpack-plugin" + }, + "peerDependencies": { + "@rspack/core": "0.x || 1.x", + "webpack": "^5.20.0" + }, + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/htmlparser2": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-6.1.0.tgz", + "integrity": "sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A==", + "dev": true, + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.0.1", + "domhandler": "^4.0.0", + "domutils": "^2.5.2", + "entities": "^2.0.0" + } + }, + "node_modules/http-deceiver": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/http-deceiver/-/http-deceiver-1.2.7.tgz", + "integrity": "sha512-LmpOGxTfbpgtGVxJrj5k7asXHCgNZp5nLfp+hWc8QQRqtb7fUy6kRY3BO1h9ddF6yIPYUARgxGOwB42DnxIaNw==", + "dev": true, + "license": "MIT" + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/http-parser-js": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz", + "integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/http-proxy": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", + "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eventemitter3": "^4.0.0", + "follow-redirects": "^1.0.0", + "requires-port": "^1.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/http-proxy-middleware": { + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.9.tgz", + "integrity": "sha512-c1IyJYLYppU574+YI7R4QyX2ystMtVXZwIdzazUIPIJsHuWNd+mho2j+bKoHftndicGj9yh+xjd+l0yj7VeT1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-proxy": "^1.17.8", + "http-proxy": "^1.18.1", + "is-glob": "^4.0.1", + "is-plain-obj": "^3.0.0", + "micromatch": "^4.0.2" + }, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "@types/express": "^4.17.13" + }, + "peerDependenciesMeta": { + "@types/express": { + "optional": true + } + } + }, + "node_modules/hyperdyperid": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/hyperdyperid/-/hyperdyperid-1.2.0.tgz", + "integrity": "sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.18" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/icss-utils": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz", + "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/immutable": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.6.tgz", + "integrity": "sha512-q1swsS8K7L8usSHuOqF2TAoCCkonYz0SG38wLAggaa4Wml70zixIvt2ql4coQ2C2B3hTjltJry4r6bULwgAXLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-local": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/interpret": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-3.1.1.tgz", + "integrity": "sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/ipaddr.js": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.4.0.tgz", + "integrity": "sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-network-error": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.3.2.tgz", + "integrity": "sha512-PhBY86zaxNZUuWP6h13Vu5oFe0XY6/UlKzQnYFELzGVHygP3MxmvTfYSG7GN3aIab/iWudSMgjSnG9Dq+nHrgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-path-cwd": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-path-cwd/-/is-path-cwd-2.2.0.tgz", + "integrity": "sha512-w942bTcih8fdJPJmQHFzkS76NEP8Kzzvmw92cXsazb8intwLqPibPPdXf4ANdKV3rYMuuQYGIWtvz9JilB3NFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/is-path-in-cwd": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-path-in-cwd/-/is-path-in-cwd-2.1.0.tgz", + "integrity": "sha512-rNocXHgipO+rvnP6dk3zI20RpOtrAM/kzbB258Uw5BWr3TpXi861yzjo16Dn4hUox07iw5AyeMLHWsujkjzvRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-path-inside": "^2.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/is-path-inside": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-2.1.0.tgz", + "integrity": "sha512-wiyhTzfDWsvwAW53OBWF5zuvaOGlZ6PwYxAbPVDhpm+gM09xKQGjBq/8uYN12aDvMxnAnq3dxTyoSoRNmg5YFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-is-inside": "^1.0.2" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/is-plain-obj": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-3.0.0.tgz", + "integrity": "sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "isobject": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-wsl": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/launch-editor": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.14.1.tgz", + "integrity": "sha512-QWBrQsMpH7gPr965dsKD/3cKWiNoTjpATQf++Xq63N6sKRGMwlVXz41O1IZTMfZQgBctD/K5Zt06+/I6pP6+HA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picocolors": "^1.1.1", + "shell-quote": "^1.8.4" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/loader-runner": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.2.tgz", + "integrity": "sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.11.5" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/loader-utils": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz", + "integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^2.1.2" + }, + "engines": { + "node": ">=8.9.0" + } + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/lower-case": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", + "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.3" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/memfs": { + "version": "4.57.6", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-4.57.6.tgz", + "integrity": "sha512-WQK+DGjKCnPdpSyJUXphz+COF2uEhhsxQ3VIWBSbzpbbXuch3h4FePMqXrXGdLjsTgo4JFzBFsP6AWd9pVazGw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/fs-core": "4.57.6", + "@jsonjoy.com/fs-fsa": "4.57.6", + "@jsonjoy.com/fs-node": "4.57.6", + "@jsonjoy.com/fs-node-builtins": "4.57.6", + "@jsonjoy.com/fs-node-to-fsa": "4.57.6", + "@jsonjoy.com/fs-node-utils": "4.57.6", + "@jsonjoy.com/fs-print": "4.57.6", + "@jsonjoy.com/fs-snapshot": "4.57.6", + "@jsonjoy.com/json-pack": "^1.11.0", + "@jsonjoy.com/util": "^1.9.0", + "glob-to-regex.js": "^1.0.1", + "thingies": "^2.5.0", + "tree-dump": "^1.0.3", + "tslib": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mini-css-extract-plugin": { + "version": "2.10.2", + "resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.10.2.tgz", + "integrity": "sha512-AOSS0IdEB95ayVkxn5oGzNQwqAi2J0Jb/kKm43t7H73s8+f5873g0yuj0PNvK4dO75mu5DHg4nlgp4k6Kga8eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "schema-utils": "^4.0.0", + "tapable": "^2.2.1" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.0.0" + } + }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "dev": true, + "license": "ISC" + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/multicast-dns": { + "version": "7.2.5", + "resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.5.tgz", + "integrity": "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==", + "dev": true, + "license": "MIT", + "dependencies": { + "dns-packet": "^5.2.2", + "thunky": "^1.0.2" + }, + "bin": { + "multicast-dns": "cli.js" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true, + "license": "MIT" + }, + "node_modules/no-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", + "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "lower-case": "^2.0.2", + "tslib": "^2.0.3" + } + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/node-releases": { + "version": "2.0.47", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", + "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/notepack.io": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/notepack.io/-/notepack.io-2.2.0.tgz", + "integrity": "sha512-9b5w3t5VSH6ZPosoYnyDONnUTF8o0UkBw7JLA6eBlYJWyGT1Q3vQa8Hmuj1/X6RYvHjjygBDgw6fJhe0JEojfw==", + "dev": true, + "license": "MIT" + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/obuf": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz", + "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==", + "dev": true, + "license": "MIT" + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/open": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", + "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "default-browser": "^5.2.1", + "define-lazy-prop": "^3.0.0", + "is-inside-container": "^1.0.0", + "wsl-utils": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-map": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-2.1.0.tgz", + "integrity": "sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/p-retry": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-6.2.1.tgz", + "integrity": "sha512-hEt02O4hUct5wtwg4H4KcWgDdm+l1bOaEy/hWzd8xtXB9BqxTWBBhb+2ImAtH4Cv4rPjV76xN3Zumqk3k3AhhQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.2", + "is-network-error": "^1.0.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/param-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz", + "integrity": "sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "dot-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/pascal-case": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/pascal-case/-/pascal-case-3.1.2.tgz", + "integrity": "sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-is-inside": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/path-is-inside/-/path-is-inside-1.0.2.tgz", + "integrity": "sha512-DUWJr3+ULp4zXmol/SZkFf3JGsS9/SIv+Y3Rt93/UjPpDpklB5f1er4O3POIbUuUJ3FXgqte2Q7SrU6zAqwk8w==", + "dev": true, + "license": "(WTFPL OR MIT)" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", + "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/pinkie": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/pinkie/-/pinkie-2.0.4.tgz", + "integrity": "sha512-MnUuEycAemtSaeFSjXKW/aroV7akBbY+Sv+RkyqFjgAe73F+MR0TBWKBRDkmfWq/HiFmdavfZ1G7h4SPZXaCSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pinkie-promise": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pinkie-promise/-/pinkie-promise-2.0.1.tgz", + "integrity": "sha512-0Gni6D4UcLTbv9c57DfxDGdr41XfgUjqWZu492f0cIGr16zDU06BWP/RAEvOuo7CQ0CNjHaLlM59YJJFm3NWlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "pinkie": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkijs": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/pkijs/-/pkijs-3.4.0.tgz", + "integrity": "sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@noble/hashes": "1.4.0", + "asn1js": "^3.0.6", + "bytestreamjs": "^2.0.1", + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.3", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-loader": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/postcss-loader/-/postcss-loader-8.2.1.tgz", + "integrity": "sha512-k98jtRzthjj3f76MYTs9JTpRqV1RaaMhEU0Lpw9OTmQZQdppg4B30VZ74BojuBHt3F4KyubHJoXCMUeM8Bqeow==", + "dev": true, + "license": "MIT", + "dependencies": { + "cosmiconfig": "^9.0.0", + "jiti": "^2.5.1", + "semver": "^7.6.2" + }, + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "@rspack/core": "0.x || ^1.0.0 || ^2.0.0-0", + "postcss": "^7.0.0 || ^8.0.1", + "webpack": "^5.0.0" + }, + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/postcss-modules-extract-imports": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.1.0.tgz", + "integrity": "sha512-k3kNe0aNFQDAZGbin48pL2VNidTF0w4/eASDsxlyspobzU3wZQLOGj7L9gfRe0Jo9/4uud09DsjFNH7winGv8Q==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-local-by-default": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.2.0.tgz", + "integrity": "sha512-5kcJm/zk+GJDSfw+V/42fJ5fhjL5YbFDl8nVdXkJPLLW+Vf9mTD5Xe0wqIaDnLuL2U6cDNpTr+UQ+v2HWIBhzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "icss-utils": "^5.0.0", + "postcss-selector-parser": "^7.0.0", + "postcss-value-parser": "^4.1.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-scope": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-3.2.1.tgz", + "integrity": "sha512-m9jZstCVaqGjTAuny8MdgE88scJnCiQSlSrOWcTQgM2t32UBe+MUmFSO5t7VMSfAf/FJKImAxBav8ooCHJXCJA==", + "dev": true, + "license": "ISC", + "dependencies": { + "postcss-selector-parser": "^7.0.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-values": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz", + "integrity": "sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "icss-utils": "^5.0.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-selector-parser": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", + "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pretty-error": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/pretty-error/-/pretty-error-4.0.0.tgz", + "integrity": "sha512-AoJ5YMAcXKYxKhuJGdcvse+Voc6v1RgnsR3nWcYU7q4t6z0Q6T86sv5Zq8VIRbOWWFpvdGE83LtdSMNd+6Y0xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lodash": "^4.17.20", + "renderkid": "^3.0.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-addr/node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/pvtsutils": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/pvutils": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz", + "integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/qs": { + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/raw-body/node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/rechoir": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz", + "integrity": "sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve": "^1.20.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/reflect-metadata": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/relateurl": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/relateurl/-/relateurl-0.2.7.tgz", + "integrity": "sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/renderkid": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/renderkid/-/renderkid-3.0.0.tgz", + "integrity": "sha512-q/7VIQA8lmM1hF+jn+sFSPWGlMkSAeNYcPLmDQx2zzuiDfaLrOmumR8iaUKlenFgh0XRPIUeSPlH3A+AW3Z5pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-select": "^4.1.3", + "dom-converter": "^0.2.0", + "htmlparser2": "^6.1.0", + "lodash": "^4.17.21", + "strip-ansi": "^6.0.1" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/resize-observer-polyfill": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", + "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==", + "dev": true, + "license": "MIT" + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-cwd/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, + "node_modules/run-applescript": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/sass": { + "version": "1.100.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.100.0.tgz", + "integrity": "sha512-B5j0rYMlinhhOo9tjQebMVVn0TfyXAF+wB3b2ggZUuJ/is/Y+7+JGjirAMxHZ9Z3hIP98NPfamlAkBHa1lAaXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^5.0.0", + "immutable": "^5.1.5", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=20.19.0" + }, + "optionalDependencies": { + "@parcel/watcher": "^2.4.1" + } + }, + "node_modules/sass-loader": { + "version": "17.0.0", + "resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-17.0.0.tgz", + "integrity": "sha512-0Ybm8ohBQ9LcrycVrFQp/KQBNX5a3Wda9/smS0mE/xLffzEnwvV8nykOzrbiSWNzTE3IB/jiXx8O4QmDPG2+Gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 22.11.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "@rspack/core": "0.x || ^1.0.0 || ^2.0.0-0", + "sass": "^1.3.0", + "sass-embedded": "*", + "webpack": "^5.0.0" + }, + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/schema-utils": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/sdf-2d": { + "resolved": "../../sdf-2d", + "link": true + }, + "node_modules/select-hose": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz", + "integrity": "sha512-mEugaLK+YfkijB4fx0e6kImuJdCIt2LxCRcbEYPqRGCs4F2ogyfZU5IAZRdjCP8JPq2AtdNoC/Dux63d9Kiryg==", + "dev": true, + "license": "MIT" + }, + "node_modules/selfsigned": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-5.5.0.tgz", + "integrity": "sha512-ftnu3TW4+3eBfLRFnDEkzGxSF/10BJBkaLJuBHZX0kiPS7bRdlpZGu6YGt4KngMkdTwJE6MbjavFpqHvqVt+Ew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/x509": "^1.14.2", + "pkijs": "^3.3.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/semver": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, + "node_modules/serialize-javascript": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.0.5.tgz", + "integrity": "sha512-F4LcB0UqUl1zErq+1nYEEzSHJnIwb3AF2XWB94b+afhrekOUijwooAYqFyRbjYkm2PAKBabx6oYv/xDxNi8IBw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/serve-index": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/serve-index/-/serve-index-1.9.2.tgz", + "integrity": "sha512-KDj11HScOaLmrPxl70KYNW1PksP4Nb/CLL2yvC+Qd2kHMPEEpfc4Re2e4FOay+bC/+XQl/7zAcWON3JVo5v3KQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "batch": "0.6.1", + "debug": "2.6.9", + "escape-html": "~1.0.3", + "http-errors": "~1.8.0", + "mime-types": "~2.1.35", + "parseurl": "~1.3.3" + }, + "engines": { + "node": ">= 0.8.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-index/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/serve-index/node_modules/depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serve-index/node_modules/http-errors": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.8.1.tgz", + "integrity": "sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "depd": "~1.1.2", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": ">= 1.5.0 < 2", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serve-index/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, + "node_modules/serve-index/node_modules/statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "dev": true, + "license": "ISC" + }, + "node_modules/shallow-clone": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz", + "integrity": "sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==", + "dev": true, + "license": "MIT", + "dependencies": { + "kind-of": "^6.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shared": { + "resolved": "../shared", + "link": true + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/shell-quote": { + "version": "1.8.4", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz", + "integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/socket.io-client": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz", + "integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-client": "~6.6.1", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-msgpack-parser": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/socket.io-msgpack-parser/-/socket.io-msgpack-parser-3.0.2.tgz", + "integrity": "sha512-1e76bJ1PCKi9H+JiYk+S29PBJvknHjQWM7Mtj0hjF2KxDA6b6rQxv3rTsnwBoz/haZOhlCDIMQvPATbqYeuMxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "~1.3.0", + "notepack.io": "~2.2.0" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz", + "integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/sockjs": { + "version": "0.3.24", + "resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz", + "integrity": "sha512-GJgLTZ7vYb/JtPSSZ10hsOYIvEYsjbNU+zPdIHcUaWVNUEPivzxku31865sSSud0Da0W4lEeOPlmw93zLQchuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "faye-websocket": "^0.11.3", + "uuid": "^8.3.2", + "websocket-driver": "^0.7.4" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-loader": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/source-map-loader/-/source-map-loader-5.0.0.tgz", + "integrity": "sha512-k2Dur7CbSLcAH73sBcIkV5xjPV4SzqO1NJ7+XaQl8if3VODDUj3FNchNGpqgJSKbvUfJuhVdv8K2Eu8/TNl2eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "^0.6.3", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.72.1" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/spdy": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/spdy/-/spdy-4.0.2.tgz", + "integrity": "sha512-r46gZQZQV+Kl9oItvl1JZZqJKGr+oEkB08A6BzkiR7593/7IbtuncXHd2YoYeTsG4157ZssMu9KYvUHLcjcDoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.0", + "handle-thing": "^2.0.0", + "http-deceiver": "^1.2.7", + "select-hose": "^2.0.0", + "spdy-transport": "^3.0.0" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/spdy-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/spdy-transport/-/spdy-transport-3.0.0.tgz", + "integrity": "sha512-hsLVFE5SjA6TCisWeJXFKniGGOpBgMLmerfO2aCyCU5s7nJ/rpAepqmFifv/GCbSbueEeAJJnmSQ2rKC/g8Fcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.0", + "detect-node": "^2.0.4", + "hpack.js": "^2.1.6", + "obuf": "^1.1.2", + "readable-stream": "^3.0.6", + "wbuf": "^1.7.3" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/terser": { + "version": "5.48.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.48.0.tgz", + "integrity": "sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser-webpack-plugin": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.6.1.tgz", + "integrity": "sha512-201R5j+sJpK8nFWwKVyNfZot8FaJbLZDq5evriVzbV1wDtSXDjRUDRfJzHpAaxFDMEhsZL1QkeqM61wgsS3KaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "jest-worker": "^27.4.5", + "schema-utils": "^4.3.0", + "terser": "^5.31.1" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@minify-html/node": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "@swc/css": { + "optional": true + }, + "@swc/html": { + "optional": true + }, + "clean-css": { + "optional": true + }, + "cssnano": { + "optional": true + }, + "csso": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "html-minifier-terser": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "postcss": { + "optional": true + }, + "uglify-js": { + "optional": true + } + } + }, + "node_modules/terser/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/thingies": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/thingies/-/thingies-2.6.0.tgz", + "integrity": "sha512-rMHRjmlFLM1R96UYPvpmnc3LYtdFrT33JIB7L9hetGue1qAPfn1N2LJeEjxUSidu1Iku+haLZXDuEXUHNGO/lg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "^2" + } + }, + "node_modules/thunky": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", + "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tree-dump": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/tree-dump/-/tree-dump-1.1.0.tgz", + "integrity": "sha512-rMuvhU4MCDbcbnleZTFezWsaZXRFemSqAM+7jPnzUl1fo9w3YEKOxAeui0fz3OI4EU4hf23iyA7uQRVko+UaBA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/ts-loader": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/ts-loader/-/ts-loader-9.6.0.tgz", + "integrity": "sha512-dsJO0S+T7grTDWTc4a0nTygXGjKncVUpx8Y+af8EvI/D5WgTJby5UEk5eoMCB9EcLQmnvitqh99MqtjtHgAwFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "enhanced-resolve": "^5.0.0", + "micromatch": "^4.0.0", + "semver": "^7.3.4", + "source-map": "^0.7.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "loader-utils": "*", + "typescript": "*", + "webpack": "^4.0.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "loader-utils": { + "optional": true + } + } + }, + "node_modules/ts-loader/node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/tsyringe": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz", + "integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^1.9.3" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/tsyringe/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true, + "license": "0BSD" + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/utila": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/utila/-/utila-0.4.0.tgz", + "integrity": "sha512-Z0DbgELS9/L/75wZbro8xAnT50pBVFQZ+hUEueGDU5FN51YSCYM+jdxsfCiHjwNP/4LCDD0i/graKpeBnOXKRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "dev": true, + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/watchpack": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz", + "integrity": "sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/wbuf": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/wbuf/-/wbuf-1.7.3.tgz", + "integrity": "sha512-O84QOnr0icsbFGLS0O3bI5FswxzRr8/gHwWkDlQFskhSPryQXvrTMxjxGP4+iWYoauLoBvfDpkrOauZ+0iZpDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimalistic-assert": "^1.0.0" + } + }, + "node_modules/webpack": { + "version": "5.107.2", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.107.2.tgz", + "integrity": "sha512-v7RhXaJbpMlV0D7hC7lb2EbnxkoeUqf9qhKr6lozx3Q48pmFrqqNRmZFUEGmi7pSwm6fCQ2H1IjvCkHqdpVdjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.8", + "@types/json-schema": "^7.0.15", + "@webassemblyjs/ast": "^1.14.1", + "@webassemblyjs/wasm-edit": "^1.14.1", + "@webassemblyjs/wasm-parser": "^1.14.1", + "acorn": "^8.16.0", + "acorn-import-phases": "^1.0.3", + "browserslist": "^4.28.1", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.22.0", + "es-module-lexer": "^2.1.0", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.2.11", + "loader-runner": "^4.3.2", + "mime-db": "^1.54.0", + "neo-async": "^2.6.2", + "schema-utils": "^4.3.3", + "tapable": "^2.3.0", + "terser-webpack-plugin": "^5.5.0", + "watchpack": "^2.5.1", + "webpack-sources": "^3.5.0" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-cli": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-7.0.3.tgz", + "integrity": "sha512-2E2C6A1e2El7791zQgTH7LPIuwLjRliow9OHS/qlJc9pwhZlCoL/uiwqd/1WSlXT83wJfmfDbkcqHXuXoPJZ3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@discoveryjs/json-ext": "^1.1.0", + "commander": "^14.0.3", + "cross-spawn": "^7.0.6", + "envinfo": "^7.14.0", + "import-local": "^3.0.2", + "interpret": "^3.1.1", + "rechoir": "^0.8.0", + "webpack-merge": "^6.0.1" + }, + "bin": { + "webpack-cli": "bin/cli.js" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.101.0", + "webpack-bundle-analyzer": "^4.0.0 || ^5.0.0", + "webpack-dev-server": "^5.0.0" + }, + "peerDependenciesMeta": { + "webpack-bundle-analyzer": { + "optional": true + }, + "webpack-dev-server": { + "optional": true + } + } + }, + "node_modules/webpack-cli/node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/webpack-dev-middleware": { + "version": "7.4.5", + "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-7.4.5.tgz", + "integrity": "sha512-uxQ6YqGdE4hgDKNf7hUiPXOdtkXvBJXrfEGYSx7P7LC8hnUYGK70X6xQXUvXeNyBDDcsiQXpG2m3G9vxowaEuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "colorette": "^2.0.10", + "memfs": "^4.43.1", + "mime-types": "^3.0.1", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "schema-utils": "^4.0.0" + }, + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.0.0" + }, + "peerDependenciesMeta": { + "webpack": { + "optional": true + } + } + }, + "node_modules/webpack-dev-middleware/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/webpack-dev-server": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-5.2.4.tgz", + "integrity": "sha512-GqDPGZN9bRqKBTkp4aWkobDDHMsrXKoGSdOH56smIri8qR0JG8gfL8/v/f/OZR3/OKXjG8uwJbFVhKm/FNU/UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/bonjour": "^3.5.13", + "@types/connect-history-api-fallback": "^1.5.4", + "@types/express": "^4.17.25", + "@types/express-serve-static-core": "^4.17.21", + "@types/serve-index": "^1.9.4", + "@types/serve-static": "^1.15.5", + "@types/sockjs": "^0.3.36", + "@types/ws": "^8.5.10", + "ansi-html-community": "^0.0.8", + "bonjour-service": "^1.2.1", + "chokidar": "^3.6.0", + "colorette": "^2.0.10", + "compression": "^1.8.1", + "connect-history-api-fallback": "^2.0.0", + "express": "^4.22.1", + "graceful-fs": "^4.2.6", + "http-proxy-middleware": "^2.0.9", + "ipaddr.js": "^2.1.0", + "launch-editor": "^2.6.1", + "open": "^10.0.3", + "p-retry": "^6.2.0", + "schema-utils": "^4.2.0", + "selfsigned": "^5.5.0", + "serve-index": "^1.9.1", + "sockjs": "^0.3.24", + "spdy": "^4.0.2", + "webpack-dev-middleware": "^7.4.2", + "ws": "^8.18.0" + }, + "bin": { + "webpack-dev-server": "bin/webpack-dev-server.js" + }, + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.0.0" + }, + "peerDependenciesMeta": { + "webpack": { + "optional": true + }, + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-dev-server/node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/webpack-dev-server/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/webpack-dev-server/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/webpack-dev-server/node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/webpack-merge": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-6.0.1.tgz", + "integrity": "sha512-hXXvrjtx2PLYx4qruKl+kyRSLc52V+cCvMxRjmKwoA+CBbbF5GfIBtR6kCvl0fYGqTUPKB+1ktVmTHqMOzgCBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone-deep": "^4.0.1", + "flat": "^5.0.2", + "wildcard": "^2.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/webpack-sources": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.5.0.tgz", + "integrity": "sha512-HPuy+uuoTCaaoEoI1LQ3JN9+vrPBvEesnnX1jADHy728cHSMlq4wUc4afYqahq2B1mhQVZxCXOkNTnXltr+2vQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/websocket-driver": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", + "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "http-parser-js": ">=0.5.1", + "safe-buffer": ">=5.1.0", + "websocket-extensions": ">=0.1.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/websocket-extensions": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", + "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wildcard": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.1.tgz", + "integrity": "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.20.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", + "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/wsl-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", + "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", + "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", + "dev": true, + "engines": { + "node": ">=0.4.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json index 57126ae..229f1e5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,59 +1,55 @@ { - "name": "decla.red", + "name": "doppler-frontend", "version": "0.0.0", - "description": "![logo](media/declared.png)", - "private": true, - "main": "index.js", - "scripts": { - "start": "webpack-dev-server --mode development", - "start-unsafe": "webpack-dev-server --mode development --useLocalIp", - "build": "webpack && find dist -type f -not -name '*.html' | xargs rm" - }, + "description": "", "keywords": [], - "author": "András Schmelczer", + "author": "András Schmelczer (https://schmelczer.dev/)", + "sideEffects": [ + "*.scss" + ], + "main": "index.js", + "engines": { + "node": ">=20" + }, + "scripts": { + "build": "npx webpack --mode production", + "dev": "npx webpack-dev-server --mode development", + "try-build": "npm run build && cd dist && python3 -m http.server 8080" + }, + "browserslist": [ + "defaults" + ], "postcss": { "plugins": { "autoprefixer": {} } }, - "browserslist": [ - "defaults" - ], - "sideEffects": [ - "*.scss" - ], - "optimization": { - "usedExports": true - }, "devDependencies": { - "@types/gl-matrix": "^2.4.5", - "@types/uuid": "^8.0.0", - "autoprefixer": "^9.8.5", - "clean-webpack-plugin": "^3.0.0", - "css-loader": "^3.5.2", - "cssnano": "^4.1.10", - "gl-matrix": "^3.3.0", - "html-webpack-inline-source-plugin": "0.0.10", - "html-webpack-plugin": "^3.2.0", - "image-webpack-loader": "^6.0.0", - "mini-css-extract-plugin": "^0.9.0", - "optimize-css-assets-webpack-plugin": "^5.0.3", - "postcss-loader": "^3.0.0", - "prettier": "^2.0.5", - "raw-loader": "^4.0.1", - "resolve-url-loader": "^3.1.1", - "responsive-loader": "^1.2.0", - "sass": "^1.26.3", - "sass-loader": "^9.0.2", - "sharp": "^0.25.4", - "style-loader": "^1.1.4", - "svg-url-loader": "^6.0.0", - "terser-webpack-plugin": "^2.3.5", - "ts-loader": "^8.0.1", - "typescript": "^3.8.3", - "uuid": "^8.2.0", - "webpack": "^4.43.0", - "webpack-cli": "^3.3.11", - "webpack-dev-server": "^3.10.3" + "@plausible-analytics/tracker": "^0.4.5", + "autoprefixer": "^10.5.0", + "clean-webpack-plugin": "^4.0.0", + "copy-webpack-plugin": "^14.0.0", + "css-loader": "^7.1.4", + "file-loader": "^6.2.0", + "gl-matrix": "3.3.0", + "html-inline-css-webpack-plugin": "^1.11.2", + "html-webpack-plugin": "^5.6.7", + "mini-css-extract-plugin": "^2.10.2", + "postcss": "^8.5.15", + "postcss-loader": "^8.2.1", + "resize-observer-polyfill": "^1.5.1", + "sass": "^1.100.0", + "sass-loader": "^17.0.0", + "sdf-2d": "^0.8.0", + "shared": "file:../shared", + "socket.io-client": "^4.8.3", + "socket.io-msgpack-parser": "^3.0.2", + "source-map-loader": "^5.0.0", + "terser-webpack-plugin": "^5.6.1", + "ts-loader": "^9.6.0", + "typescript": "^6.0.3", + "webpack": "^5.107.2", + "webpack-cli": "^7.0.3", + "webpack-dev-server": "^5.2.4" } } diff --git a/frontend/src/custom.d.ts b/frontend/src/custom.d.ts new file mode 100644 index 0000000..f053a20 --- /dev/null +++ b/frontend/src/custom.d.ts @@ -0,0 +1,15 @@ +declare module '*.mp3' { + const content: string; + export default content; +} + +// Asset imports handled by webpack loaders. TypeScript 5.x+ requires ambient +// declarations for side-effect imports of these, so declare them here. +declare module '*.png'; +declare module '*.ico'; +declare module '*.svg'; +declare module '*.scss'; + +// webpack's `mode` inlines `process.env.NODE_ENV` via DefinePlugin. Declare just +// that one global so browser code can read it without pulling in all of @types/node. +declare const process: { env: { NODE_ENV?: string } }; diff --git a/frontend/src/index.html b/frontend/src/index.html index d899376..0d4d201 100644 --- a/frontend/src/index.html +++ b/frontend/src/index.html @@ -2,21 +2,124 @@ - - - + + + + + + + - Test + + + + + doppler + - + + +
- + +
+

doppler

+
+
+ Join a game + +
+ + +
+ +
+ +
+
+
+ +
+
+ + + + + + + logout +
+
+ +
+ toggle-settings +
+ + minimize-application + maximize-application + +
+ waiting +
diff --git a/frontend/src/index.ts b/frontend/src/index.ts index ff2abea..00f04c0 100644 --- a/frontend/src/index.ts +++ b/frontend/src/index.ts @@ -1,31 +1,160 @@ import { glMatrix } from 'gl-matrix'; +import { + LampBase, + overrideDeserialization, + PlanetBase, + CharacterBase, + ProjectileBase, +} from 'shared'; +import './main.scss'; +import './scripts/analytics'; +import '../static/og-image.png'; +import '../static/favicons/apple-touch-icon.png'; +import '../static/favicons/favicon-16x16.png'; +import '../static/favicons/favicon-32x32.png'; +import '../static/favicons/favicon.ico'; +import { LandingPageBackground } from './scripts/landing-page-background'; +import { JoinFormHandler } from './scripts/join-form-handler'; +import { handleFullScreen } from './scripts/helper/handle-full-screen'; import { Game } from './scripts/game'; -import { applyArrayPlugins } from './scripts/helper/array'; -import './styles/main.scss'; +import ResizeObserver from 'resize-observer-polyfill'; +import { OptionsHandler } from './scripts/options-handler'; +import { hide } from './scripts/helper/hide'; +import { show } from './scripts/helper/show'; +import { SoundHandler, Sounds } from './scripts/sound-handler'; +import { VibrationHandler } from './scripts/vibration-handler'; +import { CharacterView } from './scripts/objects/types/character-view'; +import { LampView } from './scripts/objects/types/lamp-view'; +import { PlanetView } from './scripts/objects/types/planet-view'; +import { ProjectileView } from './scripts/objects/types/projectile-view'; glMatrix.setMatrixArrayType(Array); -applyArrayPlugins(); -/* -const tree = new BoundingBoxTree([ - new BoundingBox(300, 550, 150, 550, 'A'), - new BoundingBox(400, 800, 50, 200, 'B'), - new BoundingBox(450, 500, 175, 185, 'C'), - new BoundingBox(100, 200, 100, 500, 'D'), - new BoundingBox(750, 950, 450, 600, 'E'), - new BoundingBox(940, 1000, -2, 180, 'F'), - new BoundingBox(960, 1050, 50, 190, 'G'), - new BoundingBox(150, 900, 0, 575, 'H'), - new BoundingBox(-10000, 10000, -10000, 10000, 'I'), -]); +overrideDeserialization(CharacterBase, CharacterView); +overrideDeserialization(PlanetBase, PlanetView); +overrideDeserialization(LampBase, LampView); +overrideDeserialization(ProjectileBase, ProjectileView); -tree.print(); -console.log(tree.findIntersecting(new BoundingBox(960, 1050, 50, 190, 'G'))); -*/ +const landingUI = document.querySelector('#landing-ui') as HTMLElement; +const nameInput = document.querySelector('#name') as HTMLInputElement; +const joinGameForm = document.querySelector('#join-game-form') as HTMLFormElement; +const serverContainer = document.querySelector('#server-container') as HTMLElement; +const canvas = document.querySelector('canvas') as HTMLCanvasElement; +const overlay = document.querySelector('#overlay') as HTMLElement; +const settings = document.querySelector('#settings') as HTMLElement; +const toggleSettingsButton = document.querySelector( + '#toggle-settings-container', +) as HTMLElement; +const minimize = document.querySelector('#minimize') as HTMLElement; +const maximize = document.querySelector('#maximize') as HTMLElement; +const logoutButton = document.querySelector('#logout') as HTMLElement; +const enableSounds = document.querySelector('#enable-sounds') as HTMLInputElement; +const enableMusic = document.querySelector('#enable-music') as HTMLInputElement; +const enableVibration = document.querySelector('#enable-vibration') as HTMLInputElement; +const spinner = document.querySelector('#spinner-container') as HTMLElement; -try { - new Game(); -} catch (e) { - console.error(e); - alert(e); -} +const toggleSettings = () => { + settings.className = settings.className === 'open' ? '' : 'open'; + SoundHandler.play(Sounds.click); +}; + +const applyServerContainerShadows = () => { + const topShadow = 'inset 0 -8px 8px -8px rgba(0, 0, 0, 0.4)'; + const bottomShadow = 'inset 0 8px 8px -8px rgba(0, 0, 0, 0.4)'; + + const { scrollHeight, clientHeight, scrollTop } = serverContainer; + if (scrollHeight > clientHeight) { + if (scrollTop <= 0) { + serverContainer.style.boxShadow = topShadow; + } else if (scrollTop + clientHeight >= scrollHeight) { + serverContainer.style.boxShadow = bottomShadow; + } else { + serverContainer.style.boxShadow = topShadow + ',' + bottomShadow; + } + } else { + serverContainer.style.boxShadow = ''; + } +}; + +const main = async () => { + try { + let game: Game; + + const storedUserName = localStorage?.getItem('userName'); + if (storedUserName) { + nameInput.value = JSON.parse(storedUserName); + } + + const firstClickListener = () => { + SoundHandler.initialize( + () => { + enableMusic.checked = true; + enableMusic.dispatchEvent(new Event('change')); + }, + () => { + enableMusic.checked = false; + enableMusic.dispatchEvent(new Event('change')); + }, + ); + document.removeEventListener('click', firstClickListener); + }; + document.addEventListener('click', firstClickListener); + + if (!VibrationHandler.isVibrationEnabledHeuristics) { + hide(document.querySelector("label[for='enable-vibration']") as HTMLElement, true); + } + + handleFullScreen(minimize, maximize); + toggleSettingsButton.addEventListener('click', toggleSettings); + + new ResizeObserver(applyServerContainerShadows).observe(serverContainer); + serverContainer.addEventListener('scroll', applyServerContainerShadows); + + OptionsHandler.initialize({ + soundsEnabled: enableSounds, + vibrationEnabled: enableVibration, + musicEnabled: enableMusic, + }); + + logoutButton.addEventListener('click', () => { + game.destroy(); + toggleSettings(); + }); + window.onpopstate = () => game.destroy(); + + for (;;) { + show(spinner); + hide(logoutButton, true); + show(landingUI, true, 'flex'); + + const background = new LandingPageBackground(canvas); + const joinHandler = new JoinFormHandler(joinGameForm, serverContainer); + + await background.renderer; + hide(spinner); + + const playerDecision = await joinHandler.getPlayerDecision(); + + localStorage?.setItem('userName', JSON.stringify(playerDecision.name)); + + if (!history.state) { + history.pushState(true, ''); + } + + hide(landingUI, true); + show(spinner); + background.destroy(); + game = new Game(playerDecision, canvas, overlay); + const gameOver = game.start(); + await game.started; + hide(spinner); + show(logoutButton, true, 'block'); + await gameOver; + } + } catch (e) { + console.error(e); + alert(e); + } +}; + +main(); diff --git a/frontend/src/main.scss b/frontend/src/main.scss new file mode 100644 index 0000000..3ab46d2 --- /dev/null +++ b/frontend/src/main.scss @@ -0,0 +1,940 @@ +@use 'styles/vars' as *; +@use 'styles/button'; +@use 'styles/form'; +@use 'styles/mixins' as *; +@use 'styles/settings'; + +@import url('https://fonts.googleapis.com/css2?family=Comfortaa:wght@300&family=Open+Sans&display=swap'); + +* { + margin: 0; + box-sizing: border-box; + color: white; + font-family: 'Open Sans', 'Segoe UI Emoji', sans-serif; + + &::selection { + color: white; + background-color: $accent; + } +} + +html { + font-size: 0.85rem; + + @media (max-width: $breakpoint) { + font-size: 0.6rem; + } +} + +img { + user-select: none; +} + +h1 { + + &, + * { + font-family: 'Comfortaa', sans-serif; + font-weight: 400; + } + + font-size: 6rem; + text-align: center; + padding-bottom: $medium-padding; + width: fit-content; + margin-inline: auto; + background: linear-gradient(90deg, $bright-blue, $bright-red); + background-clip: text; + -webkit-background-clip: text; + color: transparent; + + &::selection { + color: white; + -webkit-text-fill-color: white; + background-color: $accent; + } + + @media (max-width: $height-breakpoint) { + font-size: 4.5rem; + } + + @media (max-height: $height-breakpoint) { + display: none; + } +} + +html, +body, +canvas { + height: 100%; + width: 100%; + background: black; +} + +body { + overflow: hidden; + + #spinner-container { + width: 100%; + height: 100%; + position: absolute; + display: flex; + justify-content: center; + align-items: center; + top: 0; + + @include background; + + #spinner { + @include square(20vmax); + + @media (max-width: $breakpoint) { + @include square(20vmax); + } + } + } + + #landing-ui { + width: 100%; + height: 100%; + position: absolute; + top: 0; + + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + } + + #overlay { + height: 100%; + width: 100%; + position: absolute; + top: 0; + left: 0; + + pointer-events: none; + user-select: none; + + overflow: hidden; + + &>* { + display: inline-block; + position: absolute; + top: 0; + left: 0; + } + + .player-tag { + border-radius: 1000px; + transition: transform 200ms; + + &.blue { + color: $bright-blue; + + .health { + background-color: $bright-blue; + + &:before { + background-color: $bright-blue; + opacity: 0.3; + } + } + } + + &.red { + color: $bright-red; + + .health { + background-color: $bright-red; + + &:before { + background-color: $bright-red; + opacity: 0.4; + } + } + } + + .health { + position: relative; + height: 5px; + border-radius: 1000px; + + &:before { + content: ''; + position: absolute; + height: 5px; + width: 50px; + box-sizing: border-box; + border-radius: 1000px; + } + } + + .charge { + height: 3px; + margin-top: 2px; + border-radius: 1000px; + background-color: rgba(255, 255, 255, 0.65); + } + + .stats { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.8em; + font-weight: 700; + text-shadow: 0 0 4px rgba(0, 0, 0, 0.9); + + .stat { + display: inline-flex; + align-items: center; + gap: 3px; + } + + .icon { + width: 1.1em; + height: 1.1em; + fill: currentColor; + filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.9)); + } + } + } + + .ownership { + font-size: 0; + box-shadow: inset 0 0 3px 0px rgba(0, 0, 0, 0.2); + + @include square(50px); + border-radius: 1000px; + mask-image: url('../static/mask.svg'); + + &.contested { + animation: contested-pulse 0.7s ease-in-out infinite; + } + + &.keystone { + @include square(72px); + } + } + + .falling-point { + font-size: 1.2em; + font-weight: 700; + animation: falling-point 2s ease-out forwards; + + &.blue { + color: $bright-blue; + } + + &.red { + color: $bright-red; + } + } + + // Top-down radar of the whole arena (see Minimap). The circular border is + // the world boundary; dots are painted onto the canvas. + .minimap { + top: $small-padding; + left: $small-padding; + @include square(132px); + + // On narrow screens the centred scoreboard bar reaches close to the left + // edge, so drop the minimap below it to avoid a corner overlap. + @media (max-width: $breakpoint) { + @include square(96px); + top: calc(#{$small-padding} + 32px); + } + + border-radius: 1000px; + background-color: rgba(0, 0, 0, 0.35); + box-shadow: + inset 0 0 3px 0 rgba(0, 0, 0, 0.4), + 0 0 10px rgba(0, 0, 0, 0.3); + border: $border-width solid rgba(255, 255, 255, 0.25); + z-index: 100; + } + + // Off-screen pointer to the keystone "Heart", tinted by its current owner. + .keystone-arrow { + transition: transform 150ms; + opacity: 0.9; + + @include square($large-icon); + + @media (max-width: $breakpoint) { + @include square($small-icon); + } + + mask-image: url('../static/chevron.svg'); + mask-size: contain; + background-color: $bright-neutral; + + &.blue { + background-color: $bright-blue; + } + + &.red { + background-color: $bright-red; + } + + &.neutral { + background-color: $bright-neutral; + } + } + + .joystick { + @include square($large-icon * 1.3); + background-color: white; + box-shadow: inset 0 0 8px 3px rgba(0, 0, 0, 0.33); + opacity: 0.35; + border-radius: 1000px; + + div { + position: absolute; + top: 50%; + left: 50%; + background-color: #444; + box-shadow: 0 0 8px 3px rgba(0, 0, 0, 0.33); + + @include square($small-icon); + border-radius: 1000px; + } + } + + .touch-button { + display: none; + pointer-events: auto; + touch-action: none; + cursor: pointer; + + $size: $large-icon * 1.6; + @include square($size); + top: auto; + left: auto; + bottom: $medium-padding; + border-radius: 1000px; + background-color: rgba(255, 255, 255, 0.12); + box-shadow: inset 0 0 8px 3px rgba(0, 0, 0, 0.33); + border: $border-width solid rgba(255, 255, 255, 0.4); + + font-size: 1.6rem; + line-height: $size - 2 * $border-width; + text-align: center; + text-shadow: 0 0 6px rgba(0, 0, 0, 0.8); + + @media (hover: none) and (pointer: coarse) { + display: inline-block; + } + + &.fire { + right: $medium-padding; + + &::after { + content: '\25C9'; + } + + // Twin-stick aim indicator: drawn from the button centre toward the + // current drag direction while aiming a shot (see TouchListener). + .aim-line { + position: absolute; + left: 50%; + top: 50%; + width: 130px; + height: 3px; + border-radius: 2px; + transform-origin: left center; + transform: translateY(-50%) rotate(0rad); + background: linear-gradient(90deg, + rgba(255, 255, 255, 0.85), + rgba(255, 255, 255, 0)); + box-shadow: 0 0 6px rgba(0, 0, 0, 0.6); + opacity: 0; + pointer-events: none; + transition: opacity 80ms; + } + } + + &.leap { + right: $medium-padding; + bottom: $medium-padding + $large-icon * 1.6 + $small-padding; + + &::after { + content: '\25B2'; + } + } + + .strength-ring { + position: absolute; + top: -3px; + left: -3px; + width: calc(100% + 6px); + height: calc(100% + 6px); + border-radius: 1000px; + pointer-events: none; + -webkit-mask: radial-gradient(farthest-side, + transparent calc(100% - 5px), + #000 calc(100% - 5px)); + mask: radial-gradient(farthest-side, + transparent calc(100% - 5px), + #000 calc(100% - 5px)); + } + } + + .announcement { + top: 25%; + transform: translateX(calc(-50% + 50vw)) translateY(-50%); + + font-size: 3rem; + @include background; + z-index: 1000; + padding: $medium-padding; + border-radius: 16px; + + &:empty { + display: none; + } + + .blue { + color: $bright-blue; + } + + .red { + color: $bright-red; + } + } + + .tutorial-hint { + top: auto; + bottom: calc(#{$medium-padding} + #{$large-icon} * 1.6 + #{$medium-padding}); + left: 50%; + transform: translateX(-50%); + white-space: nowrap; + + font-size: 1.6rem; + @include background; + z-index: 1000; + padding: $small-padding $medium-padding; + border-radius: 1000px; + opacity: 0.9; + + &:empty { + display: none; + } + } + + .planet-progress { + top: $small-padding; + left: 50%; + transform: translateX(-50%); + width: 50%; + $height: 20px; + height: $height; + + z-index: 100; + pointer-events: none; + + background-color: rgba(0, 0, 0, 0.35); + box-shadow: inset 0 0 3px 0px rgba(0, 0, 0, 0.4); + border-radius: 1000px; + + .fill { + position: absolute; + top: 0; + height: $height; + transition: width $animation-time; + } + + .fill.blue { + right: 50%; + background: $bright-blue; + color: $bright-blue; + border-radius: 1000px 0 0 1000px; + } + + .fill.red { + left: 50%; + background: $bright-red; + color: $bright-red; + border-radius: 0 1000px 1000px 0; + } + + .fill.match-point { + z-index: 1; + animation: match-point-pulse 1.2s ease-in-out infinite; + } + + &::before { + content: ''; + position: absolute; + left: 50%; + top: 50%; + transform: translateX(-50%) translateY(-50%); + background-color: #fff; + height: $height + 8px; + width: 3px; + border-radius: 1000px; + z-index: 2; + } + + .score { + position: absolute; + top: 50%; + transform: translateY(-50%); + z-index: 3; + font-size: 0.95rem; + font-weight: 700; + line-height: 1; + color: #fff; + text-shadow: + 0 0 3px rgba(0, 0, 0, 0.95), + 0 0 6px rgba(0, 0, 0, 0.8); + opacity: 0.8; + transition: + opacity $animation-time, + text-shadow $animation-time, + font-size $animation-time; + } + + .score.blue { + right: calc(50% + #{$small-padding}); + } + + .score.red { + left: calc(50% + #{$small-padding}); + } + + .score.leading { + opacity: 1; + font-size: 1.2rem; + } + + .score.blue.leading { + text-shadow: + 0 0 3px rgba(0, 0, 0, 0.95), + 0 0 8px $bright-blue; + } + + .score.red.leading { + text-shadow: + 0 0 3px rgba(0, 0, 0, 0.95), + 0 0 8px $bright-red; + } + + .you-marker { + position: absolute; + top: 100%; + margin-top: 4px; + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.08em; + white-space: nowrap; + text-shadow: 0 0 4px rgba(0, 0, 0, 0.9); + + &::after { + content: ''; + position: absolute; + left: 50%; + bottom: 100%; + transform: translateX(-50%); + border-left: 4px solid transparent; + border-right: 4px solid transparent; + border-bottom: 5px solid currentColor; + } + + &.blue { + right: calc(50% + #{$small-padding}); + color: $bright-blue; + } + + &.red { + left: calc(50% + #{$small-padding}); + color: $bright-red; + } + } + } + } + + #server-container { + max-height: 30vh; + overflow-y: auto; + + &::-webkit-scrollbar-track, + &::-webkit-scrollbar { + background-color: transparent; + width: 3px; + } + + &::-webkit-scrollbar-thumb { + background-color: $accent; + border-radius: $border-radius; + } + + transition: box-shadow $animation-time; + } + + .full-screen-controllers { + position: absolute; + bottom: 0; + left: 0; + + @media (hover: none) and (pointer: coarse) { + display: none; + } + + box-sizing: content-box; + + user-select: none; + cursor: pointer; + + padding: $medium-padding; + @include square($large-icon); + + @media (max-width: $breakpoint) { + @include square($small-icon); + padding: $small-padding; + } + + &:not(:first-child) { + visibility: hidden; + } + } +} + +.feedback-hud { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + pointer-events: none; + user-select: none; + z-index: 2000; + overflow: hidden; + + .hitmarker { + position: absolute; + transform: translate(-50%, -50%); + @include square(34px); + opacity: 0.95; + + &::before, + &::after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 16px; + height: 3px; + border-radius: 2px; + background-color: white; + box-shadow: + 0 0 6px rgba(255, 255, 255, 0.9), + 0 0 3px rgba(0, 0, 0, 0.9); + transform-origin: center; + } + + &::before { + transform: translate(-50%, -50%) rotate(45deg); + } + + &::after { + transform: translate(-50%, -50%) rotate(-45deg); + } + + animation: hitmarker-pop 220ms cubic-bezier(0.2, 0.9, 0.3, 1) forwards; + + &.charged { + @include square(44px); + + &::before, + &::after { + width: 24px; + height: 4px; + background-color: $accent; + box-shadow: + 0 0 10px rgba($accent, 0.9), + 0 0 4px rgba(0, 0, 0, 0.9); + } + } + } + + .kill-flash { + position: absolute; + inset: 0; + pointer-events: none; + background: radial-gradient(ellipse at center, + transparent 42%, + rgba($accent, 0) 56%, + rgba($accent, 0.55) 100%); + animation: kill-flash 420ms ease-out forwards; + + &.charged { + background: radial-gradient(ellipse at center, + transparent 36%, + rgba($accent, 0) 50%, + rgba($accent, 0.8) 100%); + } + } + + .kill-popup { + position: absolute; + transform: translate(-50%, -50%); + font-weight: 800; + font-size: 1.7rem; + white-space: nowrap; + color: $bright-neutral; + text-shadow: + 0 0 10px rgba(0, 0, 0, 0.9), + 0 0 18px rgba(255, 255, 255, 0.35); + animation: kill-popup-rise 1200ms cubic-bezier(0.18, 0.9, 0.3, 1) forwards; + + &.charged { + color: $accent; + text-shadow: + 0 0 10px rgba(0, 0, 0, 0.9), + 0 0 18px rgba($accent, 0.6); + } + + .heal { + color: #6fcf6f; + } + } + + .streak-callout { + position: absolute; + top: 33%; + left: 50%; + transform: translateX(-50%); + font-weight: 800; + font-size: 3rem; + color: $accent; + text-shadow: + 0 0 12px rgba(0, 0, 0, 0.9), + 0 0 24px rgba($accent, 0.7); + white-space: nowrap; + animation: streak-pop 1400ms cubic-bezier(0.18, 0.9, 0.3, 1) forwards; + } + + .killfeed { + position: absolute; + top: calc(#{$small-padding} + 36px); + right: $medium-padding; + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 6px; + + .kill-entry { + font-size: 1rem; + padding: 4px 10px; + border-radius: 1000px; + @include background; + text-shadow: 0 0 4px rgba(0, 0, 0, 0.9); + animation: kill-entry-fade 4500ms ease-out forwards; + + b { + color: $accent; + } + } + } + + // Centred "Eliminated" overlay shown while the local player is dead; the + // respawn countdown is the server-driven "Reviving in N…" announcement. + .elimination { + position: absolute; + top: 42%; + left: 50%; + transform: translate(-50%, -50%); + text-align: center; + animation: elimination-in 220ms ease-out; + + .elimination-title { + font-weight: 800; + font-size: 3.4rem; + letter-spacing: 0.04em; + color: $accent; + text-shadow: + 0 0 12px rgba(0, 0, 0, 0.9), + 0 0 28px rgba($accent, 0.6); + } + + .elimination-sub { + margin-top: 6px; + font-size: 1.3rem; + opacity: 0.85; + text-shadow: 0 0 8px rgba(0, 0, 0, 0.9); + } + } +} + +@keyframes contested-pulse { + + 0%, + 100% { + opacity: 1; + } + + 50% { + opacity: 0.35; + } +} + +.charge-ring { + position: fixed; + @include square(56px); + margin: -28px 0 0 -28px; + border-radius: 1000px; + pointer-events: none; + user-select: none; + z-index: 2000; + opacity: 0; + transition: opacity 100ms; + -webkit-mask: radial-gradient(farthest-side, + transparent calc(100% - 6px), + #000 calc(100% - 6px)); + mask: radial-gradient(farthest-side, + transparent calc(100% - 6px), + #000 calc(100% - 6px)); + + &.full { + filter: drop-shadow(0 0 6px rgba(255, 255, 255, 0.8)); + } +} + +@keyframes match-point-pulse { + + 0%, + 100% { + box-shadow: 0 0 4px 0 currentColor; + } + + 50% { + box-shadow: 0 0 14px 3px currentColor; + } +} + +@keyframes falling-point { + 0% { + opacity: 1; + transform: translate(-50%, -50%); + } + + 100% { + opacity: 0; + transform: translate(-50%, calc(-50% - 90px)); + } +} + +@keyframes hitmarker-pop { + 0% { + opacity: 0; + transform: translate(-50%, -50%) scale(2); + } + + 18% { + opacity: 1; + transform: translate(-50%, -50%) scale(0.82); + } + + 36% { + transform: translate(-50%, -50%) scale(1.06); + } + + 100% { + opacity: 0; + transform: translate(-50%, -50%) scale(0.95); + } +} + +@keyframes kill-flash { + 0% { + opacity: 0; + } + + 12% { + opacity: 1; + } + + 100% { + opacity: 0; + } +} + +@keyframes kill-popup-rise { + 0% { + opacity: 0; + transform: translate(-50%, -20%) scale(0.6); + } + + 12% { + opacity: 1; + transform: translate(-50%, -56%) scale(1.25); + } + + 28% { + transform: translate(-50%, -62%) scale(1); + } + + 100% { + opacity: 0; + transform: translate(-50%, -170%) scale(1); + } +} + +@keyframes streak-pop { + 0% { + opacity: 0; + transform: translateX(-50%) scale(0.5); + } + + 16% { + opacity: 1; + transform: translateX(-50%) scale(1.25); + } + + 32% { + transform: translateX(-50%) scale(0.95); + } + + 70% { + opacity: 1; + transform: translateX(-50%) scale(1); + } + + 100% { + opacity: 0; + transform: translateX(-50%) scale(1); + } +} + +@keyframes kill-entry-fade { + 0% { + opacity: 0; + transform: translateX(20px); + } + + 10% { + opacity: 1; + transform: translateX(0); + } + + 80% { + opacity: 1; + } + + 100% { + opacity: 0; + } +} + +@keyframes elimination-in { + 0% { + opacity: 0; + transform: translate(-50%, -42%) scale(0.9); + } + + 100% { + opacity: 1; + transform: translate(-50%, -50%) scale(1); + } +} \ No newline at end of file diff --git a/frontend/src/scripts/analytics.ts b/frontend/src/scripts/analytics.ts new file mode 100644 index 0000000..e732cc4 --- /dev/null +++ b/frontend/src/scripts/analytics.ts @@ -0,0 +1,17 @@ +import { init as plausibleInit } from '@plausible-analytics/tracker'; + +const ANALYTICS_AUTO_CAPTURE_PAGEVIEWS = true; +const ANALYTICS_DOMAIN = 'doppler.schmelczer.dev'; +const ANALYTICS_ENDPOINT = 'https://stats.schmelczer.dev/status'; +const ANALYTICS_LOGGING = process.env.NODE_ENV !== 'production'; + +try { + plausibleInit({ + domain: ANALYTICS_DOMAIN, + endpoint: ANALYTICS_ENDPOINT, + autoCapturePageviews: ANALYTICS_AUTO_CAPTURE_PAGEVIEWS, + logging: ANALYTICS_LOGGING, + }); +} catch (error) { + console.warn('Could not initialize analytics.', error); +} diff --git a/frontend/src/scripts/charge-indicator.ts b/frontend/src/scripts/charge-indicator.ts new file mode 100644 index 0000000..239e33b --- /dev/null +++ b/frontend/src/scripts/charge-indicator.ts @@ -0,0 +1,58 @@ +import { holdDurationToCharge } from 'shared'; +import { Pointer } from './helper/pointer'; + +export abstract class ChargeIndicator { + private static element?: HTMLElement; + private static heldSince = 0; + private static raf = 0; + private static followPointer = false; + + public static begin(x: number, y: number, followPointer = false) { + this.end(); + + const element = document.createElement('div'); + element.className = 'charge-ring'; + element.style.left = `${x}px`; + element.style.top = `${y}px`; + document.body.appendChild(element); + + this.element = element; + this.heldSince = performance.now(); + this.followPointer = followPointer; + this.raf = requestAnimationFrame(this.update); + } + + public static end() { + if (this.element) { + cancelAnimationFrame(this.raf); + this.element.parentElement?.removeChild(this.element); + this.element = undefined; + } + } + + private static update = () => { + const element = ChargeIndicator.element; + if (!element) { + return; + } + + const charge = holdDurationToCharge( + (performance.now() - ChargeIndicator.heldSince) / 1000, + ); + element.style.opacity = charge < 0.12 ? '0' : '1'; + element.style.background = `conic-gradient(rgba(255, 255, 255, 0.85) ${ + charge * 360 + }deg, rgba(255, 255, 255, 0.15) 0deg)`; + element.classList.toggle('full', charge >= 1); + + if (ChargeIndicator.followPointer) { + const cursor = Pointer.getDisplayPosition(); + if (cursor) { + element.style.left = `${cursor.x}px`; + element.style.top = `${cursor.y}px`; + } + } + + ChargeIndicator.raf = requestAnimationFrame(ChargeIndicator.update); + }; +} diff --git a/frontend/src/scripts/commands/command-broadcaster.ts b/frontend/src/scripts/commands/command-broadcaster.ts deleted file mode 100644 index 8703fcf..0000000 --- a/frontend/src/scripts/commands/command-broadcaster.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { CommandReceiver } from './command-receiver'; -import { CommandGenerator } from './command-generator'; - -export class CommandBroadcaster { - constructor( - commandGenerators: Array, - commandReceivers: Array - ) { - commandReceivers.forEach((r) => - commandGenerators.forEach((g) => g.subscribe(r)) - ); - } -} diff --git a/frontend/src/scripts/commands/command-receiver.ts b/frontend/src/scripts/commands/command-receiver.ts deleted file mode 100644 index bf6e119..0000000 --- a/frontend/src/scripts/commands/command-receiver.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { Command } from './command'; - -export interface CommandReceiver { - sendCommand(command: Command): void; -} diff --git a/frontend/src/scripts/commands/command-socket.ts b/frontend/src/scripts/commands/command-socket.ts new file mode 100644 index 0000000..830b19b --- /dev/null +++ b/frontend/src/scripts/commands/command-socket.ts @@ -0,0 +1,20 @@ +import { Command, CommandReceiver, serialize, TransportEvents } from 'shared'; +import { Socket } from 'socket.io-client'; + +export class CommandSocket extends CommandReceiver { + constructor(private readonly socket: Socket) { + super(); + } + + private commandQueue: Array = []; + 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 = []; + } + } +} diff --git a/frontend/src/scripts/commands/command.ts b/frontend/src/scripts/commands/command.ts deleted file mode 100644 index ed5a9ff..0000000 --- a/frontend/src/scripts/commands/command.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { Typed } from '../transport/serializable'; -import { Id } from '../identity/identity'; - -export abstract class Command extends Typed { - target?: Id; -} diff --git a/frontend/src/scripts/commands/keyboard-listener.ts b/frontend/src/scripts/commands/keyboard-listener.ts new file mode 100644 index 0000000..342d851 --- /dev/null +++ b/frontend/src/scripts/commands/keyboard-listener.ts @@ -0,0 +1,58 @@ +import { vec2 } from 'gl-matrix'; +import { CommandGenerator, LeapActionCommand, MoveActionCommand } from 'shared'; +import { localCharacterPredictor } from '../helper/prediction/local-character-predictor'; + +export class KeyboardListener extends CommandGenerator { + private keysDown: Set = 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); + } +} diff --git a/frontend/src/scripts/commands/mouse-listener.ts b/frontend/src/scripts/commands/mouse-listener.ts new file mode 100644 index 0000000..c4bd30d --- /dev/null +++ b/frontend/src/scripts/commands/mouse-listener.ts @@ -0,0 +1,71 @@ +import { vec2 } from 'gl-matrix'; +import { CommandGenerator, PrimaryActionCommand, holdDurationToCharge } from 'shared'; +import { Game } from '../game'; +import { ChargeIndicator } from '../charge-indicator'; +import { Pointer } from '../helper/pointer'; + +export class MouseListener extends CommandGenerator { + // Timestamp (ms) of the primary press, or null when not held. On release the + // held duration is mapped to the charge scalar; a quick tap reads as ~0. + private primaryDownAt: number | null = null; + + constructor( + private target: HTMLElement, + private readonly game: Game, + ) { + super(); + + target.addEventListener('mousedown', this.mouseDownListener); + target.addEventListener('mouseup', this.mouseUpListener); + target.addEventListener('mousemove', this.mouseMoveListener); + target.addEventListener('contextmenu', this.contextMenuListener); + } + + // Only the screen position is stored; it is reprojected to world space each + // frame so the gaze stays correct even while the camera pans under a still + // cursor. + private mouseMoveListener = (event: MouseEvent) => { + Pointer.setDisplayPosition(event.clientX, event.clientY); + }; + + private mouseDownListener = (event: MouseEvent) => { + if (event.button === 0) { + this.primaryDownAt = performance.now(); + // The ring follows the cursor and only fades in once this press has + // clearly become a hold. + ChargeIndicator.begin(event.clientX, event.clientY, true); + } + }; + + private mouseUpListener = (event: MouseEvent) => { + if (event.button !== 0 || this.primaryDownAt === null) { + return; + } + + ChargeIndicator.end(); + const charge = holdDurationToCharge((performance.now() - this.primaryDownAt) / 1000); + this.primaryDownAt = null; + this.sendCommandToSubscribers( + new PrimaryActionCommand(this.positionFromEvent(event), charge), + ); + }; + + // Suppress the browser context menu on the canvas; right-click has no action. + private contextMenuListener = (event: MouseEvent) => { + event.preventDefault(); + }; + + private positionFromEvent(event: MouseEvent): vec2 { + return this.game.displayToWorldCoordinates( + vec2.fromValues(event.clientX, event.clientY), + ); + } + + public destroy() { + ChargeIndicator.end(); + this.target.removeEventListener('mousedown', this.mouseDownListener); + this.target.removeEventListener('mouseup', this.mouseUpListener); + this.target.removeEventListener('mousemove', this.mouseMoveListener); + this.target.removeEventListener('contextmenu', this.contextMenuListener); + } +} diff --git a/frontend/src/scripts/commands/touch-listener.ts b/frontend/src/scripts/commands/touch-listener.ts new file mode 100644 index 0000000..ab91ce8 --- /dev/null +++ b/frontend/src/scripts/commands/touch-listener.ts @@ -0,0 +1,273 @@ +import { vec2 } from 'gl-matrix'; +import { + CommandGenerator, + MoveActionCommand, + last, + PrimaryActionCommand, + LeapActionCommand, + holdDurationToCharge, + settings, +} from 'shared'; +import { Game } from '../game'; +import { ChargeIndicator } from '../charge-indicator'; +import { localCharacterPredictor } from '../helper/prediction/local-character-predictor'; + +export class TouchListener extends CommandGenerator { + private static readonly deadZone = 8; + private static readonly deltaScaling = 0.4; + // Min screen drag (px) from the fire button before a shot is aimed by the + // drag direction instead of firing straight ahead. + private static readonly aimDeadZone = 18; + + private joystick: HTMLElement; + private joystickButton: HTMLElement; + private isJoystickActive = false; + private touchStartPosition!: vec2; + private primaryDownAt: number | null = null; + + private fireButton: HTMLElement; + private fireStrengthRing: HTMLElement; + private fireAimLine!: HTMLElement; + private leapButton: HTMLElement; + + private fireDownAt: number | null = null; + private fireButtonCenter: vec2 | null = null; + private fireAimScreen: vec2 | null = null; + + constructor( + private target: HTMLElement, + private overlay: HTMLElement, + private readonly game: Game, + ) { + super(); + + this.joystick = document.createElement('div'); + this.joystick.className = 'joystick'; + this.joystickButton = document.createElement('div'); + this.joystick.appendChild(this.joystickButton); + + this.fireButton = document.createElement('div'); + this.fireButton.className = 'touch-button fire'; + this.fireStrengthRing = document.createElement('div'); + this.fireStrengthRing.className = 'strength-ring'; + this.fireButton.appendChild(this.fireStrengthRing); + this.fireAimLine = document.createElement('div'); + this.fireAimLine.className = 'aim-line'; + this.fireButton.appendChild(this.fireAimLine); + + this.fireButton.addEventListener('touchstart', this.fireButtonDownListener); + this.fireButton.addEventListener('touchmove', this.fireButtonMoveListener); + this.fireButton.addEventListener('touchend', this.fireButtonUpListener); + + this.leapButton = document.createElement('div'); + this.leapButton.className = 'touch-button leap'; + this.leapButton.addEventListener('touchstart', this.leapButtonListener); + + this.overlay.appendChild(this.fireButton); + this.overlay.appendChild(this.leapButton); + + target.addEventListener('touchstart', this.touchStartListener); + target.addEventListener('touchmove', this.touchMoveListener); + target.addEventListener('touchend', this.touchEndListener); + } + + private touchStartListener = (event: TouchEvent) => { + event.preventDefault(); + if (this.isJoystickActive) { + const center = vec2.fromValues( + last(event.touches)!.clientX, + last(event.touches)!.clientY, + ); + this.sendCommandToSubscribers( + new PrimaryActionCommand(this.game.displayToWorldCoordinates(center)), + ); + } else { + this.touchStartPosition = vec2.fromValues( + event.touches[0].clientX, + event.touches[0].clientY, + ); + this.primaryDownAt = performance.now(); + ChargeIndicator.begin(this.touchStartPosition.x, this.touchStartPosition.y); + } + }; + + private touchMoveListener = (event: TouchEvent) => { + event.preventDefault(); + + const touchPosition = vec2.fromValues( + event.touches[0].clientX, + event.touches[0].clientY, + ); + + const delta = vec2.subtract(vec2.create(), touchPosition, this.touchStartPosition); + vec2.scale(delta, delta, TouchListener.deltaScaling); + const deltaLength = vec2.length(delta); + + if (!this.isJoystickActive && deltaLength > TouchListener.deadZone) { + this.isJoystickActive = true; + this.primaryDownAt = null; + ChargeIndicator.end(); + this.overlay.appendChild(this.joystick); + this.joystickButton.style.transform = `translateX(-50%) translateY(-50%)`; + this.joystick.style.transform = `translateX(${this.touchStartPosition.x}px) translateY(${this.touchStartPosition.y}px) translateX(-50%) translateY(-50%)`; + } + + const maxLength = 20; + vec2.scale(delta, delta, Math.min(1, maxLength / deltaLength)); + this.joystickButton.style.transform = `translateX(${delta.x}px) translateY(${delta.y}px) translateX(-50%) translateY(-50%)`; + + vec2.set(delta, delta.x, -delta.y); + if (deltaLength > TouchListener.deadZone) { + const direction = vec2.normalize(delta, delta); + this.sendMove(direction); + } else { + this.sendMove(vec2.create()); + } + }; + + private sendMove(direction: vec2) { + const clientTimeMs = localCharacterPredictor.recordInput(direction); + this.sendCommandToSubscribers(new MoveActionCommand(direction, clientTimeMs)); + } + + private touchEndListener = (event: TouchEvent) => { + event.preventDefault(); + + if (!this.isJoystickActive) { + ChargeIndicator.end(); + const charge = + this.primaryDownAt === null + ? 0 + : holdDurationToCharge((performance.now() - this.primaryDownAt) / 1000); + this.primaryDownAt = null; + const center = vec2.fromValues( + event.changedTouches[0].clientX, + event.changedTouches[0].clientY, + ); + this.sendCommandToSubscribers( + new PrimaryActionCommand(this.game.displayToWorldCoordinates(center), charge), + ); + } else if (event.touches.length === 0) { + this.isJoystickActive = false; + this.joystick.parentElement?.removeChild(this.joystick); + this.sendMove(vec2.create()); + } + }; + + private swallowTouch = (event: TouchEvent) => { + event.preventDefault(); + event.stopPropagation(); + }; + + private leapButtonListener = (event: TouchEvent) => { + this.swallowTouch(event); + const clientTimeMs = localCharacterPredictor.recordLeap(); + this.sendCommandToSubscribers(new LeapActionCommand(clientTimeMs)); + }; + + private fireButtonDownListener = (event: TouchEvent) => { + this.swallowTouch(event); + this.fireDownAt = performance.now(); + const rect = this.fireButton.getBoundingClientRect(); + this.fireButtonCenter = vec2.fromValues( + rect.left + rect.width / 2, + rect.top + rect.height / 2, + ); + this.fireAimScreen = null; + ChargeIndicator.begin(this.fireButtonCenter[0], this.fireButtonCenter[1]); + }; + + // Dragging from the fire button aims the shot: the drag vector sets the + // direction, decoupling aim from movement so a touch player can fire one way + // while walking another. A tap with no meaningful drag fires straight ahead. + private fireButtonMoveListener = (event: TouchEvent) => { + this.swallowTouch(event); + if (this.fireDownAt === null || !this.fireButtonCenter) { + return; + } + const touch = event.targetTouches[0] ?? event.changedTouches[0]; + if (!touch) { + return; + } + this.fireAimScreen = vec2.fromValues(touch.clientX, touch.clientY); + const dx = this.fireAimScreen[0] - this.fireButtonCenter[0]; + const dy = this.fireAimScreen[1] - this.fireButtonCenter[1]; + if (dx * dx + dy * dy > TouchListener.aimDeadZone * TouchListener.aimDeadZone) { + this.fireAimLine.style.opacity = '1'; + this.fireAimLine.style.transform = `translateY(-50%) rotate(${Math.atan2(dy, dx)}rad)`; + } else { + this.fireAimLine.style.opacity = '0'; + } + }; + + private fireButtonUpListener = (event: TouchEvent) => { + this.swallowTouch(event); + ChargeIndicator.end(); + this.fireAimLine.style.opacity = '0'; + if (this.fireDownAt === null) { + return; + } + + const charge = holdDurationToCharge((performance.now() - this.fireDownAt) / 1000); + this.fireDownAt = null; + + const character = this.game.gameObjects.player; + if (!character) { + this.fireButtonCenter = null; + this.fireAimScreen = null; + return; + } + + // Screen drag → world aim direction (flip Y: screen +y is down). Below the + // dead-zone it's a tap, so fall back to firing along the facing direction. + let direction = character.facingDirection; + if (this.fireButtonCenter && this.fireAimScreen) { + const dx = this.fireAimScreen[0] - this.fireButtonCenter[0]; + const dy = this.fireAimScreen[1] - this.fireButtonCenter[1]; + if (dx * dx + dy * dy > TouchListener.aimDeadZone * TouchListener.aimDeadZone) { + direction = vec2.normalize(vec2.create(), vec2.fromValues(dx, -dy)); + } + } + this.fireButtonCenter = null; + this.fireAimScreen = null; + + const aim = vec2.scaleAndAdd( + vec2.create(), + character.bodyCenter, + direction, + settings.touchAimRange, + ); + this.sendCommandToSubscribers(new PrimaryActionCommand(aim, charge)); + }; + + public update(_deltaTimeInSeconds: number) { + if (!this.fireButton.parentElement) { + this.overlay.appendChild(this.fireButton); + } + if (!this.leapButton.parentElement) { + this.overlay.appendChild(this.leapButton); + } + + const character = this.game.gameObjects.player; + if (character) { + this.fireStrengthRing.style.background = `conic-gradient(rgba(255, 255, 255, 0.75) ${ + character.strengthFraction * 360 + }deg, transparent 0deg)`; + } + } + + public destroy() { + ChargeIndicator.end(); + this.target.removeEventListener('touchstart', this.touchStartListener); + this.target.removeEventListener('touchmove', this.touchMoveListener); + this.target.removeEventListener('touchend', this.touchEndListener); + + this.fireButton.removeEventListener('touchstart', this.fireButtonDownListener); + this.fireButton.removeEventListener('touchmove', this.fireButtonMoveListener); + this.fireButton.removeEventListener('touchend', this.fireButtonUpListener); + this.leapButton.removeEventListener('touchstart', this.leapButtonListener); + + this.fireButton.parentElement?.removeChild(this.fireButton); + this.leapButton.parentElement?.removeChild(this.leapButton); + } +} diff --git a/frontend/src/scripts/commands/types/before-destroy.ts b/frontend/src/scripts/commands/types/before-destroy.ts new file mode 100644 index 0000000..3020623 --- /dev/null +++ b/frontend/src/scripts/commands/types/before-destroy.ts @@ -0,0 +1,7 @@ +import { Command } from 'shared'; + +export class BeforeDestroyCommand extends Command { + public constructor() { + super(); + } +} diff --git a/frontend/src/scripts/commands/types/render.ts b/frontend/src/scripts/commands/types/render.ts new file mode 100644 index 0000000..c83f5a1 --- /dev/null +++ b/frontend/src/scripts/commands/types/render.ts @@ -0,0 +1,12 @@ +import { Renderer } from 'sdf-2d'; +import { Command } from 'shared'; + +export class RenderCommand extends Command { + public constructor( + public readonly renderer: Renderer, + public readonly overlay: HTMLElement, + public readonly shouldChangeLayout: boolean, + ) { + super(); + } +} diff --git a/frontend/src/scripts/commands/types/step.ts b/frontend/src/scripts/commands/types/step.ts new file mode 100644 index 0000000..ff0d3cc --- /dev/null +++ b/frontend/src/scripts/commands/types/step.ts @@ -0,0 +1,7 @@ +import { Command } from 'shared'; + +export class StepCommand extends Command { + public constructor(public readonly deltaTimeInSeconds: number) { + super(); + } +} diff --git a/frontend/src/scripts/configuration.ts b/frontend/src/scripts/configuration.ts new file mode 100644 index 0000000..9ab3fb6 --- /dev/null +++ b/frontend/src/scripts/configuration.ts @@ -0,0 +1,35 @@ +/** + * Hardcoded list of game servers the landing page offers to players. + * + * Each entry is the public origin of a dockerized `doppler-server` instance. + * The join screen polls `/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 = ['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 = isDevelopment + ? [`http://${location.hostname}:3000`, ...productionServers] + : productionServers; + +export abstract class Configuration { + public static async initialize(): Promise { + // Kept async for call-site compatibility; the server list is static now. + } + + public static get servers(): Array { + return servers; + } +} diff --git a/frontend/src/scripts/drawing/commands/before-render.ts b/frontend/src/scripts/drawing/commands/before-render.ts deleted file mode 100644 index 982f6ad..0000000 --- a/frontend/src/scripts/drawing/commands/before-render.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Command } from '../../commands/command'; -import { IRenderer } from '../i-renderer'; - -export class BeforeRenderCommand extends Command { - public constructor(public readonly renderer?: IRenderer) { - super(); - } - - public get type(): string { - return 'BeforeRenderCommand'; - } -} diff --git a/frontend/src/scripts/drawing/commands/render.ts b/frontend/src/scripts/drawing/commands/render.ts deleted file mode 100644 index 719c2d4..0000000 --- a/frontend/src/scripts/drawing/commands/render.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { IRenderer } from '../../drawing/i-renderer'; -import { Command } from '../../commands/command'; - -export class RenderCommand extends Command { - public constructor(public readonly renderer?: IRenderer) { - super(); - } - - public get type(): string { - return 'RenderCommand'; - } -} diff --git a/frontend/src/scripts/drawing/drawables/i-drawable-descriptor.ts b/frontend/src/scripts/drawing/drawables/i-drawable-descriptor.ts deleted file mode 100644 index bd85e4e..0000000 --- a/frontend/src/scripts/drawing/drawables/i-drawable-descriptor.ts +++ /dev/null @@ -1,5 +0,0 @@ -export interface IDrawableDescriptor { - uniformName: string; - countMacroName: string; - shaderCombinationSteps: Array; -} diff --git a/frontend/src/scripts/drawing/drawables/i-drawable.ts b/frontend/src/scripts/drawing/drawables/i-drawable.ts deleted file mode 100644 index 70d2618..0000000 --- a/frontend/src/scripts/drawing/drawables/i-drawable.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { vec2 } from 'gl-matrix'; -import { ImmutableBoundingBox } from '../../physics/containers/immutable-bounding-box'; -import { GameObject } from '../../objects/game-object'; - -export interface IDrawable { - serializeToUniforms(uniforms: any): void; - distance(target: vec2): number; - minimumDistance(target: vec2): number; - readonly owner: GameObject; - readonly boundingBox: ImmutableBoundingBox; -} diff --git a/frontend/src/scripts/drawing/drawables/lights/circle-light.ts b/frontend/src/scripts/drawing/drawables/lights/circle-light.ts deleted file mode 100644 index 2f90ed8..0000000 --- a/frontend/src/scripts/drawing/drawables/lights/circle-light.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { vec2, vec3 } from 'gl-matrix'; -import { GameObject } from '../../../objects/game-object'; -import { ImmutableBoundingBox } from '../../../physics/containers/immutable-bounding-box'; -import { settings } from '../../settings'; -import { IDrawableDescriptor } from '../i-drawable-descriptor'; -import { ILight } from './i-light'; - -export class CircleLight implements ILight { - public static descriptor: IDrawableDescriptor = { - uniformName: 'circleLights', - countMacroName: 'circleLightCount', - shaderCombinationSteps: settings.shaderCombinations.circleLightSteps, - }; - - constructor( - public readonly owner: GameObject, - public center: vec2, - public radius: number, - public color: vec3, - public lightness: number - ) { } - - boundingBox: ImmutableBoundingBox; - - public distance(target: vec2): number { - return 0; - } - - public minimumDistance(target: vec2): number { - return 0; - } - - public serializeToUniforms(uniforms: any): void { - const listName = CircleLight.descriptor.uniformName; - - if (!uniforms.hasOwnProperty(listName)) { - uniforms[listName] = []; - } - - uniforms[listName].push({ - center: this.center, - radius: this.radius, - value: this.value, - }); - } - - public get value(): vec3 { - return vec3.scale( - vec3.create(), - vec3.normalize(this.color, this.color), - this.lightness - ); - } -} diff --git a/frontend/src/scripts/drawing/drawables/lights/i-light.ts b/frontend/src/scripts/drawing/drawables/lights/i-light.ts deleted file mode 100644 index 0ed88cd..0000000 --- a/frontend/src/scripts/drawing/drawables/lights/i-light.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { IDrawable } from '../i-drawable'; - -export interface ILight extends IDrawable {} diff --git a/frontend/src/scripts/drawing/drawables/lights/point-light.ts b/frontend/src/scripts/drawing/drawables/lights/point-light.ts deleted file mode 100644 index 0832db9..0000000 --- a/frontend/src/scripts/drawing/drawables/lights/point-light.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { ILight } from './i-light'; -import { vec2, vec3 } from 'gl-matrix'; -import { IDrawableDescriptor } from '../i-drawable-descriptor'; -import { settings } from '../../settings'; -import { ImmutableBoundingBox } from '../../../physics/containers/immutable-bounding-box'; -import { GameObject } from '../../../objects/game-object'; - -export class PointLight implements ILight { - public static descriptor: IDrawableDescriptor = { - uniformName: 'pointLights', - countMacroName: 'pointLightCount', - shaderCombinationSteps: settings.shaderCombinations.pointLightSteps, - }; - - public constructor( - public readonly owner: GameObject, - public center: vec2, - public radius: number, - public color: vec3, - public lightness: number - ) {} - - boundingBox: ImmutableBoundingBox; - - public distance(target: vec2): number { - return vec2.distance(this.center, target) - this.radius; - } - - public minimumDistance(target: vec2): number { - return vec2.distance(this.center, target) - this.radius; - } - - public serializeToUniforms(uniforms: any): void { - const listName = PointLight.descriptor.uniformName; - - if (!uniforms.hasOwnProperty(listName)) { - uniforms[listName] = []; - } - - uniforms[listName].push({ - center: this.center, - radius: this.radius, - value: this.value, - }); - } - - public get value(): vec3 { - return vec3.scale(vec3.create(), this.color, this.lightness); - } -} diff --git a/frontend/src/scripts/drawing/drawables/primitives/circle.ts b/frontend/src/scripts/drawing/drawables/primitives/circle.ts deleted file mode 100644 index d55977a..0000000 --- a/frontend/src/scripts/drawing/drawables/primitives/circle.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { vec2 } from 'gl-matrix'; -import { GameObject } from '../../../objects/game-object'; -import { ImmutableBoundingBox } from '../../../physics/containers/immutable-bounding-box'; -import { IPrimitive } from './i-primitive'; - -export class Circle implements IPrimitive { - public constructor( - public readonly owner: GameObject, - public center = vec2.create(), - public radius = 0 - ) { } - - public serializeToUniforms(uniforms: any): void { - throw new Error('Method not implemented.'); - } - - public distance(target: vec2): number { - return vec2.distance(this.center, target) - this.radius; - } - - public minimumDistance(target: vec2): number { - return vec2.distance(this.center, target) - this.radius; - } - - public get boundingBox(): ImmutableBoundingBox { - return new ImmutableBoundingBox( - this, - this.center.x - this.radius, - this.center.x + this.radius, - this.center.y - this.radius, - this.center.y + this.radius - ); - } - - public isInside(target: vec2): boolean { - return this.distance(target) < 0; - } - - public areIntersecting(other: Circle): boolean { - const distance = vec2.distance(this.center, other.center); - return distance < this.radius + other.radius; - } - - public clone(): Circle { - return new Circle(this.owner, this.center, this.radius); - } -} diff --git a/frontend/src/scripts/drawing/drawables/primitives/i-primitive.ts b/frontend/src/scripts/drawing/drawables/primitives/i-primitive.ts deleted file mode 100644 index 2675475..0000000 --- a/frontend/src/scripts/drawing/drawables/primitives/i-primitive.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { IDrawable } from '../i-drawable'; - -export interface IPrimitive extends IDrawable { - clone(): IPrimitive -} diff --git a/frontend/src/scripts/drawing/drawables/primitives/tunnel-shape.ts b/frontend/src/scripts/drawing/drawables/primitives/tunnel-shape.ts deleted file mode 100644 index f8197d3..0000000 --- a/frontend/src/scripts/drawing/drawables/primitives/tunnel-shape.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { vec2 } from 'gl-matrix'; -import { clamp01 } from '../../../helper/clamp'; -import { mix } from '../../../helper/mix'; -import { GameObject } from '../../../objects/game-object'; -import { ImmutableBoundingBox } from '../../../physics/containers/immutable-bounding-box'; -import { settings } from '../../settings'; -import { IDrawableDescriptor } from '../i-drawable-descriptor'; -import { Circle } from './circle'; -import { IPrimitive } from './i-primitive'; - -export class TunnelShape implements IPrimitive { - public static descriptor: IDrawableDescriptor = { - uniformName: 'lines', - countMacroName: 'lineCount', - shaderCombinationSteps: settings.shaderCombinations.lineSteps, - }; - - public readonly toFromDelta: vec2; - - private boundingCircle: Circle; - - constructor( - public readonly owner: GameObject, - public readonly from: vec2, - public readonly to: vec2, - public readonly fromRadius: number, - public readonly toRadius: number - ) { - this.toFromDelta = vec2.subtract(vec2.create(), to, from); - - this.boundingCircle = new Circle( - this.owner, - vec2.fromValues(from.x / 2 + to.x / 2, from.y / 2 + to.y / 2), - Math.max(fromRadius, toRadius) + vec2.distance(from, to) - ); - } - - public serializeToUniforms(uniforms: any): void { - if (!uniforms.hasOwnProperty(TunnelShape.descriptor.uniformName)) { - uniforms[TunnelShape.descriptor.uniformName] = []; - } - - uniforms[TunnelShape.descriptor.uniformName].push({ - from: this.from, - toFromDelta: this.toFromDelta, - fromRadius: this.fromRadius, - toRadius: this.toRadius, - }); - } - - public get boundingBox(): ImmutableBoundingBox { - const xMin = Math.min( - this.from.x - this.fromRadius, - this.to.x - this.toRadius - ); - const yMin = Math.min( - this.from.y - this.fromRadius, - this.to.y - this.toRadius - ); - const xMax = Math.max( - this.from.x + this.fromRadius, - this.to.x + this.toRadius - ); - const yMax = Math.max( - this.from.y + this.fromRadius, - this.to.y + this.toRadius - ); - - return new ImmutableBoundingBox(this, xMin, xMax, yMin, yMax); - } - - public distance(target: vec2): number { - const targetFromDelta = vec2.subtract(vec2.create(), target, this.from); - const h = clamp01( - vec2.dot(targetFromDelta, this.toFromDelta) / - vec2.dot(this.toFromDelta, this.toFromDelta) - ); - return ( - vec2.distance( - targetFromDelta, - vec2.scale(vec2.create(), this.toFromDelta, h) - ) - mix(this.fromRadius, this.toRadius, h) - ); - } - - public minimumDistance(target: vec2): number { - return this.boundingCircle.distance(target); - } - - public clone(): TunnelShape { - return new TunnelShape( - this.owner, - this.from, - this.to, - this.fromRadius, - this.toRadius - ); - } -} diff --git a/frontend/src/scripts/drawing/graphics-library/frame-buffer/default-frame-buffer.ts b/frontend/src/scripts/drawing/graphics-library/frame-buffer/default-frame-buffer.ts deleted file mode 100644 index 07290dd..0000000 --- a/frontend/src/scripts/drawing/graphics-library/frame-buffer/default-frame-buffer.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { FrameBuffer } from './frame-buffer'; - -export class DefaultFrameBuffer extends FrameBuffer { - constructor(gl: WebGL2RenderingContext) { - super(gl); - this.frameBuffer = null; - - this.setSize(); - } - - public setSize() { - super.setSize(); - - if ( - this.gl.canvas.width !== this.size.x || - this.gl.canvas.height !== this.size.y - ) { - this.gl.canvas.width = this.size.x; - this.gl.canvas.height = this.size.y; - } - } -} diff --git a/frontend/src/scripts/drawing/graphics-library/frame-buffer/frame-buffer.ts b/frontend/src/scripts/drawing/graphics-library/frame-buffer/frame-buffer.ts deleted file mode 100644 index aeee6e1..0000000 --- a/frontend/src/scripts/drawing/graphics-library/frame-buffer/frame-buffer.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { vec2 } from 'gl-matrix'; - -export abstract class FrameBuffer { - public renderScale = 1; - public enableHighDpiRendering = false; - - protected size: vec2; - protected frameBuffer: WebGLFramebuffer; - - constructor(protected gl: WebGL2RenderingContext) {} - - public bindAndClear(colorInput?: WebGLTexture) { - this.gl.bindFramebuffer(this.gl.FRAMEBUFFER, this.frameBuffer); - - if (colorInput !== null) { - this.gl.bindTexture(this.gl.TEXTURE_2D, colorInput); - } - - this.gl.viewport(0, 0, this.size.x, this.size.y); - this.gl.clearColor(0, 0, 0, 0); - this.gl.clear(this.gl.COLOR_BUFFER_BIT | this.gl.DEPTH_BUFFER_BIT); - } - - public setSize() { - const realToCssPixels = window.devicePixelRatio * this.renderScale; - const canvasWidth = (this.gl.canvas as HTMLCanvasElement).clientWidth; - const canvasHeight = (this.gl.canvas as HTMLCanvasElement).clientHeight; - - const displayWidth = Math.floor(canvasWidth * realToCssPixels); - const displayHeight = Math.floor(canvasHeight * realToCssPixels); - - this.size = vec2.fromValues(displayWidth, displayHeight); - } - - public getSize(): vec2 { - return this.size; - } -} diff --git a/frontend/src/scripts/drawing/graphics-library/frame-buffer/intermediate-frame-buffer.ts b/frontend/src/scripts/drawing/graphics-library/frame-buffer/intermediate-frame-buffer.ts deleted file mode 100644 index f9c2518..0000000 --- a/frontend/src/scripts/drawing/graphics-library/frame-buffer/intermediate-frame-buffer.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { FrameBuffer } from './frame-buffer'; - -export class IntermediateFrameBuffer extends FrameBuffer { - private frameTexture: WebGLTexture; - - constructor(gl: WebGL2RenderingContext) { - super(gl); - - this.frameTexture = this.gl.createTexture(); - this.configureTexture(); - - this.frameBuffer = this.gl.createFramebuffer(); - this.configureFrameBuffer(); - - this.setSize(); - } - - public get colorTexture(): WebGLTexture { - return this.frameTexture; - } - - public setSize() { - super.setSize(); - - this.gl.bindTexture(this.gl.TEXTURE_2D, this.frameTexture); - - this.gl.texImage2D( - this.gl.TEXTURE_2D, - 0, - this.gl.RGBA, - this.size.x, - this.size.y, - 0, - this.gl.RGBA, - this.gl.UNSIGNED_BYTE, - null - ); - } - - private configureTexture() { - this.gl.bindTexture(this.gl.TEXTURE_2D, this.frameTexture); - this.gl.texParameteri( - this.gl.TEXTURE_2D, - this.gl.TEXTURE_MAG_FILTER, - this.gl.LINEAR - ); - this.gl.texParameteri( - this.gl.TEXTURE_2D, - this.gl.TEXTURE_MIN_FILTER, - this.gl.LINEAR - ); - this.gl.texParameteri( - this.gl.TEXTURE_2D, - this.gl.TEXTURE_WRAP_S, - this.gl.CLAMP_TO_EDGE - ); - this.gl.texParameteri( - this.gl.TEXTURE_2D, - this.gl.TEXTURE_WRAP_T, - this.gl.CLAMP_TO_EDGE - ); - } - - private configureFrameBuffer() { - this.gl.bindFramebuffer(this.gl.FRAMEBUFFER, this.frameBuffer); - const attachmentPoint = this.gl.COLOR_ATTACHMENT0; - this.gl.framebufferTexture2D( - this.gl.FRAMEBUFFER, - attachmentPoint, - this.gl.TEXTURE_2D, - this.frameTexture, - 0 - ); - } -} diff --git a/frontend/src/scripts/drawing/graphics-library/helper/create-shader.ts b/frontend/src/scripts/drawing/graphics-library/helper/create-shader.ts deleted file mode 100644 index 0c6901f..0000000 --- a/frontend/src/scripts/drawing/graphics-library/helper/create-shader.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { settings } from '../../settings'; - -export const createShader = ( - gl: WebGL2RenderingContext, - type: GLenum, - source: string, - substitutions: { [name: string]: string } -): WebGLShader => { - const allSubstitutions = { ...settings.shaderMacros, ...substitutions }; - - source = source.replace(/{(.+)}/gm, (_, name: string): string => { - const value = allSubstitutions[name]; - return Number.isInteger(value) ? `${value}.0` : value; - }); - - const shader = gl.createShader(type); - gl.shaderSource(shader, source); - gl.compileShader(shader); - const success = gl.getShaderParameter(shader, gl.COMPILE_STATUS); - - if (!success) { - throw new Error(gl.getShaderInfoLog(shader)); - } - - return shader; -}; diff --git a/frontend/src/scripts/drawing/graphics-library/helper/get-webgl2-context.ts b/frontend/src/scripts/drawing/graphics-library/helper/get-webgl2-context.ts deleted file mode 100644 index 426004c..0000000 --- a/frontend/src/scripts/drawing/graphics-library/helper/get-webgl2-context.ts +++ /dev/null @@ -1,11 +0,0 @@ -export const getWebGl2Context = ( - canvas: HTMLCanvasElement -): WebGL2RenderingContext => { - const gl = canvas.getContext('webgl2'); - - if (!gl) { - throw new Error('WebGl2 is not supported'); - } - - return gl; -}; diff --git a/frontend/src/scripts/drawing/graphics-library/helper/load-uniform.ts b/frontend/src/scripts/drawing/graphics-library/helper/load-uniform.ts deleted file mode 100644 index 9a2a16d..0000000 --- a/frontend/src/scripts/drawing/graphics-library/helper/load-uniform.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { mat3, ReadonlyVec3, vec2, vec3 } from 'gl-matrix'; - -const loaderMat3 = mat3.create(); - -export const loadUniform = ( - gl: WebGL2RenderingContext, - value: any, - type: GLenum, - location: WebGLUniformLocation -): any => { - const converters: Map< - GLenum, - ( - gl: WebGL2RenderingContext, - value: any, - location: WebGLUniformLocation - ) => void - > = new Map(); - { - converters.set(WebGL2RenderingContext.FLOAT, (gl, v, l) => { - if (v instanceof Array) { - if (v.length == 0) { - return; - } - - gl.uniform1fv(l, new Float32Array(v)); - } else { - gl.uniform1f(l, v); - } - }); - - converters.set( - WebGL2RenderingContext.FLOAT_VEC2, - (gl, v: vec2 | Array, l) => { - if (v.length == 0) { - return; - } - - if (v[0] instanceof Array) { - const result = new Float32Array(v.length * 2); - - for (let i = 0; i < v.length; i++) { - result[2 * i] = (v[i] as Array).x; - result[2 * i + 1] = (v[i] as Array).y; - } - - gl.uniform2fv(l, result); - } else { - gl.uniform2fv(l, v as vec2); - } - } - ); - - converters.set( - WebGL2RenderingContext.FLOAT_VEC3, - (gl, v: ReadonlyVec3 | Array, l) => { - if (v.length == 0) { - return; - } - - if (v[0] instanceof Array) { - const result = new Float32Array(v.length * 3); - - for (let i = 0; i < v.length; i++) { - result[3 * i] = (v[i] as Array)[0]; - result[3 * i + 1] = (v[i] as Array)[1]; - result[3 * i + 2] = (v[i] as Array)[2]; - } - - gl.uniform3fv(l, result); - } else { - gl.uniform3fv(l, v as vec3); - } - } - ); - - converters.set(WebGL2RenderingContext.FLOAT_MAT3, (gl, v, l) => { - gl.uniformMatrix3fv(l, true, mat3.fromMat2d(loaderMat3, v)); - }); - - converters.set(WebGL2RenderingContext.BOOL, (gl, v, l) => - gl.uniform1i(l, v) - ); - - if (!converters.has(type)) { - throw new Error(`Unimplemented webgl type: ${type}`); - } - - converters.get(type)(gl, value, location); - } -}; diff --git a/frontend/src/scripts/drawing/graphics-library/helper/stopwatch.ts b/frontend/src/scripts/drawing/graphics-library/helper/stopwatch.ts deleted file mode 100644 index 7eb3d10..0000000 --- a/frontend/src/scripts/drawing/graphics-library/helper/stopwatch.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { InfoText } from '../../../objects/types/info-text'; - -// https://www.khronos.org/registry/webgl/extensions/EXT_disjoint_timer_query_webgl2/ - -export class WebGlStopwatch { - private timerExtension: any; - private timerQuery: WebGLQuery; - private isReady = true; - - private resultsInNanoSeconds: number; - - constructor(private gl: WebGL2RenderingContext) { - if ( - this.gl - .getSupportedExtensions() - .indexOf('EXT_disjoint_timer_query_webgl2') == -1 - ) { - throw new Error('Unsupported extension'); - } - - this.timerExtension = this.gl.getExtension( - 'EXT_disjoint_timer_query_webgl2' - ); - } - - public start() { - if (this.isReady) { - this.timerQuery = this.gl.createQuery(); - this.gl.beginQuery(this.timerExtension.TIME_ELAPSED_EXT, this.timerQuery); - } - } - - public stop() { - if (this.isReady) { - this.gl.endQuery(this.timerExtension.TIME_ELAPSED_EXT); - this.isReady = false; - } - - const available = this.gl.getQueryParameter( - this.timerQuery, - this.gl.QUERY_RESULT_AVAILABLE - ); - const disjoint = this.gl.getParameter(this.timerExtension.GPU_DISJOINT_EXT); - - if (available && !disjoint) { - this.resultsInNanoSeconds = this.gl.getQueryParameter( - this.timerQuery, - this.gl.QUERY_RESULT - ); - - InfoText.modifyRecord( - 'Draw time', - `${this.resultsInMilliSeconds.toFixed(2)} ms` - ); - - this.isReady = true; - } - } - - public get resultsInMilliSeconds(): number { - return this.resultsInNanoSeconds / 1000 / 1000; - } -} diff --git a/frontend/src/scripts/drawing/graphics-library/program/fragment-shader-only-program.ts b/frontend/src/scripts/drawing/graphics-library/program/fragment-shader-only-program.ts deleted file mode 100644 index 568c760..0000000 --- a/frontend/src/scripts/drawing/graphics-library/program/fragment-shader-only-program.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { Program } from './program'; - -export class FragmentShaderOnlyProgram extends Program { - private vao: WebGLVertexArrayObject; - - constructor( - gl: WebGL2RenderingContext, - shaderSources: [string, string], - substitutions: { [name: string]: string } - ) { - super(gl, shaderSources, substitutions); - this.prepareScreenQuad('vertexPosition'); - } - - public bind() { - super.bind(); - this.gl.bindVertexArray(this.vao); - } - - public draw() { - this.gl.drawArrays(this.gl.TRIANGLE_STRIP, 0, 4); - } - - private prepareScreenQuad(attributeName: string) { - const positionAttributeLocation = this.gl.getAttribLocation( - this.program, - attributeName - ); - - const positionBuffer = this.gl.createBuffer(); - - this.gl.bindBuffer(this.gl.ARRAY_BUFFER, positionBuffer); - this.gl.bufferData( - this.gl.ARRAY_BUFFER, - new Float32Array([-1.0, -1.0, -1.0, 1.0, 1.0, -1.0, 1.0, 1.0]), - this.gl.STATIC_DRAW - ); - this.vao = this.gl.createVertexArray(); - - this.gl.bindVertexArray(this.vao); - this.gl.enableVertexAttribArray(positionAttributeLocation); - this.gl.vertexAttribPointer( - positionAttributeLocation, - 2, - this.gl.FLOAT, - false, - 0, - 0 - ); - } -} diff --git a/frontend/src/scripts/drawing/graphics-library/program/i-program.ts b/frontend/src/scripts/drawing/graphics-library/program/i-program.ts deleted file mode 100644 index 086026a..0000000 --- a/frontend/src/scripts/drawing/graphics-library/program/i-program.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { vec2 } from 'gl-matrix'; - -export interface IProgram { - setDrawingRectangle(bottomLeft: vec2, size: vec2): void; - bindAndSetUniforms(values: { [name: string]: any }): void; - draw(): void; - delete(): void; -} diff --git a/frontend/src/scripts/drawing/graphics-library/program/program.ts b/frontend/src/scripts/drawing/graphics-library/program/program.ts deleted file mode 100644 index fd91937..0000000 --- a/frontend/src/scripts/drawing/graphics-library/program/program.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { mat2d, vec2 } from 'gl-matrix'; -import { createShader } from '../helper/create-shader'; -import { loadUniform } from '../helper/load-uniform'; -import { IProgram } from './i-program'; - -export abstract class Program implements IProgram { - protected program: WebGLProgram; - private shaders: Array = []; - private modelTransform = mat2d.identity(mat2d.create()); - private readonly ndcToUv: mat2d; - - private uniforms: Array<{ - name: Array; - location: WebGLUniformLocation; - type: GLenum; - }> = []; - - constructor( - protected gl: WebGL2RenderingContext, - [vertexShaderSource, fragmentShaderSource]: [string, string], - substitutions: { [name: string]: string } - ) { - this.createProgram(vertexShaderSource, fragmentShaderSource, substitutions); - this.queryUniforms(); - - this.ndcToUv = mat2d.fromScaling(mat2d.create(), vec2.fromValues(0.5, 0.5)); - mat2d.translate(this.ndcToUv, this.ndcToUv, vec2.fromValues(1, 1)); - } - - public bindAndSetUniforms(values: { [name: string]: any }) { - this.bind(); - this.setUniforms({ modelTransform: this.modelTransform, ...values }); - } - - public setDrawingRectangle(bottomLeft: vec2, size: vec2) { - mat2d.invert(this.modelTransform, this.ndcToUv); - mat2d.translate(this.modelTransform, this.modelTransform, bottomLeft); - mat2d.scale(this.modelTransform, this.modelTransform, size); - mat2d.multiply(this.modelTransform, this.modelTransform, this.ndcToUv); - } - - public setUniforms(values: { [name: string]: any }) { - this.uniforms.forEach(({ name, location, type }) => { - const value = name.reduce( - (prev, prop) => (prev !== null && prop in prev ? prev[prop] : null), - values - ); - - if (value !== null) { - loadUniform(this.gl, value, type, location); - } - }); - } - - public delete() { - this.shaders.forEach((s) => this.gl.deleteShader(s)); - this.gl.deleteProgram(this.program); - } - - public abstract draw(): void; - - protected bind() { - this.gl.useProgram(this.program); - } - - private queryUniforms() { - const uniformCount = this.gl.getProgramParameter( - this.program, - WebGL2RenderingContext.ACTIVE_UNIFORMS - ); - - for (let i = 0; i < uniformCount; i++) { - const glUniform = this.gl.getActiveUniform(this.program, i); - - this.uniforms.push({ - name: glUniform.name.split(/\[|\]\.|\]|\./).filter((p) => p !== ''), - location: this.gl.getUniformLocation(this.program, glUniform.name), - type: glUniform.type, - }); - } - - this.uniforms.map((u1) => { - const isSingle = - this.uniforms.filter((u2) => u2.name.includes(u1.name[0])).length == 1; - if (u1.name.includes('0') && isSingle) { - u1.name = u1.name.slice(0, -1); - } - return u1; - }); - } - - private createProgram( - passthroughVertexShaderSource: string, - fragmentShaderSource: string, - substitutions: { [name: string]: string } - ) { - this.program = this.gl.createProgram(); - - const vertexShader = createShader( - this.gl, - this.gl.VERTEX_SHADER, - passthroughVertexShaderSource, - substitutions - ); - this.gl.attachShader(this.program, vertexShader); - this.shaders.push(vertexShader); - - const fragmentShader = createShader( - this.gl, - this.gl.FRAGMENT_SHADER, - fragmentShaderSource, - substitutions - ); - this.gl.attachShader(this.program, fragmentShader); - this.shaders.push(fragmentShader); - - this.gl.linkProgram(this.program); - - const success = this.gl.getProgramParameter( - this.program, - this.gl.LINK_STATUS - ); - - if (!success) { - throw new Error(this.gl.getProgramInfoLog(this.program)); - } - } -} diff --git a/frontend/src/scripts/drawing/graphics-library/program/uniform-array-autoscaling-program.ts b/frontend/src/scripts/drawing/graphics-library/program/uniform-array-autoscaling-program.ts deleted file mode 100644 index 9b7a8dc..0000000 --- a/frontend/src/scripts/drawing/graphics-library/program/uniform-array-autoscaling-program.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { vec2 } from 'gl-matrix'; -import { FragmentShaderOnlyProgram } from './fragment-shader-only-program'; -import { IProgram } from './i-program'; -import { last } from '../../../helper/last'; -import { getCombinations } from '../../../helper/get-combinations'; -import { IDrawableDescriptor } from '../../drawables/i-drawable-descriptor'; - -export class UniformArrayAutoScalingProgram implements IProgram { - private programs: Array<{ - program: FragmentShaderOnlyProgram; - values: Array; - }> = []; - private current: FragmentShaderOnlyProgram; - - private drawingRectangleTopLeft = vec2.fromValues(0, 0); - private drawingRectangleSize = vec2.fromValues(1, 1); - - constructor( - private gl: WebGL2RenderingContext, - shaderSources: [string, string], - private options: Array - ) { - const names = options.map((o) => o.countMacroName); - for (let combination of getCombinations( - options.map((o) => o.shaderCombinationSteps) - )) { - this.createProgram(names, combination, shaderSources); - } - } - - public bindAndSetUniforms(uniforms: { [name: string]: any }): void { - let values = this.options.map((o) => - uniforms[o.uniformName] ? uniforms[o.uniformName].length : 0 - ); - - const closest = this.programs.find((p) => - p.values.every((v, i) => v > values[i]) - ); - - this.current = closest ? closest.program : last(this.programs).program; - - this.current.setDrawingRectangle( - this.drawingRectangleTopLeft, - this.drawingRectangleSize - ); - this.current.bindAndSetUniforms(uniforms); - } - - public setDrawingRectangle(topLeft: vec2, size: vec2) { - this.drawingRectangleTopLeft = topLeft; - this.drawingRectangleSize = size; - } - - public draw(): void { - this.current.draw(); - } - - public delete(): void { - this.programs.forEach((p) => p.program.delete()); - } - - private createProgram( - names: Array, - combination: Array, - shaderSources: [string, string] - ): FragmentShaderOnlyProgram { - const substitutions = {}; - names.forEach((v, i) => (substitutions[v] = combination[i].toString())); - - const program = new FragmentShaderOnlyProgram( - this.gl, - shaderSources, - substitutions - ); - - this.programs.push({ - program, - values: combination, - }); - - return program; - } -} diff --git a/frontend/src/scripts/drawing/i-renderer.ts b/frontend/src/scripts/drawing/i-renderer.ts deleted file mode 100644 index 76bc606..0000000 --- a/frontend/src/scripts/drawing/i-renderer.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { vec2 } from 'gl-matrix'; -import { ILight } from './drawables/lights/i-light'; -import { IPrimitive } from './drawables/primitives/i-primitive'; - -export interface IRenderer { - startFrame(deltaTime: DOMHighResTimeStamp): void; - finishFrame(): void; - - drawPrimitive(primitive: IPrimitive): void; - drawLight(light: ILight): void; - drawInfoText(text: string): void; - - setCameraPosition(position: vec2): void; - setCursorPosition(position: vec2): void; - setInViewArea(size: number): vec2; -} diff --git a/frontend/src/scripts/drawing/rendering/fps-autoscaler.ts b/frontend/src/scripts/drawing/rendering/fps-autoscaler.ts deleted file mode 100644 index 72fee3f..0000000 --- a/frontend/src/scripts/drawing/rendering/fps-autoscaler.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { Autoscaler } from '../../helper/autoscaler'; -import { settings } from '../settings'; -import { exponentialDecay } from '../../helper/exponential-decay'; -import { InfoText } from '../../objects/types/info-text'; -import { FrameBuffer } from '../graphics-library/frame-buffer/frame-buffer'; -import { toPercent } from '../../helper/to-percent'; - -export class FpsAutoscaler extends Autoscaler { - private timeSinceLastAdjusment = 0; - private exponentialDecayedDeltaTime = 0.0; - - constructor(private frameBuffers: Array) { - super( - frameBuffers.map((f) => (v) => (f.renderScale = v)), - settings.qualityScaling.scaleTargets, - settings.qualityScaling.startingTargetIndex, - settings.qualityScaling.scalingOptions - ); - } - - public autoscale(lastDeltaTime: DOMHighResTimeStamp) { - this.timeSinceLastAdjusment += lastDeltaTime; - if ( - this.timeSinceLastAdjusment >= - settings.qualityScaling.adjusmentRateInMilliseconds - ) { - this.timeSinceLastAdjusment = 0; - this.exponentialDecayedDeltaTime = exponentialDecay( - this.exponentialDecayedDeltaTime, - lastDeltaTime, - settings.qualityScaling.deltaTimeResponsiveness - ); - - if ( - this.exponentialDecayedDeltaTime <= - settings.qualityScaling.targetDeltaTimeInMilliseconds - - settings.qualityScaling.deltaTimeError - ) { - this.increase(); - } else if ( - this.exponentialDecayedDeltaTime > - settings.qualityScaling.targetDeltaTimeInMilliseconds + - settings.qualityScaling.deltaTimeError - ) { - this.decrease(); - } - } - - InfoText.modifyRecord( - 'quality', - this.frameBuffers.map((f) => toPercent(f.renderScale)).join(', ') - ); - } -} diff --git a/frontend/src/scripts/drawing/rendering/rendering-pass.ts b/frontend/src/scripts/drawing/rendering/rendering-pass.ts deleted file mode 100644 index 0d647d9..0000000 --- a/frontend/src/scripts/drawing/rendering/rendering-pass.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { vec2 } from 'gl-matrix'; -import { InfoText } from '../../objects/types/info-text'; -import { IDrawable } from '../drawables/i-drawable'; -import { IDrawableDescriptor } from '../drawables/i-drawable-descriptor'; -import { FrameBuffer } from '../graphics-library/frame-buffer/frame-buffer'; -import { UniformArrayAutoScalingProgram } from '../graphics-library/program/uniform-array-autoscaling-program'; -import { settings } from '../settings'; - -export class RenderingPass { - private drawables: Array = []; - private program: UniformArrayAutoScalingProgram; - - constructor( - gl: WebGL2RenderingContext, - shaderSources: [string, string], - private drawableDescriptors: Array, - private frame: FrameBuffer - ) { - this.program = new UniformArrayAutoScalingProgram( - gl, - shaderSources, - drawableDescriptors - ); - } - - public addDrawable(drawable: IDrawable) { - this.drawables.push(drawable); - } - - public render( - commonUniforms: any, - viewBoxCenter: vec2, - viewBoxRadius: number, - inputTexture?: WebGLTexture - ) { - this.frame.bindAndClear(inputTexture); - const q = 1 / settings.tileMultiplier; - const tileUvSize = vec2.fromValues(q, q); - - const possiblyOnScreenDrawables = this.drawables.filter( - (p) => p.minimumDistance(viewBoxCenter) < viewBoxRadius - ); - - const origin = vec2.transformMat2d( - vec2.create(), - vec2.fromValues(0, 0), - commonUniforms.uvToWorld - ); - - const firstCenter = vec2.transformMat2d( - vec2.create(), - vec2.fromValues(q, q), - commonUniforms.uvToWorld - ); - - vec2.subtract(firstCenter, firstCenter, origin); - - const worldR = vec2.length(firstCenter); - - let sumLineCount = 0; - - for (let x = 0; x < 1; x += q) { - for (let y = 0; y < 1; y += q) { - const uniforms = { ...commonUniforms }; - uniforms.maxMinDistance = 2 * worldR; - - const uvBottomLeft = vec2.fromValues(x, y); - this.program.setDrawingRectangle(uvBottomLeft, tileUvSize); - - const tileCenterWorldCoordinates = vec2.transformMat2d( - vec2.create(), - vec2.add(vec2.create(), uvBottomLeft, vec2.fromValues(q, q)), - uniforms.uvToWorld - ); - - const primitivesNearTile = possiblyOnScreenDrawables.filter( - (p) => p.distance(tileCenterWorldCoordinates) < 2 * worldR - ); - - sumLineCount += primitivesNearTile.length; - - primitivesNearTile.forEach((p) => p.serializeToUniforms(uniforms)); - - this.program.bindAndSetUniforms(uniforms); - this.program.draw(); - } - } - - this.drawables = []; - - InfoText.modifyRecord( - 'nearby ' + this.drawableDescriptors[0].countMacroName, - possiblyOnScreenDrawables.length.toFixed(2) - ); - - InfoText.modifyRecord( - 'drawn ' + this.drawableDescriptors[0].countMacroName, - ( - sumLineCount / - settings.tileMultiplier / - settings.tileMultiplier - ).toFixed(2) - ); - } -} diff --git a/frontend/src/scripts/drawing/rendering/webgl2-renderer.ts b/frontend/src/scripts/drawing/rendering/webgl2-renderer.ts deleted file mode 100644 index 1b5ed7b..0000000 --- a/frontend/src/scripts/drawing/rendering/webgl2-renderer.ts +++ /dev/null @@ -1,195 +0,0 @@ -import { mat2d, vec2 } from 'gl-matrix'; -import { CircleLight } from '../drawables/lights/circle-light'; -import { ILight } from '../drawables/lights/i-light'; -import { PointLight } from '../drawables/lights/point-light'; -import { IPrimitive } from '../drawables/primitives/i-primitive'; -import { TunnelShape } from '../drawables/primitives/tunnel-shape'; -// import lightsShader from '../shaders/rainbow-shading-fs.glsl'; -import { DefaultFrameBuffer } from '../graphics-library/frame-buffer/default-frame-buffer'; -import { IntermediateFrameBuffer } from '../graphics-library/frame-buffer/intermediate-frame-buffer'; -import { getWebGl2Context } from '../graphics-library/helper/get-webgl2-context'; -import { WebGlStopwatch } from '../graphics-library/helper/stopwatch'; -import { IRenderer } from '../i-renderer'; -import caveFragmentShader from '../shaders/cave-distance-fs.glsl'; -import lightsFragmentShader from '../shaders/lights-shading-fs.glsl'; -import caveVertexShader from '../shaders/passthrough-distance-vs.glsl'; -import lightsVertexShader from '../shaders/passthrough-shading-vs.glsl'; -import { FpsAutoscaler } from './fps-autoscaler'; -import { RenderingPass } from './rendering-pass'; - -export class WebGl2Renderer implements IRenderer { - private gl: WebGL2RenderingContext; - private stopwatch?: WebGlStopwatch; - - private viewBoxBottomLeft = vec2.create(); - private cameraPosition = vec2.create(); - private viewBoxSize = vec2.create(); - private cursorPosition = vec2.create(); - - private distanceFieldFrameBuffer: IntermediateFrameBuffer; - private lightingFrameBuffer: DefaultFrameBuffer; - private distancePass: RenderingPass; - private lightingPass: RenderingPass; - - private autoscaler: FpsAutoscaler; - - private matrices: { - distanceScreenToWorld?: mat2d; - worldToDistanceUV?: mat2d; - cursorPosition?: mat2d; - ndcToUv?: mat2d; - uvToWorld?: mat2d; - } = { ndcToUv: mat2d.fromValues(0.5, 0, 0, 0.5, 0.5, 0.5) }; - - constructor(private canvas: HTMLCanvasElement, private overlay: HTMLElement) { - this.gl = getWebGl2Context(canvas); - - this.distanceFieldFrameBuffer = new IntermediateFrameBuffer(this.gl); - this.lightingFrameBuffer = new DefaultFrameBuffer(this.gl); - - this.distancePass = new RenderingPass( - this.gl, - [caveVertexShader, caveFragmentShader], - [TunnelShape.descriptor], - this.distanceFieldFrameBuffer - ); - - this.lightingPass = new RenderingPass( - this.gl, - [lightsVertexShader, lightsFragmentShader], - [CircleLight.descriptor, PointLight.descriptor], - this.lightingFrameBuffer - ); - - this.autoscaler = new FpsAutoscaler([ - this.lightingFrameBuffer, - this.distanceFieldFrameBuffer, - ]); - - try { - this.stopwatch = new WebGlStopwatch(this.gl); - } catch { } - } - - public drawPrimitive(primitive: IPrimitive) { - this.distancePass.addDrawable(primitive); - } - - public drawLight(light: ILight) { - this.lightingPass.addDrawable(light); - } - - public startFrame(deltaTime: DOMHighResTimeStamp): void { - this.autoscaler.autoscale(deltaTime); - - this.stopwatch?.start(); - this.distanceFieldFrameBuffer.setSize(); - this.lightingFrameBuffer.setSize(); - } - - public finishFrame() { - this.calculateMatrices(); - - const viewBoxRadius = vec2.length( - vec2.scale(vec2.create(), this.viewBoxSize, 0.5) - ); - - this.distancePass.render( - this.uniforms, - this.cameraPosition, - viewBoxRadius - ); - - this.lightingPass.render( - this.uniforms, this.cameraPosition, - viewBoxRadius, - this.distanceFieldFrameBuffer.colorTexture - ); - - this.stopwatch?.stop(); - } - - private get uniforms(): any { - const cursorPosition = this.screenUvToWorldCoordinate(this.cursorPosition); - return { ...this.matrices, cursorPosition, viewBoxSize: this.viewBoxSize } - } - - private calculateMatrices() { - this.matrices.uvToWorld = mat2d.fromTranslation( - mat2d.create(), - this.viewBoxBottomLeft - ); - mat2d.scale( - this.matrices.uvToWorld, - this.matrices.uvToWorld, - this.viewBoxSize - ); - - this.matrices.distanceScreenToWorld = this.getScreenToWorldTransform( - this.distanceFieldFrameBuffer.getSize() - ); - - this.matrices.worldToDistanceUV = mat2d.scale( - mat2d.create(), - this.matrices.distanceScreenToWorld, - this.distanceFieldFrameBuffer.getSize() - ); - mat2d.invert( - this.matrices.worldToDistanceUV, - this.matrices.worldToDistanceUV - ); - } - - private getScreenToWorldTransform(screenSize: vec2) { - const transform = mat2d.fromTranslation( - mat2d.create(), - this.viewBoxBottomLeft - ); - mat2d.scale( - transform, - transform, - vec2.divide(vec2.create(), this.viewBoxSize, screenSize) - ); - mat2d.translate(transform, transform, vec2.fromValues(0.5, 0.5)); - - return transform; - } - - public screenUvToWorldCoordinate(screenUvPosition: vec2): vec2 { - const resolution = vec2.fromValues(this.canvas.width, this.canvas.height); - - return vec2.transformMat2d( - vec2.create(), - vec2.multiply(vec2.create(), screenUvPosition, resolution), - this.getScreenToWorldTransform(resolution) - ); - } - - public setCameraPosition(position: vec2) { - this.cameraPosition = position; - this.viewBoxBottomLeft = vec2.fromValues( - this.cameraPosition.x - this.viewBoxSize.x / 2, - this.cameraPosition.y - this.viewBoxSize.y / 2 - ); - } - - public setCursorPosition(position: vec2): void { - this.cursorPosition = position; - } - - public setInViewArea(size: number): vec2 { - const canvasAspectRatio = - this.canvas.clientWidth / this.canvas.clientHeight; - - return (this.viewBoxSize = vec2.fromValues( - Math.sqrt(size * canvasAspectRatio), - Math.sqrt(size / canvasAspectRatio) - )); - } - - public drawInfoText(text: string) { - if (this.overlay.innerText != text) { - this.overlay.innerText = text; - } - } -} diff --git a/frontend/src/scripts/drawing/settings.ts b/frontend/src/scripts/drawing/settings.ts deleted file mode 100644 index 6d8d0d3..0000000 --- a/frontend/src/scripts/drawing/settings.ts +++ /dev/null @@ -1,34 +0,0 @@ -export const settings = { - qualityScaling: { - targetDeltaTimeInMilliseconds: 30, - deltaTimeError: 2, - deltaTimeResponsiveness: 1 / 16, - adjusmentRateInMilliseconds: 300, - scaleTargets: [ - [0.2, 0.1], - [0.6, 0.1], - [1, 0.3], - [1.25, 0.75], - [1.5, 1], - [1.75, 1.25], - [1.75, 1.75], - ], - startingTargetIndex: 2, - scalingOptions: { - additiveIncrease: 0.2, - multiplicativeDecrease: 1.15, - }, - }, - tileMultiplier: 5, - shaderMacros: { - distanceScale: 64, - distanceOffset: 0.15, - //edgeSmoothing: 0, - edgeSmoothing: 10, - }, - shaderCombinations: { - lineSteps: [0, 1, 2, 3, 4, 8, 16, 32], - circleLightSteps: [0, 1, 2, 3], - pointLightSteps: [0, 1, 2, 3], - }, -}; diff --git a/frontend/src/scripts/drawing/shaders/cave-distance-fs.glsl b/frontend/src/scripts/drawing/shaders/cave-distance-fs.glsl deleted file mode 100644 index 64c8179..0000000 --- a/frontend/src/scripts/drawing/shaders/cave-distance-fs.glsl +++ /dev/null @@ -1,47 +0,0 @@ -#version 300 es - -precision mediump float; - -#define LINE_COUNT {lineCount} -#define CAVE_COLOR vec3(0.36, 0.38, 0.76) -#define AIR_COLOR vec3(0.7) -#define DISTANCE_SCALE {distanceScale} -#define DISTANCE_OFFSET {distanceOffset} - -uniform float maxMinDistance; - -#if LINE_COUNT > 0 - uniform struct Line { - vec2 from; - vec2 toFromDelta; - float fromRadius; - float toRadius; - }[LINE_COUNT] lines; -#endif - -in vec2 worldCoordinates; -out vec4 fragmentColor; - -void main() { - float realDistance = 0.0; - - #if LINE_COUNT > 0 - float minDistance = maxMinDistance; - - for (int i = 0; i < LINE_COUNT; i++) { - vec2 pa = worldCoordinates - lines[i].from; - vec2 ba = lines[i].toFromDelta; - float h = clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0); - float lineDistance = distance(pa, ba * h) - mix(lines[i].fromRadius, lines[i].toRadius, h); - - minDistance = min(minDistance, lineDistance); - } - - realDistance = -minDistance; - #endif - - fragmentColor = vec4( - mix(CAVE_COLOR, AIR_COLOR, clamp(realDistance, 0.0, 1.0)), - (realDistance + DISTANCE_OFFSET) / DISTANCE_SCALE - 1.0/255.0 - ); -} diff --git a/frontend/src/scripts/drawing/shaders/lights-shading-fs.glsl b/frontend/src/scripts/drawing/shaders/lights-shading-fs.glsl deleted file mode 100644 index 9e0fb2e..0000000 --- a/frontend/src/scripts/drawing/shaders/lights-shading-fs.glsl +++ /dev/null @@ -1,124 +0,0 @@ -#version 300 es - -precision mediump float; - -#define INFINITY 1000.0 -#define LIGHT_DROP 500.0 -#define MIN_STEP 1.0 -#define AMBIENT_LIGHT vec3(0.15) - -#define CIRCLE_LIGHT_COUNT {circleLightCount} -#define POINT_LIGHT_COUNT {pointLightCount} -#define DISTANCE_SCALE {distanceScale} -#define DISTANCE_OFFSET {distanceOffset} -#define EDGE_SMOOTHING {edgeSmoothing} - -uniform sampler2D distanceTexture; -uniform vec2 viewBoxSize; - -float getDistance(in vec2 target, out vec3 color) { - vec4 values = texture(distanceTexture, target); - color = values.rgb; - return (values.w - DISTANCE_OFFSET) * DISTANCE_SCALE; -} - -float getDistance(in vec2 target) { - return (texture(distanceTexture, target).w - DISTANCE_OFFSET) * DISTANCE_SCALE; -} - -#if CIRCLE_LIGHT_COUNT > 0 - uniform struct CircleLight { - vec2 center; - float radius; - vec3 value; - }[CIRCLE_LIGHT_COUNT] circleLights; - - in vec2[CIRCLE_LIGHT_COUNT] circleLightDirections; -#endif - -#if POINT_LIGHT_COUNT > 0 - uniform struct PointLight { - vec2 center; - float radius; - vec3 value; - }[POINT_LIGHT_COUNT] pointLights; - - in vec2[POINT_LIGHT_COUNT] pointLightDirections; -#endif - -in vec2 worldCoordinates; -in vec2 uvCoordinates; -out vec4 fragmentColor; - -void main() { - vec3 colorAtPosition; - float startingDistance = getDistance(uvCoordinates, colorAtPosition); - vec3 lighting = AMBIENT_LIGHT; - - #if CIRCLE_LIGHT_COUNT > 0 - for (int i = 0; i < CIRCLE_LIGHT_COUNT; i++) { - float lightCenterDistance = distance(circleLights[i].center, worldCoordinates); - - /*if (lightCenterDistance < circleLights[i].radius) { - lighting = vec3(1.0, 1.0, 0.0); - }*/ - - vec3 lightColorAtPosition = circleLights[i].value / pow( - lightCenterDistance / LIGHT_DROP + 1.0, 2.0 - ); - - float q = INFINITY; - float rayLength = startingDistance; - float exponentialDecayDistance = rayLength; - vec2 direction = normalize(circleLightDirections[i]) / viewBoxSize; - - for (int j = 0; j < 48; j++) { - if (rayLength > lightCenterDistance) { - lighting += lightColorAtPosition * clamp( - q / circleLights[i].radius * (lightCenterDistance + 1.0), 0.0, 1.0 - ) * step(circleLights[i].radius, getDistance( - uvCoordinates + direction * lightCenterDistance - )); - break; - } - - float minDistance = getDistance(uvCoordinates + direction * rayLength); - exponentialDecayDistance = (exponentialDecayDistance + minDistance) / 2.0; - q = min(q, exponentialDecayDistance / rayLength); - rayLength += max(MIN_STEP, minDistance); - } - } - #endif - - #if POINT_LIGHT_COUNT > 0 - for (int i = 0; i < POINT_LIGHT_COUNT; i++) { - float lightDistance = distance(pointLights[i].center, worldCoordinates); - - vec3 lightColorAtPosition = mix( - pointLights[i].value, - vec3(0.0), - sqrt(clamp(lightDistance / pointLights[i].radius, 0.0, 1.0)) - ); - - - float q = INFINITY; - float rayLength = startingDistance; - float exponentialDecayDistance = startingDistance; - vec2 direction = normalize(pointLightDirections[i]) / viewBoxSize; - - for (int j = 0; j < 48; j++) { - if (rayLength > lightDistance) { - lighting += lightColorAtPosition * step(0.0, q); - break; - } - - float minDistance = getDistance(uvCoordinates + direction * rayLength); - exponentialDecayDistance = (exponentialDecayDistance + minDistance) / 2.0; - q = min(q, exponentialDecayDistance); - rayLength += max(MIN_STEP, minDistance); - } - } - #endif - - fragmentColor = vec4(colorAtPosition * lighting * clamp(startingDistance, 0.0, 1.0), 1.0); -} diff --git a/frontend/src/scripts/drawing/shaders/passthrough-distance-vs.glsl b/frontend/src/scripts/drawing/shaders/passthrough-distance-vs.glsl deleted file mode 100644 index 5248949..0000000 --- a/frontend/src/scripts/drawing/shaders/passthrough-distance-vs.glsl +++ /dev/null @@ -1,16 +0,0 @@ -#version 300 es - -precision mediump float; - -uniform mat3 modelTransform; -uniform mat3 uvToWorld; -uniform mat3 ndcToUv; - -in vec4 vertexPosition; -out vec2 worldCoordinates; - -void main() { - vec3 vertexPosition2D = vec3(vertexPosition.xy, 1.0) * modelTransform; - worldCoordinates = (vertexPosition2D * ndcToUv * uvToWorld).xy; - gl_Position = vec4(vertexPosition2D.xy, 0.0, 1.0); -} diff --git a/frontend/src/scripts/drawing/shaders/passthrough-shading-vs.glsl b/frontend/src/scripts/drawing/shaders/passthrough-shading-vs.glsl deleted file mode 100644 index dcb4baa..0000000 --- a/frontend/src/scripts/drawing/shaders/passthrough-shading-vs.glsl +++ /dev/null @@ -1,57 +0,0 @@ -#version 300 es - -precision mediump float; - -#define CIRCLE_LIGHT_COUNT {circleLightCount} -#define POINT_LIGHT_COUNT {pointLightCount} - -uniform mat3 modelTransform; -uniform mat3 cameraTransform; -uniform mat3 ndcToUv; -uniform mat3 uvToWorld; - -in vec4 vertexPosition; - -out vec2 worldCoordinates; -out vec2 uvCoordinates; - -#if CIRCLE_LIGHT_COUNT > 0 - uniform struct CircleLight { - vec2 center; - float radius; - vec3 value; - }[CIRCLE_LIGHT_COUNT] circleLights; - - out vec2[CIRCLE_LIGHT_COUNT] circleLightDirections; -#endif - -#if POINT_LIGHT_COUNT > 0 - uniform struct PointLight { - vec2 center; - float radius; - vec3 value; - }[POINT_LIGHT_COUNT] pointLights; - - out vec2[POINT_LIGHT_COUNT] pointLightDirections; -#endif - -void main() { - vec3 vertexPosition2D = vec3(vertexPosition.xy, 1.0) * modelTransform; - vec3 uvCoordinates1 = vertexPosition2D * ndcToUv; - worldCoordinates = (uvCoordinates1 * uvToWorld).xy; - uvCoordinates = (uvCoordinates1).xy; - - #if CIRCLE_LIGHT_COUNT > 0 - for (int i = 0; i < CIRCLE_LIGHT_COUNT; i++) { - circleLightDirections[i] = circleLights[i].center - worldCoordinates; - } - #endif - - #if POINT_LIGHT_COUNT > 0 - for (int i = 0; i < POINT_LIGHT_COUNT; i++) { - pointLightDirections[i] = pointLights[i].center - worldCoordinates; - } - #endif - - gl_Position = vec4(vertexPosition2D.xy, 0.0, 1.0); -} diff --git a/frontend/src/scripts/drawing/shaders/rainbow-shading-fs.glsl b/frontend/src/scripts/drawing/shaders/rainbow-shading-fs.glsl deleted file mode 100644 index 8ef59b2..0000000 --- a/frontend/src/scripts/drawing/shaders/rainbow-shading-fs.glsl +++ /dev/null @@ -1,49 +0,0 @@ -#version 300 es - -precision mediump float; - -vec3 rainbow(float level) { - float r = float(level <= 2.0) + float(level > 4.0) * 0.5; - float g = max(1.0 - abs(level - 2.0) * 0.5, 0.0); - float b = (1.0 - (level - 4.0) * 0.5) * float(level >= 4.0); - return vec3(r, g, b); -} - -vec4 smoothRainbow(float x) { - float level1 = floor(x*6.0); - float level2 = min(6.0, floor(x*6.0) + 1.0); - - vec3 a = rainbow(level1); - vec3 b = rainbow(level2); - - return vec4(mix(a, b, fract(x*6.0)), 1.0); -} - - -uniform sampler2D distanceTexture; -uniform mat3 worldToDistanceUV; -uniform mat3 lightingScreenToWorld; -out vec4 fragmentColor; -uniform vec2 cursorPosition; - - -float getDistance(in vec2 targetUV, out vec3 color) { - vec4 values = texture(distanceTexture, targetUV); - color = values.rgb; - return values.a; -} - -in vec2 worldCoordinates; - -void main() { - vec2 targetUV = (vec3(worldCoordinates.xy, 1.0) * worldToDistanceUV).xy; - - vec4 previous = texture(distanceTexture, targetUV); - - //fragmentColor = smoothRainbow(previous.a); - fragmentColor = previous.a > 0.5 ? vec4(1.0, 1.0, 1.0, 1.0) : vec4(0.0, 0.0, 0.0, 1.0); - - if (distance(worldCoordinates, cursorPosition) < 10.0) { - fragmentColor = vec4(1.0, 1.0, 0.0, 1.0); - } -} diff --git a/frontend/src/scripts/drawing/shaders/random.frag b/frontend/src/scripts/drawing/shaders/random.frag deleted file mode 100644 index 56ce0b4..0000000 --- a/frontend/src/scripts/drawing/shaders/random.frag +++ /dev/null @@ -1,48 +0,0 @@ -#version 300 es - -precision mediump float; - -const float inf = 1000000.0; -const float pi = atan(1.0) * 4.0; - -float interpolate(float from, float to, float quotient) { - float steppedQ = sin(quotient * pi - pi * 0.5) * 0.5 + 0.5; - return from + (to - from) * clamp(steppedQ, 0.0, 1.0); -} - -float noise(float x){ - return fract(sin(x) * 43758.5453123); -} - -float terrain(float x) { - float result = 0.0; - - float frequency = 0.01; - float amplitude = 1.0; - - for (int i = 0; i < 8; i++) { - result += sin(2.0 * pi * x * frequency - 2.0 * pi * noise(float(i) * 200.0)) * amplitude; - frequency *= 1.5; - amplitude /= 1.2; - } - - return result; -} - -vec2 random2(vec2 st){ - st = vec2( dot(st,vec2(127.1,311.7)), - dot(st,vec2(269.5,183.3)) ); - return -1.0 + 2.0*fract(sin(st)*43758.5453123); -} - -float noise(vec2 st) { - vec2 i = floor(st); - vec2 f = fract(st); - - vec2 u = f*f*(3.0-2.0*f); - - return mix( mix( dot( random2(i + vec2(0.0,0.0) ), f - vec2(0.0,0.0) ), - dot( random2(i + vec2(1.0,0.0) ), f - vec2(1.0,0.0) ), u.x), - mix( dot( random2(i + vec2(0.0,1.0) ), f - vec2(0.0,1.0) ), - dot( random2(i + vec2(1.0,1.0) ), f - vec2(1.0,1.0) ), u.x), u.y); -} \ No newline at end of file diff --git a/frontend/src/scripts/feedback-hud.ts b/frontend/src/scripts/feedback-hud.ts new file mode 100644 index 0000000..adbf068 --- /dev/null +++ b/frontend/src/scripts/feedback-hud.ts @@ -0,0 +1,120 @@ +import { settings } from 'shared'; +import { Pointer } from './helper/pointer'; + +export abstract class FeedbackHud { + private static root?: HTMLElement; + private static killfeed?: HTMLElement; + private static elimination?: HTMLElement; + + private static ensureRoot(): { root: HTMLElement; killfeed: HTMLElement } { + if (!this.root || !this.killfeed) { + this.root = document.createElement('div'); + this.root.className = 'feedback-hud'; + + this.killfeed = document.createElement('div'); + this.killfeed.className = 'killfeed'; + this.root.appendChild(this.killfeed); + + document.body.appendChild(this.root); + } + return { root: this.root, killfeed: this.killfeed }; + } + + private static focusPoint(): { x: number; y: number } { + const cursor = Pointer.getDisplayPosition(); + if (cursor) { + return { x: cursor.x, y: cursor.y }; + } + return { x: window.innerWidth / 2, y: window.innerHeight / 2 }; + } + + private static addTransient(element: HTMLElement, lifetimeMs: number) { + const { root } = this.ensureRoot(); + root.appendChild(element); + setTimeout(() => element.parentElement?.removeChild(element), lifetimeMs); + } + + public static hitMarker(charge = 0) { + const { x, y } = this.focusPoint(); + const marker = document.createElement('div'); + marker.className = + 'hitmarker' + (charge >= settings.chargedHitThreshold ? ' charged' : ''); + marker.style.left = `${x}px`; + marker.style.top = `${y}px`; + this.addTransient(marker, 250); + } + + public static killConfirmed(victimName?: string, streak = 1, charge = 0) { + const { killfeed } = this.ensureRoot(); + const charged = charge >= settings.chargedHitThreshold; + + // A quick crimson vignette pulse around the whole frame to punctuate the kill. + const flash = document.createElement('div'); + flash.className = 'kill-flash' + (charged ? ' charged' : ''); + this.addTransient(flash, 420); + + const entry = document.createElement('div'); + entry.className = 'kill-entry'; + entry.innerHTML = `Eliminated ${this.escape(victimName ?? 'enemy')}`; + killfeed.insertBefore(entry, killfeed.firstChild); + setTimeout(() => entry.parentElement?.removeChild(entry), 4500); + + const { x, y } = this.focusPoint(); + const popup = document.createElement('div'); + popup.className = 'kill-popup' + (charged ? ' charged' : ''); + popup.innerHTML = `+${settings.playerKillPoint} +${settings.playerKillHealthReward}❤`; + popup.style.left = `${x}px`; + popup.style.top = `${y}px`; + this.addTransient(popup, 1200); + + const callout = this.streakName(streak) ?? (charged ? 'Charged Kill!' : undefined); + if (callout) { + const el = document.createElement('div'); + el.className = 'streak-callout'; + el.innerText = callout; + this.addTransient(el, 1400); + } + } + + // Persistent centred overlay shown while the local player is dead and waiting + // to respawn. The countdown itself is the server-driven "Reviving in N…" + // announcement; this makes the death state unmistakable and stays up until + // hideElimination() is called on respawn. + public static showElimination(): void { + if (this.elimination) { + return; + } + const { root } = this.ensureRoot(); + const el = document.createElement('div'); + el.className = 'elimination'; + el.innerHTML = + '
Eliminated
' + + '
Respawning…
'; + root.appendChild(el); + this.elimination = el; + } + + public static hideElimination(): void { + this.elimination?.parentElement?.removeChild(this.elimination); + this.elimination = undefined; + } + + private static streakName(streak: number): string | undefined { + switch (streak) { + case 2: + return 'Double Kill!'; + case 3: + return 'Triple Kill!'; + case 4: + return 'Quad Kill!'; + default: + return streak >= 5 ? 'Rampage!' : undefined; + } + } + + private static escape(text: string): string { + const div = document.createElement('div'); + div.innerText = text; + return div.innerHTML; + } +} diff --git a/frontend/src/scripts/game.ts b/frontend/src/scripts/game.ts index 07f55fe..4b8678f 100644 --- a/frontend/src/scripts/game.ts +++ b/frontend/src/scripts/game.ts @@ -1,103 +1,361 @@ -import { CommandBroadcaster } from './commands/command-broadcaster'; -import { BeforeRenderCommand } from './drawing/commands/before-render'; -import { StepCommand } from './physics/commands/step'; -import { WebGl2Renderer } from './drawing/rendering/webgl2-renderer'; -import { timeIt } from './helper/timing'; -import { KeyboardListener } from './input/keyboard-listener'; -import { MouseListener } from './input/mouse-listener'; -import { TouchListener } from './input/touch-listener'; -import { Objects } from './objects/objects'; -import { InfoText } from './objects/types/info-text'; -import { createCharacter } from './objects/world/create-character'; -import { createDungeon } from './objects/world/create-dungeon'; -import { RenderCommand } from './drawing/commands/render'; -import { Physics } from './physics/physics'; -import { MoveToCommand } from './physics/commands/move-to'; -import { TeleportToCommand } from './physics/commands/teleport-to'; -import { IRenderer } from './drawing/i-renderer'; +import { vec2 } from 'gl-matrix'; +import { + CircleLight, + FilteringOptions, + Renderer, + renderNoise, + runAnimation, + WrapOptions, +} from 'sdf-2d'; +import { + deserialize, + TransportEvents, + SetAspectRatioActionCommand, + UpdateMinimap, + UpdateGameState, + GameEndCommand, + ServerAnnouncement, + GameStartCommand, + CommandReceiver, + CommandExecutors, + Command, + settings, + InputAcknowledgement, +} from 'shared'; +import { io, Socket } from 'socket.io-client'; +import { KeyboardListener } from './commands/keyboard-listener'; +import { MouseListener } from './commands/mouse-listener'; +import { TouchListener } from './commands/touch-listener'; +import { CommandSocket } from './commands/command-socket'; +import { PlayerDecision } from './join-form-handler'; +import { GameObjectContainer } from './objects/game-object-container'; +import parser from 'socket.io-msgpack-parser'; +import { CharacterShape } from './shapes/character-shape'; +import { PlanetShape } from './shapes/planet-shape'; +import { RenderCommand } from './commands/types/render'; +import { StepCommand } from './commands/types/step'; +import { serverTimeline } from './helper/server-timeline'; +import { localCharacterPredictor } from './helper/prediction/local-character-predictor'; +import { Tutorial } from './tutorial'; +import { Scoreboard } from './scoreboard'; +import { Minimap } from './minimap'; +import { ScreenShake } from './screen-shake'; -export class Game { - private previousTime?: DOMHighResTimeStamp = null; - private objects = new Objects(); - private physics = new Physics(); +export class Game extends CommandReceiver { + public gameObjects = new GameObjectContainer(this); + public renderer?: Renderer; + private socket!: Socket; + private isBetweenGames = false; - private renderer: IRenderer; - private previousFpsValues: Array = []; + public started: Promise; + private resolveStarted!: () => unknown; - constructor() { - const canvas: HTMLCanvasElement = document.querySelector('canvas#main'); - const overlay: HTMLElement = document.querySelector('#overlay'); + private keyboardListener: KeyboardListener; + private mouseListener: MouseListener; + private touchListener: TouchListener; - document.addEventListener( - 'visibilitychange', - this.handleVisibilityChange.bind(this) - ); + private scoreboard = new Scoreboard(); + private minimap = new Minimap(); + private announcementText = document.createElement('h2'); + private keystoneArrow?: HTMLElement; + private socketReceiver!: CommandSocket; + private tutorial!: Tutorial; - new CommandBroadcaster( + constructor( + private readonly playerDecision: PlayerDecision, + private readonly canvas: HTMLCanvasElement, + private readonly overlay: HTMLElement, + ) { + super(); + this.started = new Promise((r) => (this.resolveStarted = r)); + this.announcementText.className = 'announcement'; + + this.keyboardListener = new KeyboardListener(); + this.mouseListener = new MouseListener(this.canvas, this); + this.touchListener = new TouchListener(this.canvas, this.overlay, this); + } + + private initialize() { + this.isBetweenGames = true; + + this.socket?.close(); + serverTimeline.reset(); + localCharacterPredictor.reset(); + // Clear any leftover shake/zoom so a kill at the end of one match can't bleed + // its camera impact into the next. + ScreenShake.reset(); + this.gameObjects = new GameObjectContainer(this); + this.overlay.innerHTML = ''; + this.keystoneArrow = undefined; + this.lastMinimap = undefined; + this.isEnding = false; + this.lastAnnouncementText = ''; + this.overlay.appendChild(this.scoreboard.element); + this.overlay.appendChild(this.minimap.element); + this.announcementText.innerText = ''; + this.timeScaling = 1; + this.overlay.appendChild(this.announcementText); + this.tutorial = new Tutorial(this.overlay); + + this.socket = io(this.playerDecision.server, { + reconnectionDelayMax: 10000, + transports: ['websocket'], + forceNew: true, + parser, + } as any); + + // In socket.io-client v4 reconnection events are emitted by the Manager + // (`socket.io`), not the Socket itself. + this.socket.io.on('reconnect_attempt', () => { + this.socket.io.opts.transports = ['polling', 'websocket']; + }); + + this.socket.on('disconnect', () => { + if (!this.isBetweenGames) { + this.destroy(); + } + }); + + this.socket.on(TransportEvents.Ping, () => { + this.socket.emit(TransportEvents.Pong); + }); + + this.socket.on(TransportEvents.ServerToPlayer, (serializedCommands: string) => { + const commands: Array = deserialize(serializedCommands); + commands.forEach((c) => this.handleCommand(c)); + }); + + this.socketReceiver = new CommandSocket(this.socket); + // The tutorial listens to the same input streams as the socket, so its + // stages clear off the player's own commands without any server involvement. + this.keyboardListener.clearSubscribers(); + this.keyboardListener.subscribe(this.socketReceiver); + this.keyboardListener.subscribe(this.tutorial); + this.mouseListener.clearSubscribers(); + this.mouseListener.subscribe(this.socketReceiver); + this.mouseListener.subscribe(this.tutorial); + this.touchListener.clearSubscribers(); + this.touchListener.subscribe(this.socketReceiver); + this.touchListener.subscribe(this.tutorial); + + this.isBetweenGames = false; + + this.socket.emit(TransportEvents.PlayerJoining, this.playerDecision); + } + + protected defaultCommandExecutor(c: Command) { + this.gameObjects.handleCommand(c); + } + + private lastGameState?: UpdateGameState; + private isEnding = false; + private timeScaling = 1; + + private lastAnnouncementText = ''; + protected commandExecutors: CommandExecutors = { + [ServerAnnouncement.type]: (c: ServerAnnouncement) => { + this.lastAnnouncementText = c.text; + this.timeSinceLastAnnouncement = 0; + }, + [UpdateGameState.type]: (c: UpdateGameState) => (this.lastGameState = c), + [InputAcknowledgement.type]: (c: InputAcknowledgement) => + localCharacterPredictor.acknowledge( + c.clientTimeMs, + c.bodyVelocity, + c.lastLeapClientTimeMs, + ), + [GameEndCommand.type]: () => (this.isEnding = true), + [UpdateMinimap.type]: (c: UpdateMinimap) => (this.lastMinimap = c), + [GameStartCommand.type]: this.initialize.bind(this), + }; + + private lastMinimap?: UpdateMinimap; + + public async start(): Promise { + const noiseTexture = await renderNoise([256, 256], 2, 1); + + this.initialize(); + + await runAnimation( + this.canvas, [ - new KeyboardListener(document.body), - new MouseListener(canvas), - new TouchListener(canvas), + PlanetShape.descriptor, + CharacterShape.descriptor, + { + ...CircleLight.descriptor, + shaderCombinationSteps: [0, 1, 2, 4, 8, 16], + }, ], - [this.objects] + this.gameLoop.bind(this), + { + shadowTraceCount: 16, + paletteSize: settings.paletteDim.length, + colorPalette: settings.paletteDim, + enableHighDpiRendering: true, + lightCutoffDistance: settings.lightCutoffDistance, + lightOverlapReduction: settings.lightOverlapReduction, + textures: { + noiseTexture: { + source: noiseTexture, + overrides: { + maxFilter: FilteringOptions.LINEAR, + wrapS: WrapOptions.MIRRORED_REPEAT, + wrapT: WrapOptions.MIRRORED_REPEAT, + }, + }, + }, + }, + ); + this.socket.close(); + this.overlay.innerHTML = ''; + this.keyboardListener.destroy(); + this.mouseListener.destroy(); + this.touchListener.destroy(); + } + + public displayToWorldCoordinates(p: vec2): vec2 { + return this.renderer?.displayToWorldCoordinates(p) ?? vec2.create(); + } + + public aspectRatioChanged(aspectRatio: number) { + this.socketReceiver.handleCommand(new SetAspectRatioActionCommand(aspectRatio)); + } + + private isActive = true; + public destroy() { + this.isActive = false; + } + + private timeSinceLastAnnouncement = 0; + private framesSinceLastLayoutUpdate = 0; + private gameLoop( + renderer: Renderer, + _: DOMHighResTimeStamp, + deltaTime: DOMHighResTimeStamp, + ): boolean { + this.resolveStarted(); + deltaTime /= 1000; + + // Decay the camera impact effects on raw wall-clock time, before any of the + // end-game slow-motion scaling below. These only adjust the rendered view, + // never the simulation, so they stay decoupled from prediction and netcode. + ScreenShake.step(deltaTime); + + // Stepped before the end-game time scaling on purpose: the slow motion is + // already baked into the snapshots the server sends, so the playback + // cursor itself must keep running on wall-clock time. + serverTimeline.step(deltaTime); + + let shouldChangeLayout = false; + if (++this.framesSinceLastLayoutUpdate > 1) { + shouldChangeLayout = true; + this.framesSinceLastLayoutUpdate = 0; + this.draw(); + } + + if ( + (this.timeSinceLastAnnouncement += deltaTime) > settings.announcementVisibleSeconds + ) { + this.lastAnnouncementText = ''; + } + + if (this.isEnding) { + this.timeScaling *= Math.pow(settings.endGameDeltaScaling, deltaTime); + deltaTime /= this.timeScaling; + } + + this.renderer = renderer; + + this.gameObjects.handleCommand(new StepCommand(deltaTime)); + this.gameObjects.handleCommand( + new RenderCommand(this.renderer, this.overlay, shouldChangeLayout), ); - this.renderer = new WebGl2Renderer(canvas, overlay); + this.touchListener.update(deltaTime); - this.initializeScene(); - this.physics.start(); + this.tutorial.step(this.gameObjects); - requestAnimationFrame(this.gameLoop.bind(this)); + this.socketReceiver.sendQueuedCommands(); + + return this.isActive; } - private initializeScene() { - this.objects.addObject(new InfoText()); - const start = createDungeon(this.objects, this.physics); - createDungeon(this.objects, this.physics); - const character = createCharacter(this.objects, this.physics); - console.log('start', start.from); - character.sendCommand(new TeleportToCommand(start.from)); - } - - private handleVisibilityChange() { - if (!document.hidden) { - this.previousTime = null; - } - } - - @timeIt() - private gameLoop(time: DOMHighResTimeStamp) { - if (this.previousTime === null) { - this.previousTime = time; + private draw() { + if (this.lastGameState) { + // The local player's team is read off the main character once it exists. + this.scoreboard.update(this.lastGameState, this.gameObjects.player?.team); } - const deltaTime = time - this.previousTime; - this.previousTime = time; - this.calculateFps(deltaTime); + this.minimap.update( + this.gameObjects.localPlayerPosition, + this.lastMinimap?.players ?? [], + ); - this.objects.sendCommand(new StepCommand(deltaTime)); + this.handleKeystoneArrow(); - this.renderer.startFrame(deltaTime); - this.objects.sendCommand(new BeforeRenderCommand(this.renderer)); - this.objects.sendCommand(new RenderCommand(this.renderer)); - this.renderer.finishFrame(); - - window.requestAnimationFrame(this.gameLoop.bind(this)); + this.announcementText.innerHTML = this.lastAnnouncementText; } - private calculateFps(deltaTime: number) { - this.previousFpsValues.push(1000 / deltaTime); - if (this.previousFpsValues.length > 30) { - this.previousFpsValues.sort(); - const text = `Min: ${this.previousFpsValues[0].toFixed( - 2 - )}\n\tMedian: ${this.previousFpsValues[ - Math.floor(this.previousFpsValues.length / 2) - ].toFixed(2)}`; - - InfoText.modifyRecord('FPS', text); - - this.previousFpsValues = []; + // Points an off-screen chevron toward the keystone "Heart" planet, tinted by + // who currently holds it, so the match's focal objective is always findable. + private handleKeystoneArrow() { + if (!this.renderer) { + return; } + const keystone = this.gameObjects.planets.find((p) => p.isKeystone); + if (!keystone) { + if (this.keystoneArrow) { + this.keystoneArrow.style.display = 'none'; + } + return; + } + + if (!this.keystoneArrow) { + this.keystoneArrow = document.createElement('div'); + this.overlay.appendChild(this.keystoneArrow); + } + + const width = this.renderer.canvasSize.x; + const height = this.renderer.canvasSize.y; + const display = this.renderer.worldToDisplayCoordinates(keystone.center); + const margin = 48; + const onScreen = + display.x >= margin && + display.x <= width - margin && + display.y >= margin && + display.y <= height - margin; + + const control = Math.abs(keystone.ownership - 0.5); + const team = + control < settings.planetControlThreshold + ? 'neutral' + : keystone.ownership < 0.5 + ? 'blue' + : 'red'; + this.keystoneArrow.className = 'keystone-arrow ' + team; + + if (onScreen) { + this.keystoneArrow.style.display = 'none'; + return; + } + this.keystoneArrow.style.display = 'block'; + + const center = vec2.fromValues(width / 2, height / 2); + const dir = vec2.fromValues(display.x - center.x, display.y - center.y); + const angle = Math.atan2(dir.y, dir.x); + + const aspectRatio = width / height; + const directionRatio = dir.x / dir.y; + let deltaX: number, deltaY: number; + if (aspectRatio < Math.abs(directionRatio)) { + deltaX = (width / 2 - margin) * Math.sign(dir.x); + deltaY = deltaX / directionRatio; + } else { + deltaY = (height / 2 - margin) * Math.sign(dir.y); + deltaX = deltaY * directionRatio; + } + + const p = vec2.add(center, center, vec2.fromValues(deltaX, deltaY)); + this.keystoneArrow.style.transform = `translateX(${p.x}px) translateY(${p.y}px) translateX(-50%) translateY(-50%) rotate(${angle + Math.PI / 2}rad)`; } } diff --git a/frontend/src/scripts/helper/array.ts b/frontend/src/scripts/helper/array.ts deleted file mode 100644 index 7523b4f..0000000 --- a/frontend/src/scripts/helper/array.ts +++ /dev/null @@ -1,49 +0,0 @@ -declare global { - interface Array { - x: number; - y: number; - } - - interface Float32Array { - x: number; - y: number; - } -} - -export const applyArrayPlugins = () => { - Object.defineProperty(Array.prototype, 'x', { - get: function () { - return this[0]; - }, - set: function (value) { - this[0] = value; - }, - }); - - Object.defineProperty(Array.prototype, 'y', { - get: function () { - return this[1]; - }, - set: function (value) { - this[1] = value; - }, - }); - - Object.defineProperty(Float32Array.prototype, 'x', { - get: function () { - return this[0]; - }, - set: function (value) { - this[0] = value; - }, - }); - - Object.defineProperty(Float32Array.prototype, 'y', { - get: function () { - return this[1]; - }, - set: function (value) { - this[1] = value; - }, - }); -}; diff --git a/frontend/src/scripts/helper/autoscaler.ts b/frontend/src/scripts/helper/autoscaler.ts deleted file mode 100644 index e4d7557..0000000 --- a/frontend/src/scripts/helper/autoscaler.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { mix } from './mix'; -import { clamp } from './clamp'; - -export class Autoscaler { - // can have fractions - private index: number; - - constructor( - private setters: Array<(value: number) => void>, - private targets: Array>, - startingIndex: number, - private scalingOptions: { - additiveIncrease: number; - multiplicativeDecrease: number; - } - ) { - this.index = startingIndex; - this.applyScaling(); - } - - public increase() { - this.index += this.scalingOptions.additiveIncrease; - this.applyScaling(); - } - - public decrease() { - this.index /= this.scalingOptions.multiplicativeDecrease; - this.applyScaling(); - } - - private applyScaling() { - this.index = clamp(this.index, 0, this.targets.length - 1); - - const floor = Math.floor(this.index); - const fract = this.index - floor; - - const previousTarget = this.targets[floor]; - const nextTarget = - floor + 1 == this.targets.length - ? previousTarget - : this.targets[floor + 1]; - - this.setters.forEach((setter, i) => - setter(mix(previousTarget[i], nextTarget[i], fract)) - ); - } -} diff --git a/frontend/src/scripts/helper/exponential-decay.ts b/frontend/src/scripts/helper/exponential-decay.ts deleted file mode 100644 index 7f321f5..0000000 --- a/frontend/src/scripts/helper/exponential-decay.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const exponentialDecay = ( - accumulator: number, - nextValue: number, - biasOfNextValue: number -) => accumulator * (1 - biasOfNextValue) + nextValue * biasOfNextValue; diff --git a/frontend/src/scripts/helper/get-combinations.ts b/frontend/src/scripts/helper/get-combinations.ts deleted file mode 100644 index 4d298ab..0000000 --- a/frontend/src/scripts/helper/get-combinations.ts +++ /dev/null @@ -1,31 +0,0 @@ -export const getCombinations = ( - values: Array> -): Array> => { - if (!values.every((a) => a.length > 0)) { - return []; - } - - const result: Array> = []; - const counters = values.map((_) => 0); - - const increaseCounter = (i: number) => { - if (i >= counters.length) { - return false; - } - - counters[i]++; - - if (counters[i] >= values[i].length) { - counters[i] = 0; - return increaseCounter(i + 1); - } - - return true; - }; - - do { - result.push(values.map((v, i) => v[counters[i]])); - } while (increaseCounter(0)); - - return result; -}; diff --git a/frontend/src/scripts/helper/handle-full-screen.ts b/frontend/src/scripts/helper/handle-full-screen.ts new file mode 100644 index 0000000..c5b414c --- /dev/null +++ b/frontend/src/scripts/helper/handle-full-screen.ts @@ -0,0 +1,52 @@ +import { SoundHandler, Sounds } from '../sound-handler'; + +export const handleFullScreen = ( + minimizeButton: HTMLElement, + maximizeButton: HTMLElement, +) => { + if (!document.fullscreenEnabled) { + minimizeButton.style.visibility = 'hidden'; + maximizeButton.style.visibility = 'hidden'; + return; + } + + const isInFullScreen = (): boolean => document.fullscreenElement !== null; + + const showButtons = () => { + minimizeButton.style.visibility = isInFullScreen() ? 'visible' : 'hidden'; + maximizeButton.style.visibility = isInFullScreen() ? 'hidden' : 'visible'; + }; + + showButtons(); + + let currentWindowHeight = innerHeight; + + const followToggle = () => { + SoundHandler.play(Sounds.click); + showButtons(); + currentWindowHeight = innerHeight; + }; + + const triggerToggle = async () => { + await (isInFullScreen() + ? document.exitFullscreen() + : document.body.requestFullscreen()); + followToggle(); + }; + + addEventListener('keydown', (e) => { + if (e.key === 'F11') { + triggerToggle(); + e.preventDefault(); + } + }); + + addEventListener('resize', () => { + if (isInFullScreen() && currentWindowHeight > innerHeight) { + followToggle(); + } + }); + + maximizeButton.addEventListener('click', triggerToggle); + minimizeButton.addEventListener('click', triggerToggle); +}; diff --git a/frontend/src/scripts/helper/hide.ts b/frontend/src/scripts/helper/hide.ts new file mode 100644 index 0000000..8283913 --- /dev/null +++ b/frontend/src/scripts/helper/hide.ts @@ -0,0 +1,7 @@ +export const hide = (e: HTMLElement, useDisplay = false) => { + if (useDisplay) { + e.style.display = 'none'; + } else { + e.style.visibility = 'hidden'; + } +}; diff --git a/frontend/src/scripts/helper/interpolators/circle-interpolator.ts b/frontend/src/scripts/helper/interpolators/circle-interpolator.ts new file mode 100644 index 0000000..62816e5 --- /dev/null +++ b/frontend/src/scripts/helper/interpolators/circle-interpolator.ts @@ -0,0 +1,22 @@ +import { Circle } from 'shared'; +import { LinearInterpolator } from './linear-interpolator'; +import { Vec2Interpolator } from './vec2-interpolator'; + +export class CircleInterpolator { + private center: Vec2Interpolator; + private radius: LinearInterpolator; + + constructor(currentValue: Circle) { + this.center = new Vec2Interpolator(currentValue.center); + this.radius = new LinearInterpolator(currentValue.radius); + } + + public addFrame(value: Circle, rateOfChange: Circle) { + this.center.addFrame(value.center, rateOfChange.center); + this.radius.addFrame(value.radius, rateOfChange.radius); + } + + public getValue(deltaTime: number): Circle { + return new Circle(this.center.getValue(deltaTime), this.radius.getValue(deltaTime)); + } +} diff --git a/frontend/src/scripts/helper/interpolators/linear-interpolator.ts b/frontend/src/scripts/helper/interpolators/linear-interpolator.ts new file mode 100644 index 0000000..bcc23b5 --- /dev/null +++ b/frontend/src/scripts/helper/interpolators/linear-interpolator.ts @@ -0,0 +1,99 @@ +import { last, mix } from 'shared'; +import { serverTimeline } from '../server-timeline'; + +interface Frame { + time: number; + value: number; + velocity: number; +} + +// How far past the newest snapshot the value may coast on its last known +// velocity (network hiccup) before freezing in place. +const maxCoastSeconds = 0.06; + +// When fresh snapshots arrive after a coast, they usually disagree with where +// the coast ended up; the difference decays away with this time constant +// instead of being shown as a jump. +const blendSeconds = 0.12; + +// At 25 snapshots per second this spans over a second of state, far more than +// rendering ever needs; it only bounds memory while the tab is hidden and +// snapshots keep arriving without being consumed. +const maxFrames = 32; + +/** + * Replays a server-streamed scalar by interpolating between timestamped + * snapshots at the shared timeline's render time, which trails the newest + * snapshot. The streamed rate of change is only used to coast briefly when + * the buffer runs dry. + */ +export class LinearInterpolator { + private frames: Array = []; + private blendOffset = 0; + private lastValue: number; + private isCoasting = false; + + constructor(private readonly initialValue: number) { + this.lastValue = initialValue; + } + + public addFrame(value: number, rateOfChange: number) { + const time = serverTimeline.snapshotTime; + const newest = last(this.frames); + const wasCoasting = this.isCoasting; + + if (newest && time <= newest.time) { + this.frames[this.frames.length - 1] = { + time: newest.time, + value, + velocity: rateOfChange, + }; + } else { + this.frames.push({ time, value, velocity: rateOfChange }); + if (this.frames.length > maxFrames) { + this.frames.shift(); + } + } + + if (wasCoasting) { + // Continue from where the coast left off and let the offset decay, + // instead of jumping onto the freshly revealed trajectory. + this.blendOffset = this.lastValue - this.sample(serverTimeline.renderTime); + } + } + + public getValue(deltaTimeInSeconds: number): number { + this.blendOffset *= Math.exp(-deltaTimeInSeconds / blendSeconds); + return (this.lastValue = this.sample(serverTimeline.renderTime) + this.blendOffset); + } + + private sample(time: number): number { + if (this.frames.length === 0) { + return this.initialValue; + } + + while (this.frames.length >= 2 && this.frames[1].time <= time) { + this.frames.shift(); + } + + const [current, next] = this.frames; + this.isCoasting = false; + + if (time <= current.time) { + return current.value; + } + + if (next) { + return mix( + current.value, + next.value, + (time - current.time) / (next.time - current.time), + ); + } + + this.isCoasting = true; + return ( + current.value + current.velocity * Math.min(time - current.time, maxCoastSeconds) + ); + } +} diff --git a/frontend/src/scripts/helper/interpolators/vec2-interpolator.ts b/frontend/src/scripts/helper/interpolators/vec2-interpolator.ts new file mode 100644 index 0000000..ae6fb52 --- /dev/null +++ b/frontend/src/scripts/helper/interpolators/vec2-interpolator.ts @@ -0,0 +1,21 @@ +import { vec2 } from 'gl-matrix'; +import { LinearInterpolator } from './linear-interpolator'; + +export class Vec2Interpolator { + private x: LinearInterpolator; + private y: LinearInterpolator; + + constructor(currentValue: vec2) { + this.x = new LinearInterpolator(currentValue.x); + this.y = new LinearInterpolator(currentValue.y); + } + + public addFrame(value: vec2, rateOfChange: vec2) { + this.x.addFrame(value.x, rateOfChange.x); + this.y.addFrame(value.y, rateOfChange.y); + } + + public getValue(deltaTime: number): vec2 { + return vec2.fromValues(this.x.getValue(deltaTime), this.y.getValue(deltaTime)); + } +} diff --git a/frontend/src/scripts/helper/last.ts b/frontend/src/scripts/helper/last.ts deleted file mode 100644 index 5c319b0..0000000 --- a/frontend/src/scripts/helper/last.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function last(a: Array): T | null { - return a.length > 0 ? a[a.length - 1] : null; -} diff --git a/frontend/src/scripts/helper/mix.ts b/frontend/src/scripts/helper/mix.ts deleted file mode 100644 index 6af50f8..0000000 --- a/frontend/src/scripts/helper/mix.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const mix = (from: number, to: number, q: number) => - from + (to - from) * q; diff --git a/frontend/src/scripts/helper/pointer.ts b/frontend/src/scripts/helper/pointer.ts new file mode 100644 index 0000000..ab8886d --- /dev/null +++ b/frontend/src/scripts/helper/pointer.ts @@ -0,0 +1,13 @@ +import { vec2 } from 'gl-matrix'; + +export class Pointer { + private static displayPosition: vec2 | null = null; + + public static setDisplayPosition(x: number, y: number): void { + Pointer.displayPosition = vec2.fromValues(x, y); + } + + public static getDisplayPosition(): vec2 | null { + return Pointer.displayPosition; + } +} diff --git a/frontend/src/scripts/helper/prediction/client-character-world.ts b/frontend/src/scripts/helper/prediction/client-character-world.ts new file mode 100644 index 0000000..a08dbc5 --- /dev/null +++ b/frontend/src/scripts/helper/prediction/client-character-world.ts @@ -0,0 +1,138 @@ +import { vec2 } from 'gl-matrix'; +import { + CharacterWorld, + GroundSurface, + Id, + PhysicsBody, + planetDistance, + planetGravity, + resolveCircleMovement, +} from 'shared'; + +// What the predictor needs to know about a planet to collide and be pulled by +// it. The client reads this off its PlanetViews. +export interface PredictablePlanet { + id: Id; + vertices: Array; + center: vec2; + radius: number; + rotation: number; + rotationSpeed: number; +} + +// A planet collision/gravity surface whose rotation can be advanced during +// replay, so its outline turns in lockstep with the body the carry term moves — +// exactly as the server steps the planet before the character each tick. +class PlanetSurface implements GroundSurface { + public readonly canCollide = true; + public readonly isGround = true; + public readonly id: Id; + public center: vec2; + public angularVelocity: number; + private vertices: Array; + private radius: number; + private rotation = 0; + private cos = 1; + private sin = 0; + + constructor(planet: PredictablePlanet) { + this.id = planet.id; + this.center = planet.center; + this.vertices = planet.vertices; + this.radius = planet.radius; + this.angularVelocity = planet.rotationSpeed; + this.setRotation(planet.rotation); + } + + public sync(planet: PredictablePlanet) { + this.center = planet.center; + this.vertices = planet.vertices; + this.radius = planet.radius; + this.angularVelocity = planet.rotationSpeed; + this.setRotation(planet.rotation); + } + + private setRotation(rotation: number) { + this.rotation = rotation; + this.cos = Math.cos(rotation); + this.sin = Math.sin(rotation); + } + + public advance(deltaTimeInSeconds: number) { + this.setRotation(this.rotation + this.angularVelocity * deltaTimeInSeconds); + } + + public distance(target: vec2): number { + return planetDistance(target, this.vertices, this.center, this.cos, this.sin); + } + + public gravityAt(target: vec2): vec2 { + return planetGravity(this.center, this.radius, target); + } +} + +// The planets-only collision world the local predictor runs against. It holds +// persistent surfaces keyed by planet id (so a `currentPlanet` reference stays +// valid across frames) and never dispatches collision reactions — damage, +// scoring and the like are server-authoritative. +export class ClientCharacterWorld implements CharacterWorld { + private surfaces = new Map(); + private ordered: Array = []; + + // Refresh from the current PlanetViews. Far planets contribute zero gravity + // (the falloff clamps to 0 past maxGravityDistance) and never collide, so the + // whole set can be handed to every query without a range filter. Ordered by + // id so the (rare) two-surface contact picks a stable surface. + public sync(planets: Array) { + const seen = new Set(); + for (const planet of planets) { + seen.add(planet.id); + const existing = this.surfaces.get(planet.id); + if (existing) { + existing.sync(planet); + } else { + this.surfaces.set(planet.id, new PlanetSurface(planet)); + } + } + for (const id of [...this.surfaces.keys()]) { + if (!seen.has(id)) { + this.surfaces.delete(id); + } + } + this.ordered = [...this.surfaces.entries()] + .sort((a, b) => Number(a[0]) - Number(b[0])) + .map((e) => e[1]); + } + + // Advance every planet's collision frame by one replay substep, so surfaces + // and the carried body rotate together. The surfaces are re-synced to the + // newest snapshot rotation each frame (see sync), so the replay only ever + // steps forward from there. + public advance(deltaTimeInSeconds: number) { + for (const surface of this.ordered) { + surface.advance(deltaTimeInSeconds); + } + } + + public surfaceById(id: Id | undefined): GroundSurface | undefined { + return id == null ? undefined : this.surfaces.get(id); + } + + public idOf(surface: GroundSurface | undefined): Id | undefined { + return surface instanceof PlanetSurface ? surface.id : undefined; + } + + public groundsNear(): Array { + return this.ordered; + } + + public stepBody( + body: PhysicsBody, + deltaTimeInSeconds: number, + ): GroundSurface | undefined { + const { hitObject } = resolveCircleMovement(body, deltaTimeInSeconds, this.ordered); + return hitObject && (hitObject as GroundSurface).isGround + ? (hitObject as GroundSurface) + : undefined; + } +} diff --git a/frontend/src/scripts/helper/prediction/input-history.ts b/frontend/src/scripts/helper/prediction/input-history.ts new file mode 100644 index 0000000..3ec2604 --- /dev/null +++ b/frontend/src/scripts/helper/prediction/input-history.ts @@ -0,0 +1,47 @@ +import { vec2 } from 'gl-matrix'; + +interface InputSample { + timeMs: number; + direction: vec2; +} + +// Keep a little over a second of input — far more than any sane reconciliation +// window — so a brief stall (or a backgrounded tab catching up) can still be +// replayed, while old samples are pruned to bound memory. +const retainMs = 1500; + +// A timeline of the local player's movement directions in client-clock time. +// Each generated MoveActionCommand is stamped with the time this hands out, and +// the same sample is recorded here so the predictor replays exactly the input +// the server will eventually receive. Direction is piecewise-constant: the +// active direction at any instant is the most recent sample at or before it. +export class InputHistory { + private samples: Array = []; + + public record(direction: vec2, timeMs: number): void { + this.samples.push({ timeMs, direction: vec2.clone(direction) }); + + const cutoff = timeMs - retainMs; + while (this.samples.length > 1 && this.samples[1].timeMs <= cutoff) { + this.samples.shift(); + } + } + + // The held direction at `timeMs`: the latest sample at or before it, or zero + // before any input exists. + public directionAt(timeMs: number): vec2 { + let direction = vec2.create(); + for (const sample of this.samples) { + if (sample.timeMs <= timeMs) { + direction = sample.direction; + } else { + break; + } + } + return vec2.clone(direction); + } + + public reset(): void { + this.samples = []; + } +} diff --git a/frontend/src/scripts/helper/prediction/local-character-predictor.ts b/frontend/src/scripts/helper/prediction/local-character-predictor.ts new file mode 100644 index 0000000..5d23c1c --- /dev/null +++ b/frontend/src/scripts/helper/prediction/local-character-predictor.ts @@ -0,0 +1,350 @@ +import { vec2 } from 'gl-matrix'; +import { + Circle, + CharacterMovementState, + Id, + PhysicsBody, + settings, + stepCharacterMovement, + applyLeapImpulse, + tickPlanetDetachment, + feetRadius, + headRadius, +} from 'shared'; +import { ClientCharacterWorld, PredictablePlanet } from './client-character-world'; +import { InputHistory } from './input-history'; + +const stepMs = 1000 / 200; // match the server's 200 Hz fixed tick +const stepSeconds = 1 / 200; + +// Clock source for the predictor. Injectable so deterministic reconciliation +// tests can drive prediction without real time passing; defaults to the +// browser wall clock in production. +let nowMs: () => number = () => performance.now(); +export const setPredictorClockForTesting = (clock: () => number): void => { + nowMs = clock; +}; + +// Don't replay more than this far back: if the last acknowledged input is older +// (a stall, or a backgrounded tab catching up) snap to the authoritative pose +// instead of grinding through hundreds of steps. +const maxReplayMs = 300; + +// Render-side easing of the correction the reconciliation produces each frame, +// so a snapshot that disagrees with the prediction is smoothed out instead of +// popping. Short, so the local player still feels immediate. +const smoothSeconds = 0.06; + +// A correction bigger than this isn't prediction error — it's a respawn, +// teleport, or a server-side impulse (leap / slingshot / recoil / death throw) +// the predictor doesn't model. Snap to it rather than gliding across the gap. +const snapDistance = 250; + +const makeBody = (center: vec2, radius: number): PhysicsBody => ({ + center: vec2.clone(center), + radius, + velocity: vec2.create(), + lastNormal: vec2.fromValues(0, 1), + restitution: 0, +}); + +// Predicts the local player's character so it responds to input immediately, +// instead of lagging ~100 ms + RTT behind like the interpolated remote objects. +// Each frame it resets to the latest authoritative snapshot and replays the +// player's own un-acknowledged input through the SAME movement simulation the +// server runs (shared/stepCharacterMovement), then eases the rendered pose +// toward the result. Discrete server-side impulses it can't model show up as a +// large correction and snap rather than rubber-band. +export class LocalCharacterPredictor { + private readonly inputHistory = new InputHistory(); + private readonly world = new ClientCharacterWorld(); + + private authoritative?: { head: Circle; leftFoot: Circle; rightFoot: Circle }; + private lastAckClientTimeMs?: number; + // Wall-clock (client) time the latest authoritative snapshot was received. The + // replay predicts forward from HERE by the snapshot's age, so the local pose + // advances smoothly with real time between the 25 Hz snapshots. Anchoring to + // the last *acked input* time instead breaks when input is sent only on change + // (a held key sends nothing): that time freezes, the window pins to the + // maxReplayMs clamp, the replay displacement goes constant, and the pose + // stair-steps at the snapshot rate. + private authReceiptMs = 0; + // Authoritative launch momentum at the last snapshot — seeds each replay so a + // leap/slingshot/recoil flight is reproduced and continuously corrected. + private authoritativeBodyVelocity = vec2.create(); + // Wall-clock times the player issued a leap, replayed (with the impulse + // applied locally) so the launch is felt immediately, not after a round trip. + private leapHistory: Array = []; + // clientTimeMs of the last leap the server has folded into the streamed + // momentum. Leaps at or before this are already in authoritativeBodyVelocity; + // only newer ones are replayed, so a leap is never applied twice. + private lastLeapAckMs = -Infinity; + // Latest streamed shooting-strength, to gate predicted leaps as the server does. + private currentStrength = settings.playerMaxStrength; + + // Whether the local body is alive on the server. While dead (awaiting respawn) + // prediction is suppressed so the corpse/ghost can't keep walking in response + // to input — the server ignores a dead player's movement, so a predicted body + // that still moved would be a pure client-side desync. + private alive = true; + + // Continuous state carried between replays (the snapshot carries only poses). + // The facing direction is NOT carried — it is re-derived from the pose each + // frame (see directionFromPose / simulate); only the latched planet and the + // time-since-surface persist. + private carriedPlanetId?: Id; + private carriedSecondsSinceSurface = 1; + + // The eased, rendered pose handed to the view. + private renderHead = new Circle(vec2.create(), headRadius); + private renderLeftFoot = new Circle(vec2.create(), feetRadius); + private renderRightFoot = new Circle(vec2.create(), feetRadius); + private hasRender = false; + + public get head(): Circle { + return this.renderHead; + } + public get leftFoot(): Circle { + return this.renderLeftFoot; + } + public get rightFoot(): Circle { + return this.renderRightFoot; + } + + // Stamp a movement command and record it for replay. Returns the wall-clock + // time the command should carry so the server can echo it back. + public recordInput(direction: vec2): number { + const timeMs = Math.round(nowMs()); + this.inputHistory.record(direction, timeMs); + return timeMs; + } + + public acknowledge( + clientTimeMs: number, + bodyVelocity: vec2, + lastLeapClientTimeMs: number, + ): void { + // Inputs only advance the acknowledgement forward; the launch momentum and + // leap boundary always adopt the latest authoritative values. + if ( + this.lastAckClientTimeMs === undefined || + clientTimeMs > this.lastAckClientTimeMs + ) { + this.lastAckClientTimeMs = clientTimeMs; + } + vec2.set(this.authoritativeBodyVelocity, bodyVelocity[0], bodyVelocity[1]); + this.lastLeapAckMs = lastLeapClientTimeMs; + } + + public setAuthoritative(head: Circle, leftFoot: Circle, rightFoot: Circle): void { + this.authoritative = { head, leftFoot, rightFoot }; + this.authReceiptMs = Math.round(nowMs()); + } + + // The player pressed leap; remember when, so the replay applies the same + // impulse the server will. Recorded regardless of whether the server accepts + // it — a rejected leap (no strength/cooldown) self-corrects via the streamed + // authoritative momentum. + public recordLeap(): number { + const timeMs = Math.round(nowMs()); + this.leapHistory.push(timeMs); + const cutoff = timeMs - 1500; + while (this.leapHistory.length > 0 && this.leapHistory[0] <= cutoff) { + this.leapHistory.shift(); + } + return timeMs; + } + + public setStrength(strength: number): void { + this.currentStrength = strength; + } + + public setAlive(alive: boolean): void { + this.alive = alive; + } + + public reset(): void { + this.inputHistory.reset(); + this.leapHistory = []; + this.lastLeapAckMs = -Infinity; + this.authoritative = undefined; + this.lastAckClientTimeMs = undefined; + this.authReceiptMs = 0; + vec2.zero(this.authoritativeBodyVelocity); + this.currentStrength = settings.playerMaxStrength; + this.carriedPlanetId = undefined; + this.carriedSecondsSinceSurface = 1; + this.hasRender = false; + this.alive = true; + } + + public get canPredict(): boolean { + return this.authoritative !== undefined && this.lastAckClientTimeMs !== undefined; + } + + // During spawn-in and death the server freezes walking and only scales the + // body (CharacterPhysical.step returns early), so its head radius is below + // nominal. Predicting then would walk the body off a position the server is + // holding still — let interpolation show the animation instead. + private get isAnimatingInOrOut(): boolean { + return ( + this.authoritative !== undefined && + this.authoritative.head.radius < headRadius - 0.5 + ); + } + + // Run one frame of prediction. Returns true if it produced a rendered pose the + // caller should use (otherwise fall back to interpolation). `planets` is the + // current collision world; `frameSeconds` is the render delta. + public update(planets: Array, frameSeconds: number): boolean { + if (!this.alive || !this.canPredict || this.isAnimatingInOrOut) { + // Resume from the authoritative pose with a snap rather than gliding from + // a stale rendered one. + this.hasRender = false; + return false; + } + + this.world.sync(planets); + const predicted = this.simulate(); + + if (!this.hasRender) { + this.snapRenderTo(predicted); + this.hasRender = true; + } else { + this.easeRenderTo(predicted, frameSeconds); + } + return true; + } + + // The body's facing angle is encoded in the pose: each part is sprung toward + // center + R(direction)*offset and the head's offset points +y, so + // direction = atan2(head - center) - PI/2. Re-deriving it from the snapshot + // each frame (rather than carrying the previous frame's evolved value onto + // this past pose, re-evolved by a variable substep count) keeps the posture + // seed a pure function of the snapshot — carrying it fed a frame-rate-dependent + // loop that wobbled the rendered limbs. + private directionFromPose(head: Circle, leftFoot: Circle, rightFoot: Circle): number { + const cx = (head.center[0] + leftFoot.center[0] + rightFoot.center[0]) / 3; + const cy = (head.center[1] + leftFoot.center[1] + rightFoot.center[1]) / 3; + return Math.atan2(head.center[1] - cy, head.center[0] - cx) - Math.PI / 2; + } + + private simulate(): CharacterMovementState { + const auth = this.authoritative!; + const now = Math.round(nowMs()); + // Predict forward from the latest snapshot by its age, clamped so a stall + // (or a backgrounded tab catching up) snaps instead of grinding hundreds of + // steps. This advances with wall-clock time even while a held key sends no + // fresh input, so the pose no longer pins to the 25 Hz snapshot cadence. + const startMs = Math.max(this.authReceiptMs, now - maxReplayMs); + const windowMs = Math.max(0, now - startMs); + const steps = Math.floor(windowMs / stepMs); + const remainderSeconds = (windowMs - steps * stepMs) / 1000; + + const state: CharacterMovementState = { + head: makeBody(auth.head.center, auth.head.radius), + leftFoot: makeBody(auth.leftFoot.center, auth.leftFoot.radius), + rightFoot: makeBody(auth.rightFoot.center, auth.rightFoot.radius), + direction: this.directionFromPose(auth.head, auth.leftFoot, auth.rightFoot), + currentPlanet: this.world.surfaceById(this.carriedPlanetId), + secondsSinceOnSurface: this.carriedSecondsSinceSurface, + // Leaps before the replay window are already baked into this; leaps inside + // the window are re-applied below, so neither is double-counted. + bodyVelocity: vec2.clone(this.authoritativeBodyVelocity), + }; + + // The planet collision frames were synced (in update(), just before this) + // to the NEWEST snapshot's rotation — the same instant the authoritative + // body pose is from — so the body and the surface start the replay at the + // same phase. The loop below advances the surfaces FORWARD in lockstep with + // the body's carry from that shared phase, so no rewind is needed (and the + // persistent surfaces are re-synced next frame, so this never accumulates). + + const cooldownMs = settings.leapCooldownSeconds * 1000; + // Mirror the server: each accepted leap spends leapStrengthCost, so a burst + // of leaps in one replay window is gated by the running strength rather than + // a single up-front affordability check. + let availableStrength = this.currentStrength; + let lastLeapMs = -Infinity; + + let t = startMs; + for (let i = 0; i < steps; i++) { + const input = this.inputHistory.directionAt(t); + tickPlanetDetachment(state, stepSeconds); + stepCharacterMovement(state, this.world, input, stepSeconds); + this.world.advance(stepSeconds); + + // A leap issued during this step launches now (the next step injects the + // momentum), gated like the server: on a surface, off cooldown, with + // strength. applyLeapImpulse is a no-op off-surface. + for (const leapMs of this.leapHistory) { + if ( + leapMs >= t && + leapMs < t + stepMs && + // Only leaps the server hasn't yet folded into the seed, so a leap + // is never both seeded and replayed. + leapMs > this.lastLeapAckMs && + state.currentPlanet && + leapMs - lastLeapMs >= cooldownMs && + availableStrength >= settings.leapStrengthCost + ) { + applyLeapImpulse(state, this.inputHistory.directionAt(leapMs)); + availableStrength -= settings.leapStrengthCost; + lastLeapMs = leapMs; + } + } + + t += stepMs; + } + + // Final sub-tick of the leftover (< stepMs) so the predicted pose is a + // continuous function of the window length rather than advancing in 5 ms + // quanta — the floor() above would otherwise surface that as a per-frame + // wobble on top of the ~16.7 ms render cadence. + if (remainderSeconds > 0) { + tickPlanetDetachment(state, remainderSeconds); + stepCharacterMovement( + state, + this.world, + this.inputHistory.directionAt(now), + remainderSeconds, + ); + this.world.advance(remainderSeconds); + } + + this.carriedPlanetId = this.world.idOf(state.currentPlanet); + this.carriedSecondsSinceSurface = state.secondsSinceOnSurface; + + return state; + } + + private snapRenderTo(state: CharacterMovementState): void { + this.renderHead = new Circle(vec2.clone(state.head.center), state.head.radius); + this.renderLeftFoot = new Circle( + vec2.clone(state.leftFoot.center), + state.leftFoot.radius, + ); + this.renderRightFoot = new Circle( + vec2.clone(state.rightFoot.center), + state.rightFoot.radius, + ); + } + + private easeRenderTo(state: CharacterMovementState, frameSeconds: number): void { + const q = 1 - Math.exp(-frameSeconds / smoothSeconds); + this.easePart(this.renderHead, state.head, q); + this.easePart(this.renderLeftFoot, state.leftFoot, q); + this.easePart(this.renderRightFoot, state.rightFoot, q); + } + + private easePart(render: Circle, target: PhysicsBody, q: number): void { + if (vec2.distance(render.center, target.center) > snapDistance) { + vec2.copy(render.center, target.center); + } else { + vec2.lerp(render.center, render.center, target.center, q); + } + render.radius = target.radius; + } +} + +export const localCharacterPredictor = new LocalCharacterPredictor(); diff --git a/frontend/src/scripts/helper/server-timeline.ts b/frontend/src/scripts/helper/server-timeline.ts new file mode 100644 index 0000000..28aab75 --- /dev/null +++ b/frontend/src/scripts/helper/server-timeline.ts @@ -0,0 +1,84 @@ +import { clamp, settings } from 'shared'; + +// Convergence rate of the playback cursor towards its target; a deviation +// decays with a time constant of 1 / rateGain seconds. +const rateGain = 2; + +// Playback speed stays within [0.75, 1.25]× so corrections are invisible. +const maxRateAdjustment = 0.25; + +// Beyond this divergence (tab was in the background, server changed) chasing +// the target is pointless: jump straight to it. +const resyncSeconds = 0.3; + +/** + * The playback clock for state streamed from the server. + * + * Snapshot timestamps estimate the server's clock: the newest received + * timestamp plus the time elapsed since it arrived. Rendering happens + * settings.interpolationDelaySeconds behind that estimate, so there is + * normally a newer snapshot to interpolate towards and network jitter is + * absorbed by the buffer instead of being shown. + * + * The cursor never jumps under normal operation: it runs at a gently adjusted + * rate to stay on target and only snaps after a long divergence. + */ +class ServerTimeline { + private cursor?: number; + private newestSnapshotTime?: number; + private sinceNewestSnapshot = 0; + private _snapshotTime = 0; + + /** Timestamp of the update batch currently being applied. */ + public get snapshotTime(): number { + return this._snapshotTime; + } + + /** The point on the server's clock that should be rendered this frame. */ + public get renderTime(): number { + return this.cursor ?? 0; + } + + public onSnapshot(timestamp: number) { + this._snapshotTime = timestamp; + if (this.newestSnapshotTime === undefined || timestamp > this.newestSnapshotTime) { + this.newestSnapshotTime = timestamp; + this.sinceNewestSnapshot = 0; + } + } + + // Must be called with unscaled wall-clock time, even during the end-game + // slow motion: the slowdown is already baked into the snapshots. + public step(deltaTimeInSeconds: number) { + if (this.newestSnapshotTime === undefined) { + return; + } + + this.sinceNewestSnapshot += deltaTimeInSeconds; + const target = + this.newestSnapshotTime + + this.sinceNewestSnapshot - + settings.interpolationDelaySeconds; + + if (this.cursor === undefined || Math.abs(target - this.cursor) > resyncSeconds) { + this.cursor = target; + return; + } + + const rate = clamp( + 1 + (target - this.cursor) * rateGain, + 1 - maxRateAdjustment, + 1 + maxRateAdjustment, + ); + this.cursor += deltaTimeInSeconds * rate; + } + + public reset() { + this.cursor = undefined; + this.newestSnapshotTime = undefined; + this.sinceNewestSnapshot = 0; + this._snapshotTime = 0; + } +} + +export const serverTimeline = new ServerTimeline(); diff --git a/frontend/src/scripts/helper/show.ts b/frontend/src/scripts/helper/show.ts new file mode 100644 index 0000000..cb43718 --- /dev/null +++ b/frontend/src/scripts/helper/show.ts @@ -0,0 +1,7 @@ +export const show = (e: HTMLElement, useDisplay = false, normalDisplayValue?: string) => { + if (useDisplay) { + e.style.display = normalDisplayValue!; + } else { + e.style.visibility = 'inherit'; + } +}; diff --git a/frontend/src/scripts/helper/timing.ts b/frontend/src/scripts/helper/timing.ts deleted file mode 100644 index 6da804a..0000000 --- a/frontend/src/scripts/helper/timing.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { InfoText } from '../objects/types/info-text'; - -export function timeIt(interval = 60) { - return function ( - target: any, - propertyKey: string, - descriptor: PropertyDescriptor - ) { - let i = 0; - let previousTimes: Array = []; - - const targetFunction = descriptor.value; - - descriptor.value = function (...values) { - const start = performance.now(); - targetFunction.bind(this)(...values); - const end = performance.now(); - - previousTimes.push(end - start); - - if (i++ % interval == 0) { - previousTimes.sort(); - const text = `Max: ${previousTimes[previousTimes.length - 1].toFixed( - 2 - )} ms\n\tMedian: ${previousTimes[ - Math.floor(previousTimes.length / 2) - ].toFixed(2)} ms`; - - InfoText.modifyRecord(propertyKey, text); - - previousTimes = []; - } - }; - - return descriptor; - }; -} diff --git a/frontend/src/scripts/helper/to-percent.ts b/frontend/src/scripts/helper/to-percent.ts deleted file mode 100644 index 0047591..0000000 --- a/frontend/src/scripts/helper/to-percent.ts +++ /dev/null @@ -1 +0,0 @@ -export const toPercent = (value: number) => `${Math.round(value * 100)}%`; diff --git a/frontend/src/scripts/identity/identity-manager.ts b/frontend/src/scripts/identity/identity-manager.ts deleted file mode 100644 index 3fe5e6a..0000000 --- a/frontend/src/scripts/identity/identity-manager.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Id } from './identity'; -import { v4 } from 'uuid'; - -export class IdentityManager { - public static generateId(): Id { - return v4(); - } -} diff --git a/frontend/src/scripts/identity/identity.ts b/frontend/src/scripts/identity/identity.ts deleted file mode 100644 index 5dfbae8..0000000 --- a/frontend/src/scripts/identity/identity.ts +++ /dev/null @@ -1 +0,0 @@ -export type Id = string; diff --git a/frontend/src/scripts/input/commands/cursor-move-command.ts b/frontend/src/scripts/input/commands/cursor-move-command.ts deleted file mode 100644 index a5101c2..0000000 --- a/frontend/src/scripts/input/commands/cursor-move-command.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Command } from '../../commands/command'; -import { vec2 } from 'gl-matrix'; - -export class CursorMoveCommand extends Command { - public constructor(public readonly position?: vec2) { - super(); - } - - public get type(): string { - return 'CursorMoveCommand'; - } -} diff --git a/frontend/src/scripts/input/commands/key-down.ts b/frontend/src/scripts/input/commands/key-down.ts deleted file mode 100644 index 41dcddc..0000000 --- a/frontend/src/scripts/input/commands/key-down.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Command } from '../../commands/command'; - -export class KeyDownCommand extends Command { - public constructor(public readonly key?: string) { - super(); - } - - public get type(): string { - return 'KeyDownCommand'; - } -} diff --git a/frontend/src/scripts/input/commands/key-up.ts b/frontend/src/scripts/input/commands/key-up.ts deleted file mode 100644 index 48446a5..0000000 --- a/frontend/src/scripts/input/commands/key-up.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Command } from '../../commands/command'; - -export class KeyUpCommand extends Command { - public constructor(public readonly key?: string) { - super(); - } - - public get type(): string { - return 'KeyUpCommand'; - } -} diff --git a/frontend/src/scripts/input/commands/primary-action.ts b/frontend/src/scripts/input/commands/primary-action.ts deleted file mode 100644 index 54f6e02..0000000 --- a/frontend/src/scripts/input/commands/primary-action.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { vec2 } from 'gl-matrix'; -import { Command } from '../../commands/command'; - -export class PrimaryActionCommand extends Command { - public constructor(public readonly position?: vec2) { - super(); - } - - public get type(): string { - return 'PrimaryActionCommand'; - } -} diff --git a/frontend/src/scripts/input/commands/secondary-action.ts b/frontend/src/scripts/input/commands/secondary-action.ts deleted file mode 100644 index 2cb310e..0000000 --- a/frontend/src/scripts/input/commands/secondary-action.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { vec2 } from 'gl-matrix'; -import { Command } from '../../commands/command'; - -export class SecondaryActionCommand extends Command { - public constructor(public readonly position?: vec2) { - super(); - } - - public get type(): string { - return 'SecondaryActionCommand'; - } -} diff --git a/frontend/src/scripts/input/commands/swipe.ts b/frontend/src/scripts/input/commands/swipe.ts deleted file mode 100644 index 7f0d795..0000000 --- a/frontend/src/scripts/input/commands/swipe.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { vec2 } from 'gl-matrix'; -import { Command } from '../../commands/command'; - -export class SwipeCommand extends Command { - public constructor(public readonly delta?: vec2) { - super(); - } - - public get type(): string { - return 'SwipeCommand'; - } -} diff --git a/frontend/src/scripts/input/commands/zoom.ts b/frontend/src/scripts/input/commands/zoom.ts deleted file mode 100644 index df036cb..0000000 --- a/frontend/src/scripts/input/commands/zoom.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Command } from '../../commands/command'; - -export class ZoomCommand extends Command { - public constructor(public readonly factor?: number) { - super(); - } - - public get type(): string { - return 'ZoomCommand'; - } -} diff --git a/frontend/src/scripts/input/keyboard-listener.ts b/frontend/src/scripts/input/keyboard-listener.ts deleted file mode 100644 index 32496d4..0000000 --- a/frontend/src/scripts/input/keyboard-listener.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { CommandGenerator } from '../commands/command-generator'; -import { KeyDownCommand } from './commands/key-down'; -import { KeyUpCommand } from './commands/key-up'; - -export class KeyboardListener extends CommandGenerator { - constructor(target: Element) { - super(); - - target.addEventListener('keydown', (event: KeyboardEvent) => { - const key = event.key.toLowerCase(); - this.sendCommand(new KeyDownCommand(key)); - }); - - target.addEventListener('keyup', (event: KeyboardEvent) => { - const key = event.key.toLowerCase(); - this.sendCommand(new KeyUpCommand(key)); - }); - } -} diff --git a/frontend/src/scripts/input/mouse-listener.ts b/frontend/src/scripts/input/mouse-listener.ts deleted file mode 100644 index df4cf1a..0000000 --- a/frontend/src/scripts/input/mouse-listener.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { CommandGenerator } from '../commands/command-generator'; -import { vec2 } from 'gl-matrix'; -import { clamp01 } from '../helper/clamp'; -import { CursorMoveCommand } from './commands/cursor-move-command'; -import { PrimaryActionCommand } from './commands/primary-action'; -import { SwipeCommand } from './commands/swipe'; -import { SecondaryActionCommand } from './commands/secondary-action'; -import { ZoomCommand } from './commands/zoom'; - -export class MouseListener extends CommandGenerator { - private previousPosition = vec2.create(); - private isMouseDown = false; - - constructor(private target: Element) { - super(); - - target.addEventListener('mousedown', (event: MouseEvent) => { - const position = this.positionFromEvent(event); - - this.previousPosition = position; - this.isMouseDown = true; - - if (event.button == 0) { - this.sendCommand(new PrimaryActionCommand(position)); - } - }); - - target.addEventListener('mousemove', (event: MouseEvent) => { - const position = this.positionFromEvent(event); - - if (this.isMouseDown) { - this.sendCommand( - new SwipeCommand( - vec2.subtract(vec2.create(), this.previousPosition, position) - ) - ); - this.previousPosition = position; - } - - this.sendCommand(new CursorMoveCommand(position)); - }); - - target.addEventListener('mouseup', (event: MouseEvent) => { - this.isMouseDown = false; - }); - - target.addEventListener('mouseleave', (event: MouseEvent) => { - this.isMouseDown = false; - }); - - target.addEventListener('contextmenu', (event: MouseEvent) => { - event.preventDefault(); - const position = this.positionFromEvent(event); - this.sendCommand(new SecondaryActionCommand(position)); - }); - - target.addEventListener('wheel', (event: MouseWheelEvent) => { - this.sendCommand(new ZoomCommand(event.deltaY > 0 ? 1.3 : 1 / 1.3)); - }); - } - - private positionFromEvent(event: MouseEvent): vec2 { - const bb = this.target.getBoundingClientRect(); - - return vec2.fromValues( - clamp01((event.clientX - bb.x) / bb.width), - clamp01(1 - (event.clientY - bb.y) / bb.height) - ); - } -} diff --git a/frontend/src/scripts/input/touch-listener.ts b/frontend/src/scripts/input/touch-listener.ts deleted file mode 100644 index e605cd4..0000000 --- a/frontend/src/scripts/input/touch-listener.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { CommandGenerator } from '../commands/command-generator'; -import { vec2 } from 'gl-matrix'; -import { clamp01 } from '../helper/clamp'; -import { PrimaryActionCommand } from './commands/primary-action'; -import { SecondaryActionCommand } from './commands/secondary-action'; -import { SwipeCommand } from './commands/swipe'; - -export class TouchListener extends CommandGenerator { - private previousPosition = vec2.create(); - - constructor(private target: HTMLElement) { - super(); - - target.addEventListener('touchstart', (event: TouchEvent) => { - event.preventDefault(); - - const touchCount = event.touches.length; - const position = this.positionFromEvent(event); - this.previousPosition = position; - - if (touchCount == 1) { - this.sendCommand(new PrimaryActionCommand(position)); - } else { - this.sendCommand(new SecondaryActionCommand(position)); - } - }); - - target.addEventListener('touchmove', (event: TouchEvent) => { - event.preventDefault(); - - const position = this.positionFromEvent(event); - - this.sendCommand( - new SwipeCommand( - vec2.subtract(vec2.create(), position, this.previousPosition) - ) - ); - - this.previousPosition = position; - }); - } - - private positionFromEvent(event: TouchEvent): vec2 { - const bb = this.target.getBoundingClientRect(); - - return vec2.fromValues( - clamp01(1 - (event.touches[0].clientX - bb.x) / bb.width), - clamp01((event.touches[0].clientY - bb.y) / bb.height) - ); - } -} diff --git a/frontend/src/scripts/join-form-handler.ts b/frontend/src/scripts/join-form-handler.ts new file mode 100644 index 0000000..c2bd37f --- /dev/null +++ b/frontend/src/scripts/join-form-handler.ts @@ -0,0 +1,201 @@ +import { ServerInformation, serverInformationEndpoint, TransportEvents } from 'shared'; +import { io, Socket } from 'socket.io-client'; +import { Configuration } from './configuration'; +import parser from 'socket.io-msgpack-parser'; +import { SoundHandler, Sounds } from './sound-handler'; + +export type PlayerDecision = { + name: string; + server: string; +}; + +const pollInterval = 8000; +export class JoinFormHandler { + private joinButton: HTMLButtonElement; + private waitingForDecision: Promise; + private resolvePlayerDecision!: (d: PlayerDecision) => void; + private pollServersTimer: any; + private keyUpListener = (e: KeyboardEvent) => { + // KeyboardEvent.key for Return is 'Enter' (capital E); the old lowercase + // comparison never matched, so pressing Enter silently did nothing. + // requestSubmit() (unlike submit()) fires the form's onsubmit handler and + // runs HTML5 validation, so Enter behaves exactly like clicking Join. + if (e.key === 'Enter' && !this.joinButton.disabled) { + this.form.requestSubmit(); + } + }; + + constructor( + private form: HTMLFormElement, + private readonly container: HTMLElement, + ) { + this.joinButton = form.querySelector('button[type="submit"]') as HTMLButtonElement; + this.joinButton.disabled = true; + this.waitingForDecision = new Promise((r) => (this.resolvePlayerDecision = r)); + + new FormData(form); + + addEventListener('keyup', this.keyUpListener); + + form.onsubmit = (e) => { + SoundHandler.play(Sounds.click); + const result: PlayerDecision = ( + Array.from((new FormData(form) as any).entries()) as Array<[string, any]> + ).reduce((result, [name, value]) => { + (result as any)[name] = value; + return result; + }, {}) as any; + + this.resolvePlayerDecision(result); + + e.preventDefault(); + }; + + this.pollServersTimer = setInterval(this.loadServers.bind(this), pollInterval); + this.loadServers(); + this.waitForFinish(); + } + + private async waitForFinish() { + await this.waitingForDecision; + this.destroy(); + } + + private destroy() { + removeEventListener('keyup', this.keyUpListener); + clearInterval(this.pollServersTimer); + this.servers.forEach((s) => s.destroy()); + } + + private servers: Array = []; + private async loadServers() { + await Configuration.initialize(); + + const serverList = Configuration.servers.filter( + (u) => !this.servers.find((s) => s.url === u), + ); + + serverList.map(async (url) => { + const controller = new AbortController(); + const signal = controller.signal; + setTimeout(() => controller.abort(), pollInterval * 0.8); + + let response: Response | undefined; + try { + response = await fetch(url + serverInformationEndpoint, { signal }); + } catch { + // it's okay + } + + if (response?.ok) { + const content: ServerInformation = await response.json(); + if (!this.servers.find((s) => s.url === url)) { + const server = new ServerChooserOption( + content, + url, + this.removeServer.bind(this), + this.servers.length === 0, + ); + this.servers.push(server); + this.joinButton.disabled = false; + this.container.appendChild(server.element); + } + } + }); + } + + private removeServer(server: ServerChooserOption) { + this.servers = this.servers.filter((s) => s !== server); + if (!this.servers.length) { + this.joinButton.disabled = true; + } + } + + public async getPlayerDecision(): Promise { + return this.waitingForDecision; + } +} + +class ServerChooserOption { + private divElement = document.createElement('div'); + private inputElement = document.createElement('input'); + private labelElement = document.createElement('label'); + private serverNameElement = document.createElement('span'); + private completionElement = document.createElement('span'); + + private socket: Socket; + + constructor( + private content: ServerInformation, + public readonly url: string, + private onDestroy: (v: ServerChooserOption) => unknown, + isFirst: boolean, + ) { + this.inputElement.required = true; + this.inputElement.type = 'radio'; + this.inputElement.id = this.inputElement.value = url; + this.inputElement.name = 'server'; + this.inputElement.checked = isFirst; + this.labelElement.htmlFor = url; + this.labelElement.onclick = () => SoundHandler.play(Sounds.click); + this.divElement.appendChild(this.inputElement); + this.divElement.appendChild(this.labelElement); + this.labelElement.appendChild(this.serverNameElement); + this.labelElement.appendChild(document.createElement('br')); + this.labelElement.appendChild(this.completionElement); + this.completionElement.className = 'completion'; + this.setServerInfoLabelText(); + + this.socket = io(url, { + reconnection: true, + reconnectionAttempts: 5, + timeout: 4000, + parser, + } as any); + + this.socket.io.on('reconnect_failed', this.destroy.bind(this)); + + this.socket.on('connect', () => + this.socket.emit(TransportEvents.SubscribeForServerInfoUpdates), + ); + this.socket.on( + TransportEvents.ServerInfoUpdate, + ([playerCount, gameState]: [number, number]) => { + this.content.playerCount = playerCount; + this.content.gameStatePercent = gameState; + this.setServerInfoLabelText(); + }, + ); + } + + public destroy() { + this.socket.close(); + this.divElement.parentElement?.removeChild(this.divElement); + this.onDestroy(this); + } + + private getRoundCompletionText(percent: number): string { + const texts = [ + 'Just started', + 'Just started', + 'Ongoing', + 'Halfway through', + 'Nearly over', + 'About to finish', + 'Game is over', + ]; + + return texts[Math.floor((percent / 100) * (texts.length - 1))]; + } + + private setServerInfoLabelText() { + this.serverNameElement.innerText = `${this.content.serverName} - ${this.content.playerCount}/${this.content.playerLimit} players`; + this.completionElement.innerText = this.getRoundCompletionText( + this.content.gameStatePercent, + ); + } + + public get element(): HTMLElement { + return this.divElement; + } +} diff --git a/frontend/src/scripts/landing-page-background.ts b/frontend/src/scripts/landing-page-background.ts new file mode 100644 index 0000000..bafe559 --- /dev/null +++ b/frontend/src/scripts/landing-page-background.ts @@ -0,0 +1,174 @@ +import { vec2 } from 'gl-matrix'; +import { + CircleLight, + FilteringOptions, + hsl, + Renderer, + renderNoise, + runAnimation, + WrapOptions, +} from 'sdf-2d'; +import { settings, rgb, PlanetBase, Random } from 'shared'; +import { PlanetShape } from './shapes/planet-shape'; + +// PlanetShape colours by mixing blue (0) -> red (1). The two backdrop planets +// read as the two in-game teams; the red planet is pulled a little off the +// pure-red end so it shows as a more muted, less saturated red. +const bluePlanet = 0; +const redPlanet = 0.85; + +export class LandingPageBackground { + private isActive = true; + public renderer: Promise; + private resolveRenderer!: (r: Renderer) => unknown; + + constructor(canvas: HTMLCanvasElement) { + this.start(canvas); + this.renderer = new Promise((r) => (this.resolveRenderer = r)); + } + + private async start(canvas: HTMLCanvasElement): Promise { + const noiseTexture = await renderNoise([256, 256], 1.2, 2); + + runAnimation( + canvas, + [ + { + ...PlanetShape.descriptor, + shaderCombinationSteps: [0, 1, 2], + }, + { + ...CircleLight.descriptor, + shaderCombinationSteps: [0, 2], + }, + ], + this.gameLoop.bind(this), + { + shadowTraceCount: 16, + paletteSize: 1, + ambientLight: rgb(0, 0, 0), + lightCutoffDistance: settings.lightCutoffDistance, + textures: { + noiseTexture: { + source: noiseTexture, + overrides: { + maxFilter: FilteringOptions.LINEAR, + wrapS: WrapOptions.MIRRORED_REPEAT, + wrapT: WrapOptions.MIRRORED_REPEAT, + }, + }, + }, + }, + ); + } + + private gameLoop(renderer: Renderer, time: DOMHighResTimeStamp, _: number): boolean { + Random.seed = 42; + + this.resolveRenderer(renderer); + + renderer.setViewArea(vec2.fromValues(0, renderer.canvasSize.y), renderer.canvasSize); + + const topPlanetPosition = vec2.fromValues( + 0.7 * renderer.canvasSize.x, + 0.7 * renderer.canvasSize.y, + ); + + const topPlanet = new PlanetShape( + PlanetBase.createPlanetVertices( + topPlanetPosition, + Random.getRandomInRange(150, 400), + Random.getRandomInRange(150, 400), + Random.getRandomInRange(10, 20), + ), + redPlanet, + ); + + // Fixed terrain phase (no longer animated -> no pulsing); the planet spins + // instead, the same way in-game planets do: PlanetShape's rotation uniform + // turns the whole body, terrain and outline together, in its own frame. + // Speeds sit in the game's per-planet range (~0.05-0.12 rad/s) and + // counter-rotate so the two planets don't drift in lockstep. + topPlanet.randomOffset = Random.getRandom(); + topPlanet.rotation = (time / 1000) * 0.09; + + const bottomPlanetPosition = vec2.fromValues( + 0.3 * renderer.canvasSize.x, + 0.3 * renderer.canvasSize.y, + ); + + const bottomPlanet = new PlanetShape( + PlanetBase.createPlanetVertices( + bottomPlanetPosition, + Random.getRandomInRange(150, 800), + Random.getRandomInRange(150, 400), + Random.getRandomInRange(10, 40), + ), + bluePlanet, + ); + + bottomPlanet.randomOffset = Random.getRandom(); + bottomPlanet.rotation = (time / 1000) * -0.06; + + const planetDistance = vec2.subtract( + vec2.create(), + topPlanetPosition, + bottomPlanetPosition, + ); + const planetDistanceLength = vec2.length(planetDistance); + const planetDirection = vec2.normalize(planetDistance, planetDistance); + const planetAngle = Math.atan2(planetDirection.y, planetDirection.x); + + renderer.addDrawable(topPlanet); + renderer.addDrawable(bottomPlanet); + renderer.addDrawable( + new CircleLight( + this.calculateLightPosition( + renderer, + planetAngle, + planetDistanceLength * 1.2, + -time / 3000, + ), + hsl(25, 75, 60), + 0.75, + ), + ); + + renderer.addDrawable( + new CircleLight( + this.calculateLightPosition( + renderer, + planetAngle, + planetDistanceLength * 1.2, + time / 2000 + Math.PI, + ), + hsl(249, 79, 70), + 0.25, + ), + ); + + return this.isActive; + } + + private calculateLightPosition( + renderer: Renderer, + angle: number, + length: number, + t: number, + ): vec2 { + const lightPosition = vec2.fromValues( + length * Math.sin(t), + length * Math.sin(t) * Math.cos(t), + ); + + const canvasCenter = vec2.scale(vec2.create(), renderer.canvasSize, 0.5); + + vec2.add(lightPosition, lightPosition, canvasCenter); + vec2.rotate(lightPosition, lightPosition, canvasCenter, angle); + return lightPosition; + } + + public destroy() { + this.isActive = false; + } +} diff --git a/frontend/src/scripts/minimap.ts b/frontend/src/scripts/minimap.ts new file mode 100644 index 0000000..64267e8 --- /dev/null +++ b/frontend/src/scripts/minimap.ts @@ -0,0 +1,126 @@ +import { vec2 } from 'gl-matrix'; +import { CharacterTeam, Id, settings } from 'shared'; + +export interface MinimapBlip { + id: Id; + position: vec2; + team: CharacterTeam; +} + +// Top-down radar of the whole circular arena, pinned to the top-left. The map's +// circular border is the world boundary (its radius maps to worldRadius), so the +// local player's bright dot reads as a true position in the arena and every other +// living player shows as a team-coloured dot. Owns its own , so the Game +// just appends `element` to the overlay and feeds it `update()` each frame. +// Replaces the off-screen chevrons that used to point at other players. +export class Minimap { + public readonly element = document.createElement('canvas'); + + private readonly ctx: CanvasRenderingContext2D; + private readonly colors: Record; + // World-space positions, smoothed per player so the 25 Hz snapshots glide + // rather than step between frames. + private readonly smoothed = new Map(); + private bufferSize = 0; + + constructor() { + this.element.className = 'minimap'; + this.ctx = this.element.getContext('2d')!; + + const theme = getComputedStyle(document.documentElement); + const read = (name: string, fallback: string) => + theme.getPropertyValue(name).trim() || fallback; + this.colors = { + [CharacterTeam.blue]: read('--bright-blue', '#4069a5'), + [CharacterTeam.red]: read('--bright-red', '#d15652'), + [CharacterTeam.neutral]: read('--bright-neutral', '#ccc'), + }; + } + + public update(localPosition: vec2 | undefined, players: Array) { + const size = this.element.clientWidth; + if (size === 0) { + return; // not laid out yet + } + this.syncBufferSize(size); + + const ctx = this.ctx; + ctx.clearRect(0, 0, size, size); + + const center = size / 2; + const innerRadius = center - 5; + const scale = innerRadius / settings.worldRadius; + + const toMap = (world: vec2): { x: number; y: number } => { + let dx = world.x * scale; + let dy = -world.y * scale; // world Y points up, canvas Y points down + const distance = Math.hypot(dx, dy); + if (distance > innerRadius) { + dx = (dx / distance) * innerRadius; + dy = (dy / distance) * innerRadius; + } + return { x: center + dx, y: center + dy }; + }; + + // Faint marker at the world's centre to aid orientation. + ctx.fillStyle = 'rgba(255, 255, 255, 0.12)'; + ctx.beginPath(); + ctx.arc(center, center, 1.5, 0, Math.PI * 2); + ctx.fill(); + + const present = new Set(); + for (const blip of players) { + present.add(blip.id); + let world = this.smoothed.get(blip.id); + if (world) { + vec2.lerp(world, world, blip.position, 0.3); + } else { + world = vec2.clone(blip.position); + this.smoothed.set(blip.id, world); + } + const { x, y } = toMap(world); + this.drawDot(x, y, 3, this.colors[blip.team]); + } + for (const id of this.smoothed.keys()) { + if (!present.has(id)) { + this.smoothed.delete(id); + } + } + + // The local player rides on top, drawn in white with a ring so "you" is + // unmistakable amongst the team-coloured dots. + if (localPosition) { + const { x, y } = toMap(localPosition); + this.drawDot(x, y, 3.5, '#ffffff'); + ctx.beginPath(); + ctx.arc(x, y, 6, 0, Math.PI * 2); + ctx.strokeStyle = 'rgba(255, 255, 255, 0.65)'; + ctx.lineWidth = 1.5; + ctx.stroke(); + } + } + + private drawDot(x: number, y: number, radius: number, color: string) { + const ctx = this.ctx; + ctx.beginPath(); + ctx.arc(x, y, radius, 0, Math.PI * 2); + ctx.fillStyle = color; + ctx.shadowColor = color; + ctx.shadowBlur = radius * 2; + ctx.fill(); + ctx.shadowBlur = 0; + } + + private syncBufferSize(cssSize: number) { + const dpr = window.devicePixelRatio || 1; + const target = Math.round(cssSize * dpr); + if (this.bufferSize !== target) { + this.bufferSize = target; + this.element.width = target; + this.element.height = target; + } + // Setting the buffer size resets the transform, so (re)establish it every + // frame and draw in CSS pixels regardless of the device pixel ratio. + this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + } +} diff --git a/frontend/src/scripts/objects/game-object-container.ts b/frontend/src/scripts/objects/game-object-container.ts new file mode 100644 index 0000000..2b03836 --- /dev/null +++ b/frontend/src/scripts/objects/game-object-container.ts @@ -0,0 +1,172 @@ +import { vec2 } from 'gl-matrix'; +import { + Circle, + Command, + CommandExecutors, + CommandReceiver, + CreateObjectsCommand, + CreatePlayerCommand, + DeleteObjectsCommand, + GameObject, + Id, + PropertyUpdatesForObjects, + RemoteCallsForObjects, + settings, + UpdatePropertyCommand, +} from 'shared'; +import { BeforeDestroyCommand } from '../commands/types/before-destroy'; +import { StepCommand } from '../commands/types/step'; +import { FeedbackHud } from '../feedback-hud'; +import { Game } from '../game'; +import { serverTimeline } from '../helper/server-timeline'; +import { PredictablePlanet } from '../helper/prediction/client-character-world'; +import { localCharacterPredictor } from '../helper/prediction/local-character-predictor'; +import { Camera } from './types/camera'; +import { CharacterView } from './types/character-view'; +import { PlanetView } from './types/planet-view'; + +export class GameObjectContainer extends CommandReceiver { + protected objects: Map = new Map(); + public player!: CharacterView; + public camera: Camera = new Camera(this.game); + private wasLocalPlayerAlive = false; + + protected commandExecutors: CommandExecutors = { + [CreatePlayerCommand.type]: (c: CreatePlayerCommand) => { + this.player = c.character as CharacterView; + this.player.isMainCharacter = true; + this.addObject(this.player); + // Fresh character (first spawn or respawn at a far planet): drop any + // prediction state so it snaps to the new body instead of gliding across. + localCharacterPredictor.reset(); + // Respawned — clear the elimination overlay. + FeedbackHud.hideElimination(); + this.wasLocalPlayerAlive = true; + }, + + [CreateObjectsCommand.type]: (c: CreateObjectsCommand) => + c.objects.forEach((o) => this.addObject(o as GameObject)), + + [StepCommand.type]: (c: StepCommand) => { + this.defaultCommandExecutor(c); + + // The local body is alive only while its object still exists (the server + // deletes it on death) and its health is above zero — `player` keeps + // pointing at the now-stale view after death, so both checks are needed. + const bodyPresent = !!this.player && this.objects.has(this.player.id); + const alive = bodyPresent && this.player.health > 0; + + // Show the elimination overlay on the alive→dead edge; CreatePlayerCommand + // clears it on respawn. + if (this.wasLocalPlayerAlive && !alive) { + FeedbackHud.showElimination(); + } + this.wasLocalPlayerAlive = alive; + + if (bodyPresent) { + // Override the interpolated pose of the local player with the predicted + // one so it responds to input immediately. Suppressed while dead so the + // corpse can't be walked around (the server ignores a dead player's + // input — a moving predicted body would be a pure client-side desync). + // A large correction (respawn / death) snaps inside the predictor. + localCharacterPredictor.setAlive(alive); + localCharacterPredictor.setStrength( + this.player.strengthFraction * settings.playerMaxStrength, + ); + if ( + localCharacterPredictor.update(this.predictablePlanets(), c.deltaTimeInSeconds) + ) { + this.player.head = localCharacterPredictor.head; + this.player.leftFoot = localCharacterPredictor.leftFoot; + this.player.rightFoot = localCharacterPredictor.rightFoot; + } + this.camera.follow(this.player.position, c.deltaTimeInSeconds); + } + }, + + [RemoteCallsForObjects.type]: (c: RemoteCallsForObjects) => + c.callsForObjects.forEach((c) => + this.objects.get(c.id)?.processRemoteCalls(c.calls), + ), + + [PropertyUpdatesForObjects.type]: (c: PropertyUpdatesForObjects) => { + serverTimeline.onSnapshot(c.timestamp); + c.updates.forEach((u) => { + u.updates.forEach((au) => this.objects.get(u.id)?.handleCommand(au)); + if (this.player && u.id === this.player.id) { + this.feedPredictor(u.updates); + } + }); + }, + + [DeleteObjectsCommand.type]: (c: DeleteObjectsCommand) => + c.ids.forEach((id: Id) => this.deleteObject(id)), + }; + + constructor(private game: Game) { + super(); + } + + // The local player's world position, but only while the body is alive. On + // death the server deletes the character object (yet `player` keeps pointing + // at the now-stale view), so gate on the object still being present — otherwise + // the minimap would pin the "you" dot at the death spot for the whole respawn. + public get localPlayerPosition(): vec2 | undefined { + return this.player && this.objects.has(this.player.id) + ? this.player.position + : undefined; + } + + public get planets(): Array { + const planets: Array = []; + this.objects.forEach((o) => { + if (o instanceof PlanetView) { + planets.push(o); + } + }); + return planets; + } + + protected defaultCommandExecutor(c: Command) { + this.objects.forEach((o) => o.handleCommand(c)); + this.camera.handleCommand(c); + } + + // Hand the local player's raw authoritative pose to the predictor (the + // interpolated pose would already be ~100 ms stale). The three body parts + // arrive together in one snapshot. + private feedPredictor(updates: Array) { + let head: Circle | undefined; + let leftFoot: Circle | undefined; + let rightFoot: Circle | undefined; + for (const u of updates) { + if (u.propertyKey === 'head') head = u.propertyValue as Circle; + else if (u.propertyKey === 'leftFoot') leftFoot = u.propertyValue as Circle; + else if (u.propertyKey === 'rightFoot') rightFoot = u.propertyValue as Circle; + } + if (head && leftFoot && rightFoot) { + localCharacterPredictor.setAuthoritative(head, leftFoot, rightFoot); + } + } + + private predictablePlanets(): Array { + return this.planets.map((p) => ({ + id: p.id, + vertices: p.vertices, + center: p.center, + radius: p.radius, + rotation: p.predictionRotation, + rotationSpeed: p.predictionRotationSpeed, + })); + } + + private addObject(object: GameObject) { + this.objects.set(object.id, object); + } + + private deleteObject(id: Id) { + const object = this.objects.get(id); + object?.handleCommand(new BeforeDestroyCommand()); + this.objects.delete(id); + } +} diff --git a/frontend/src/scripts/objects/game-object.ts b/frontend/src/scripts/objects/game-object.ts deleted file mode 100644 index 3662f15..0000000 --- a/frontend/src/scripts/objects/game-object.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { Command } from '../commands/command'; -import { CommandReceiver } from '../commands/command-receiver'; -import { IdentityManager } from '../identity/identity-manager'; -import { Objects } from './objects'; -import { Physics } from '../physics/physics'; - -export abstract class GameObject implements CommandReceiver { - public readonly id = IdentityManager.generateId(); - - private commandExecutors: { - [commandType: string]: (e: Command) => void; - } = {}; - - public reactsToCommand(commandType: string): boolean { - return this.commandExecutors.hasOwnProperty(commandType); - } - - public sendCommand(command: Command) { - const commandType = command.type; - - if (this.commandExecutors.hasOwnProperty(commandType)) { - this.commandExecutors[commandType](command); - } - } - - // can only be called inside the constructor - protected addCommandExecutor( - commandType: new () => T, - handler: (command: T) => void - ) { - this.commandExecutors[new commandType().type] = handler; - } -} diff --git a/frontend/src/scripts/objects/objects.ts b/frontend/src/scripts/objects/objects.ts deleted file mode 100644 index cc46933..0000000 --- a/frontend/src/scripts/objects/objects.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { Command } from '../commands/command'; -import { CommandReceiver } from '../commands/command-receiver'; -import { Id } from '../identity/identity'; -import { GameObject } from './game-object'; - -export class Objects implements CommandReceiver { - private objects: Map = new Map(); - private objectsGroupedByAbilities: Map> = new Map(); - - public addObject(o: GameObject) { - this.objects.set(o.id, o); - - for (let command of this.objectsGroupedByAbilities.keys()) { - if (o.reactsToCommand(command)) { - this.objectsGroupedByAbilities.get(command).push(o); - } - } - } - - public removeObject(o: GameObject) { - this.objects.delete(o.id); - } - - public sendCommandToSingleObject(id: Id, e: Command) { - this.objects.get(id)?.sendCommand(e); - } - - public sendCommand(e: Command) { - if (!this.objectsGroupedByAbilities.has(e.type)) { - this.createGroupForCommand(e.type); - } - - this.objectsGroupedByAbilities - .get(e.type) - .forEach((o, _) => o.sendCommand(e)); - } - - private createGroupForCommand(commandType: string) { - const objectsReactingToCommand = []; - - this.objects.forEach((o, _) => { - if (o.reactsToCommand(commandType)) { - objectsReactingToCommand.push(o); - } - }); - - this.objectsGroupedByAbilities.set(commandType, objectsReactingToCommand); - } -} diff --git a/frontend/src/scripts/objects/types/camera.ts b/frontend/src/scripts/objects/types/camera.ts index ed2aead..c607c38 100644 --- a/frontend/src/scripts/objects/types/camera.ts +++ b/frontend/src/scripts/objects/types/camera.ts @@ -1,53 +1,70 @@ import { vec2 } from 'gl-matrix'; -import { BeforeRenderCommand } from '../../drawing/commands/before-render'; -import { CursorMoveCommand } from '../../input/commands/cursor-move-command'; -import { ZoomCommand } from '../../input/commands/zoom'; -import { MoveToCommand } from '../../physics/commands/move-to'; -import { BoundingBox } from '../../physics/containers/bounding-box'; -import { Physics } from '../../physics/physics'; -import { GameObject } from '../game-object'; -import { Lamp } from './lamp'; +import { + calculateViewArea, + CommandExecutors, + CommandReceiver, + mixRgb, + settings, +} from 'shared'; +import { RenderCommand } from '../../commands/types/render'; +import { Game } from '../../game'; +import { ScreenShake } from '../../screen-shake'; -export class Camera extends GameObject { - private inViewArea = 1920 * 1080 * 5; - private cursorPosition = vec2.create(); - private boundingBox: BoundingBox; +export class Camera extends CommandReceiver { + public center: vec2 = vec2.create(); + private aspectRatio?: number; - constructor(physics: Physics, private light: Lamp) { + // A short exponential lag masks any residual stepping in the followed + // position without the camera noticeably trailing during normal movement. + private static readonly followSeconds = 0.08; + + // Swooshing across half the map after a respawn would be disorienting; + // beyond this distance the camera cuts instead. + private static readonly snapDistance = 1500; + + constructor(private game: Game) { super(); + } - this.boundingBox = new BoundingBox(null); - //physics.addDynamicBoundingBox(this.boundingBox); + public follow(target: vec2, deltaTimeInSeconds: number) { + if (vec2.distance(target, this.center) > Camera.snapDistance) { + vec2.copy(this.center, target); + return; + } - this.addCommandExecutor(BeforeRenderCommand, this.draw.bind(this)); - this.addCommandExecutor(MoveToCommand, this.moveTo.bind(this)); - this.addCommandExecutor( - CursorMoveCommand, - this.setCursorPosition.bind(this) + const q = 1 - Math.exp(-deltaTimeInSeconds / Camera.followSeconds); + vec2.lerp(this.center, this.center, target, q); + } + + protected commandExecutors: CommandExecutors = { + [RenderCommand.type]: this.draw.bind(this), + }; + + private draw({ renderer }: RenderCommand) { + const canvasAspectRatio = renderer.canvasSize.x / renderer.canvasSize.y; + if (canvasAspectRatio !== this.aspectRatio) { + this.aspectRatio = canvasAspectRatio; + this.game.aspectRatioChanged(canvasAspectRatio); + } + + // Shake displaces only the rendered view centre and the zoom-punch shrinks + // only the rendered view area — neither touches the followed position, so + // impacts jolt the frame without nudging the camera off the player. Passing + // the zoom as oversizeRatio scales the area about the (shaken) centre. + const shakenCenter = vec2.fromValues( + this.center[0] + ScreenShake.offsetX, + this.center[1] + ScreenShake.offsetY, ); - this.addCommandExecutor(ZoomCommand, this.zoom.bind(this)); - } + const scale = ScreenShake.viewScale; + const viewArea = calculateViewArea(shakenCenter, canvasAspectRatio, scale * scale); + renderer.setViewArea(viewArea.topLeft, viewArea.size); - public get viewAreaSize(): vec2 { - return this.boundingBox.size; - } - - private draw(c: BeforeRenderCommand) { - c.renderer.setCameraPosition(this.boundingBox.topLeft); - c.renderer.setCursorPosition(this.cursorPosition); - this.boundingBox.size = c.renderer.setInViewArea(this.inViewArea); - } - - private moveTo(c: MoveToCommand) { - this.boundingBox.topLeft = c.position; - this.light.sendCommand(c); - } - - private zoom(c: ZoomCommand) { - this.inViewArea *= c.factor; - } - - private setCursorPosition(c: CursorMoveCommand) { - this.cursorPosition = c.position; + renderer.setRuntimeSettings({ + ambientLight: mixRgb( + settings.backgroundGradient[0], + settings.backgroundGradient[1], + vec2.length(this.center) / settings.worldRadius, + ), + }); } } diff --git a/frontend/src/scripts/objects/types/character-view.ts b/frontend/src/scripts/objects/types/character-view.ts new file mode 100644 index 0000000..5c0d751 --- /dev/null +++ b/frontend/src/scripts/objects/types/character-view.ts @@ -0,0 +1,347 @@ +import { vec2, vec3 } from 'gl-matrix'; +import { CircleLight, Renderer } from 'sdf-2d'; + +import { + Circle, + Id, + CharacterBase, + CharacterTeam, + settings, + clamp, + clamp01, + mix, + CommandExecutors, + UpdatePropertyCommand, +} from 'shared'; +import { BeforeDestroyCommand } from '../../commands/types/before-destroy'; +import { RenderCommand } from '../../commands/types/render'; +import { StepCommand } from '../../commands/types/step'; +import { CircleInterpolator } from '../../helper/interpolators/circle-interpolator'; +import { LinearInterpolator } from '../../helper/interpolators/linear-interpolator'; +import { Pointer } from '../../helper/pointer'; +import { CharacterShape } from '../../shapes/character-shape'; +import { SoundHandler, Sounds } from '../../sound-handler'; +import { VibrationHandler } from '../../vibration-handler'; +import { FeedbackHud } from '../../feedback-hud'; +import { ScreenShake } from '../../screen-shake'; + +const muzzleFlashDecaySeconds = 0.12; +const hitFlashDecaySeconds = 0.15; + +// A white-hot pop of light thrown at the spot a character dies, seen by everyone +// who can see the body. No radius knob on a CircleLight, so the burst is sold by +// a bright (HDR) colour with a fast-decaying intensity envelope. +const deathBurstDecaySeconds = 0.42; +const deathBurstMaxIntensity = 1.7; +const deathBurstColor = vec3.fromValues(2.5, 2.3, 2.1); + +const killIcon = + ''; +const deathIcon = + ''; + +export class CharacterView extends CharacterBase { + private shape: CharacterShape; + private muzzleFlash: CircleLight; + private muzzleFlashIntensity = 0; + private hitFlashIntensity = 0; + private deathBurst: CircleLight; + private deathBurstIntensity = 0; + private strength = settings.playerMaxStrength; + private strengthInterpolator = new LinearInterpolator(settings.playerMaxStrength); + private nameElement: HTMLElement = document.createElement('div'); + private statsElement: HTMLElement = document.createElement('div'); + private killCountElement: HTMLElement = document.createElement('span'); + private deathCountElement: HTMLElement = document.createElement('span'); + private healthElement: HTMLElement = document.createElement('div'); + private chargeElement: HTMLElement = document.createElement('div'); + + public isMainCharacter = false; + + private leftFootInterpolator: CircleInterpolator; + private rightFootInterpolator: CircleInterpolator; + private headInterpolator: CircleInterpolator; + + protected commandExecutors: CommandExecutors = { + [RenderCommand.type]: this.draw.bind(this), + [StepCommand.type]: this.step.bind(this), + [BeforeDestroyCommand.type]: this.beforeDestroy.bind(this), + [UpdatePropertyCommand.type]: this.updateProperty.bind(this), + }; + + constructor( + id: Id, + name: string, + killCount: number, + deathCount: number, + team: CharacterTeam, + health: number, + head?: Circle, + leftFoot?: Circle, + rightFoot?: Circle, + ) { + super(id, name, killCount, deathCount, team, health, head, leftFoot, rightFoot); + this.shape = new CharacterShape(settings.colorIndices[team]); + this.muzzleFlash = new CircleLight( + vec2.clone(this.head!.center), + settings.paletteDim[settings.colorIndices[team]], + 0, + ); + this.deathBurst = new CircleLight(vec2.clone(this.head!.center), deathBurstColor, 0); + + this.leftFootInterpolator = new CircleInterpolator(this.leftFoot!); + this.rightFootInterpolator = new CircleInterpolator(this.rightFoot!); + this.headInterpolator = new CircleInterpolator(this.head!); + + this.nameElement.className = 'player-tag ' + this.team; + this.nameElement.innerText = this.name; + this.healthElement.className = 'health'; + this.chargeElement.className = 'charge'; + + this.statsElement.className = 'stats'; + this.killCountElement.className = 'value'; + this.deathCountElement.className = 'value'; + const killStat = document.createElement('span'); + killStat.className = 'stat kills'; + killStat.innerHTML = killIcon; + killStat.appendChild(this.killCountElement); + const deathStat = document.createElement('span'); + deathStat.className = 'stat deaths'; + deathStat.innerHTML = deathIcon; + deathStat.appendChild(this.deathCountElement); + this.statsElement.append(killStat, deathStat); + + this.nameElement.appendChild(this.healthElement); + this.nameElement.appendChild(this.chargeElement); + this.nameElement.appendChild(this.statsElement); + } + + public get position(): vec2 { + return this.head!.center; + } + + public get bodyCenter(): vec2 { + const center = vec2.add(vec2.create(), this.head!.center, this.leftFoot!.center); + vec2.add(center, center, this.rightFoot!.center); + return vec2.scale(center, center, 1 / 3); + } + + public get facingDirection(): vec2 { + const footAverage = vec2.add( + vec2.create(), + this.leftFoot!.center, + this.rightFoot!.center, + ); + vec2.scale(footAverage, footAverage, 0.5); + const forward = vec2.subtract(footAverage, this.head!.center, footAverage); + return vec2.length(forward) > 0 + ? vec2.normalize(forward, forward) + : vec2.fromValues(0, 1); + } + + public get strengthFraction(): number { + return clamp01(this.strength / settings.playerMaxStrength); + } + + private updateProperty({ + propertyKey, + propertyValue, + rateOfChange, + }: UpdatePropertyCommand) { + if (propertyKey === 'head') { + this.headInterpolator.addFrame(propertyValue, rateOfChange); + } + if (propertyKey === 'leftFoot') { + this.leftFootInterpolator.addFrame(propertyValue, rateOfChange); + } + if (propertyKey === 'rightFoot') { + this.rightFootInterpolator.addFrame(propertyValue, rateOfChange); + } + if (propertyKey === 'strength') { + this.strengthInterpolator.addFrame(propertyValue, rateOfChange); + } + } + + public setHealth(health: number) { + const damage = this.health - health; + super.setHealth(health); + + if (damage > 0) { + SoundHandler.play( + Sounds.hit, + Math.min(1, (0.8 * damage) / settings.playerMaxStrength), + ); + this.hitFlashIntensity = Math.min(1, 0.4 + damage / settings.playerMaxStrength); + + if (this.isMainCharacter) { + VibrationHandler.vibrate(Math.min(200, damage * 4)); + // Getting hit jolts the frame too, so taking fire has weight, not just + // dealing it. + ScreenShake.add(clamp01(0.12 + (0.5 * damage) / settings.playerMaxStrength)); + } + } + } + + public onDie() { + if (this.isMainCharacter) { + VibrationHandler.vibrate(150); + } + // Visible to everyone who can see the body: a white-hot flash plus a + // full-body whiteout, so a kill reads as a violent burst rather than the + // character quietly blinking out. + this.deathBurstIntensity = 1; + this.hitFlashIntensity = 1; + } + + public onHitConfirmed(charge = 0) { + if (!this.isMainCharacter) { + return; + } + // Layer a meaty thud under the crisp confirmation tick; a charged hit lands + // lower and harder than a panic tap. + SoundHandler.play(Sounds.hit, mix(0.35, 0.7, charge), mix(1.3, 0.95, charge)); + SoundHandler.play(Sounds.click, mix(0.4, 0.75, charge), mix(1.7, 1.1, charge)); + ScreenShake.add(mix(0.22, 0.5, charge)); + VibrationHandler.vibrate(Math.round(mix(12, 35, charge))); + FeedbackHud.hitMarker(charge); + } + + public onKillConfirmed(victimName?: string, streak = 1, charge = 0) { + if (!this.isMainCharacter) { + return; + } + // A heavy low thud for the kill with a brighter confirmation over the top, + // a hard frame jolt, a zoom-punch toward the action for weight, and a + // double-thump rumble. + SoundHandler.play(Sounds.hit, 1, mix(0.62, 0.5, charge)); + SoundHandler.play(Sounds.click, 0.9, mix(0.6, 0.45, charge)); + ScreenShake.add(mix(0.75, 1, charge)); + ScreenShake.addPunch(mix(0.7, 1, charge)); + VibrationHandler.vibrate([45, 35, Math.round(mix(80, 130, charge))]); + FeedbackHud.killConfirmed(victimName, streak, charge); + } + + public onLeap() { + if (!this.isMainCharacter) { + return; + } + SoundHandler.play(Sounds.shoot, 0.3, 1.5); + } + + private step({ deltaTimeInSeconds }: StepCommand): void { + this.head! = this.headInterpolator.getValue(deltaTimeInSeconds); + this.leftFoot! = this.leftFootInterpolator.getValue(deltaTimeInSeconds); + this.rightFoot! = this.rightFootInterpolator.getValue(deltaTimeInSeconds); + + this.strength = clamp( + this.strengthInterpolator.getValue(deltaTimeInSeconds), + 0, + settings.playerMaxStrength, + ); + + if (this.muzzleFlashIntensity > 0) { + this.muzzleFlashIntensity = Math.max( + 0, + this.muzzleFlashIntensity - deltaTimeInSeconds / muzzleFlashDecaySeconds, + ); + this.muzzleFlash.center = this.head!.center; + this.muzzleFlash.intensity = this.muzzleFlashIntensity; + } + + if (this.hitFlashIntensity > 0) { + this.hitFlashIntensity = Math.max( + 0, + this.hitFlashIntensity - deltaTimeInSeconds / hitFlashDecaySeconds, + ); + } + + if (this.deathBurstIntensity > 0) { + this.deathBurstIntensity = Math.max( + 0, + this.deathBurstIntensity - deltaTimeInSeconds / deathBurstDecaySeconds, + ); + this.deathBurst.center = this.bodyCenter; + // Square the envelope so the flash blooms then drops off sharply rather + // than fading out in a flat ramp. + this.deathBurst.intensity = + deathBurstMaxIntensity * this.deathBurstIntensity * this.deathBurstIntensity; + } + } + + public onShoot(strength: number) { + const q = clamp01( + (strength - settings.chargeShotStrengthMin) / + (settings.chargeShotStrengthMax - settings.chargeShotStrengthMin), + ); + SoundHandler.play(Sounds.shoot, mix(0.55, 1, q), mix(1.15, 0.8, q)); + this.muzzleFlashIntensity = mix(0.35, 1, q); + } + + private beforeDestroy(): void { + this.nameElement.parentElement?.removeChild(this.nameElement); + } + + private draw({ renderer, overlay, shouldChangeLayout }: RenderCommand): void { + if (shouldChangeLayout) { + if (!this.nameElement.parentElement) { + overlay.appendChild(this.nameElement); + } + + const screenPosition = renderer.worldToDisplayCoordinates( + this.calculateTextPosition(), + ); + + this.nameElement.style.transform = `translateX(${screenPosition.x}px) translateY(${screenPosition.y}px) translateX(-50%) translateY(-50%) rotate(-15deg)`; + + this.healthElement.style.width = + (50 * this.health) / settings.playerMaxHealth + 'px'; + this.chargeElement.style.width = + (50 * this.strength) / settings.playerMaxStrength + 'px'; + this.killCountElement.innerText = String(this.killCount); + this.deathCountElement.innerText = String(this.deathCount); + } + + this.shape.hitFlash = this.hitFlashIntensity; + this.shape.gazeTarget = this.calculateGazeTarget(renderer); + this.shape.setCircles([this.head!, this.leftFoot!, this.rightFoot!]); + renderer.addDrawable(this.shape); + + if (this.muzzleFlashIntensity > 0) { + renderer.addDrawable(this.muzzleFlash); + } + + if (this.deathBurstIntensity > 0) { + renderer.addDrawable(this.deathBurst); + } + } + + private calculateGazeTarget(renderer: Renderer): vec2 { + const cursor = Pointer.getDisplayPosition(); + if (this.isMainCharacter && cursor) { + return renderer.displayToWorldCoordinates(cursor); + } + + return vec2.scaleAndAdd( + vec2.create(), + this.head!.center, + this.facingDirection, + this.head!.radius * 8, + ); + } + + private calculateTextPosition(): vec2 { + const footAverage = vec2.add( + vec2.create(), + this.leftFoot!.center, + this.rightFoot!.center, + ); + vec2.scale(footAverage, footAverage, 0.5); + + const headFeetDelta = vec2.subtract(footAverage, this.head!.center, footAverage); + vec2.normalize(headFeetDelta, headFeetDelta); + const textOffset = vec2.scale(headFeetDelta, headFeetDelta, this.head!.radius + 80); + return vec2.add(textOffset, this.head!.center, textOffset); + } +} diff --git a/frontend/src/scripts/objects/types/character.ts b/frontend/src/scripts/objects/types/character.ts deleted file mode 100644 index 256d295..0000000 --- a/frontend/src/scripts/objects/types/character.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { vec2 } from 'gl-matrix'; -import { Circle } from '../../drawing/drawables/primitives/circle'; -import { KeyDownCommand } from '../../input/commands/key-down'; -import { KeyUpCommand } from '../../input/commands/key-up'; -import { SwipeCommand } from '../../input/commands/swipe'; -import { MoveToCommand } from '../../physics/commands/move-to'; -import { StepCommand } from '../../physics/commands/step'; -import { TeleportToCommand } from '../../physics/commands/teleport-to'; -import { Physics } from '../../physics/physics'; -import { GameObject } from '../game-object'; -import { Camera } from './camera'; - -export class Character extends GameObject { - private keysDown: Set = new Set(); - - private primitive: Circle; - private static speed = 1.5; - - constructor(private physics: Physics, private camera: Camera) { - super(); - - this.primitive = new Circle(this); - this.primitive.radius = 40; - - this.addCommandExecutor(StepCommand, this.stepHandler.bind(this)); - this.addCommandExecutor(TeleportToCommand, (c) => - this.setPosition(c.position) - ); - this.addCommandExecutor(KeyDownCommand, (c) => this.keysDown.add(c.key)); - this.addCommandExecutor(KeyUpCommand, (c) => this.keysDown.delete(c.key)); - this.addCommandExecutor(SwipeCommand, (c) => - this.checkAndSetPosition( - vec2.add( - vec2.create(), - this.primitive.center, - vec2.multiply(vec2.create(), c.delta, this.camera.viewAreaSize) - ) - ) - ); - } - - private checkAndSetPosition(value: vec2) { - const nextPrimitive = this.primitive.clone(); - nextPrimitive.center = value; - - if ( - this.physics - .findIntersecting(nextPrimitive.boundingBox) - .filter((b) => b.value) - .map( - (b) => b.value.distance(nextPrimitive.center) + nextPrimitive.radius - ) - .find((d) => d < 0) !== undefined - ) { - this.setPosition(value); - } - } - - private setPosition(value: vec2) { - // console.log('character', value); - - this.primitive.center = value; - this.camera.sendCommand(new MoveToCommand(this.primitive.center)); - } - - public stepHandler(c: StepCommand) { - const deltaTime = c.deltaTimeInMiliseconds; - - const up = ~~this.keysDown.has('w'); - const down = ~~this.keysDown.has('s'); - const left = ~~this.keysDown.has('a'); - const right = ~~this.keysDown.has('d'); - - const movementVector = vec2.fromValues(right - left, up - down); - if (movementVector.length > 0) { - vec2.normalize(movementVector, movementVector); - vec2.scale(movementVector, movementVector, Character.speed * deltaTime); - - this.checkAndSetPosition( - vec2.add(vec2.create(), this.primitive.center, movementVector) - ); - } - } -} diff --git a/frontend/src/scripts/objects/types/info-text.ts b/frontend/src/scripts/objects/types/info-text.ts deleted file mode 100644 index 88a88dc..0000000 --- a/frontend/src/scripts/objects/types/info-text.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { GameObject } from '../game-object'; -import { RenderCommand } from '../../drawing/commands/render'; - -export class InfoText extends GameObject { - constructor() { - super(); - - this.addCommandExecutor(RenderCommand, this.draw.bind(this)); - } - - private static records: Map = new Map(); - - public static modifyRecord(key: string, value: string) { - InfoText.records.set(key, value); - } - - private draw(e: RenderCommand) { - let text = ''; - InfoText.records.forEach((v, k) => (text += `${k}\n\t${v}\n`)); - e.renderer.drawInfoText(text); - } -} diff --git a/frontend/src/scripts/objects/types/lamp-view.ts b/frontend/src/scripts/objects/types/lamp-view.ts new file mode 100644 index 0000000..e4db4f6 --- /dev/null +++ b/frontend/src/scripts/objects/types/lamp-view.ts @@ -0,0 +1,39 @@ +import { vec2, vec3 } from 'gl-matrix'; +import { CircleLight } from 'sdf-2d'; +import { CommandExecutors, Id, LampBase, mixRgb, settings } from 'shared'; +import { RenderCommand } from '../../commands/types/render'; +import { StepCommand } from '../../commands/types/step'; + +export class LampView extends LampBase { + private light: CircleLight; + + private targetColor: vec3; + private targetLightness: number; + + protected commandExecutors: CommandExecutors = { + [RenderCommand.type]: this.draw.bind(this), + [StepCommand.type]: this.step.bind(this), + }; + + constructor(id: Id, center: vec2, color: vec3, lightness: number) { + super(id, center, color, lightness); + this.light = new CircleLight(vec2.clone(center), vec3.clone(color), lightness); + this.targetColor = vec3.clone(color); + this.targetLightness = lightness; + } + + public setLight(color: vec3, lightness: number) { + this.targetColor = vec3.clone(color); + this.targetLightness = lightness; + } + + private step({ deltaTimeInSeconds }: StepCommand): void { + const t = 1 - Math.exp(-deltaTimeInSeconds / settings.lampLerpSeconds); + this.light.color = mixRgb(this.light.color, this.targetColor, t); + this.light.intensity += (this.targetLightness - this.light.intensity) * t; + } + + private draw({ renderer }: RenderCommand): void { + renderer.addDrawable(this.light); + } +} diff --git a/frontend/src/scripts/objects/types/lamp.ts b/frontend/src/scripts/objects/types/lamp.ts deleted file mode 100644 index fbb19af..0000000 --- a/frontend/src/scripts/objects/types/lamp.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { vec2, vec3 } from 'gl-matrix'; -import { GameObject } from '../game-object'; -import { CircleLight } from '../../drawing/drawables/lights/circle-light'; -import { RenderCommand } from '../../drawing/commands/render'; -import { MoveToCommand } from '../../physics/commands/move-to'; - -export class Lamp extends GameObject { - private light: CircleLight; - - constructor(center: vec2, radius: number, color: vec3, lightness: number) { - super(); - - this.light = new CircleLight(this, center, radius, color, lightness); - - this.addCommandExecutor(RenderCommand, this.draw.bind(this)); - this.addCommandExecutor(MoveToCommand, this.moveTo.bind(this)); - } - - private draw(c: RenderCommand) { - c.renderer.drawLight(this.light); - } - - private moveTo(c: MoveToCommand) { - this.light.center = c.position; - } -} diff --git a/frontend/src/scripts/objects/types/planet-view.ts b/frontend/src/scripts/objects/types/planet-view.ts new file mode 100644 index 0000000..06e6880 --- /dev/null +++ b/frontend/src/scripts/objects/types/planet-view.ts @@ -0,0 +1,217 @@ +import { vec2, vec3 } from 'gl-matrix'; +import { CircleLight } from 'sdf-2d'; +import { + Id, + Random, + PlanetBase, + UpdatePropertyCommand, + CommandExecutors, + CharacterTeam, + settings, +} from 'shared'; +import { BeforeDestroyCommand } from '../../commands/types/before-destroy'; +import { RenderCommand } from '../../commands/types/render'; +import { StepCommand } from '../../commands/types/step'; +import { LinearInterpolator } from '../../helper/interpolators/linear-interpolator'; +import { PlanetShape } from '../../shapes/planet-shape'; + +const fallingPointLifetimeMs = 2000; + +// Global budget for simultaneously-lit capture flares, so a clustered wave of +// captures can never white out the SDF exposure — excess flips still pulse the +// ring and toast, they just skip the extra light. The acquire/release pair keeps +// the count in one place instead of being hand-maintained at every call site. +abstract class FlareBudget { + private static active = 0; + + public static tryAcquire(): boolean { + if (FlareBudget.active >= settings.maxConcurrentFlipFlares) { + return false; + } + FlareBudget.active++; + return true; + } + + public static release(): void { + FlareBudget.active = Math.max(0, FlareBudget.active - 1); + } +} + +export class PlanetView extends PlanetBase { + private shape: PlanetShape; + private ownershipProgress: HTMLElement; + // Rotation is owned by the backend (it drives the collision polygon too) and + // streamed in; the interpolator replays the angle on the shared snapshot + // timeline, in sync with the characters standing on the surface. + private readonly rotationInterpolator = new LinearInterpolator(0); + + private flareLight?: CircleLight; + private flareIntensity = 0; + private holdsFlareSlot = false; + + protected commandExecutors: CommandExecutors = { + [RenderCommand.type]: this.draw.bind(this), + [StepCommand.type]: this.step.bind(this), + [BeforeDestroyCommand.type]: this.beforeDestroy.bind(this), + [UpdatePropertyCommand.type]: this.updateProperty.bind(this), + }; + + constructor(id: Id, vertices: Array, ownership = 0.5, isKeystone = false) { + super(id, vertices, ownership, isKeystone); + this.shape = new PlanetShape(vertices, ownership); + this.shape.randomOffset = Random.getRandom(); + + this.ownershipProgress = document.createElement('div'); + this.ownershipProgress.className = 'ownership' + (isKeystone ? ' keystone' : ''); + } + + public setContested(contested: boolean) { + this.ownershipProgress.classList.toggle('contested', contested); + } + + private renderedRotation = 0; + private rotationSpeed = 0; + // Newest streamed rotation VALUE — the server-current angle at the latest + // snapshot — as opposed to renderedRotation, which the interpolator holds + // ~interpolationDelaySeconds in the PAST for drawing. The predictor seeds the + // local body from the same snapshot's pose, so it must collide against the + // planet at THIS (newest) phase and advance forward from it; using the drawn + // lagged angle biases the body off the surface by omega*delay*radius and + // wobbles it whenever the spin or the interpolator's rate cursor varies. + private latestRotation = 0; + public get predictionRotation(): number { + return this.latestRotation; + } + public get predictionRotationSpeed(): number { + return this.rotationSpeed; + } + + private step({ deltaTimeInSeconds }: StepCommand): void { + this.renderedRotation = this.rotationInterpolator.getValue(deltaTimeInSeconds); + this.shape.rotation = this.renderedRotation; + this.shape.colorMixQ = this.ownership; + + if (this.flareIntensity > 0) { + this.flareIntensity = Math.max( + 0, + this.flareIntensity - deltaTimeInSeconds / settings.lampFlareDecaySeconds, + ); + + if (this.flareLight) { + this.flareLight.intensity = + settings.lampFlareIntensity * this.flareIntensity * this.flareIntensity; + } + + if (this.flareIntensity === 0) { + this.releaseFlareSlot(); + } + } + } + + private releaseFlareSlot(): void { + if (this.holdsFlareSlot) { + this.holdsFlareSlot = false; + FlareBudget.release(); + } + } + + private lastGeneratedPoint?: number; + public generatedPoints(value: number) { + this.lastGeneratedPoint = value; + } + + public onFlipped(team: CharacterTeam): void { + const color = settings.palette[settings.colorIndices[team]]; + + if (!this.flareLight) { + this.flareLight = new CircleLight(vec2.clone(this.center), vec3.clone(color), 0); + } else { + this.flareLight.color = vec3.clone(color); + } + + if (!this.holdsFlareSlot) { + if (!FlareBudget.tryAcquire()) { + return; + } + this.holdsFlareSlot = true; + } + + this.flareIntensity = 1; + } + + private beforeDestroy(): void { + this.ownershipProgress.parentElement?.removeChild(this.ownershipProgress); + this.releaseFlareSlot(); + } + + private updateProperty({ + propertyKey, + propertyValue, + rateOfChange, + }: UpdatePropertyCommand): void { + if (propertyKey === 'rotation') { + this.rotationInterpolator.addFrame(propertyValue, rateOfChange); + this.latestRotation = propertyValue; + this.rotationSpeed = rateOfChange; + } else { + this.ownership = propertyValue; + } + } + + private draw({ renderer, overlay, shouldChangeLayout }: RenderCommand): void { + if (shouldChangeLayout) { + if (!this.ownershipProgress.parentElement) { + overlay.appendChild(this.ownershipProgress); + } + + const screenPosition = renderer.worldToDisplayCoordinates(this.center); + + this.ownershipProgress.style.transform = `translateX(${screenPosition.x}px) translateY(${screenPosition.y}px) translateX(-50%) translateY(-50%)`; + this.ownershipProgress.style.background = this.getGradient(); + + if (this.lastGeneratedPoint !== undefined) { + const element = document.createElement('div'); + element.className = 'falling-point ' + (this.ownership < 0.5 ? 'blue' : 'red'); + element.innerText = '+' + this.lastGeneratedPoint; + element.style.left = `${screenPosition.x}px`; + element.style.top = `${screenPosition.y}px`; + overlay.appendChild(element); + setTimeout( + () => element.parentElement?.removeChild(element), + fallingPointLifetimeMs, + ); + + this.lastGeneratedPoint = undefined; + } + } + + renderer.addDrawable(this.shape); + + if (this.flareIntensity > 0 && this.flareLight) { + renderer.addDrawable(this.flareLight); + } + } + + private getGradient(): string { + const sideBlue = this.ownership < 0.5; + // Keep the ring neutral through the same dead-band that gates scoring + // (settings.planetControlThreshold), so "the ring fills" and "this planet + // pays my team" happen together rather than disagreeing. + const control = Math.abs(this.ownership - 0.5); + const t = settings.planetControlThreshold; + const sidePercent = control <= t ? 0 : ((control - t) / (0.5 - t)) * 100; + return sideBlue + ? `conic-gradient( + var(--bright-blue) ${sidePercent}%, + var(--bright-blue) ${sidePercent}%, + rgba(0, 0, 0, 0) ${sidePercent}%, + rgba(0, 0, 0, 0) 100% + )` + : `conic-gradient( + rgba(0, 0, 0, 0) 0%, + rgba(0, 0, 0, 0) ${100 - sidePercent}%, + var(--bright-red) ${100 - sidePercent}%, + var(--bright-red) 100% + )`; + } +} diff --git a/frontend/src/scripts/objects/types/projectile-view.ts b/frontend/src/scripts/objects/types/projectile-view.ts new file mode 100644 index 0000000..5843e59 --- /dev/null +++ b/frontend/src/scripts/objects/types/projectile-view.ts @@ -0,0 +1,60 @@ +import { vec2 } from 'gl-matrix'; +import { CircleLight } from 'sdf-2d'; +import { + CharacterTeam, + CommandExecutors, + Id, + ProjectileBase, + settings, + UpdatePropertyCommand, +} from 'shared'; +import { RenderCommand } from '../../commands/types/render'; +import { StepCommand } from '../../commands/types/step'; +import { Vec2Interpolator } from '../../helper/interpolators/vec2-interpolator'; + +export class ProjectileView extends ProjectileBase { + private light: CircleLight; + + private centerInterpolator: Vec2Interpolator; + + protected commandExecutors: CommandExecutors = { + [RenderCommand.type]: this.draw.bind(this), + [StepCommand.type]: this.handleStep.bind(this), + [UpdatePropertyCommand.type]: this.updateProperty.bind(this), + }; + + constructor( + id: Id, + center: vec2, + radius: number, + team: CharacterTeam, + strength: number, + ) { + super(id, center, radius, team, strength); + this.light = new CircleLight( + center, + settings.paletteDim[settings.colorIndices[team]], + 0, + ); + this.centerInterpolator = new Vec2Interpolator(center); + } + + private updateProperty({ propertyValue, rateOfChange }: UpdatePropertyCommand): void { + this.centerInterpolator.addFrame(propertyValue, rateOfChange); + } + + private handleStep({ deltaTimeInSeconds }: StepCommand): void { + this.step(deltaTimeInSeconds); + + this.center = this.centerInterpolator.getValue(deltaTimeInSeconds); + this.light.center = this.center; + this.light.intensity = Math.min( + 0.1, + (0.15 * this.strength) / settings.projectileMaxStrength, + ); + } + + private draw({ renderer }: RenderCommand): void { + renderer.addDrawable(this.light); + } +} diff --git a/frontend/src/scripts/objects/types/tunnel.ts b/frontend/src/scripts/objects/types/tunnel.ts deleted file mode 100644 index 7e8f972..0000000 --- a/frontend/src/scripts/objects/types/tunnel.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { vec2 } from 'gl-matrix'; -import { GameObject } from '../game-object'; -import { RenderCommand } from '../../drawing/commands/render'; -import { Physics } from '../../physics/physics'; -import { TunnelShape } from '../../drawing/drawables/primitives/tunnel-shape'; - -export interface Line {} - -export class Tunnel extends GameObject { - private primitive: TunnelShape; - - constructor( - physics: Physics, - public readonly from: vec2, - to: vec2, - fromRadius: number, - toRadius: number - ) { - super(); - - this.primitive = new TunnelShape(this, from, to, fromRadius, toRadius); - physics.addStaticBoundingBox(this.primitive.boundingBox); - this.addCommandExecutor(RenderCommand, this.draw.bind(this)); - } - - private draw(c: RenderCommand) { - c.renderer.drawPrimitive(this.primitive); - } -} diff --git a/frontend/src/scripts/objects/world/create-character.ts b/frontend/src/scripts/objects/world/create-character.ts deleted file mode 100644 index 8f0e1e0..0000000 --- a/frontend/src/scripts/objects/world/create-character.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { vec2, vec3 } from 'gl-matrix'; -import { Objects } from '../objects'; -import { Camera } from '../types/camera'; -import { Character } from '../types/character'; -import { Lamp } from '../types/lamp'; -import { Physics } from '../../physics/physics'; -import { GameObject } from '../game-object'; - -export const createCharacter = ( - objects: Objects, - physics: Physics -): GameObject => { - const light = new Lamp( - vec2.create(), - 40, - vec3.fromValues(0.67, 0.0, 0.33), - 2 - ); - - const camera = new Camera(physics, light); - const character = new Character(physics, camera); - objects.addObject(light); - objects.addObject(camera); - objects.addObject(character); - - return character; -}; diff --git a/frontend/src/scripts/objects/world/create-dungeon.ts b/frontend/src/scripts/objects/world/create-dungeon.ts deleted file mode 100644 index a2b6d94..0000000 --- a/frontend/src/scripts/objects/world/create-dungeon.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { vec2 } from 'gl-matrix'; -import { Physics } from '../../physics/physics'; -import { Objects } from '../objects'; -import { Tunnel } from '../types/tunnel'; - -export const createDungeon = (objects: Objects, physics: Physics): Tunnel => { - let previousRadius = 350; - let previousEnd = vec2.create(); - - let first: Tunnel; - - for (let i = 0; i < 50000; i += 500) { - const deltaHeight = (Math.random() - 0.5) * 2000; - const height = previousEnd.y + deltaHeight; - const currentEnd = vec2.fromValues(i, height); - const currentToRadius = Math.random() * 300 + 150; - - const tunnel = new Tunnel( - physics, - previousEnd, - currentEnd, - previousRadius, - currentToRadius - ); - - if (!first) { - first = tunnel; - } - - objects.addObject(tunnel); - - /*if (deltaHeight > 0 && Math.random() > 0.8) { - objects.addObject( - new Lamp( - currentEnd, - Math.random() * 20 + 30, - vec3.scale( - vec3.create(), - vec3.normalize(vec3.create(), vec3.fromValues(0.5, 0.1, 0.8)), - Math.random() * 0.5 + 0.5 - ), - 1 - ) - ); - }*/ - - previousEnd = currentEnd; - previousRadius = currentToRadius; - } - - return first; -}; diff --git a/frontend/src/scripts/options-handler.ts b/frontend/src/scripts/options-handler.ts new file mode 100644 index 0000000..8479fb4 --- /dev/null +++ b/frontend/src/scripts/options-handler.ts @@ -0,0 +1,77 @@ +import { SoundHandler, Sounds } from './sound-handler'; +import { VibrationHandler } from './vibration-handler'; + +interface Options { + vibrationEnabled: boolean; + soundsEnabled: boolean; + musicEnabled: boolean; +} + +export abstract class OptionsHandler { + private static initialized = false; + private static _options: Options = { + vibrationEnabled: true, + soundsEnabled: true, + musicEnabled: true, + }; + + public static initialize(inputElements: { + [k in Extract]: HTMLInputElement; + }) { + if (localStorage.getItem('options')) { + const stored: Partial | null = JSON.parse( + localStorage.getItem('options')!, + ); + this._options = { + ...this._options, + ...stored, + }; + } + + if (this._options.musicEnabled) { + SoundHandler.playAmbient(); + } + + for (const k in inputElements) { + const element = inputElements[k as keyof Options]; + element.checked = OptionsHandler._options[k as keyof Options]; + element.addEventListener('change', function () { + OptionsHandler._options[k as keyof Options] = this.checked; + if (!this.checked && k === 'soundsEnabled') { + OptionsHandler._options.musicEnabled = false; + inputElements.musicEnabled.checked = false; + SoundHandler.stopAmbient(); + } + + if (k === 'musicEnabled') { + if (this.checked) { + SoundHandler.playAmbient(); + } else { + SoundHandler.stopAmbient(); + } + } + + if (this.checked && k === 'vibrationEnabled') { + VibrationHandler.vibrate(100); + } + + SoundHandler.play(Sounds.click); + OptionsHandler.save(); + }); + } + + this.initialized = true; + } + + private static save() { + localStorage.setItem('options', JSON.stringify(this._options)); + } + + public static get options(): Options { + if (!this.initialized) { + throw new Error('Uninitialized'); + } + + return this._options; + } +} diff --git a/frontend/src/scripts/physics/commands/move-to.ts b/frontend/src/scripts/physics/commands/move-to.ts deleted file mode 100644 index 1e9810f..0000000 --- a/frontend/src/scripts/physics/commands/move-to.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { vec2 } from 'gl-matrix'; -import { Command } from '../../commands/command'; - -export class MoveToCommand extends Command { - public constructor(public readonly position?: vec2) { - super(); - } - - public get type(): string { - return 'MoveToCommand'; - } -} diff --git a/frontend/src/scripts/physics/commands/step.ts b/frontend/src/scripts/physics/commands/step.ts deleted file mode 100644 index 8df26a5..0000000 --- a/frontend/src/scripts/physics/commands/step.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Command } from '../../commands/command'; - -export class StepCommand extends Command { - public constructor( - public readonly deltaTimeInMiliseconds?: DOMHighResTimeStamp - ) { - super(); - } - - public get type(): string { - return 'StepCommand'; - } -} diff --git a/frontend/src/scripts/physics/commands/teleport-to.ts b/frontend/src/scripts/physics/commands/teleport-to.ts deleted file mode 100644 index 46d1758..0000000 --- a/frontend/src/scripts/physics/commands/teleport-to.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { vec2 } from 'gl-matrix'; -import { Command } from '../../commands/command'; - -export class TeleportToCommand extends Command { - public constructor(public readonly position?: vec2) { - super(); - } - - public get type(): string { - return 'TeleportToCommand'; - } -} diff --git a/frontend/src/scripts/physics/containers/bounding-box-list.ts b/frontend/src/scripts/physics/containers/bounding-box-list.ts deleted file mode 100644 index a16f70e..0000000 --- a/frontend/src/scripts/physics/containers/bounding-box-list.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { BoundingBoxBase } from './bounding-box-base'; - -export class BoundingBoxList { - constructor(private boundingBoxes: Array = []) {} - - public insert(box: BoundingBoxBase) { - this.boundingBoxes.push(box); - } - - public findIntersecting(box: BoundingBoxBase): Array { - return this.boundingBoxes.filter((b) => b.intersects(box)); - } -} diff --git a/frontend/src/scripts/physics/containers/bounding-box-tree.ts b/frontend/src/scripts/physics/containers/bounding-box-tree.ts deleted file mode 100644 index 5598838..0000000 --- a/frontend/src/scripts/physics/containers/bounding-box-tree.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { ImmutableBoundingBox } from './immutable-bounding-box'; - -// source: https://github.com/ubilabs/kd-tree-javascript/blob/master/kdTree.js - -class Node { - public left?: Node = null; - public right?: Node = null; - - constructor(public rectangle: ImmutableBoundingBox, public parent: Node) {} -} - -export class BoundingBoxTree { - root?: Node; - - constructor(boxes: Array = []) { - this.build(boxes); - } - - public build(boxes: Array) { - this.root = this.buildRecursive(boxes, 0, null); - } - - private buildRecursive( - boxes: Array, - depth: number, - parent: Node - ): Node { - if (boxes.length === 0) { - return null; - } - - if (boxes.length === 1) { - return new Node(boxes[0], parent); - } - - const dimension = depth % 4; - - boxes.sort((a, b) => a[dimension] - b[dimension]); - - const median = Math.floor(boxes.length / 2); - - const node = new Node(boxes[median], parent); - node.left = this.buildRecursive(boxes.slice(0, median), depth + 1, node); - node.right = this.buildRecursive(boxes.slice(median + 1), depth + 1, node); - - return node; - } - - public insert(box: ImmutableBoundingBox) { - const [insertPosition, depth] = this.findParent(box, this.root, 0, null); - - if (insertPosition === null) { - this.root = new Node(box, null); - } else { - const node = new Node(box, insertPosition); - const dimension = depth % 4; - - if (box[dimension] < insertPosition.rectangle[dimension]) { - insertPosition.left = node; - } else { - insertPosition.right = node; - } - } - } - - public print() { - this.printRecursive(this.root, 0); - } - - private printRecursive(node: Node, tabCount: number) { - if (node === null) { - return; - } - - console.log(' '.repeat(tabCount) + '- ' + node.rectangle.value); - this.printRecursive(node.left, tabCount + 2); - this.printRecursive(node.right, tabCount + 2); - } - - public findIntersecting( - box: ImmutableBoundingBox - ): Array { - const maybeResults = this.findMaybeIntersecting(box, this.root, 0); - const results = maybeResults.filter((b) => b.intersects(box)); - return results; - } - - private findMaybeIntersecting( - box: ImmutableBoundingBox, - node: Node, - depth: number - ): Array { - if (node === null) { - return []; - } - - if (depth % 4 == 0 && box.xMax < node.rectangle.xMin) { - return this.findMaybeIntersecting(box, node.left, depth + 1); - } - - if (depth % 4 == 1 && box.xMin > node.rectangle.xMax) { - return this.findMaybeIntersecting(box, node.right, depth + 1); - } - - if (depth % 4 == 2 && box.yMax < node.rectangle.yMin) { - return this.findMaybeIntersecting(box, node.left, depth + 1); - } - - if (depth % 4 == 3 && box.yMin > node.rectangle.yMax) { - return this.findMaybeIntersecting(box, node.right, depth + 1); - } - - return [ - node.rectangle, - ...this.findMaybeIntersecting(box, node.left, depth + 1), - ...this.findMaybeIntersecting(box, node.right, depth + 1), - ]; - } - - private findParent( - box: ImmutableBoundingBox, - node: Node, - depth: number, - parent: Node - ): [Node, number] { - if (node === null) { - return [parent, depth - 1]; - } - - const dimension = depth % 4; - - if (box[dimension] < node.rectangle[dimension]) { - return this.findParent(box, node.left, depth + 1, node); - } - - return this.findParent(box, node.right, depth + 1, node); - } -} diff --git a/frontend/src/scripts/physics/containers/bounding-box.ts b/frontend/src/scripts/physics/containers/bounding-box.ts deleted file mode 100644 index 5fda1ab..0000000 --- a/frontend/src/scripts/physics/containers/bounding-box.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { vec2 } from 'gl-matrix'; -import { BoundingBoxBase } from './bounding-box-base'; -import { IPrimitive } from '../../drawing/drawables/primitives/i-primitive'; - -export class BoundingBox extends BoundingBoxBase { - constructor( - value: IPrimitive, - xMin: number = 0, - xMax: number = 0, - yMin: number = 0, - yMax: number = 0 - ) { - super(value, xMin, xMax, yMin, yMax); - } - - public get topLeft(): vec2 { - return vec2.fromValues(this._xMin, this._yMax); - } - - public get size(): vec2 { - return vec2.fromValues(this._xMax - this._xMin, this._yMax - this._yMin); - } - 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; - } -} diff --git a/frontend/src/scripts/physics/containers/immutable-bounding-box.ts b/frontend/src/scripts/physics/containers/immutable-bounding-box.ts deleted file mode 100644 index 1aabc35..0000000 --- a/frontend/src/scripts/physics/containers/immutable-bounding-box.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { BoundingBoxBase } from './bounding-box-base'; -import { IPrimitive } from '../../drawing/drawables/primitives/i-primitive'; - -export class ImmutableBoundingBox extends BoundingBoxBase { - constructor( - value: IPrimitive, - xMin: number = 0, - xMax: number = 0, - yMin: number = 0, - yMax: number = 0 - ) { - super(value, xMin, xMax, yMin, yMax); - } -} diff --git a/frontend/src/scripts/physics/physics.ts b/frontend/src/scripts/physics/physics.ts deleted file mode 100644 index 0d541ba..0000000 --- a/frontend/src/scripts/physics/physics.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { BoundingBoxTree } from './containers/bounding-box-tree'; -import { BoundingBoxList } from './containers/bounding-box-list'; -import { ImmutableBoundingBox } from './containers/immutable-bounding-box'; -import { BoundingBoxBase } from './containers/bounding-box-base'; - -export class Physics { - private isTreeInitialized = false; - private staticBoundingBoxesWaitList = []; - private staticBoundingBoxes = new BoundingBoxTree(); - - private dynamicBoundingBoxes = new BoundingBoxList(); - - public addStaticBoundingBox(boundingBox: ImmutableBoundingBox) { - if (!this.isTreeInitialized) { - this.staticBoundingBoxesWaitList.push(boundingBox); - } else { - this.staticBoundingBoxes.insert(boundingBox); - } - } - - public addDynamicBoundingBox(boundingBox: BoundingBoxBase) { - this.dynamicBoundingBoxes.insert(boundingBox); - } - - public findIntersecting(box: BoundingBoxBase): Array { - return [ - ...this.staticBoundingBoxes.findIntersecting(box), - ...this.dynamicBoundingBoxes.findIntersecting(box), - ]; - } - - public start() { - this.staticBoundingBoxes.build(this.staticBoundingBoxesWaitList); - this.isTreeInitialized = true; - } -} diff --git a/frontend/src/scripts/scoreboard.ts b/frontend/src/scripts/scoreboard.ts new file mode 100644 index 0000000..41b02ba --- /dev/null +++ b/frontend/src/scripts/scoreboard.ts @@ -0,0 +1,71 @@ +import { CharacterTeam, UpdateGameState, clamp, settings } from 'shared'; + +// Centred tug-of-war territory bar. The two fills meet at the bar's centre and +// grow outward by each team's share of the score limit, so the meeting point +// reads as the lead at a glance. Numeric scores are overlaid (the leader's is +// emphasised) and a "YOU" tick marks the local team's half. Owns its own DOM so +// the Game just appends `element` to the overlay and feeds it `update()`. +export class Scoreboard { + public readonly element = document.createElement('div'); + + private readonly blueFill = document.createElement('div'); + private readonly redFill = document.createElement('div'); + private readonly blueScore = document.createElement('span'); + private readonly redScore = document.createElement('span'); + private readonly youMarker = document.createElement('div'); + + constructor() { + this.element.className = 'planet-progress'; + + this.blueFill.className = 'fill blue'; + this.redFill.className = 'fill red'; + + this.blueScore.className = 'score blue'; + this.redScore.className = 'score red'; + + this.youMarker.className = 'you-marker'; + this.youMarker.innerText = 'YOU'; + + this.element.append( + this.blueFill, + this.redFill, + this.blueScore, + this.redScore, + this.youMarker, + ); + } + + public update( + { blueCount, redCount, limit }: UpdateGameState, + localTeam: CharacterTeam | undefined, + ) { + const { scoreboardHalfWidthPercent, scoreboardMinFillPercent } = settings; + const fraction = (count: number) => + Math.max( + count > 0 ? scoreboardMinFillPercent : 0, + clamp(count / limit, 0, 1) * scoreboardHalfWidthPercent, + ); + + this.blueFill.style.width = fraction(blueCount) + '%'; + this.redFill.style.width = fraction(redCount) + '%'; + + const isMatchPoint = (count: number) => + count / limit >= settings.matchPointScoreRatio; + this.blueFill.classList.toggle('match-point', isMatchPoint(blueCount)); + this.redFill.classList.toggle('match-point', isMatchPoint(redCount)); + + this.blueScore.innerText = String(Math.round(blueCount)); + this.redScore.innerText = String(Math.round(redCount)); + + this.blueScore.classList.toggle('leading', blueCount > redCount); + this.redScore.classList.toggle('leading', redCount > blueCount); + + if (localTeam === CharacterTeam.blue || localTeam === CharacterTeam.red) { + this.youMarker.style.display = ''; + this.youMarker.classList.toggle('blue', localTeam === CharacterTeam.blue); + this.youMarker.classList.toggle('red', localTeam === CharacterTeam.red); + } else { + this.youMarker.style.display = 'none'; + } + } +} diff --git a/frontend/src/scripts/screen-shake.ts b/frontend/src/scripts/screen-shake.ts new file mode 100644 index 0000000..75c5bce --- /dev/null +++ b/frontend/src/scripts/screen-shake.ts @@ -0,0 +1,84 @@ +// Camera impact effects fed by combat hits, sampled by the camera at render +// time. Kept as a process-wide singleton so any view can feed it without +// threading a camera reference through the deserialized object graph. Both +// effects touch only the *rendered* view (centre offset + view-area zoom), never +// the followed position or the simulation clock — so they can't drift the camera +// off the player, and they're completely decoupled from prediction/netcode. +// +// Two effects: +// - shake: a trauma model (Eiserloh) — the felt shake is trauma SQUARED so +// small taps stay subtle while a kill punches hard; it bleeds off linearly. +// - punch: a brief zoom-in on a kill that snaps back fast, for "weight". +// +// Honours prefers-reduced-motion: callers can fire freely and it simply no-ops +// for players who have asked the OS to minimise motion. +export abstract class ScreenShake { + private static trauma = 0; + private static punch = 0; + + // Peak translation, in world units, at full trauma. The visible world is + // ~3800 units wide, so this tops out at a few percent of the screen. + private static readonly maxTranslation = 150; + // Fraction the view zooms in at full punch (smaller view area = zoomed in). + private static readonly maxZoom = 0.07; + private static readonly traumaDecayPerSecond = 1.7; + // Snaps back quickly so the punch reads as a sharp bite, not a slow drift. + private static readonly punchDecayPerSecond = 6; + + private static offsetXValue = 0; + private static offsetYValue = 0; + + private static get reducedMotion(): boolean { + return ( + typeof window !== 'undefined' && + typeof window.matchMedia === 'function' && + window.matchMedia('(prefers-reduced-motion: reduce)').matches + ); + } + + public static add(trauma: number): void { + if (this.reducedMotion) { + return; + } + this.trauma = Math.min(1, this.trauma + trauma); + } + + public static addPunch(amount: number): void { + if (this.reducedMotion) { + return; + } + this.punch = Math.min(1, this.punch + amount); + } + + public static step(deltaTimeInSeconds: number): void { + this.trauma = Math.max( + 0, + this.trauma - this.traumaDecayPerSecond * deltaTimeInSeconds, + ); + this.punch = Math.max(0, this.punch - this.punchDecayPerSecond * deltaTimeInSeconds); + const shake = this.trauma * this.trauma; + this.offsetXValue = this.maxTranslation * shake * (Math.random() * 2 - 1); + this.offsetYValue = this.maxTranslation * shake * (Math.random() * 2 - 1); + } + + public static get offsetX(): number { + return this.offsetXValue; + } + + public static get offsetY(): number { + return this.offsetYValue; + } + + // Multiplier for the view-area size: <1 zooms in. Squared so the punch bites + // sharply near its peak rather than ramping linearly. + public static get viewScale(): number { + return 1 - this.maxZoom * this.punch * this.punch; + } + + public static reset(): void { + this.trauma = 0; + this.punch = 0; + this.offsetXValue = 0; + this.offsetYValue = 0; + } +} diff --git a/frontend/src/scripts/shapes/character-shape.ts b/frontend/src/scripts/shapes/character-shape.ts new file mode 100644 index 0000000..711f70a --- /dev/null +++ b/frontend/src/scripts/shapes/character-shape.ts @@ -0,0 +1,219 @@ +import { mat2d, vec2 } from 'gl-matrix'; +import { Drawable, DrawableDescriptor } from 'sdf-2d'; +import { Circle } from 'shared'; + +// A single character silhouette shared by every team (teams differ only by +// colour): a circular head sitting on two thin line-legs that run down to the +// two feet. +export class CharacterShape extends Drawable { + public static descriptor: DrawableDescriptor = { + sdf: { + shader: ` + uniform vec2 characterHeadCenters[CHARACTER_COUNT]; + uniform vec2 characterLeftFeet[CHARACTER_COUNT]; + uniform vec2 characterRightFeet[CHARACTER_COUNT]; + uniform vec2 characterGazeTargets[CHARACTER_COUNT]; + uniform float characterHeadRadii[CHARACTER_COUNT]; + uniform float characterFootRadii[CHARACTER_COUNT]; + uniform int characterColors[CHARACTER_COUNT]; + uniform float characterFlash[CHARACTER_COUNT]; + + float circleDistance(vec2 circleCenter, float radius, vec2 target) { + return distance(target, circleCenter) - radius; + } + + // Distance to the segment a->b; subtract a radius to get a capsule (a + // rounded thick line). + float segmentDistance(vec2 a, vec2 b, vec2 target) { + vec2 pa = target - a; + vec2 ba = b - a; + float h = clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0); + return length(pa - ba * h); + } + + float characterMinDistance(vec2 target, out vec4 color) { + float minDistance = 1000.0; + + for (int i = 0; i < CHARACTER_COUNT; i++) { + vec2 head = characterHeadCenters[i]; + vec2 leftFoot = characterLeftFeet[i]; + vec2 rightFoot = characterRightFeet[i]; + float headRadius = characterHeadRadii[i]; + float footRadius = characterFootRadii[i]; + + // The server rotates the whole posture toward travel, so the head + // leads: forward is the facing direction, perp its 90deg rotation, so + // the face sits on the leading side and reads at any rotation. + vec2 footAverage = (leftFoot + rightFoot) * 0.5; + vec2 toHead = head - footAverage; + float toHeadLength = length(toHead); + vec2 forward = toHeadLength > 0.001 ? toHead / toHeadLength : vec2(0.0, 1.0); + vec2 perp = vec2(-forward.y, forward.x); + + // A circular head, drawn a bit smaller than the head->feet gap so the + // legs below it read as two distinct LINES rather than being swallowed + // by the head. + float renderRadius = headRadius * 0.75; + float headDistance = circleDistance(head, renderRadius, target); + + // Legs as thin line-segments (capsules) from the head down to each + // foot, hard-min'd onto the head so the head stays a crisp circle. + float legThickness = footRadius * 0.35; + float leftLeg = segmentDistance(head, leftFoot, target) - legThickness; + float rightLeg = segmentDistance(head, rightFoot, target) - legThickness; + + // Rounded feet at the ends of the lines, kept large enough to stay + // visible even when the leg is short and the head nearly reaches them. + float footRender = footRadius * 0.7; + float leftFootDistance = circleDistance(leftFoot, footRender, target); + float rightFootDistance = circleDistance(rightFoot, footRender, target); + + float body = min( + headDistance, + min(min(leftLeg, rightLeg), min(leftFootDistance, rightFootDistance)) + ); + + // Real eyes painted on the leading face — a bright white sclera with a + // dark pupil — rather than carved holes, so the character reads as + // alive instead of hollow-socketed. + // Big white sclera, small pupil: the eye must read as mostly white so + // it looks like an eye, not a dark socket. (An 8-bit albedo caps the + // sclera at pure white, so its area — not its colour — is what makes + // the eye read brighter against the body.) + vec2 eyeBase = head + forward * (renderRadius * 0.26); + vec2 leftEyeCenter = eyeBase + perp * (renderRadius * 0.38); + vec2 rightEyeCenter = eyeBase - perp * (renderRadius * 0.38); + float scleraRadius = renderRadius * 0.26; + float pupilRadius = renderRadius * 0.11; + + // The pupil slides toward the gaze target (the cursor for the local + // player, otherwise the travel direction), kept well inside the rim so + // a generous ring of white always frames it. The normalize is guarded + // so a cursor resting exactly on an eye can't produce NaNs. + vec2 gazeTarget = characterGazeTargets[i]; + float pupilReach = (scleraRadius - pupilRadius) * 0.6; + vec2 toLeftGaze = gazeTarget - leftEyeCenter; + vec2 toRightGaze = gazeTarget - rightEyeCenter; + vec2 leftGaze = length(toLeftGaze) > 0.001 ? normalize(toLeftGaze) : forward; + vec2 rightGaze = length(toRightGaze) > 0.001 ? normalize(toRightGaze) : forward; + + float sclera = min( + circleDistance(leftEyeCenter, scleraRadius, target), + circleDistance(rightEyeCenter, scleraRadius, target) + ); + float pupil = min( + circleDistance(leftEyeCenter + leftGaze * pupilReach, pupilRadius, target), + circleDistance(rightEyeCenter + rightGaze * pupilReach, pupilRadius, target) + ); + + // Soften each eye edge by at least one screen texel so the colour + // boundary antialiases instead of staircasing when the character is + // small on screen. The fixed world-space term sets a floor on the + // softness when zoomed in; fwidth widens the band to a texel as the + // head shrinks. Computed here in uniform control flow — NOT inside the + // body branch below — so the screen-space derivatives stay defined. + // fwidth needs WebGL2 derivatives (core in GLSL ES 3.00), so the + // WebGL1 fallback keeps the plain world-space band. + float eyeAaBase = renderRadius * 0.025; + #ifdef WEBGL2_IS_AVAILABLE + float scleraAa = max(eyeAaBase, fwidth(sclera)); + float pupilAa = max(eyeAaBase, fwidth(pupil)); + #else + float scleraAa = eyeAaBase; + float pupilAa = eyeAaBase; + #endif + + if (body < minDistance) { + minDistance = body; + // Brief white punch when taking a hit. + color = mix( + readFromPalette(characterColors[i]), + vec4(1.0), + clamp(characterFlash[i], 0.0, 1.0) + ); + + // Paint the eyes over the body colour. The sclera albedo is HDR + // (>> 1): the shading pass multiplies it by the (often dim, reddish) + // scene light before clamping to the screen, so an over-bright white + // reads as a clean white eye everywhere instead of dimming into a + // dark socket. Needs the float colour buffer in sdf-2d; on 8-bit + // fallback it simply clamps back to plain white. The smoothstep + // widths (scleraAa / pupilAa, computed above) antialias the colour + // boundary in a zoom-aware way. + color = mix(color, vec4(10.0, 10.0, 10.0, 1.0), 1.0 - smoothstep(-scleraAa, scleraAa, sclera)); + color = mix(color, vec4(0.04, 0.04, 0.07, 1.0), 1.0 - smoothstep(-pupilAa, pupilAa, pupil)); + } + } + + return minDistance; + } + `, + distanceFunctionName: 'characterMinDistance', + }, + propertyUniformMapping: { + footRadius: 'characterFootRadii', + headRadius: 'characterHeadRadii', + rightFootCenter: 'characterRightFeet', + leftFootCenter: 'characterLeftFeet', + headCenter: 'characterHeadCenters', + gazeTarget: 'characterGazeTargets', + color: 'characterColors', + flash: 'characterFlash', + }, + uniformCountMacroName: 'CHARACTER_COUNT', + shaderCombinationSteps: [0, 1, 2, 8], + empty: new CharacterShape(0), + }; + + protected head!: Circle; + protected leftFoot!: Circle; + protected rightFoot!: Circle; + + // 0..1 transient white flash on taking a hit. Set per frame. + public hitFlash = 0; + // World point the eyes look at; the pupils slide toward it. Set per frame + // (the cursor for the local player, the travel direction for everyone else). + public gazeTarget = vec2.create(); + + public constructor(private readonly color: number) { + super(); + + const circle = new Circle(vec2.create(), 200); + this.setCircles([circle, circle, circle]); + } + + public setCircles([head, leftFoot, rightFoot]: [Circle, Circle, Circle]) { + this.head = head; + this.leftFoot = leftFoot; + this.rightFoot = rightFoot; + } + + public minDistance(target: vec2): number { + return Math.min( + this.head.distance(target), + this.leftFoot.distance(target), + this.rightFoot.distance(target), + ); + } + + protected getObjectToSerialize(transform2d: mat2d, transform1d: number): any { + return { + headCenter: vec2.transformMat2d(vec2.create(), this.head.center, transform2d), + leftFootCenter: vec2.transformMat2d( + vec2.create(), + this.leftFoot.center, + transform2d, + ), + rightFootCenter: vec2.transformMat2d( + vec2.create(), + this.rightFoot.center, + transform2d, + ), + gazeTarget: vec2.transformMat2d(vec2.create(), this.gazeTarget, transform2d), + headRadius: this.head.radius * transform1d, + footRadius: this.leftFoot.radius * transform1d, + color: this.color, + flash: this.hitFlash, + }; + } +} diff --git a/frontend/src/scripts/shapes/planet-shape.ts b/frontend/src/scripts/shapes/planet-shape.ts new file mode 100644 index 0000000..ceb2e35 --- /dev/null +++ b/frontend/src/scripts/shapes/planet-shape.ts @@ -0,0 +1,203 @@ +import { mat2d, vec2, vec3, vec4 } from 'gl-matrix'; +import { PolygonFactory, DrawableDescriptor, Drawable } from 'sdf-2d'; +import { settings } from 'shared'; + +export const colorToString = (v: vec3 | vec4): string => + `vec4(${v[0]}, ${v[1]}, ${v[2]}, ${v.length > 3 ? v[3] : 1})`; + +export class PlanetShape extends PolygonFactory(settings.planetEdgeCount, 0) { + public static descriptor: DrawableDescriptor = { + sdf: { + shader: ` + uniform vec2 planetVertices[PLANET_COUNT * ${settings.planetEdgeCount}]; + uniform vec2 planetCenters[PLANET_COUNT]; + uniform float planetLengths[PLANET_COUNT]; + uniform float planetRandoms[PLANET_COUNT]; + uniform float planetColorMixQ[PLANET_COUNT]; + uniform float planetRotations[PLANET_COUNT]; + + uniform sampler2D noiseTexture; + + #ifdef WEBGL2_IS_AVAILABLE + float planetTerrain(vec2 h) { + return texture(noiseTexture, h)[0] - 0.5; + } + #else + float planetTerrain(vec2 h) { + return texture2D(noiseTexture, h)[0] - 0.5; + } + #endif + + vec2 planetLineDistance(vec2 target, vec2 from, vec2 to) { + vec2 targetFromDelta = target - from; + vec2 toFromDelta = to - from; + float h = clamp( + dot(targetFromDelta, toFromDelta) / dot(toFromDelta, toFromDelta), + 0.0, 1.0 + ); + + vec2 diff = targetFromDelta - toFromDelta * h; + return vec2( + dot(diff, diff), + toFromDelta.x * targetFromDelta.y - toFromDelta.y * targetFromDelta.x + ); + } + + float planetMinDistance(vec2 target, out vec4 color) { + float minDistance = 100.0; + + for (int j = 0; j < PLANET_COUNT; j++) { + vec2 startEnd = planetVertices[j * ${settings.planetEdgeCount}]; + vec2 vb = startEnd; + + vec2 center = planetCenters[j]; + float l = planetLengths[j]; + float randomOffset = planetRandoms[j]; + float rotation = planetRotations[j]; + + float cr = cos(rotation); + float sr = sin(rotation); + + // Spin the whole planet: evaluate the SDF in the planet's own + // rotating frame so the polygon outline turns together with its + // terrain, instead of the terrain sliding over a fixed outline. + vec2 targetCenterDelta = target - center; + float targetDistance = length(targetCenterDelta); + vec2 localTarget = center + vec2( + cr * targetCenterDelta.x - sr * targetCenterDelta.y, + sr * targetCenterDelta.x + cr * targetCenterDelta.y + ); + vec2 targetTangent = (localTarget - center) / clamp(targetDistance, 0.01, 1000.0); + + vec2 noisyTarget = localTarget - ( + targetTangent * planetTerrain(vec2( + l * abs(atan(targetTangent.y, targetTangent.x)), + randomOffset + )) / 12.0 + ); + + float d = 10000.0; + float s = 1.0; + for (int k = 1; k < ${settings.planetEdgeCount}; k++) { + vec2 va = vb; + vb = planetVertices[j * ${settings.planetEdgeCount} + k]; + vec2 ds = planetLineDistance(noisyTarget, va, vb); + + bvec3 cond = bvec3(noisyTarget.y >= va.y, noisyTarget.y < vb.y, ds.y > 0.0); + if (all(cond) || all(not(cond))) { + s *= -1.0; + } + + d = min(d, ds.x); + } + + vec2 ds = planetLineDistance(noisyTarget, vb, startEnd); + bvec3 cond = bvec3(noisyTarget.y >= vb.y, noisyTarget.y < startEnd.y, ds.y > 0.0); + if (all(cond) || all(not(cond))) { + s *= -1.0; + } + + d = min(d, ds.x); + float dist = s * sqrt(d); + + if (dist < minDistance) { + minDistance = dist; + color = mix(${colorToString(settings.bluePlanetColor)}, ${colorToString( + settings.redPlanetColor, + )}, planetColorMixQ[j]); + } + } + + return minDistance; + } + `, + distanceFunctionName: 'planetMinDistance', + }, + propertyUniformMapping: { + length: 'planetLengths', + random: 'planetRandoms', + center: 'planetCenters', + vertices: 'planetVertices', + colorMixQ: 'planetColorMixQ', + rotation: 'planetRotations', + }, + uniformCountMacroName: `PLANET_COUNT`, + shaderCombinationSteps: [0, 1, 2, 3], + empty: new PlanetShape(new Array(settings.planetEdgeCount).fill(vec2.create()), 0), + }; + + public randomOffset = 0; + public rotation = 0; + + // Circle about the rotation centre (the vertex centroid, which is what the + // shader spins around — see planetMinDistance above). The vertices never + // change after construction, so cache it once. + private readonly cullCenter: vec2; + private readonly cullRadius: number; + + constructor( + public vertices: Array, + public colorMixQ: number, + ) { + super(vertices); + + this.cullCenter = vertices.reduce((sum, v) => vec2.add(sum, sum, v), vec2.create()); + vec2.scale(this.cullCenter, this.cullCenter, 1 / vertices.length); + + this.cullRadius = vertices.reduce( + (max, v) => Math.max(max, vec2.distance(this.cullCenter, v)), + 0, + ); + } + + public minDistance(target: vec2): number { + return vec2.distance(target, this.cullCenter) - this.cullRadius; + } + + protected getObjectToSerialize(transform2d: mat2d, _: number): any { + const transformedVertices = (this as any).actualVertices.map((v: vec2) => + vec2.transformMat2d(vec2.create(), v, transform2d), + ); + + const center = transformedVertices.reduce( + (sum: vec2, v: vec2) => vec2.add(sum, sum, v), + vec2.create(), + ); + vec2.scale(center, center, 1 / transformedVertices.length); + + let length = 0; + for (let i = 1; i < this.vertices.length; i++) { + length += vec2.distance(transformedVertices[i - 1], transformedVertices[i]); + } + + return { + vertices: transformedVertices, + center, + length, + random: this.randomOffset, + colorMixQ: this.colorMixQ, + rotation: this.rotation, + }; + } + + public serializeToUniforms( + uniforms: any, + transform2d: mat2d, + transform1d: number, + ): void { + const { propertyUniformMapping } = (this.constructor as typeof Drawable).descriptor; + + const serialized = this.getObjectToSerialize(transform2d, transform1d); + Object.entries(propertyUniformMapping).forEach(([k, v]) => { + if (!Object.prototype.hasOwnProperty.call(uniforms, v)) { + uniforms[v] = []; + } + + if (k === 'vertices') { + uniforms[v].push(...serialized[k]); + } else { + uniforms[v].push(serialized[k]); + } + }); + } +} diff --git a/frontend/src/scripts/sound-handler.ts b/frontend/src/scripts/sound-handler.ts new file mode 100644 index 0000000..81607ad --- /dev/null +++ b/frontend/src/scripts/sound-handler.ts @@ -0,0 +1,82 @@ +import hitSound from '../../static/hit.mp3'; +import shootSound from '../../static/shoot.mp3'; +import clickSound from '../../static/click.mp3'; +import ambientSound from '../../static/ambient.mp3'; +import { OptionsHandler } from './options-handler'; + +export enum Sounds { + hit = 'hit', + shoot = 'shoot', + click = 'click', +} + +export abstract class SoundHandler { + private static sounds: { [key in Sounds]: HTMLAudioElement }; + private static isAmbientPlaying = false; + + private static ambientSound = new Audio(ambientSound); + + private static initialized = false; + public static async initialize( + onPlayKeypress: () => unknown = () => null, + onPauseKeypress: () => unknown = () => null, + ) { + this.sounds = { + [Sounds.hit]: await this.initializeSound(hitSound), + [Sounds.shoot]: await this.initializeSound(shootSound), + [Sounds.click]: await this.initializeSound(clickSound), + }; + + await this.ambientSound.play(); + this.ambientSound.muted = true; + this.initialized = true; + this.ambientSound.onpause = onPauseKeypress; + this.ambientSound.onplay = onPlayKeypress; + + this.ambientSound.muted = false; + this.ambientSound.volume = 0.5; + this.ambientSound.loop = true; + + if (!this.isAmbientPlaying) { + this.ambientSound.pause(); + } + } + + private static async initializeSound(hitSound: string): Promise { + const sound = new Audio(hitSound); + sound.muted = true; + await sound.play(); + sound.pause(); + sound.muted = false; + sound.currentTime = 0; + return sound; + } + + public static play(sound: Sounds, volume = 1, playbackRate = 1) { + if (!this.initialized || !OptionsHandler.options.soundsEnabled) { + return; + } + + const audio = + this.sounds[sound].currentTime > 0 + ? (this.sounds[sound].cloneNode(true) as HTMLAudioElement) + : this.sounds[sound]; + audio.volume = Math.max(0, Math.min(1, volume)); + audio.playbackRate = playbackRate; + audio.play(); + } + + public static playAmbient() { + this.isAmbientPlaying = true; + if (this.initialized) { + this.ambientSound.play(); + } + } + + public static stopAmbient() { + this.isAmbientPlaying = false; + if (this.initialized) { + this.ambientSound.pause(); + } + } +} diff --git a/frontend/src/scripts/transport/serializable.ts b/frontend/src/scripts/transport/serializable.ts deleted file mode 100644 index 2378ada..0000000 --- a/frontend/src/scripts/transport/serializable.ts +++ /dev/null @@ -1,7 +0,0 @@ -export abstract class Typed { - public abstract get type(): string; - - public toJSON() { - return { type: this.type, ...this }; - } -} diff --git a/frontend/src/scripts/tutorial.ts b/frontend/src/scripts/tutorial.ts new file mode 100644 index 0000000..c0d1155 --- /dev/null +++ b/frontend/src/scripts/tutorial.ts @@ -0,0 +1,112 @@ +import { vec2 } from 'gl-matrix'; +import { + CharacterTeam, + CommandExecutors, + CommandReceiver, + Id, + LeapActionCommand, + MoveActionCommand, + PrimaryActionCommand, +} from 'shared'; +import { GameObjectContainer } from './objects/game-object-container'; +import { PlanetView } from './objects/types/planet-view'; + +type StageTrigger = 'move' | 'shoot' | 'leap' | 'capture'; + +const stages: ReadonlyArray<{ hint: string; clearsOn: StageTrigger }> = [ + { hint: 'WASD / drag to walk', clearsOn: 'move' }, + { hint: 'Click / tap to shoot', clearsOn: 'shoot' }, + { hint: 'Space / leap button to launch off a planet', clearsOn: 'leap' }, + { hint: 'Stand on a planet to capture it', clearsOn: 'capture' }, +]; + +const captureProgressThreshold = 0.05; + +const standingSlack = 200; + +export class Tutorial extends CommandReceiver { + private stage = 0; + private element = document.createElement('div'); + + private characterId?: Id; + + private standingPlanet?: PlanetView; + private standingBaseline = 0; + + protected commandExecutors: CommandExecutors = { + [MoveActionCommand.type]: (c: MoveActionCommand) => { + if (this.clearsOn() === 'move' && vec2.length(c.direction) > 0) { + this.advance(); + } + }, + [PrimaryActionCommand.type]: () => { + if (this.clearsOn() === 'shoot') { + this.advance(); + } + }, + [LeapActionCommand.type]: () => { + if (this.clearsOn() === 'leap') { + this.advance(); + } + }, + }; + + constructor(overlay: HTMLElement) { + super(); + this.element.className = 'tutorial-hint'; + this.element.innerText = stages[0].hint; + overlay.appendChild(this.element); + } + + private clearsOn(): StageTrigger | undefined { + return stages[this.stage]?.clearsOn; + } + + private advance() { + this.setStage(this.stage + 1); + } + + public step(gameObjects: GameObjectContainer) { + if (this.stage >= stages.length) { + return; + } + + const player = gameObjects.player; + if (!player) { + return; + } + + if (this.characterId === undefined) { + this.characterId = player.id; + } else if (player.id !== this.characterId) { + this.finish(); + return; + } + + if (this.clearsOn() === 'capture') { + const standing = gameObjects.planets.find( + (p) => vec2.distance(p.center, player.bodyCenter) < p.radius + standingSlack, + ); + if (standing !== this.standingPlanet) { + this.standingPlanet = standing; + this.standingBaseline = standing?.ownership ?? 0; + } + if (standing) { + const drift = standing.ownership - this.standingBaseline; + const progress = player.team === CharacterTeam.blue ? -drift : drift; + if (progress > captureProgressThreshold) { + this.finish(); + } + } + } + } + + private finish() { + this.setStage(stages.length); + } + + private setStage(stage: number) { + this.stage = stage; + this.element.innerText = stages[stage]?.hint ?? ''; + } +} diff --git a/frontend/src/scripts/vibration-handler.ts b/frontend/src/scripts/vibration-handler.ts new file mode 100644 index 0000000..3ae8420 --- /dev/null +++ b/frontend/src/scripts/vibration-handler.ts @@ -0,0 +1,19 @@ +import { OptionsHandler } from './options-handler'; + +export abstract class VibrationHandler { + // Accepts either a single duration or an on/off pattern (e.g. a double-thump + // [40, 30, 90] for a kill). + public static vibrate(pattern: number | number[]): void { + if (OptionsHandler.options.vibrationEnabled && this.isVibrationEnabled) { + navigator.vibrate(pattern); + } + } + + public static get isVibrationEnabled(): boolean { + return 'vibrate' in navigator; + } + + public static get isVibrationEnabledHeuristics(): boolean { + return this.isVibrationEnabled && 'ontouchstart' in window; + } +} diff --git a/frontend/src/styles/_mixins.scss b/frontend/src/styles/_mixins.scss new file mode 100644 index 0000000..d845573 --- /dev/null +++ b/frontend/src/styles/_mixins.scss @@ -0,0 +1,11 @@ +@mixin square($size) { + width: $size; + height: $size; +} + +@mixin background { + backdrop-filter: blur(24px); + @supports not (backdrop-filter: blur(24px)) { + background-color: rgba(0, 0, 0, 0.5); + } +} diff --git a/frontend/src/styles/_vars.scss b/frontend/src/styles/_vars.scss new file mode 100644 index 0000000..f19825b --- /dev/null +++ b/frontend/src/styles/_vars.scss @@ -0,0 +1,22 @@ +$accent: #b33951; +$border-width: 1.5px; +$border-width-focused: 3px; +$border: $border-width solid white; +$small-padding: 12px; +$medium-padding: 24px; +$large-padding: 64px; +$border-radius: 15px; +$animation-time: 200ms ease; +$breakpoint: 700px; +$height-breakpoint: 500px; +$large-icon: 48px; +$small-icon: 32px; +$bright-blue: #4069a5; +$bright-red: #d15652; +$bright-neutral: #ccc; + +:root { + --bright-blue: #{$bright-blue}; + --bright-red: #{$bright-red}; + --bright-neutral: #{$bright-neutral}; +} diff --git a/frontend/src/styles/button.scss b/frontend/src/styles/button.scss new file mode 100644 index 0000000..13ac5b6 --- /dev/null +++ b/frontend/src/styles/button.scss @@ -0,0 +1,31 @@ +@use './vars.scss' as *; + +button { + border: none; + background: none; + font-size: 2rem; + font-family: 'Comfortaa', sans-serif; + cursor: pointer; + display: block; + margin: auto; + + $ascend: 6px; + padding: $medium-padding 0 0 0; + margin-bottom: $ascend; + transition: transform $animation-time, padding-bottom $animation-time, + margin-bottom $animation-time; + + &:not(:disabled):hover, + &:not(:disabled):focus { + outline: none; + color: $accent; + transform: translateY(-$ascend); + padding-bottom: $ascend; + margin-bottom: 0; + } + + &:disabled { + color: #777; + cursor: not-allowed; + } +} diff --git a/frontend/src/styles/form.scss b/frontend/src/styles/form.scss new file mode 100644 index 0000000..d096895 --- /dev/null +++ b/frontend/src/styles/form.scss @@ -0,0 +1,123 @@ +@use 'sass:math'; +@use 'vars' as *; +@use 'mixins' as *; + +form { + @include background; + + padding: math.div($small-padding, 2) $small-padding $small-padding $small-padding; + border-radius: $border-radius; + + input:-webkit-autofill, + input:-webkit-autofill:hover, + input:-webkit-autofill:focus { + -webkit-text-fill-color: white; + transition: background-color 5000s ease-in-out 0s; + } + + input, + label { + font-size: 1.5rem; + } + + fieldset { + border: $border; + padding: 0 $medium-padding $medium-padding $medium-padding; + border-radius: $border-radius; + + div { + // disable margin collapse + overflow: auto; + } + + legend { + font-family: 'Comfortaa', sans-serif; + font-size: 2rem; + } + + .name-group { + position: relative; + padding: 15px 0 0; + margin-top: 10px; + margin: 10px math.div($small-padding, 2) $small-padding math.div($small-padding, 2); + + input { + font-family: inherit; + border: none; + border-bottom: $border; + color: white; + padding: 10px 0 0px 0; + background: transparent; + margin-bottom: $border-width-focused - $border-width; + width: 100%; + + &::placeholder { + color: transparent; + } + + &:placeholder-shown ~ label { + font-size: 1.3rem; + cursor: text; + top: 20px; + } + } + + label { + position: absolute; + top: 0; + display: block; + transition: $animation-time; + color: $bright-neutral; + font-size: 1.1rem; + } + + input:focus, + input:not(:placeholder-shown) { + outline: none; + margin-bottom: 0; + border-width: $border-width-focused; + border-color: $accent; + + ~ label { + position: absolute; + top: 0; + display: block; + transition: $animation-time; + } + } + } + + input[type='radio'] { + width: 0; + height: 0; + appearance: none; + + + label { + display: block; + border-radius: $border-radius; + padding: $small-padding; + border: $border; + + cursor: pointer; + margin: $border-width-focused - $border-width $border-width-focused - + $border-width + math.div($small-padding, 2); + + .completion { + font-size: 0.7em; + color: $bright-neutral; + } + } + + &:focus { + outline: none; + } + + &:focus + label, + &:checked + label { + border-color: $accent; + border-width: $border-width-focused; + margin: 0 math.div($small-padding, 2); + } + } + } +} diff --git a/frontend/src/styles/main.scss b/frontend/src/styles/main.scss deleted file mode 100644 index 3781003..0000000 --- a/frontend/src/styles/main.scss +++ /dev/null @@ -1,37 +0,0 @@ -html, -body, -canvas { - height: 100%; - width: 100%; -} - -body { - position: relative; - margin: 0; - - #overlay { - font-family: Helvetica, Arial, sans-serif; - font-size: 1em; - margin: 0.75em 1em; - user-select: none; - pointer-events: none; - position: absolute; - right: 0; - $outline-width: 0.5px; - text-shadow: -$outline-width -$outline-width 0 #000, - $outline-width -$outline-width 0 #000, - -$outline-width $outline-width 0 #000, - $outline-width $outline-width 0 #000; - color: white; - white-space: pre; - - @media (max-width: 800px) { - font-size: 0.75em; - } - } - - canvas#main { - width: 100%; - background-color: hotpink; - } -} diff --git a/frontend/src/styles/settings.scss b/frontend/src/styles/settings.scss new file mode 100644 index 0000000..376284a --- /dev/null +++ b/frontend/src/styles/settings.scss @@ -0,0 +1,164 @@ +@use 'sass:math'; +@use 'vars' as *; +@use 'mixins' as *; + +#toggle-settings-container, +#settings-container { + position: absolute; + display: flex; + justify-content: center; + top: 0; + right: 0; + font-size: 0; +} + +#toggle-settings-container { + padding: $medium-padding; + + cursor: pointer; + + padding: $medium-padding $medium-padding 0 $medium-padding; + @media (max-width: $breakpoint) { + padding: $medium-padding $medium-padding $medium-padding 0; + } + + #toggle-settings { + animation: spin 32s linear infinite; + + @keyframes spin { + 100% { + transform: rotate(360deg); + } + } + + @include square($large-icon); + @media (max-width: $breakpoint) { + @include square($small-icon); + } + } +} + +#settings-container { + top: $medium-padding + $large-icon + $small-padding; + @media (max-width: $breakpoint) { + top: 0; + right: $medium-padding + $small-icon + $small-padding; + } + pointer-events: none; + overflow: hidden; +} + +#settings { + display: flex; + flex-direction: column; + @include background; + border-radius: 12px; + z-index: 100; + margin-right: $medium-padding - $small-padding; + padding: $small-padding; + + transform: translateY(-100%); + @media (max-width: $breakpoint) { + flex-direction: row-reverse; + transform: translateX(100%); + margin-top: $medium-padding - $small-padding; + margin-right: 0; + } + + opacity: 0; + transition: transform $animation-time, opacity $animation-time; + + &.open { + transform: none; + opacity: 1; + pointer-events: all; + } + + img, + svg { + cursor: pointer; + transition: opacity $animation-time; + @include square($large-icon); + @media (max-width: $breakpoint) { + @include square($small-icon); + } + } + + label { + position: relative; + + input[type='checkbox'] { + width: 0; + height: 0; + appearance: none; + cursor: pointer; + + &:not(:checked) + * { + opacity: 0.4; + } + + &:focus { + outline: none; + } + + &:after { + content: ''; + background: white; + position: absolute; + + $height: 4px; + width: $height; + top: math.div($height, 2); + + right: 0; + transform-origin: top right; + border-radius: 100px; + height: 0; + transform: rotate(45deg) translateY(17%); + transition: height $animation-time; + + @media (max-width: $breakpoint) { + $height: 2px; + width: $height; + top: math.div($height, 2); + } + } + + &:not(:checked):after { + height: $large-icon; + @media (max-width: $breakpoint) { + height: $small-icon; + } + } + } + } + + img, + svg { + margin-top: $small-padding; + @media (max-width: $breakpoint) { + margin-top: 0; + margin-right: $small-padding; + } + } + + label:first-child { + img, + svg { + margin-top: 0; + margin-right: 0; + } + } + + label:not(:first-child) { + input[type='checkbox']:after { + $height: 4px; + top: math.div($height, 2) + $small-padding; + + @media (max-width: $breakpoint) { + right: $small-padding; + top: math.div($height, 2); + } + } + } +} diff --git a/frontend/static/ambient.mp3 b/frontend/static/ambient.mp3 new file mode 100644 index 0000000..218cc96 Binary files /dev/null and b/frontend/static/ambient.mp3 differ diff --git a/frontend/static/chevron.svg b/frontend/static/chevron.svg new file mode 100644 index 0000000..0178fd9 --- /dev/null +++ b/frontend/static/chevron.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/circle.svg b/frontend/static/circle.svg new file mode 100644 index 0000000..f4fb483 --- /dev/null +++ b/frontend/static/circle.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/static/click.mp3 b/frontend/static/click.mp3 new file mode 100644 index 0000000..50a022c Binary files /dev/null and b/frontend/static/click.mp3 differ diff --git a/frontend/static/declared.png b/frontend/static/declared.png deleted file mode 100644 index 897278d..0000000 Binary files a/frontend/static/declared.png and /dev/null differ diff --git a/frontend/static/declared.psd b/frontend/static/declared.psd deleted file mode 100644 index e2f814d..0000000 --- a/frontend/static/declared.psd +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:03ed783e268bc05d40997239f6118767c5d5d8b15d23fa7b329321c0a1529c46 -size 437314 diff --git a/frontend/static/favicons/apple-touch-icon.png b/frontend/static/favicons/apple-touch-icon.png new file mode 100644 index 0000000..cabfeb2 Binary files /dev/null and b/frontend/static/favicons/apple-touch-icon.png differ diff --git a/frontend/static/favicons/favicon-16x16.png b/frontend/static/favicons/favicon-16x16.png new file mode 100644 index 0000000..5fe28d0 Binary files /dev/null and b/frontend/static/favicons/favicon-16x16.png differ diff --git a/frontend/static/favicons/favicon-32x32.png b/frontend/static/favicons/favicon-32x32.png new file mode 100644 index 0000000..d007086 Binary files /dev/null and b/frontend/static/favicons/favicon-32x32.png differ diff --git a/frontend/static/favicons/favicon.ico b/frontend/static/favicons/favicon.ico new file mode 100644 index 0000000..a733520 Binary files /dev/null and b/frontend/static/favicons/favicon.ico differ diff --git a/frontend/static/flag.svg b/frontend/static/flag.svg new file mode 100644 index 0000000..20653df --- /dev/null +++ b/frontend/static/flag.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/frontend/static/hit.mp3 b/frontend/static/hit.mp3 new file mode 100644 index 0000000..d088388 Binary files /dev/null and b/frontend/static/hit.mp3 differ diff --git a/frontend/static/logout.svg b/frontend/static/logout.svg new file mode 100644 index 0000000..ab168e8 --- /dev/null +++ b/frontend/static/logout.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/frontend/static/mask.svg b/frontend/static/mask.svg new file mode 100644 index 0000000..8c7ecbf --- /dev/null +++ b/frontend/static/mask.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/frontend/static/maximize.svg b/frontend/static/maximize.svg new file mode 100644 index 0000000..7118488 --- /dev/null +++ b/frontend/static/maximize.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/frontend/static/minimize.svg b/frontend/static/minimize.svg new file mode 100644 index 0000000..93a212a --- /dev/null +++ b/frontend/static/minimize.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/frontend/static/music.svg b/frontend/static/music.svg new file mode 100644 index 0000000..5f68ba1 --- /dev/null +++ b/frontend/static/music.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/frontend/static/og-image.png b/frontend/static/og-image.png new file mode 100644 index 0000000..86fa9f9 Binary files /dev/null and b/frontend/static/og-image.png differ diff --git a/frontend/static/settings.svg b/frontend/static/settings.svg new file mode 100644 index 0000000..8e99bbe --- /dev/null +++ b/frontend/static/settings.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/shoot.mp3 b/frontend/static/shoot.mp3 new file mode 100644 index 0000000..435a99e Binary files /dev/null and b/frontend/static/shoot.mp3 differ diff --git a/frontend/static/shoot2.mp3 b/frontend/static/shoot2.mp3 new file mode 100644 index 0000000..de8c874 Binary files /dev/null and b/frontend/static/shoot2.mp3 differ diff --git a/frontend/static/shoot3.mp3 b/frontend/static/shoot3.mp3 new file mode 100644 index 0000000..89face6 Binary files /dev/null and b/frontend/static/shoot3.mp3 differ diff --git a/frontend/static/shoot4.mp3 b/frontend/static/shoot4.mp3 new file mode 100644 index 0000000..ce505d0 Binary files /dev/null and b/frontend/static/shoot4.mp3 differ diff --git a/frontend/static/shoot5.mp3 b/frontend/static/shoot5.mp3 new file mode 100644 index 0000000..0dfa8b9 Binary files /dev/null and b/frontend/static/shoot5.mp3 differ diff --git a/frontend/static/shoot6.mp3 b/frontend/static/shoot6.mp3 new file mode 100644 index 0000000..2ce159b Binary files /dev/null and b/frontend/static/shoot6.mp3 differ diff --git a/frontend/static/spinner.svg b/frontend/static/spinner.svg new file mode 100644 index 0000000..5d0cc38 --- /dev/null +++ b/frontend/static/spinner.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/frontend/static/vibrate.svg b/frontend/static/vibrate.svg new file mode 100644 index 0000000..ce6d594 --- /dev/null +++ b/frontend/static/vibrate.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/frontend/static/volume.svg b/frontend/static/volume.svg new file mode 100644 index 0000000..dedc4d8 --- /dev/null +++ b/frontend/static/volume.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index dd8863d..b12fbd2 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -1,13 +1,18 @@ { "compilerOptions": { - "outDir": "./dist/", - "sourceMap": true, - "noImplicitAny": false, - "module": "es6", - "target": "es5", - "downlevelIteration": true, - "allowJs": true, + "outDir": "dist", + "rootDir": "src", + "target": "es6", + "esModuleInterop": true, + "strict": true, "experimentalDecorators": true, - "moduleResolution": "Node" - } + "moduleResolution": "node", + "ignoreDeprecations": "6.0", + "skipLibCheck": true, + "module": "commonjs", + "lib": ["dom", "es2017"], + "typeRoots": ["./types", "./node_modules/@types"] + }, + "include": ["src/**/*.ts", "src/*.ts"], + "exclude": ["node_modules", "typings"] } diff --git a/frontend/types/socket.io-msgpack-parser/index.d.ts b/frontend/types/socket.io-msgpack-parser/index.d.ts new file mode 100644 index 0000000..4065a1e --- /dev/null +++ b/frontend/types/socket.io-msgpack-parser/index.d.ts @@ -0,0 +1 @@ +declare module 'socket.io-msgpack-parser'; diff --git a/frontend/webpack.config.js b/frontend/webpack.config.js index c63efba..97f6f6b 100644 --- a/frontend/webpack.config.js +++ b/frontend/webpack.config.js @@ -1,144 +1,142 @@ const path = require('path'); -const HtmlWebpackPlugin = require('html-webpack-plugin'); const { CleanWebpackPlugin } = require('clean-webpack-plugin'); -const TerserJSPlugin = require('terser-webpack-plugin'); -const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin'); +const HtmlWebpackPlugin = require('html-webpack-plugin'); const MiniCssExtractPlugin = require('mini-css-extract-plugin'); -const HtmlWebpackInlineSourcePlugin = require('html-webpack-inline-source-plugin'); +const TerserJSPlugin = require('terser-webpack-plugin'); +const CopyWebpackPlugin = require('copy-webpack-plugin'); +const HTMLInlineCSSWebpackPlugin = require('html-inline-css-webpack-plugin').default; +// const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin; const Sass = require('sass'); -const isProduction = process.env.NODE_ENV === 'production'; -const isDevelopment = !isProduction; +module.exports = (env, argv) => { + const isProduction = argv.mode !== 'development'; -module.exports = { - watchOptions: { - ignored: /node_modules/, - }, - devServer: { - host: '0.0.0.0', - disableHostCheck: true, - }, - optimization: { - minimize: true, - minimizer: [ - new TerserJSPlugin({ - sourceMap: isDevelopment, - cache: true, - terserOptions: { - ecma: 5, - warnings: true, - parse: {}, - compress: { defaults: true }, - mangle: true, - module: false, - output: null, - toplevel: true, - nameCache: null, - ie8: false, - keep_classnames: false, - keep_fnames: false, - safari10: false, - }, + return { + entry: { + main: path.resolve(__dirname, 'src/index.ts'), + }, + output: { + filename: 'main.js', + path: path.resolve(__dirname, 'dist'), + }, + devServer: { + host: '0.0.0.0', + allowedHosts: 'all', + }, + // webpack-dev-server 5 no longer accepts `watchOptions` under `devServer`; + // polling is configured on the compiler instead (needed for some FS mounts). + watchOptions: { + poll: true, + }, + plugins: [ + new CleanWebpackPlugin(), + new MiniCssExtractPlugin(), + //new BundleAnalyzerPlugin(), + new HtmlWebpackPlugin({ + template: './src/index.html', }), - new OptimizeCSSAssetsPlugin({}), + // The SVG UI icons used to be inlined into the HTML by the (abandoned, + // webpack-4-only) html-webpack-inline-svg-plugin. They are now emitted as + // static files and referenced via `static/.svg` from index.html. + new CopyWebpackPlugin({ + patterns: [{ from: 'static/*.svg', to: 'static/[name][ext]' }], + }), + // Inline the extracted CSS into the HTML for production builds (replaces + // the abandoned html-webpack-inline-source-plugin). In development the CSS + // stays a separate, linked file for faster rebuilds. + ...(isProduction ? [new HTMLInlineCSSWebpackPlugin()] : []), ], - }, - plugins: [ - new CleanWebpackPlugin(), - new HtmlWebpackPlugin({ - xhtml: true, - template: './src/index.html', - minify: { - collapseWhitespace: true, - removeComments: true, - removeRedundantAttributes: true, - removeScriptTypeAttributes: true, - removeStyleLinkTypeAttributes: true, - useShortDoctype: true, - }, - inlineSource: '.(js|css)$', - }), - new HtmlWebpackInlineSourcePlugin(), - new MiniCssExtractPlugin({ - filename: '[name].[contenthash].css', - chunkFilename: '[id].[contenthash].css', - }), - ], - entry: { - index: './src/index.ts', - }, - module: { - rules: [ - { - test: /\.(glsl)$/, - use: { - loader: 'raw-loader', - }, - }, - { - test: /\.svg$/, - loader: 'svg-url-loader', - options: { - limit: 10 * 1024, - noquotes: true, - }, - }, - { - test: /\.ico$/i, - use: { - loader: 'file-loader', - query: { - outputPath: '/', - name: '[name].[ext]', + optimization: { + 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, }, + }), + ], + }, + module: { + rules: [ + { + test: /\.js$/, + enforce: 'pre', + use: ['source-map-loader'], }, - }, - { - test: /\.scss$/i, - use: [ - MiniCssExtractPlugin.loader, - 'css-loader', - 'postcss-loader', - { - loader: 'resolve-url-loader', - options: { - keepQuery: true, + { + test: /\.ts$/, + use: 'ts-loader', + exclude: /node_modules/, + }, + { + test: /\.scss$/i, + use: [ + MiniCssExtractPlugin.loader, + 'css-loader', + 'postcss-loader', + { + loader: 'sass-loader', + options: { + sourceMap: true, + implementation: Sass, + }, }, - }, - { - loader: 'sass-loader', - options: { - sourceMap: true, - implementation: Sass, + ], + }, + { + // SVGs referenced from CSS (`mask-image`/`background-image`) and JS + // must be inlined as real `data:` URIs. svg-url-loader emits a CJS + // module (`module.exports = "data:..."`) which webpack 5 + css-loader 7 + // write out verbatim as a `.svg` asset file, so the browser fetches JS + // instead of an image and the mask/background silently fails. webpack 5's + // built-in `asset/inline` produces a proper data URI instead. + // (HTML icons are unaffected: they are copied by CopyWebpackPlugin + // and referenced by literal `static/*.svg` paths, not through this rule.) + test: /\.svg$/, + type: 'asset/inline', + }, + { + // Use oneOf so each asset matches exactly one rule (og-image.png would + // otherwise match both the generic png rule and its own rule). + // The directory is encoded in `name` (not `outputPath`) and has no + // leading slash: with the default `publicPath: 'auto'` a leading slash + // produces a doubled slash in the emitted URL (e.g. `//static/x.mp3`). + oneOf: [ + { + test: /og-image\.png$/, + use: { + loader: 'file-loader', + options: { + name: '[name].[ext]', + }, + }, }, - }, - ], - }, - { - test: /\.(woff2?|ttf|eot|svg)(?:[?#].+)?$/, - use: { - loader: 'file-loader', - options: { - name: '[name].[ext]', - outputPath: 'static/fonts/', - }, + { + test: /\.ico$/, + use: { + loader: 'file-loader', + options: { + name: '[name].[ext]', + }, + }, + }, + { + test: /\.(mp3|png)$/, + use: { + loader: 'file-loader', + options: { + name: 'static/[name].[ext]', + }, + }, + }, + ], }, - include: /fonts/, - }, - { - test: /\.ts$/, - use: { - loader: 'ts-loader', - }, - exclude: /node_modules/, - }, - ], - }, - resolve: { - extensions: ['.ts', '.js', '.glsl'], - }, - output: { - filename: '[name].[contenthash].js', - path: path.resolve(__dirname, 'dist'), - }, + ], + }, + resolve: { + extensions: ['.ts', '.js', '.json'], + }, + }; }; diff --git a/media/collage-iphone.png b/media/collage-iphone.png new file mode 100644 index 0000000..e2f9f33 Binary files /dev/null and b/media/collage-iphone.png differ diff --git a/media/game-pc.png b/media/game-pc.png new file mode 100644 index 0000000..4169164 Binary files /dev/null and b/media/game-pc.png differ diff --git a/package.json b/package.json new file mode 100644 index 0000000..3e123bd --- /dev/null +++ b/package.json @@ -0,0 +1,27 @@ +{ + "name": "root", + "private": true, + "engines": { + "node": ">=20" + }, + "devDependencies": { + "concurrently": "^10.0.3", + "eslint": "^10.4.1", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-prettier": "^5.5.6", + "eslint-plugin-unused-imports": "^4.4.1", + "prettier": "^3.8.3", + "typescript": "^6.0.3", + "typescript-eslint": "^8.60.1", + "vitest": "^4.1.9" + }, + "scripts": { + "build": "cd shared && npm run build && cd ../frontend && npm run build && cd ../backend && npm run build", + "lint": "eslint ./**/src/**/*.{js,ts} --fix && prettier --write ./**/src/**/*.{js,ts,json}", + "lint:check": "eslint ./**/src/**/*.{js,ts} && prettier --check ./**/src/**/*.{js,ts,json}", + "test": "vitest run", + "test:watch": "vitest", + "init": "cd shared && npm install && cd ../frontend && npm install && cd ../backend && npm install", + "dev": "concurrently --kill-others-on-fail \"cd shared && npm dev\" \"cd backend && npm dev\" \"cd frontend && npm dev\"" + } +} diff --git a/shared/package.json b/shared/package.json new file mode 100644 index 0000000..bf2597b --- /dev/null +++ b/shared/package.json @@ -0,0 +1,29 @@ +{ + "name": "shared", + "version": "0.0.0", + "description": "Shared library between backend and frontend", + "main": "lib/main.js", + "types": "lib/src/main.d.ts", + "files": [ + "lib" + ], + "engines": { + "node": ">=20" + }, + "scripts": { + "build": "npx webpack --mode production", + "dev": "npx webpack --mode development --watch", + "try-build-before": "npx webpack --mode production" + }, + "devDependencies": { + "clean-webpack-plugin": "^4.0.0", + "file-loader": "^6.2.0", + "gl-matrix": "^3.3.0", + "prettier": "^3.8.3", + "terser-webpack-plugin": "^5.6.1", + "ts-loader": "^9.6.0", + "typescript": "^6.0.3", + "webpack": "^5.107.2", + "webpack-cli": "^7.0.3" + } +} diff --git a/shared/src/commands/command-executors.ts b/shared/src/commands/command-executors.ts new file mode 100644 index 0000000..a5271f5 --- /dev/null +++ b/shared/src/commands/command-executors.ts @@ -0,0 +1,3 @@ +export type CommandExecutors = { + [type: string]: (command: any) => unknown; +}; diff --git a/frontend/src/scripts/commands/command-generator.ts b/shared/src/commands/command-generator.ts similarity index 51% rename from frontend/src/scripts/commands/command-generator.ts rename to shared/src/commands/command-generator.ts index 01fb65b..27db5f3 100644 --- a/frontend/src/scripts/commands/command-generator.ts +++ b/shared/src/commands/command-generator.ts @@ -1,14 +1,18 @@ import { CommandReceiver } from './command-receiver'; import { Command } from './command'; -export class CommandGenerator { +export abstract class CommandGenerator { private subscribers: Array = []; public subscribe(subscriber: CommandReceiver): void { this.subscribers.push(subscriber); } - protected sendCommand(command: Command): void { - this.subscribers.forEach((s) => s.sendCommand(command)); + public clearSubscribers(): void { + this.subscribers = []; + } + + protected sendCommandToSubscribers(command: Command): void { + this.subscribers.forEach((s) => s.handleCommand(command)); } } diff --git a/shared/src/commands/command-receiver.ts b/shared/src/commands/command-receiver.ts new file mode 100644 index 0000000..0eff7c4 --- /dev/null +++ b/shared/src/commands/command-receiver.ts @@ -0,0 +1,18 @@ +import { CommandExecutors } from './command-executors'; +import { Command } from './command'; + +export abstract class CommandReceiver { + protected commandExecutors: CommandExecutors = {}; + + protected defaultCommandExecutor(_: Command) {} + + public handleCommand(command: Command) { + const commandType = command.type; + + if (Object.prototype.hasOwnProperty.call(this.commandExecutors, commandType)) { + this.commandExecutors[commandType]!(command); + } else { + this.defaultCommandExecutor(command); + } + } +} diff --git a/shared/src/commands/command.ts b/shared/src/commands/command.ts new file mode 100644 index 0000000..1142b7a --- /dev/null +++ b/shared/src/commands/command.ts @@ -0,0 +1,9 @@ +export abstract class Command { + public static get type(): string { + return (this as any).name; + } + + public get type(): string { + return (this as any).constructor.name; + } +} diff --git a/shared/src/commands/types/actions/leap-action.ts b/shared/src/commands/types/actions/leap-action.ts new file mode 100644 index 0000000..0115e73 --- /dev/null +++ b/shared/src/commands/types/actions/leap-action.ts @@ -0,0 +1,19 @@ +import { serializable } from '../../../serialization/serializable'; +import { Command } from '../../command'; + +// Sent client -> server when the player triggers a leap (Space / leap button). +// The launch direction is derived server-side from the character's surface +// normal and current movement input. clientTimeMs is the client's wall-clock at +// the press, echoed back via InputAcknowledgement so the predictor knows which +// leaps the server has already folded into the streamed launch momentum and +// must not replay again. +@serializable +export class LeapActionCommand extends Command { + public constructor(public readonly clientTimeMs: number = 0) { + super(); + } + + public toArray(): Array { + return [this.clientTimeMs]; + } +} diff --git a/shared/src/commands/types/actions/move-action.ts b/shared/src/commands/types/actions/move-action.ts new file mode 100644 index 0000000..117d3e3 --- /dev/null +++ b/shared/src/commands/types/actions/move-action.ts @@ -0,0 +1,22 @@ +import { vec2 } from 'gl-matrix'; +import { serializable } from '../../../serialization/serializable'; +import { Command } from '../../command'; + +@serializable +export class MoveActionCommand extends Command { + // clientTimeMs is the client's wall-clock (integer ms, survives the + // serializer's toFixed(3)) when the input was generated. The server echoes + // the latest one back via InputAcknowledgement so the client knows how much + // of its input timeline is already baked into a snapshot and can replay the + // rest for prediction. Defaults to 0 for inputs the server itself synthesises. + public constructor( + public readonly direction: vec2, + public readonly clientTimeMs: number = 0, + ) { + super(); + } + + public toArray(): Array { + return [this.direction, this.clientTimeMs]; + } +} diff --git a/shared/src/commands/types/actions/primary-action.ts b/shared/src/commands/types/actions/primary-action.ts new file mode 100644 index 0000000..54b92c9 --- /dev/null +++ b/shared/src/commands/types/actions/primary-action.ts @@ -0,0 +1,17 @@ +import { vec2 } from 'gl-matrix'; +import { serializable } from '../../../serialization/serializable'; +import { Command } from '../../command'; + +@serializable +export class PrimaryActionCommand extends Command { + public constructor( + public readonly position: vec2, + public readonly charge: number = 0, + ) { + super(); + } + + public toArray(): Array { + return [this.position, this.charge]; + } +} diff --git a/shared/src/commands/types/actions/set-aspect-ratio-action.ts b/shared/src/commands/types/actions/set-aspect-ratio-action.ts new file mode 100644 index 0000000..f2b0d5c --- /dev/null +++ b/shared/src/commands/types/actions/set-aspect-ratio-action.ts @@ -0,0 +1,13 @@ +import { serializable } from '../../../serialization/serializable'; +import { Command } from '../../command'; + +@serializable +export class SetAspectRatioActionCommand extends Command { + public constructor(public readonly aspectRatio: number) { + super(); + } + + public toArray(): Array { + return [this.aspectRatio]; + } +} diff --git a/shared/src/commands/types/create-objects.ts b/shared/src/commands/types/create-objects.ts new file mode 100644 index 0000000..108dc06 --- /dev/null +++ b/shared/src/commands/types/create-objects.ts @@ -0,0 +1,14 @@ +import { GameObject } from '../../objects/game-object'; +import { serializable } from '../../serialization/serializable'; +import { Command } from '../command'; + +@serializable +export class CreateObjectsCommand extends Command { + public constructor(public readonly objects: Array) { + super(); + } + + public toArray(): Array { + return [this.objects]; + } +} diff --git a/shared/src/commands/types/create-player.ts b/shared/src/commands/types/create-player.ts new file mode 100644 index 0000000..35ff556 --- /dev/null +++ b/shared/src/commands/types/create-player.ts @@ -0,0 +1,14 @@ +import { CharacterBase } from '../../objects/types/character-base'; +import { serializable } from '../../serialization/serializable'; +import { Command } from '../command'; + +@serializable +export class CreatePlayerCommand extends Command { + public constructor(public readonly character: CharacterBase) { + super(); + } + + public toArray(): Array { + return [this.character]; + } +} diff --git a/shared/src/commands/types/delete-objects.ts b/shared/src/commands/types/delete-objects.ts new file mode 100644 index 0000000..6bf9ecf --- /dev/null +++ b/shared/src/commands/types/delete-objects.ts @@ -0,0 +1,14 @@ +import { Id } from '../../communication/id'; +import { serializable } from '../../serialization/serializable'; +import { Command } from '../command'; + +@serializable +export class DeleteObjectsCommand extends Command { + public constructor(public readonly ids: Array) { + super(); + } + + public toArray(): Array { + return [this.ids]; + } +} diff --git a/shared/src/commands/types/game-end.ts b/shared/src/commands/types/game-end.ts new file mode 100644 index 0000000..32bb84c --- /dev/null +++ b/shared/src/commands/types/game-end.ts @@ -0,0 +1,18 @@ +import { CharacterTeam } from '../../objects/types/character-base'; +import { serializable } from '../../serialization/serializable'; +import { Command } from '../command'; + +@serializable +export class GameEndCommand extends Command { + constructor( + public readonly winningTeam: CharacterTeam, + public readonly endCardLengthInSeconds: number, + public readonly shouldReconnect: boolean, + ) { + super(); + } + + public toArray(): Array { + return [this.winningTeam, this.endCardLengthInSeconds, this.shouldReconnect]; + } +} diff --git a/shared/src/commands/types/game-start.ts b/shared/src/commands/types/game-start.ts new file mode 100644 index 0000000..15f14c8 --- /dev/null +++ b/shared/src/commands/types/game-start.ts @@ -0,0 +1,13 @@ +import { serializable } from '../../serialization/serializable'; +import { Command } from '../command'; + +@serializable +export class GameStartCommand extends Command { + constructor() { + super(); + } + + public toArray(): Array { + return []; + } +} diff --git a/shared/src/commands/types/input-acknowledgement.ts b/shared/src/commands/types/input-acknowledgement.ts new file mode 100644 index 0000000..3f488e7 --- /dev/null +++ b/shared/src/commands/types/input-acknowledgement.ts @@ -0,0 +1,28 @@ +import { vec2 } from 'gl-matrix'; +import { serializable } from '../../serialization/serializable'; +import { Command } from '../command'; + +// Sent server -> owning client only, alongside that client's own character +// snapshot. Carries the clientTimeMs of the most recent movement input the +// server had received when the snapshot was taken (so the client's predictor +// can reset to the snapshot and replay just the inputs the server hasn't seen +// yet) and the authoritative launch momentum (so the predictor reproduces a +// leap/slingshot/recoil flight rather than only snapping to it). See +// LocalCharacterPredictor. +@serializable +export class InputAcknowledgement extends Command { + public constructor( + public readonly clientTimeMs: number, + public readonly bodyVelocity: vec2, + // clientTimeMs of the most recent leap the server has received: any leap at + // or before it is already reflected in bodyVelocity, so the predictor must + // not replay it. + public readonly lastLeapClientTimeMs: number, + ) { + super(); + } + + public toArray(): Array { + return [this.clientTimeMs, this.bodyVelocity, this.lastLeapClientTimeMs]; + } +} diff --git a/shared/src/commands/types/property-updates-for-objects.ts b/shared/src/commands/types/property-updates-for-objects.ts new file mode 100644 index 0000000..bef2d6b --- /dev/null +++ b/shared/src/commands/types/property-updates-for-objects.ts @@ -0,0 +1,19 @@ +import { PropertyUpdatesForObject } from '../../objects/game-object'; +import { serializable } from '../../serialization/serializable'; +import { Command } from '../command'; + +@serializable +export class PropertyUpdatesForObjects extends Command { + constructor( + public readonly updates: Array, + // Seconds on the server's monotonic clock at the moment this state was + // captured. Only differences between timestamps are meaningful. + public readonly timestamp: number, + ) { + super(); + } + + public toArray(): Array { + return [this.updates, this.timestamp]; + } +} diff --git a/shared/src/commands/types/remote-calls-for-objects.ts b/shared/src/commands/types/remote-calls-for-objects.ts new file mode 100644 index 0000000..3a99962 --- /dev/null +++ b/shared/src/commands/types/remote-calls-for-objects.ts @@ -0,0 +1,26 @@ +import { Id, RemoteCall } from '../../main'; +import { serializable } from '../../serialization/serializable'; +import { Command } from '../command'; + +@serializable +export class RemoteCallsForObject { + constructor( + public readonly id: Id, + public readonly calls: Array, + ) {} + + public toArray(): Array { + return [this.id, this.calls]; + } +} + +@serializable +export class RemoteCallsForObjects extends Command { + constructor(public readonly callsForObjects: Array) { + super(); + } + + public toArray(): Array { + return [this.callsForObjects]; + } +} diff --git a/shared/src/commands/types/server-announcement.ts b/shared/src/commands/types/server-announcement.ts new file mode 100644 index 0000000..49d2be0 --- /dev/null +++ b/shared/src/commands/types/server-announcement.ts @@ -0,0 +1,13 @@ +import { serializable } from '../../serialization/serializable'; +import { Command } from '../command'; + +@serializable +export class ServerAnnouncement extends Command { + constructor(public readonly text: string) { + super(); + } + + public toArray(): Array { + return [this.text]; + } +} diff --git a/shared/src/commands/types/update-game-state.ts b/shared/src/commands/types/update-game-state.ts new file mode 100644 index 0000000..7e66c19 --- /dev/null +++ b/shared/src/commands/types/update-game-state.ts @@ -0,0 +1,17 @@ +import { serializable } from '../../serialization/serializable'; +import { Command } from '../command'; + +@serializable +export class UpdateGameState extends Command { + public constructor( + public readonly blueCount: number, + public readonly redCount: number, + public readonly limit: number, + ) { + super(); + } + + public toArray(): Array { + return [this.blueCount, this.redCount, this.limit]; + } +} diff --git a/shared/src/commands/types/update-minimap.ts b/shared/src/commands/types/update-minimap.ts new file mode 100644 index 0000000..a24e371 --- /dev/null +++ b/shared/src/commands/types/update-minimap.ts @@ -0,0 +1,29 @@ +import { vec2 } from 'gl-matrix'; +import { Id } from '../../communication/id'; +import { CharacterTeam } from '../../objects/types/character-base'; +import { serializable } from '../../serialization/serializable'; +import { Command } from '../command'; + +@serializable +export class MinimapPlayer { + public constructor( + public readonly id: Id, + public readonly position: vec2, + public readonly team: CharacterTeam, + ) {} + + public toArray(): Array { + return [this.id, this.position, this.team]; + } +} + +@serializable +export class UpdateMinimap extends Command { + public constructor(public readonly players: Array) { + super(); + } + + public toArray(): Array { + return [this.players]; + } +} diff --git a/shared/src/communication/id.ts b/shared/src/communication/id.ts new file mode 100644 index 0000000..c6c1e4d --- /dev/null +++ b/shared/src/communication/id.ts @@ -0,0 +1,7 @@ +export type Id = number | null; + +let currentId = 0; + +export const id = (): number => { + return currentId++; +}; diff --git a/shared/src/communication/player-information.ts b/shared/src/communication/player-information.ts new file mode 100644 index 0000000..1130b7b --- /dev/null +++ b/shared/src/communication/player-information.ts @@ -0,0 +1,3 @@ +export interface PlayerInformation { + name: string; +} diff --git a/shared/src/communication/server-information.ts b/shared/src/communication/server-information.ts new file mode 100644 index 0000000..425ef2a --- /dev/null +++ b/shared/src/communication/server-information.ts @@ -0,0 +1,8 @@ +export interface ServerInformation { + playerLimit: number; + playerCount: number; + serverName: string; + gameStatePercent: number; +} + +export const serverInformationEndpoint = '/state'; diff --git a/shared/src/communication/transport-events.ts b/shared/src/communication/transport-events.ts new file mode 100644 index 0000000..395a564 --- /dev/null +++ b/shared/src/communication/transport-events.ts @@ -0,0 +1,9 @@ +export enum TransportEvents { + PlayerJoining = 'PlayerJoining', + PlayerToServer = 'PlayerToServer', + ServerToPlayer = 'ServerToPlayer', + SubscribeForServerInfoUpdates = 'SubscribeForServerInfoUpdates', + ServerInfoUpdate = 'ServerInfoUpdate', + Ping = 'Ping', + Pong = 'Pong', +} diff --git a/shared/src/helper/array.ts b/shared/src/helper/array.ts new file mode 100644 index 0000000..8c86ffa --- /dev/null +++ b/shared/src/helper/array.ts @@ -0,0 +1,34 @@ +declare global { + // `T` must stay to merge with the global `Array` (otherwise TS2428), but it + // is unused by the x/y aliases below. + // eslint-disable-next-line @typescript-eslint/no-unused-vars + interface Array { + x: number; + y: number; + } + + interface Float32Array { + x: number; + y: number; + } +} + +const setIndexAlias = (name: string, index: number, type: any) => { + if (!Object.prototype.hasOwnProperty.call(type.prototype, name)) { + Object.defineProperty(type.prototype, name, { + get() { + return this[index]; + }, + set(value) { + this[index] = value; + }, + }); + } +}; + +export const applyArrayPlugins = () => { + setIndexAlias('x', 0, Array); + setIndexAlias('y', 1, Array); + setIndexAlias('x', 0, Float32Array); + setIndexAlias('y', 1, Float32Array); +}; diff --git a/shared/src/helper/calculate-view-area.ts b/shared/src/helper/calculate-view-area.ts new file mode 100644 index 0000000..00ebd50 --- /dev/null +++ b/shared/src/helper/calculate-view-area.ts @@ -0,0 +1,22 @@ +import { vec2 } from 'gl-matrix'; +import { Rectangle, settings } from '../main'; + +export const calculateViewArea = ( + center: vec2, + aspectRatio: number, + oversizeRatio = 1, +): Rectangle => { + const viewArea = new Rectangle(); + + viewArea.size = vec2.fromValues( + Math.sqrt(settings.inViewAreaSize * oversizeRatio * aspectRatio), + Math.sqrt((settings.inViewAreaSize * oversizeRatio) / aspectRatio), + ); + + viewArea.topLeft = vec2.fromValues( + center[0] - viewArea.size[0] / 2, + center[1] + viewArea.size[1] / 2, + ); + + return viewArea; +}; diff --git a/shared/src/helper/charge.ts b/shared/src/helper/charge.ts new file mode 100644 index 0000000..1ab6ece --- /dev/null +++ b/shared/src/helper/charge.ts @@ -0,0 +1,5 @@ +import { clamp01 } from './clamp'; +import { settings } from '../settings'; + +export const holdDurationToCharge = (heldSeconds: number): number => + clamp01(heldSeconds / settings.chargeShotFullHoldSeconds); diff --git a/shared/src/helper/circle.ts b/shared/src/helper/circle.ts new file mode 100644 index 0000000..854adbb --- /dev/null +++ b/shared/src/helper/circle.ts @@ -0,0 +1,18 @@ +import { vec2 } from 'gl-matrix'; +import { serializable } from '../serialization/serializable'; + +@serializable +export class Circle { + constructor( + public center: vec2, + public radius: number, + ) {} + + public distance(target: vec2): number { + return vec2.distance(this.center, target) - this.radius; + } + + public toArray(): Array { + return [this.center, this.radius]; + } +} diff --git a/frontend/src/scripts/helper/clamp.ts b/shared/src/helper/clamp.ts similarity index 57% rename from frontend/src/scripts/helper/clamp.ts rename to shared/src/helper/clamp.ts index d1d639b..45da555 100644 --- a/frontend/src/scripts/helper/clamp.ts +++ b/shared/src/helper/clamp.ts @@ -1,5 +1,4 @@ export const clamp = (value: number, min: number, max: number): number => Math.min(max, Math.max(min, value)); -export const clamp01 = (value: number): number => - Math.min(1, Math.max(0, value)); +export const clamp01 = (value: number): number => Math.min(1, Math.max(0, value)); diff --git a/shared/src/helper/hsl.ts b/shared/src/helper/hsl.ts new file mode 100644 index 0000000..5b4aaf0 --- /dev/null +++ b/shared/src/helper/hsl.ts @@ -0,0 +1,35 @@ +import { vec3 } from 'gl-matrix'; +import { rgb } from './rgb'; + +// source: https://stackoverflow.com/questions/2353211/hsl-to-rgb-color-conversion +export const hsl = (hue: number, saturation: number, lightness: number): vec3 => { + hue /= 360; + saturation /= 100; + lightness /= 100; + let r, g, b; + + if (saturation == 0) { + r = g = b = lightness; + } else { + const hue2rgb = (p: number, q: number, t: number) => { + if (t < 0) t += 1; + if (t > 1) t -= 1; + if (t < 1 / 6) return p + (q - p) * 6 * t; + if (t < 1 / 2) return q; + if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6; + return p; + }; + + const q = + lightness < 0.5 + ? lightness * (1 + saturation) + : lightness + saturation - lightness * saturation; + const p = 2 * lightness - q; + + r = hue2rgb(p, q, hue + 1 / 3); + g = hue2rgb(p, q, hue); + b = hue2rgb(p, q, hue - 1 / 3); + } + + return rgb(r, g, b); +}; diff --git a/shared/src/helper/last.ts b/shared/src/helper/last.ts new file mode 100644 index 0000000..39a5c1f --- /dev/null +++ b/shared/src/helper/last.ts @@ -0,0 +1,3 @@ +export function last(a: ArrayLike): T | null { + return a.length > 0 ? a[a.length - 1] : null; +} diff --git a/shared/src/helper/mix-rgb.ts b/shared/src/helper/mix-rgb.ts new file mode 100644 index 0000000..17195af --- /dev/null +++ b/shared/src/helper/mix-rgb.ts @@ -0,0 +1,12 @@ +import { vec3 } from 'gl-matrix'; +import { clamp01 } from './clamp'; +import { mix } from './mix'; + +export const mixRgb = (a: vec3, b: vec3, q: number): vec3 => { + const clampedQ = clamp01(q); + return vec3.fromValues( + mix(a[0], b[0], clampedQ), + mix(a[1], b[1], clampedQ), + mix(a[2], b[2], clampedQ), + ); +}; diff --git a/shared/src/helper/mix.ts b/shared/src/helper/mix.ts new file mode 100644 index 0000000..16a76ed --- /dev/null +++ b/shared/src/helper/mix.ts @@ -0,0 +1 @@ +export const mix = (from: number, to: number, q: number) => from + (to - from) * q; diff --git a/shared/src/helper/random.ts b/shared/src/helper/random.ts new file mode 100644 index 0000000..d7a0de3 --- /dev/null +++ b/shared/src/helper/random.ts @@ -0,0 +1,31 @@ +// source +// https://stackoverflow.com/questions/521295/seeding-the-random-number-generator-in-javascript +// Mulberry32 + +export abstract class Random { + private static _seed = Math.random(); + + public static set seed(value: number) { + Random._seed = value; + } + + public static choose(values: Array): T | undefined { + const to = values.length; + if (to === 0) { + return undefined; + } + + return values[Math.floor(this.getRandomInRange(0, to))]; + } + + public static getRandomInRange(from: number, to: number): number { + return from + this.getRandom() * (to - from); + } + + public static getRandom(): number { + let t = (Random._seed += 0x6d2b79f5); + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + } +} diff --git a/shared/src/helper/rectangle.ts b/shared/src/helper/rectangle.ts new file mode 100644 index 0000000..f1aa8f3 --- /dev/null +++ b/shared/src/helper/rectangle.ts @@ -0,0 +1,14 @@ +import { vec2 } from 'gl-matrix'; +import { serializable } from '../serialization/serializable'; + +@serializable +export class Rectangle { + constructor( + public topLeft = vec2.create(), + public size = vec2.create(), + ) {} + + public toArray(): Array { + return [this.topLeft, this.size]; + } +} diff --git a/shared/src/helper/rgb.ts b/shared/src/helper/rgb.ts new file mode 100644 index 0000000..a6ef20a --- /dev/null +++ b/shared/src/helper/rgb.ts @@ -0,0 +1,3 @@ +import { vec3 } from 'gl-matrix'; + +export const rgb = (r: number, g: number, b: number): vec3 => vec3.fromValues(r, g, b); diff --git a/shared/src/helper/rgb255.ts b/shared/src/helper/rgb255.ts new file mode 100644 index 0000000..56c47ff --- /dev/null +++ b/shared/src/helper/rgb255.ts @@ -0,0 +1,4 @@ +import { vec3 } from 'gl-matrix'; + +export const rgb255 = (r: number, g: number, b: number): vec3 => + vec3.fromValues(r / 255, g / 255, b / 255); diff --git a/shared/src/helper/rotate-90-deg.ts b/shared/src/helper/rotate-90-deg.ts new file mode 100644 index 0000000..2196fa8 --- /dev/null +++ b/shared/src/helper/rotate-90-deg.ts @@ -0,0 +1,3 @@ +import { vec2 } from 'gl-matrix'; + +export const rotate90Deg = (vec: vec2): vec2 => vec2.fromValues(-vec[1], vec[0]); diff --git a/shared/src/helper/rotate-minus-90-deg.ts b/shared/src/helper/rotate-minus-90-deg.ts new file mode 100644 index 0000000..3f4773f --- /dev/null +++ b/shared/src/helper/rotate-minus-90-deg.ts @@ -0,0 +1,3 @@ +import { vec2 } from 'gl-matrix'; + +export const rotateMinus90Deg = (vec: vec2): vec2 => vec2.fromValues(vec[1], -vec[0]); diff --git a/shared/src/helper/wait.ts b/shared/src/helper/wait.ts new file mode 100644 index 0000000..73dd2c2 --- /dev/null +++ b/shared/src/helper/wait.ts @@ -0,0 +1,3 @@ +export const wait = (ms: number): Promise => { + return new Promise((resolve, _) => setTimeout(resolve, ms)); +}; diff --git a/shared/src/main.ts b/shared/src/main.ts new file mode 100644 index 0000000..7adfcf6 --- /dev/null +++ b/shared/src/main.ts @@ -0,0 +1,58 @@ +export * from './commands/command'; +export * from './commands/types/create-objects'; +export * from './commands/types/create-player'; +export * from './commands/types/delete-objects'; +export * from './commands/types/remote-calls-for-objects'; +export * from './commands/types/server-announcement'; +export * from './commands/types/property-updates-for-objects'; +export * from './commands/types/game-end'; +export * from './commands/types/game-start'; +export * from './commands/types/update-minimap'; +export * from './commands/types/update-game-state'; +export * from './commands/command-receiver'; +export * from './commands/command-executors'; +export * from './commands/command-generator'; +export * from './commands/types/actions/move-action'; +export * from './commands/types/actions/primary-action'; +export * from './commands/types/actions/leap-action'; +export * from './commands/types/actions/set-aspect-ratio-action'; +export * from './helper/array'; +export * from './helper/last'; +export * from './helper/rgb'; +export * from './helper/hsl'; +export * from './helper/rgb255'; +export * from './helper/mix-rgb'; +export * from './helper/clamp'; +export * from './helper/charge'; +export * from './helper/calculate-view-area'; +export * from './helper/circle'; +export * from './helper/rectangle'; +export * from './helper/mix'; +export * from './helper/random'; +export * from './communication/id'; +export * from './communication/server-information'; +export * from './communication/player-information'; +export * from './helper/rotate-90-deg'; +export * from './helper/rotate-minus-90-deg'; +export * from './objects/game-object'; +export * from './serialization/deserialize'; +export * from './serialization/serialize'; +export * from './serialization/serializes-to'; +export * from './serialization/serializable'; +export * from './serialization/override-deserialization'; +export * from './objects/types/character-base'; +export * from './objects/types/lamp-base'; +export * from './objects/types/planet-base'; +export * from './objects/types/projectile-base'; +export * from './settings'; +export * from './communication/transport-events'; +export * from './commands/types/input-acknowledgement'; +export * from './physics/sdf'; +export * from './physics/evaluate-sdf'; +export * from './physics/sdf-normal'; +export * from './physics/march-circle'; +export * from './physics/depenetrate-circle'; +export * from './physics/resolve-circle-movement'; +export * from './physics/planet-sdf'; +export * from './physics/interpolate-angles'; +export * from './physics/character-movement'; diff --git a/shared/src/objects/game-object.ts b/shared/src/objects/game-object.ts new file mode 100644 index 0000000..f851137 --- /dev/null +++ b/shared/src/objects/game-object.ts @@ -0,0 +1,111 @@ +import { Command } from '../commands/command'; +import { CommandReceiver } from '../commands/command-receiver'; +import { Id } from '../communication/id'; +import { serializable } from '../serialization/serializable'; + +@serializable +export class RemoteCall { + constructor( + public readonly functionName: string, + public readonly args: Array, + ) {} + + public toArray(): Array { + return [this.functionName, this.args]; + } +} + +// Every object property streamed via UpdatePropertyCommand. A single union means +// a typo or rename on the producing (server *-physical) or consuming (client +// *-view) side is a compile error instead of a silently dropped update that +// just stops a body interpolating. The wire format is unchanged — these remain +// the same strings, only now compiler-checked at both ends. +export type SyncPropertyKey = + | 'head' + | 'leftFoot' + | 'rightFoot' + | 'strength' + | 'center' + | 'ownership' + | 'rotation'; + +@serializable +export class UpdatePropertyCommand extends Command { + constructor( + public readonly propertyKey: SyncPropertyKey, + public readonly propertyValue: any, + public readonly rateOfChange: any, + ) { + super(); + } + + public toArray(): Array { + return [this.propertyKey, this.propertyValue, this.rateOfChange]; + } +} + +@serializable +export class PropertyUpdatesForObject { + constructor( + public readonly id: Id, + public readonly updates: Array, + ) {} + + public toArray(): Array { + return [this.id, this.updates]; + } +} + +export abstract class GameObject extends CommandReceiver { + private remoteCalls: Array = []; + + // The only methods a peer may invoke over the wire. processRemoteCalls + // dispatches by a raw string taken straight off the network, so without this + // gate a malformed or hostile packet could call ANY method on the object + // (toArray, resetRemoteCalls, even prototype methods). Keep in sync with the + // remoteCall() emitters on the *-physical classes. + private static readonly allowedRemoteCalls: ReadonlySet = new Set([ + 'onShoot', + 'onLeap', + 'onDie', + 'onHitConfirmed', + 'onKillConfirmed', + 'setHealth', + 'setKillCount', + 'onFlipped', + 'setContested', + 'generatedPoints', + 'setLight', + ]); + + constructor(public readonly id: Id) { + super(); + } + + public processRemoteCalls(remoteCalls: Array) { + remoteCalls.forEach((r) => { + if (!GameObject.allowedRemoteCalls.has(r.functionName)) { + console.warn(`Dropped disallowed remote call: ${r.functionName}`); + return; + } + const fn = this[r.functionName as keyof this]; + if (typeof fn === 'function') { + (fn as (...args: Array) => unknown).apply(this, r.args); + } + }); + } + + public getPropertyUpdates(): PropertyUpdatesForObject | void {} + + public getRemoteCalls(): Array { + return this.remoteCalls; + } + + public resetRemoteCalls() { + this.remoteCalls = []; + } + + protected remoteCall(name: string & keyof this, ...args: Array) { + this.remoteCalls.push(new RemoteCall(name, args)); + } +} diff --git a/shared/src/objects/types/character-base.ts b/shared/src/objects/types/character-base.ts new file mode 100644 index 0000000..f6cad5c --- /dev/null +++ b/shared/src/objects/types/character-base.ts @@ -0,0 +1,65 @@ +import { Id } from '../../communication/id'; +import { Circle } from '../../helper/circle'; +import { serializable } from '../../serialization/serializable'; +import { toArrayFromFields } from '../../serialization/serialized-fields'; +import { GameObject } from '../game-object'; + +export enum CharacterTeam { + blue = 'blue', + neutral = 'neutral', + red = 'red', +} + +@serializable +export class CharacterBase extends GameObject { + private static readonly serializedFields = [ + 'id', + 'name', + 'killCount', + 'deathCount', + 'team', + 'health', + 'head', + 'leftFoot', + 'rightFoot', + ] as const; + + constructor( + id: Id, + public name: string, + public killCount: number, + public deathCount: number, + public team: CharacterTeam, + public health: number, + public head?: Circle, + public leftFoot?: Circle, + public rightFoot?: Circle, + ) { + super(id); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public onShoot(strength: number) {} + + public onLeap() {} + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public onHitConfirmed(charge?: number) {} + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public onKillConfirmed(victimName?: string, streak?: number, charge?: number) {} + + public setHealth(health: number) { + this.health = health; + } + + public onDie() {} + + public setKillCount(killCount: number) { + this.killCount = killCount; + } + + public toArray(): Array { + return toArrayFromFields(this, CharacterBase.serializedFields); + } +} diff --git a/shared/src/objects/types/lamp-base.ts b/shared/src/objects/types/lamp-base.ts new file mode 100644 index 0000000..9f86ca6 --- /dev/null +++ b/shared/src/objects/types/lamp-base.ts @@ -0,0 +1,32 @@ +import { vec2, vec3 } from 'gl-matrix'; +import { Id, serializable } from '../../main'; +import { toArrayFromFields } from '../../serialization/serialized-fields'; +import { GameObject } from '../game-object'; + +@serializable +export class LampBase extends GameObject { + private static readonly serializedFields = [ + 'id', + 'center', + 'color', + 'lightness', + ] as const; + + constructor( + id: Id, + public center: vec2, + public color: vec3, + public lightness: number, + ) { + super(id); + } + + // Overridden by LampView (which lerps toward the new colour/brightness); a + // no-op on the wire base. + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public setLight(color: vec3, lightness: number) {} + + public toArray(): Array { + return toArrayFromFields(this, LampBase.serializedFields); + } +} diff --git a/shared/src/objects/types/planet-base.ts b/shared/src/objects/types/planet-base.ts new file mode 100644 index 0000000..2f90eb1 --- /dev/null +++ b/shared/src/objects/types/planet-base.ts @@ -0,0 +1,72 @@ +import { vec2 } from 'gl-matrix'; +import { Random } from '../../helper/random'; +import { settings } from '../../settings'; +import { serializable } from '../../serialization/serializable'; +import { toArrayFromFields } from '../../serialization/serialized-fields'; +import { GameObject } from '../game-object'; +import { Id } from '../../communication/id'; +import { CharacterTeam } from './character-base'; + +@serializable +export class PlanetBase extends GameObject { + // centre/radius are derived from vertices in the constructor, so they are not + // serialized — only the constructor parameters are. + private static readonly serializedFields = [ + 'id', + 'vertices', + 'ownership', + 'isKeystone', + ] as const; + + public readonly center: vec2; + public readonly radius: number; + + constructor( + id: Id, + public readonly vertices: Array, + public ownership: number = 0.5, + public readonly isKeystone: boolean = false, + ) { + super(id); + this.center = vertices.reduce((sum, v) => vec2.add(sum, sum, v), vec2.create()); + vec2.scale(this.center, this.center, 1 / vertices.length); + this.radius = + vertices.reduce((sum, v) => sum + vec2.distance(this.center, v), 0) / + this.vertices.length; + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public generatedPoints(value: number) {} + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public onFlipped(team: CharacterTeam) {} + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public setContested(contested: boolean) {} + + public static createPlanetVertices( + center: vec2, + width: number, + height: number, + randomness: number, + vertexCount = settings.planetEdgeCount, + ): Array { + const vertices = []; + + for (let i = 0; i < vertexCount; i++) { + vertices.push( + vec2.fromValues( + center[0] + + (width / 2) * Math.cos((i / vertexCount) * -Math.PI * 2) + + Random.getRandomInRange(-randomness, randomness), + center[1] + + (height / 2) * Math.sin((i / vertexCount) * -Math.PI * 2) + + Random.getRandomInRange(-randomness, randomness), + ), + ); + } + return vertices; + } + + public toArray(): Array { + return toArrayFromFields(this, PlanetBase.serializedFields); + } +} diff --git a/shared/src/objects/types/projectile-base.ts b/shared/src/objects/types/projectile-base.ts new file mode 100644 index 0000000..f96fd94 --- /dev/null +++ b/shared/src/objects/types/projectile-base.ts @@ -0,0 +1,37 @@ +import { vec2 } from 'gl-matrix'; +import { settings } from '../../settings'; +import { serializable } from '../../serialization/serializable'; +import { toArrayFromFields } from '../../serialization/serialized-fields'; +import { GameObject } from '../game-object'; +import { Id } from '../../communication/id'; +import { CharacterTeam } from './character-base'; + +@serializable +export class ProjectileBase extends GameObject { + private static readonly serializedFields = [ + 'id', + 'center', + 'radius', + 'team', + 'strength', + ] as const; + + constructor( + id: Id, + public center: vec2, + public radius: number, + public team: CharacterTeam, + public strength: number, + ) { + super(id); + } + + public step(deltaTimeInSeconds: number) { + this.strength -= settings.projectileFadeSpeed * deltaTimeInSeconds; + this.strength = Math.max(0, this.strength); + } + + public toArray(): Array { + return toArrayFromFields(this, ProjectileBase.serializedFields); + } +} diff --git a/shared/src/physics/character-movement.ts b/shared/src/physics/character-movement.ts new file mode 100644 index 0000000..21355a5 --- /dev/null +++ b/shared/src/physics/character-movement.ts @@ -0,0 +1,365 @@ +import { vec2 } from 'gl-matrix'; +import { settings } from '../settings'; +import { GroundSurface, PhysicsBody } from './sdf'; +import { interpolateAngles } from './interpolate-angles'; + +// Body layout, copied verbatim from CharacterPhysical so the predicted body +// matches the authoritative one to the bit. The head sits this far above the +// feet; offsets are measured from the centre of mass. +export const headRadius = 50; +export const feetRadius = 20; + +const desiredHeadOffset = vec2.fromValues(0, 55); +const desiredLeftFootOffset = vec2.fromValues(-20, 0); +const desiredRightFootOffset = vec2.fromValues(20, 0); +const centerOfMass = vec2.scale( + vec2.create(), + vec2.add( + vec2.create(), + vec2.add(vec2.create(), desiredHeadOffset, desiredLeftFootOffset), + desiredRightFootOffset, + ), + 1 / 3, +); +export const headOffset = vec2.subtract(vec2.create(), desiredHeadOffset, centerOfMass); +export const leftFootOffset = vec2.subtract( + vec2.create(), + desiredLeftFootOffset, + centerOfMass, +); +export const rightFootOffset = vec2.subtract( + vec2.create(), + desiredRightFootOffset, + centerOfMass, +); +export const boundRadius = (headRadius + feetRadius * 2) * 2; + +// The character's movement state: the three body parts plus the small amount of +// carried state the step reads and writes. Backend CharacterPhysical and the +// client predictor both expose this shape. +export interface CharacterMovementState { + readonly head: PhysicsBody; + readonly leftFoot: PhysicsBody; + readonly rightFoot: PhysicsBody; + direction: number; + currentPlanet: GroundSurface | undefined; + secondsSinceOnSurface: number; + // Persistent launch momentum (leap / slingshot / recoil / death throw). + // Walking rebuilds and zeroes each body part's velocity every tick, + // so anything that should carry accumulates here, is injected into the parts + // before they step, and decays by friction. Stays zero for ordinary walking. + // The client predictor leaves this zero and relies on the reconciliation snap + // to follow server-side impulses, but the field is here so the server can + // delegate its full movement to stepCharacterMovement unchanged. + bodyVelocity: vec2; +} + +// The collision/gravity world the movement queries. The server backs this with +// its spatial container (planets + dynamics, dispatching collision reactions); +// the client backs it with the planets it knows about, dispatching nothing. +export interface CharacterWorld { + // Planets within `radius` of `center` that exert gravity / can be stood on. + groundsNear(center: vec2, radius: number): Array; + // Resolve one body part's motion this tick; returns the ground it landed on. + stepBody(body: PhysicsBody, deltaTimeInSeconds: number): GroundSurface | undefined; +} + +const applyForce = (body: PhysicsBody, force: vec2, deltaTimeInSeconds: number) => { + vec2.add( + body.velocity, + body.velocity, + vec2.scale(vec2.create(), force, deltaTimeInSeconds), + ); +}; + +// ((head + leftFoot) + rightFoot) / 3, in the exact association the server uses +// everywhere it reads the character centre — do not reassociate. +export const characterCenter = (state: CharacterMovementState): vec2 => { + const center = vec2.add(vec2.create(), state.head.center, state.leftFoot.center); + vec2.add(center, center, state.rightFoot.center); + return vec2.scale(center, center, 1 / 3); +}; + +const setDirection = (state: CharacterMovementState, direction: vec2) => { + state.direction = interpolateAngles( + state.direction, + Math.atan2(direction[1], direction[0]) + Math.PI / 2, + 0.2, + ); +}; + +const springMove = ( + state: CharacterMovementState, + body: PhysicsBody, + center: vec2, + offset: vec2, + stiffness: number, +) => { + const desiredPosition = vec2.add(vec2.create(), center, offset); + vec2.rotate(desiredPosition, desiredPosition, center, state.direction); + const positionDelta = vec2.subtract(vec2.create(), desiredPosition, body.center); + // First-order velocity relaxation toward the desired posture position, added + // to the gravity/movement velocity already accumulated this tick. The dt + // arrives later when the body integrates velocity, so the per-tick + // displacement is positionDelta * stiffness * dt. + vec2.scaleAndAdd(body.velocity, body.velocity, positionDelta, stiffness); +}; + +const keepPosture = (state: CharacterMovementState) => { + const center = characterCenter(state); + springMove( + state, + state.leftFoot, + center, + leftFootOffset, + settings.postureFeetStiffness, + ); + springMove( + state, + state.rightFoot, + center, + rightFootOffset, + settings.postureFeetStiffness, + ); + springMove(state, state.head, center, headOffset, settings.postureHeadStiffness); +}; + +// While standing on a planet, ride its spin: rigidly rotate the whole body +// about the planet centre by the same per-tick angle the collision SDF turns +// by (negative, matching R(-rotation)), so the player is carried with the +// surface instead of sliding across it. +const carryWithRotatingPlanet = ( + state: CharacterMovementState, + deltaTimeInSeconds: number, +) => { + const planet = state.currentPlanet; + if (!planet) { + return; + } + const angle = -planet.angularVelocity * deltaTimeInSeconds; + const center = planet.center; + state.head.center = vec2.rotate(vec2.create(), state.head.center, center, angle); + state.leftFoot.center = vec2.rotate( + vec2.create(), + state.leftFoot.center, + center, + angle, + ); + state.rightFoot.center = vec2.rotate( + vec2.create(), + state.rightFoot.center, + center, + angle, + ); +}; + +// Launch off the current surface: directed by the foot contact normals plus +// the movement input, slingshotted by the planet's spin. Mutates bodyVelocity +// (which the next tick injects into the body) and detaches. Shared so the +// server's leap() and the client's prediction apply the exact same impulse. +// The caller does the gating (strength, cooldown, alive); this is a no-op when +// not on a surface. +export const applyLeapImpulse = (state: CharacterMovementState, moveDirection: vec2) => { + const planet = state.currentPlanet; + if (!planet) { + return; + } + + const up = vec2.add( + vec2.create(), + state.leftFoot.lastNormal, + state.rightFoot.lastNormal, + ); + if (vec2.length(up) === 0) { + vec2.set(up, 0, 1); + } else { + vec2.normalize(up, up); + } + + const launch = vec2.scale(vec2.create(), up, settings.leapUpBias); + if (vec2.length(moveDirection) > 0) { + vec2.scaleAndAdd( + launch, + launch, + vec2.normalize(vec2.create(), moveDirection), + settings.leapMoveBias, + ); + } + vec2.normalize(launch, launch); + vec2.scaleAndAdd(state.bodyVelocity, state.bodyVelocity, launch, settings.leapSpeed); + + // Slingshot: add the tangential velocity of the spinning surface under the + // body (the same motion carryWithRotatingPlanet imparts). + const center = characterCenter(state); + const omega = planet.angularVelocity; + const surfaceVelocity = vec2.fromValues( + omega * (center[1] - planet.center[1]), + -omega * (center[0] - planet.center[0]), + ); + vec2.scaleAndAdd( + state.bodyVelocity, + state.bodyVelocity, + surfaceVelocity, + settings.slingshotScale, + ); + + state.currentPlanet = undefined; + state.secondsSinceOnSurface = settings.planetDetachmentSeconds; +}; + +// Time-based detachment: a grounded body that hasn't touched a surface for a +// while floats free. Kept as its own step because on the server it runs before +// the ownership/scoring blocks; the client calls it at the head of each tick. +export const tickPlanetDetachment = ( + state: CharacterMovementState, + deltaTimeInSeconds: number, +) => { + if ( + (state.secondsSinceOnSurface += deltaTimeInSeconds) > settings.planetDetachmentSeconds + ) { + state.currentPlanet = undefined; + } +}; + +// Inject the persistent launch momentum onto every body part right before they +// step, so the whole body translates rigidly without disturbing the posture +// springs (which only set up relative offsets). No-op while walking. +const applyBodyMomentum = (state: CharacterMovementState) => { + if (vec2.squaredLength(state.bodyVelocity) === 0) { + return; + } + vec2.add(state.leftFoot.velocity, state.leftFoot.velocity, state.bodyVelocity); + vec2.add(state.rightFoot.velocity, state.rightFoot.velocity, state.bodyVelocity); + vec2.add(state.head.velocity, state.head.velocity, state.bodyVelocity); +}; + +// Decay one launch-momentum vector in place by one tick. Stiff on the ground +// (skid to a stop), gentle in the air so a leap or slingshot still carries +// across the gaps. The gentle exponential only asymptotes, though, so on top of +// it a constant deceleration brakes the momentum to a definite stop in a couple +// of seconds instead of leaving a 15+ second drift, and a hard cap stops stacked +// impulses (rapid recoil, a leap into a slingshot) from building without bound. +// Shared so the living body, the client predictor, and the ragdoll corpse all +// brake identically. +export const decayMomentum = ( + bodyVelocity: vec2, + onGround: boolean, + deltaTimeInSeconds: number, +) => { + const speed = vec2.length(bodyVelocity); + if (speed === 0) { + return; + } + const friction = onGround + ? settings.groundMomentumFriction + : settings.airMomentumFriction; + const target = Math.min( + speed * Math.exp(-friction * deltaTimeInSeconds) - + settings.momentumStopDeceleration * deltaTimeInSeconds, + settings.maxBodyMomentum, + ); + if (target <= 1) { + vec2.zero(bodyVelocity); + } else { + vec2.scale(bodyVelocity, bodyVelocity, target / speed); + } +}; + +const decayBodyMomentum = (state: CharacterMovementState, deltaTimeInSeconds: number) => { + decayMomentum(state.bodyVelocity, !!state.currentPlanet, deltaTimeInSeconds); +}; + +const sumGravity = (grounds: Array, position: vec2): vec2 => + grounds.reduce( + (sum, ground) => vec2.add(sum, sum, ground.gravityAt(position)), + vec2.create(), + ); + +const latchGround = ( + state: CharacterMovementState, + ground: GroundSurface | undefined, +) => { + if (ground) { + state.secondsSinceOnSurface = 0; + state.currentPlanet = ground; + } +}; + +// One tick of character movement. This is the exact movement block of the +// authoritative CharacterPhysical.step (gravity gather → movement force → +// on/off-planet branch → posture → step the three body parts), with all +// server-only concerns (scoring, health, shooting, spawn/death animation, +// ownership) left to the caller. `inputDirection` is the already-averaged, +// already-normalized movement direction for this tick and is consumed in place. +export const stepCharacterMovement = ( + state: CharacterMovementState, + world: CharacterWorld, + inputDirection: vec2, + deltaTimeInSeconds: number, +) => { + const center = characterCenter(state); + const grounds = world.groundsNear(center, boundRadius + settings.maxGravityDistance); + + const movementForce = vec2.scale( + inputDirection, + inputDirection, + settings.maxAcceleration, + ); + applyForce(state.leftFoot, movementForce, deltaTimeInSeconds); + applyForce(state.rightFoot, movementForce, deltaTimeInSeconds); + + if (!state.currentPlanet) { + const leftFootGravity = sumGravity(grounds, state.leftFoot.center); + const rightFootGravity = sumGravity(grounds, state.rightFoot.center); + + applyForce(state.leftFoot, leftFootGravity, deltaTimeInSeconds); + applyForce(state.rightFoot, rightFootGravity, deltaTimeInSeconds); + + const sumForce = vec2.subtract(vec2.create(), leftFootGravity, movementForce); + setDirection(state, vec2.length(sumForce) === 0 ? vec2.fromValues(0, -1) : sumForce); + } else { + carryWithRotatingPlanet(state, deltaTimeInSeconds); + + const leftFootGravity = state.currentPlanet.gravityAt(state.leftFoot.center); + const rightFootGravity = state.currentPlanet.gravityAt(state.rightFoot.center); + + vec2.add(leftFootGravity, leftFootGravity, rightFootGravity); + const gravity = vec2.scale(leftFootGravity, leftFootGravity, 0.5); + + if ( + vec2.dot(movementForce, gravity) < + -vec2.length(movementForce) * settings.climbDotThreshold + ) { + vec2.scale(gravity, gravity, settings.climbGravityScale); + } + + const scaledLeftFootGravity = vec2.scale( + vec2.create(), + state.leftFoot.lastNormal, + vec2.dot(state.leftFoot.lastNormal, gravity), + ); + applyForce(state.leftFoot, scaledLeftFootGravity, deltaTimeInSeconds); + + const scaledRightFootGravity = vec2.scale( + vec2.create(), + state.rightFoot.lastNormal, + vec2.dot(state.rightFoot.lastNormal, gravity), + ); + applyForce(state.rightFoot, scaledRightFootGravity, deltaTimeInSeconds); + + if (vec2.length(gravity) <= settings.planetDetachmentForceThreshold) { + state.currentPlanet = undefined; + } + setDirection(state, gravity); + } + + keepPosture(state); + + applyBodyMomentum(state); + + latchGround(state, world.stepBody(state.leftFoot, deltaTimeInSeconds)); + latchGround(state, world.stepBody(state.rightFoot, deltaTimeInSeconds)); + latchGround(state, world.stepBody(state.head, deltaTimeInSeconds)); + + decayBodyMomentum(state, deltaTimeInSeconds); +}; diff --git a/shared/src/physics/depenetrate-circle.ts b/shared/src/physics/depenetrate-circle.ts new file mode 100644 index 0000000..7232f73 --- /dev/null +++ b/shared/src/physics/depenetrate-circle.ts @@ -0,0 +1,36 @@ +import { vec2 } from 'gl-matrix'; +import { PhysicsBody, Sdf } from './sdf'; +import { evaluateSdf } from './evaluate-sdf'; +import { sdfNormal } from './sdf-normal'; + +// Planet collision outlines rotate (see PlanetPhysical.distance), so a surface +// can sweep into a circle that hasn't itself moved. marchCircle assumes an +// overlap-free start — beginning inside, it registers a zero-distance hit and +// never moves again — so any overlap must be resolved here, before marching. +// Iterating handles concave spots, where leaving one face pushes into another; +// if no overlap-free position exists nearby (a crevice narrower than the +// circle), it gives up and leaves the rest to a later tick. +export const depenetrateCircle = ( + body: PhysicsBody, + possibleIntersectors: Array, +) => { + for (let i = 0; i < 4; i++) { + const distance = evaluateSdf(body.center, possibleIntersectors); + if (distance >= body.radius) { + return; + } + + const normal = sdfNormal(body.center, possibleIntersectors); + if (vec2.squaredLength(normal) === 0) { + return; + } + + vec2.copy(body.lastNormal, normal); + body.center = vec2.scaleAndAdd( + vec2.create(), + body.center, + normal, + body.radius - distance + 0.01, + ); + } +}; diff --git a/shared/src/physics/evaluate-sdf.ts b/shared/src/physics/evaluate-sdf.ts new file mode 100644 index 0000000..f3c60cf --- /dev/null +++ b/shared/src/physics/evaluate-sdf.ts @@ -0,0 +1,7 @@ +import { vec2 } from 'gl-matrix'; +import { Sdf } from './sdf'; + +export const evaluateSdf = (target: vec2, objects: Array) => + objects + .filter((i) => i.canCollide) + .reduce((min, i) => (min = Math.min(min, i.distance(target))), 1000000); diff --git a/shared/src/physics/interpolate-angles.ts b/shared/src/physics/interpolate-angles.ts new file mode 100644 index 0000000..1fb46d6 --- /dev/null +++ b/shared/src/physics/interpolate-angles.ts @@ -0,0 +1,6 @@ +export const interpolateAngles = (from: number, to: number, q: number) => { + const max = Math.PI * 2; + const possibleDistance = (to - from) % max; + const shorterDistance = ((2 * possibleDistance) % max) - possibleDistance; + return from + shorterDistance * q; +}; diff --git a/shared/src/physics/march-circle.ts b/shared/src/physics/march-circle.ts new file mode 100644 index 0000000..0a8f0c8 --- /dev/null +++ b/shared/src/physics/march-circle.ts @@ -0,0 +1,80 @@ +import { vec2 } from 'gl-matrix'; +import { PhysicsBody, Sdf } from './sdf'; +import { evaluateSdf } from './evaluate-sdf'; +import { sdfNormal } from './sdf-normal'; + +export interface MarchResult { + hitSurface: boolean; + normal?: vec2; + hitObject?: Sdf; +} + +// Raymarch a circle by `delta`, stopping at the first surface it would overlap. +// Extracted verbatim from the backend's move-circle so server and client +// resolve motion identically. The collision *reaction* is no longer dispatched +// here: on a real (non-ignored) hit, `onHit(intersecting)` is invoked at the +// exact point the backend used to dispatch its ReactToCollisionCommands, and +// the backend wrapper supplies that callback. The client passes none. +export const marchCircle = ( + body: PhysicsBody, + delta: vec2, + possibleIntersectors: Array, + ignoreCollision = false, + onHit?: (intersecting: Sdf) => void, +): MarchResult => { + const direction = vec2.clone(delta); + + if (vec2.length(delta) > 0) { + vec2.normalize(direction, direction); + } + + const deltaLength = vec2.length(delta); + let travelled = 0; + const rayEnd = vec2.create(); + let prevMinDistance = 0; + while (travelled < deltaLength) { + travelled += prevMinDistance; + vec2.add( + rayEnd, + body.center, + vec2.scale(vec2.create(), direction, Math.min(travelled, deltaLength)), + ); + + const minDistance = evaluateSdf(rayEnd, possibleIntersectors); + + if (minDistance < body.radius) { + const intersecting = possibleIntersectors.find( + (i) => i.distance(rayEnd) <= body.radius, + )!; + + if (ignoreCollision) { + body.center = vec2.add(body.center, body.center, delta); + } else { + onHit?.(intersecting); + } + + vec2.add( + rayEnd, + body.center, + vec2.scale(vec2.create(), direction, travelled - prevMinDistance), + ); + + vec2.copy(body.center, rayEnd); + + const normal = sdfNormal(rayEnd, [intersecting]); + return { + hitSurface: true, + normal, + hitObject: intersecting, + }; + } + + prevMinDistance = minDistance; + } + + vec2.add(body.center, body.center, delta); + + return { + hitSurface: false, + }; +}; diff --git a/shared/src/physics/planet-sdf.ts b/shared/src/physics/planet-sdf.ts new file mode 100644 index 0000000..24af49d --- /dev/null +++ b/shared/src/physics/planet-sdf.ts @@ -0,0 +1,80 @@ +import { vec2 } from 'gl-matrix'; +import { clamp, clamp01 } from '../helper/clamp'; +import { settings } from '../settings'; + +// Rotate a world point into the planet's own frame: R(-rotation) about the +// centre, matching the shader's localTarget = center + R(rotation)*(target-center). +// cos/sin are passed in so the server can keep memoising them per angle. +export const toPlanetLocalFrame = ( + target: vec2, + center: vec2, + cos: number, + sin: number, +): vec2 => { + const dx = target[0] - center[0]; + const dy = target[1] - center[1]; + return vec2.fromValues( + center[0] + cos * dx - sin * dy, + center[1] + sin * dx + cos * dy, + ); +}; + +// Signed distance to the (smooth, noise-free) planet polygon, evaluated in the +// planet's rotating frame. Extracted verbatim from PlanetPhysical.distance so +// the client collides against the exact same outline the server does. Indexed +// vector access keeps it independent of the .x/.y prototype plugin. +export const planetDistance = ( + target: vec2, + vertices: Array, + center: vec2, + cos: number, + sin: number, +): number => { + const local = toPlanetLocalFrame(target, center, cos, sin); + + const startEnd = vertices[0]; + let vb = startEnd; + + let d = vec2.dist(local, vb); + let sign = 1; + + for (let i = 1; i <= vertices.length; i++) { + const va = vb; + vb = i === vertices.length ? startEnd : 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[0] * targetFromDelta[1] - toFromDelta[1] * targetFromDelta[0], + ); + + if ( + (local[1] >= va[1] && local[1] < vb[1] && ds[1] > 0) || + (local[1] < va[1] && local[1] >= vb[1] && ds[1] <= 0) + ) { + sign *= -1; + } + + d = Math.min(d, ds[0]); + } + + return sign * d; +}; + +// Gravity a planet exerts at a world position. Verbatim from +// PlanetPhysical.getForce. +export const planetGravity = (center: vec2, radius: number, position: vec2): vec2 => { + const diff = vec2.subtract(vec2.create(), center, position); + const dist = Math.max(settings.minGravityDistance, vec2.length(diff) - 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); +}; diff --git a/shared/src/physics/resolve-circle-movement.ts b/shared/src/physics/resolve-circle-movement.ts new file mode 100644 index 0000000..47f0c1c --- /dev/null +++ b/shared/src/physics/resolve-circle-movement.ts @@ -0,0 +1,58 @@ +import { vec2 } from 'gl-matrix'; +import { PhysicsBody, Sdf } from './sdf'; +import { depenetrateCircle } from './depenetrate-circle'; +import { marchCircle } from './march-circle'; + +// The position-resolution half of the backend's CirclePhysical.stepManually, +// extracted so server and client integrate a body identically. The caller is +// responsible for the broadphase (gathering `possibleIntersectors`, including +// the swept-radius bump) because that depends on each side's spatial structure; +// everything from depenetration onward lives here. +// +// `onHit` is threaded through to marchCircle so the backend can dispatch its +// collision reactions at the same points as before (including the second, +// post-bounce slide march); the client passes none. Velocity is reset to zero +// at the end — the character re-applies all forces from zero every tick, so a +// body that retained velocity would double-integrate and diverge. +export const resolveCircleMovement = ( + body: PhysicsBody, + deltaTimeInSeconds: number, + possibleIntersectors: Array, + onHit?: (intersecting: Sdf) => void, +): { hitObject?: Sdf; velocity: vec2 } => { + let delta = vec2.scale(vec2.create(), body.velocity, deltaTimeInSeconds); + + depenetrateCircle(body, possibleIntersectors); + + const { normal, hitSurface, hitObject } = marchCircle( + body, + delta, + possibleIntersectors, + false, + onHit, + ); + + if (hitSurface) { + vec2.copy(body.lastNormal, normal!); + + vec2.subtract( + body.velocity, + body.velocity, + vec2.scale( + normal!, + normal!, + (1 + body.restitution) * vec2.dot(normal!, body.velocity), + ), + ); + + if (vec2.length(body.velocity) > 50) { + delta = vec2.scale(vec2.create(), body.velocity, deltaTimeInSeconds); + marchCircle(body, delta, possibleIntersectors, false, onHit); + } + } + + const lastVelocity = vec2.clone(body.velocity); + vec2.zero(body.velocity); + + return { hitObject, velocity: lastVelocity }; +}; diff --git a/shared/src/physics/sdf-normal.ts b/shared/src/physics/sdf-normal.ts new file mode 100644 index 0000000..507a8eb --- /dev/null +++ b/shared/src/physics/sdf-normal.ts @@ -0,0 +1,19 @@ +import { vec2 } from 'gl-matrix'; +import { Sdf } from './sdf'; +import { evaluateSdf } from './evaluate-sdf'; + +// Central-difference gradient of the combined SDF. Can be zero where the +// samples cancel out (e.g. on the medial axis of a shape) — callers must +// handle that case. Uses indexed access so it never depends on the vec2 .x/.y +// prototype plugin (identical numerically: .x === [0]). +export const sdfNormal = (target: vec2, objects: Array): vec2 => { + const dx = + evaluateSdf(vec2.fromValues(target[0] + 0.01, target[1]), objects) - + evaluateSdf(vec2.fromValues(target[0] - 0.01, target[1]), objects); + const dy = + evaluateSdf(vec2.fromValues(target[0], target[1] + 0.01), objects) - + evaluateSdf(vec2.fromValues(target[0], target[1] - 0.01), objects); + + const normal = vec2.fromValues(dx, dy); + return vec2.squaredLength(normal) > 0 ? vec2.normalize(normal, normal) : normal; +}; diff --git a/shared/src/physics/sdf.ts b/shared/src/physics/sdf.ts new file mode 100644 index 0000000..38bb74c --- /dev/null +++ b/shared/src/physics/sdf.ts @@ -0,0 +1,31 @@ +import { vec2 } from 'gl-matrix'; + +// Minimal signed-distance collider the geometry functions need. Backend +// Physicals and the client's planet surfaces both satisfy this structurally. +export interface Sdf { + readonly canCollide: boolean; + // Planets set this true so a body landing on one can latch it as ground; + // everything else leaves it falsy. Replaces the server's + // `instanceof PlanetPhysical` check with a structural flag both sides share. + readonly isGround?: boolean; + distance(target: vec2): number; +} + +// A movable circle the geometry resolves in place. Backend CirclePhysical and +// the client predictor's plain bodies both satisfy this. +export interface PhysicsBody { + center: vec2; + radius: number; + velocity: vec2; + lastNormal: vec2; + readonly restitution: number; +} + +// A planet-like surface: collidable, exerts gravity, and spins. Drives the +// character's on-surface movement branch. +export interface GroundSurface extends Sdf { + readonly isGround: true; + readonly center: vec2; + readonly angularVelocity: number; + gravityAt(target: vec2): vec2; +} diff --git a/shared/src/serialization/deserializable-class.ts b/shared/src/serialization/deserializable-class.ts new file mode 100644 index 0000000..11d0a4f --- /dev/null +++ b/shared/src/serialization/deserializable-class.ts @@ -0,0 +1 @@ +export type DeserializableClass = { new (...args: Array): unknown; name: string }; diff --git a/shared/src/serialization/deserialize.ts b/shared/src/serialization/deserialize.ts new file mode 100644 index 0000000..6ba3a67 --- /dev/null +++ b/shared/src/serialization/deserialize.ts @@ -0,0 +1,15 @@ +import { serializableMapping } from './serializable-mapping'; + +export const deserialize = (json: string): any => { + return JSON.parse(json, (_, v) => { + if (v !== null && Object.prototype.hasOwnProperty.call(v, '0')) { + const possibleType = v[0]; + const overridableConstructor = serializableMapping.get(possibleType); + if (overridableConstructor) { + return new overridableConstructor.constructor(...v.slice(1)); + } + return v; + } + return v; + }); +}; diff --git a/shared/src/serialization/mangled-type-key.ts b/shared/src/serialization/mangled-type-key.ts new file mode 100644 index 0000000..2460725 --- /dev/null +++ b/shared/src/serialization/mangled-type-key.ts @@ -0,0 +1 @@ +export const mangledTypeKey = '__serializable_type'; diff --git a/shared/src/serialization/override-deserialization.ts b/shared/src/serialization/override-deserialization.ts new file mode 100644 index 0000000..7b8d6d9 --- /dev/null +++ b/shared/src/serialization/override-deserialization.ts @@ -0,0 +1,13 @@ +import { DeserializableClass } from './deserializable-class'; +import { SerializableClass } from './serializable-class'; +import { serializableMapping } from './serializable-mapping'; + +export const overrideDeserialization = ( + source: SerializableClass, + target: DeserializableClass, +) => { + serializableMapping.set(source.name, { + constructor: target, + overridden: true, + }); +}; diff --git a/shared/src/serialization/serializable-class.ts b/shared/src/serialization/serializable-class.ts new file mode 100644 index 0000000..88eb2d4 --- /dev/null +++ b/shared/src/serialization/serializable-class.ts @@ -0,0 +1,4 @@ +export type SerializableClass = { + new (...args: Array): { toArray(): Array }; + name: string; +}; diff --git a/shared/src/serialization/serializable-mapping.ts b/shared/src/serialization/serializable-mapping.ts new file mode 100644 index 0000000..5c9d7cb --- /dev/null +++ b/shared/src/serialization/serializable-mapping.ts @@ -0,0 +1,9 @@ +import { DeserializableClass } from './deserializable-class'; + +export const serializableMapping = new Map< + string, + { + constructor: DeserializableClass; + overridden: boolean; + } +>(); diff --git a/shared/src/serialization/serializable.ts b/shared/src/serialization/serializable.ts new file mode 100644 index 0000000..4605f65 --- /dev/null +++ b/shared/src/serialization/serializable.ts @@ -0,0 +1,17 @@ +import { mangledTypeKey } from './mangled-type-key'; +import { SerializableClass } from './serializable-class'; +import { serializableMapping } from './serializable-mapping'; + +export const serializable = (type: SerializableClass): any => { + if (!serializableMapping.get(type.name)) { + serializableMapping.set(type.name, { + constructor: type, + overridden: false, + }); + } + + Object.defineProperty(type, mangledTypeKey, { value: type.name }); + Object.defineProperty(type.prototype, mangledTypeKey, { value: type.name }); + + return type; +}; diff --git a/shared/src/serialization/serialize.ts b/shared/src/serialization/serialize.ts new file mode 100644 index 0000000..bd72218 --- /dev/null +++ b/shared/src/serialization/serialize.ts @@ -0,0 +1,12 @@ +import { mangledTypeKey } from './mangled-type-key'; + +export const serialize = (object: any): string => { + return JSON.stringify(object, (_, value) => { + if (value && value[mangledTypeKey]) { + const props = value.toArray() as Array; + props.unshift(value[mangledTypeKey]); + return props; + } + return value?.toFixed ? Number(value.toFixed(3)) : value; + }); +}; diff --git a/shared/src/serialization/serialized-fields.ts b/shared/src/serialization/serialized-fields.ts new file mode 100644 index 0000000..48733e8 --- /dev/null +++ b/shared/src/serialization/serialized-fields.ts @@ -0,0 +1,13 @@ +// Derive a serializable object's positional wire array from a single declared +// list of field names, so toArray() and the constructor share ONE source of +// truth for field order instead of a hand-written array that can silently drift +// out of step with the parameters. +// +// deserialize reconstructs via `new Ctor(...array.slice(1))`, so the field list +// MUST name the constructor's parameters in order. Fields a constructor +// recomputes from others (e.g. a planet's centre/radius from its vertices) are +// derived, not serialized, and so are deliberately absent from the list. +export const toArrayFromFields = ( + object: any, + fields: ReadonlyArray, +): Array => fields.map((field) => object[field]); diff --git a/shared/src/serialization/serializes-to.ts b/shared/src/serialization/serializes-to.ts new file mode 100644 index 0000000..47030de --- /dev/null +++ b/shared/src/serialization/serializes-to.ts @@ -0,0 +1,21 @@ +import { mangledTypeKey } from './mangled-type-key'; +import { SerializableClass } from './serializable-class'; +import { serializableMapping } from './serializable-mapping'; + +export const serializesTo = (target: SerializableClass) => { + return (actual: SerializableClass): any => { + if (!serializableMapping.get(target.name)) { + serializableMapping.set(target.name, { + constructor: target, + overridden: false, + }); + } + + Object.defineProperty(actual, mangledTypeKey, { value: target.name }); + Object.defineProperty(actual.prototype, mangledTypeKey, { + value: target.name, + }); + + return actual; + }; +}; diff --git a/shared/src/settings.ts b/shared/src/settings.ts new file mode 100644 index 0000000..61041a5 --- /dev/null +++ b/shared/src/settings.ts @@ -0,0 +1,209 @@ +import { rgb255 } from './helper/rgb255'; +import { CharacterTeam } from './objects/types/character-base'; + +const blueColor = rgb255(64, 105, 165); +const neutralColor = rgb255(82, 165, 64); +const redColor = rgb255(209, 86, 82); +const q = 2.5; +const blueColorDim = rgb255(64 * q, 105 * q, 165 * q); +const redColorDim = rgb255(209 * q, 86 * q, 82 * q); +const bluePlanetColor = blueColorDim; +const redPlanetColor = redColorDim; + +export const settings = { + lightCutoffDistance: 600, + lightOverlapReduction: 0.85, + radiusSteps: 500, + spawnDespawnTime: 0.7, + worldRadius: 4000, + objectsOnCircleLength: 0.002, + updateMessageInterval: 1 / 25, + // How far behind the estimated server time remote state is rendered: ~2.5 + // update intervals, so a late packet rarely leaves the client without a + // newer snapshot to interpolate towards. + interpolationDelaySeconds: 0.1, + planetEdgeCount: 7, + playerKillPoint: 25, + takeControlTimeInSeconds: 2.5, + loseControlTimeInSeconds: 16, + planetPointGenerationInterval: 1.5, + planetPointGenerationValue: 1, + planetSizePointMultiplierMax: 4, + captureFlipPointReward: 12, + maxGravityDistance: 800, + minGravityDistance: 1, + maxGravityQ: 5000, + // Half-width of the neutral dead-band around 50% ownership. A planet only + // counts as captured (team(), point generation, flip) once |ownership-0.5| + // exceeds this, and the rendered ownership ring stays neutral until the same + // point — so what you see matches what scores. + planetControlThreshold: 0.12, + playerMaxHealth: 100, + maxGravityStrength: 50000, + planetMinReferenceRadius: 150, + planetMaxReferenceRadius: 1200, + maxAcceleration: 120000, + playerMaxStrength: 80, + endGameDeltaScaling: 2, + playerDiedTimeout: 5, + playerStrengthRegenerationPerSeconds: 80, + playerOutOfCombatDelaySeconds: 5, + playerHealthRegenerationPerSeconds: 8, + playerKillHealthReward: 25, + spawnInvulnerabilityExtraSeconds: 0.4, + spawnSafetyDistance: 2000, + touchAimRange: 1000, + postureHeadStiffness: 12, + postureFeetStiffness: 10, + planetDetachmentSeconds: 0.5, + planetDetachmentForceThreshold: 100, + climbDotThreshold: 0.8, + climbGravityScale: 0.35, + projectileMaxStrength: 40, + projectileMaxBounceCount: 2, + projectileFadeSpeed: 20, + projectileCreationInterval: 0.1, + chargeShotFullHoldSeconds: 0.7, + chargeShotStrengthMin: 40, + chargeShotStrengthMax: 100, + chargeShotRadiusMin: 20, + chargeShotRadiusMax: 32, + chargeShotSpeedMin: 2500, + chargeShotSpeedMax: 3400, + playerColorIndexOffset: 3, + backgroundGradient: [rgb255(90, 38, 43), rgb255(43, 39, 73)], + blueColor, + bluePlanetColor, + npcNames: [ + 'Adam', + 'Andrew', + 'Blaise', + 'Clarence', + 'Dean', + 'Dustin', + 'Elliot', + 'Ernie', + 'Ethan', + 'Frank', + 'Fred', + 'George', + 'Graham', + 'Harold', + 'Harvey', + 'Henry', + 'Mingan', + 'Irving', + 'Irwin', + 'Jason', + 'Jenssen', + 'Josh', + 'Ladislaus', + 'Larry', + 'Lester', + 'Martin', + 'Marvin', + 'Neil', + 'Nick', + 'Niles', + 'Norm', + 'Oliver', + 'Orin', + 'Pat', + 'Perry', + 'Ron', + 'Ryan', + 'Sisyphus', + 'Tim', + 'Toby', + 'Ulysses', + 'Uri', + 'Waldo', + 'Wally', + 'Walt', + 'Wesley', + 'Will', + 'Wyatt', + ], + redColor, + redPlanetColor, + colorIndices: { + [CharacterTeam.blue]: 0, + [CharacterTeam.neutral]: 1, + [CharacterTeam.red]: 2, + }, + palette: [blueColor, neutralColor, redColor], + paletteDim: [blueColorDim, neutralColor, redColorDim], + targetPhysicsDeltaTimeInSeconds: 1 / 200, + inViewAreaSize: 1920 * 1080 * 4, + scoreboardHalfWidthPercent: 50, + scoreboardMinFillPercent: 1.5, + matchPointScoreRatio: 0.9, + lampLerpSeconds: 0.5, + lampMinLightness: 0.4, + lampMaxLightness: 1, + lampFlareIntensity: 1.2, + lampFlareDecaySeconds: 0.6, + maxConcurrentFlipFlares: 3, + announcementVisibleSeconds: 2, + + chargedHitThreshold: 0.6, + + // Projectiles fall through planetary gravity like a free-falling character, + // so slower (charged) shots arc. Scale kept tiny: near a surface gravity is + // maxGravityStrength=50000, which at full strength would corkscrew a shot into + // the planet — 0.04 gives a readable bend instead. + projectileGravityEnabled: true, + projectileGravityScale: 0.04, + + // Speed the corpse is flung at along the killing shot's direction, lerped by + // that shot's charge. Added to whatever momentum the victim already carried. + deathImpulseMin: 280, + deathImpulseMax: 1300, + + // A planet's net team head-count drives a single capture step per tick; the + // lead multiplier is capped so a zerg can't flip instantly. Equal head-counts + // freeze the planet (contested) instead of silently cancelling. + maxContestLeadMultiplier: 2, + + // Persistent body momentum decays per second by these exponents. Airborne is + // near-frictionless so leaps and slingshots carry across the gaps; grounded is + // stiff so you skid to a stop on landing rather than sliding forever. + airMomentumFriction: 0.4, + + groundMomentumFriction: 7, + + // On top of the exponential frictions above, a constant deceleration (u/s^2) + // applied to body momentum. The exponential alone only asymptotes toward zero, + // so a fast launch keeps a slow tail for 15+ seconds — it reads as drifting + // forever with nothing slowing you. This constant brake brings the momentum to + // a definite stop in a couple of seconds, while the gentle exponential still + // lets the launch cover its distance first. + + momentumStopDeceleration: 400, + // Hard ceiling (u/s) on body momentum, so stacked impulses — rapid charged-shot + // recoil, or a leap chained into a spin slingshot — can't build speed without + // bound. Kept above the overcharge fling (1700) so single launches survive. + + maxBodyMomentum: 2000, + + // Leap: a charged-cost launch off a surface, paid from the shared shooting + // strength pool so it trades against firepower. + leapStrengthCost: 32, + + leapSpeed: 1350, + leapUpBias: 1, + leapMoveBias: 0.65, + leapCooldownSeconds: 0.35, + + // Fraction of the planet's tangential surface velocity you keep when you leave + // it (slingshot). Leap off a fast spinner to be flung far. + slingshotScale: 1, + + // Recoil speed imparted opposite a shot, scaled by its charge (0 for taps). + chargeShotRecoilMax: 650, + + // The central giant is a named, always-contested focus. Its neutral decay is + // slowed so control lingers and teams keep fighting over it; flips are + // announced to everyone and an off-screen arrow points the way. + keystoneLoseControlScale: 2.5, +}; diff --git a/shared/tsconfig.json b/shared/tsconfig.json new file mode 100644 index 0000000..91326be --- /dev/null +++ b/shared/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "outDir": "lib", + "sourceMap": true, + "declaration": true, + "declarationMap": true, + "target": "es6", + "esModuleInterop": true, + "strict": true, + "experimentalDecorators": true, + "moduleResolution": "node", + "ignoreDeprecations": "6.0", + "module": "commonjs", + "skipLibCheck": true, + "composite": true, + "lib": ["dom", "es2017"] + }, + "include": ["src/**/*.ts", "src/*.ts"] +} diff --git a/shared/webpack.config.js b/shared/webpack.config.js new file mode 100644 index 0000000..2eebc06 --- /dev/null +++ b/shared/webpack.config.js @@ -0,0 +1,45 @@ +const path = require('path'); + +const PATHS = { + entryPoint: path.resolve(__dirname, 'src/main.ts'), + bundles: path.resolve(__dirname, 'lib'), +}; + +module.exports = (env, argv) => ({ + entry: { + main: PATHS.entryPoint, + }, + target: 'node', + output: { + filename: '[name].js', + path: PATHS.bundles, + libraryTarget: 'umd', + umdNamedDefine: true, + }, + devtool: argv.mode === 'development' ? 'source-map' : false, + watchOptions: { + poll: true, + ignored: /node_modules/, + }, + externals: { + 'gl-matrix': 'gl-matrix', + }, + optimization: { + minimize: false, + }, + plugins: [], + module: { + rules: [ + { + test: /\.ts$/, + use: { + loader: 'ts-loader', + }, + exclude: /node_modules/, + }, + ], + }, + resolve: { + extensions: ['.ts'], + }, +}); diff --git a/test/__snapshots__/physics-determinism.test.mjs.snap b/test/__snapshots__/physics-determinism.test.mjs.snap new file mode 100644 index 0000000..50ab1c3 --- /dev/null +++ b/test/__snapshots__/physics-determinism.test.mjs.snap @@ -0,0 +1,3 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`shared character simulation determinism > matches the pinned reference pose (changes only with intentional physics edits) 1`] = `"{"head":[76.14,123.412],"leftFoot":[83.214,105.036],"rightFoot":[79.974,123.01],"bodyVelocity":[0,0]}"`; diff --git a/test/physics-determinism.test.mjs b/test/physics-determinism.test.mjs new file mode 100644 index 0000000..d0fee1a --- /dev/null +++ b/test/physics-determinism.test.mjs @@ -0,0 +1,83 @@ +// Determinism guard for the shared character simulation. The client predictor +// and the authoritative server run the EXACT same stepCharacterMovement; if it +// were non-deterministic (a stray Math.random / Date, or an order-dependent +// reduce) prediction would rubber-band and could never reconcile. This is also +// the regression net for the upcoming GC/scratch-pool perf rewrites: the pinned +// hash must not change unless physics behaviour is intentionally changed. +import { describe, it, expect } from 'vitest'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const shared = require('../shared/lib/main.js'); +const { vec2 } = require('../shared/node_modules/gl-matrix'); + +const { + stepCharacterMovement, + resolveCircleMovement, + headRadius, + feetRadius, + headOffset, + leftFootOffset, + rightFootOffset, +} = shared; + +const makeBody = (center, radius) => ({ + center: vec2.clone(center), + radius, + velocity: vec2.create(), + lastNormal: vec2.fromValues(0, 1), + restitution: 0, +}); + +// A free-space world (no planets): the body is driven purely by the movement +// input force, posture springs and momentum decay — enough to exercise the +// deterministic core without coupling the test to planet SDF geometry. +const emptyWorld = { + groundsNear: () => [], + stepBody: (body, dt) => { + resolveCircleMovement(body, dt, []); + return undefined; + }, +}; + +const stepSeconds = 1 / 200; + +// Run a fixed, scripted input sequence through the shared sim and return a +// stable string snapshot of the final pose + carried momentum. +const runSimulation = () => { + const start = vec2.fromValues(100, 100); + const state = { + head: makeBody(vec2.add(vec2.create(), start, headOffset), headRadius), + leftFoot: makeBody(vec2.add(vec2.create(), start, leftFootOffset), feetRadius), + rightFoot: makeBody(vec2.add(vec2.create(), start, rightFootOffset), feetRadius), + direction: 0, + currentPlanet: undefined, + secondsSinceOnSurface: 1, + bodyVelocity: vec2.create(), + }; + + for (let i = 0; i < 300; i++) { + const angle = i * 0.1; + const input = vec2.fromValues(Math.cos(angle), Math.sin(angle)); + stepCharacterMovement(state, emptyWorld, input, stepSeconds); + } + + const round = (v) => Math.round(v * 1000) / 1000; + return JSON.stringify({ + head: [round(state.head.center[0]), round(state.head.center[1])], + leftFoot: [round(state.leftFoot.center[0]), round(state.leftFoot.center[1])], + rightFoot: [round(state.rightFoot.center[0]), round(state.rightFoot.center[1])], + bodyVelocity: [round(state.bodyVelocity[0]), round(state.bodyVelocity[1])], + }); +}; + +describe('shared character simulation determinism', () => { + it('produces identical output across independent runs', () => { + expect(runSimulation()).toBe(runSimulation()); + }); + + it('matches the pinned reference pose (changes only with intentional physics edits)', () => { + // Regression pin — update deliberately when physics behaviour changes. + expect(runSimulation()).toMatchSnapshot(); + }); +}); diff --git a/test/prediction-reconciliation.test.ts b/test/prediction-reconciliation.test.ts new file mode 100644 index 0000000..98ffc1d --- /dev/null +++ b/test/prediction-reconciliation.test.ts @@ -0,0 +1,96 @@ +// Reconciliation guard for the REAL client predictor. It exercises +// LocalCharacterPredictor end-to-end with an injected clock, verifying the two +// properties reconciliation depends on: +// 1. a fully-acknowledged snapshot reproduces the authoritative pose exactly +// (no spurious drift on top of server truth), and +// 2. un-acknowledged input is replayed forward deterministically. +// This is the net for prediction-touching changes (the death-while-dead fix, +// future netcode work) — a regression shows up as drift or non-determinism. +import { describe, it, expect } from 'vitest'; +import { + LocalCharacterPredictor, + setPredictorClockForTesting, +} from '../frontend/src/scripts/helper/prediction/local-character-predictor'; + +const HEAD_RADIUS = 50; +const FEET_RADIUS = 20; + +// A Circle-shaped pose; the predictor only reads .center / .radius, so plain +// objects with array centres are sufficient (and avoid importing gl-matrix). +const poseAt = (cx: number, cy: number) => ({ + head: { center: [cx, cy + 37], radius: HEAD_RADIUS }, + leftFoot: { center: [cx - 33, cy - 18], radius: FEET_RADIUS }, + rightFoot: { center: [cx + 33, cy - 18], radius: FEET_RADIUS }, +}); + +describe('local prediction reconciliation', () => { + it('reproduces the authoritative pose exactly when all input is acknowledged', () => { + let clock = 1000; + setPredictorClockForTesting(() => clock); + + const predictor = new LocalCharacterPredictor(); + const auth = poseAt(500, 500); + + const t = predictor.recordInput([1, 0]); + predictor.acknowledge(t, [0, 0], -Infinity); // server has consumed this input + predictor.setStrength(80); + predictor.setAuthoritative(auth.head as never, auth.leftFoot as never, auth.rightFoot as never); + + // No clock advance → zero replay window → predicted pose == authoritative. + const used = predictor.update([], 1 / 60); + + expect(used).toBe(true); + expect(predictor.head.center[0]).toBeCloseTo(auth.head.center[0], 5); + expect(predictor.head.center[1]).toBeCloseTo(auth.head.center[1], 5); + expect(predictor.leftFoot.center[0]).toBeCloseTo(auth.leftFoot.center[0], 5); + expect(predictor.rightFoot.center[0]).toBeCloseTo(auth.rightFoot.center[0], 5); + }); + + it('replays un-acknowledged input deterministically', () => { + const drive = () => { + let clock = 0; + setPredictorClockForTesting(() => clock); + + const predictor = new LocalCharacterPredictor(); + clock = 1000; + predictor.acknowledge(900, [0, 0], -Infinity); // baseline ack (older than the input below) + predictor.setStrength(80); + const auth = poseAt(0, 0); + predictor.setAuthoritative(auth.head as never, auth.leftFoot as never, auth.rightFoot as never); + predictor.recordInput([1, 0]); // unacked rightward input at t=1000 + + clock = 1100; // replay ~100 ms forward + predictor.update([], 1 / 60); + return [ + predictor.head.center[0], + predictor.head.center[1], + predictor.leftFoot.center[0], + predictor.rightFoot.center[0], + ]; + }; + + const a = drive(); + const b = drive(); + expect(a).toEqual(b); // deterministic replay + expect(a.every((n) => Number.isFinite(n))).toBe(true); + expect(a[0]).toBeGreaterThan(0.5); // rightward input actually moved the body + }); + + it('suppresses prediction while the local player is dead', () => { + let clock = 5000; + setPredictorClockForTesting(() => clock); + + const predictor = new LocalCharacterPredictor(); + const auth = poseAt(200, 200); + const t = predictor.recordInput([1, 0]); + predictor.acknowledge(t, [0, 0], -Infinity); + predictor.setStrength(80); + predictor.setAuthoritative(auth.head as never, auth.leftFoot as never, auth.rightFoot as never); + predictor.setAlive(false); // dead, awaiting respawn + + clock = 5200; // input + elapsed time that would otherwise be replayed forward + // Even with a valid authoritative pose and pending input, a dead body must + // not be predicted/moved — that was the "move while dead" bug. + expect(predictor.update([], 1 / 60)).toBe(false); + }); +}); diff --git a/test/serialization.test.mjs b/test/serialization.test.mjs new file mode 100644 index 0000000..9c7bc11 --- /dev/null +++ b/test/serialization.test.mjs @@ -0,0 +1,159 @@ +// Round-trip tests for the custom wire serializer — the single most fragile, +// highest-blast-radius mechanism in the codebase (every networked message goes +// through it, and dispatch is keyed on class name). We exercise the BUILT shared +// bundle (shared/lib/main.js) rather than the TS source, so the decorators are +// already applied exactly as they ship and there is no transform/ESM ambiguity. +import { describe, it, expect } from 'vitest'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const shared = require('../shared/lib/main.js'); + +const { + serialize, + deserialize, + Circle, + ServerAnnouncement, + MoveActionCommand, + UpdatePropertyCommand, + PropertyUpdatesForObject, + // networked entity bases — their toArray() must mirror their constructor order + CharacterBase, + PlanetBase, + ProjectileBase, + LampBase, + // geometry — single source of truth shared by server & client prediction + headRadius, + feetRadius, + boundRadius, + headOffset, + leftFootOffset, + rightFootOffset, +} = shared; + +describe('serialization round-trip (built shared lib)', () => { + it('reconstructs a ServerAnnouncement as the right class with its payload', () => { + const [out] = deserialize(serialize([new ServerAnnouncement('Hello world')])); + expect(out).toBeInstanceOf(ServerAnnouncement); + expect(out.text).toBe('Hello world'); + }); + + it('round-trips a Circle and rounds floats to 3 decimals', () => { + const out = deserialize(serialize(new Circle([12.34567, -7.1], 5.43219))); + expect(out).toBeInstanceOf(Circle); + // serialize.ts rounds every float via toFixed(3) + expect(out.center[0]).toBeCloseTo(12.346, 6); + expect(out.center[1]).toBeCloseTo(-7.1, 6); + expect(out.radius).toBeCloseTo(5.432, 6); + }); + + it('keeps integer clientTimeMs exact (the predictor matches on it)', () => { + const t = 1718000000123; + const out = deserialize(serialize(new MoveActionCommand([1, 0], t))); + expect(out).toBeInstanceOf(MoveActionCommand); + expect(out.clientTimeMs).toBe(t); + }); + + it('round-trips nested property-update commands', () => { + const out = deserialize( + serialize( + new PropertyUpdatesForObject(42, [new UpdatePropertyCommand('strength', 80, 8)]), + ), + ); + expect(out).toBeInstanceOf(PropertyUpdatesForObject); + expect(out.id).toBe(42); + expect(out.updates[0]).toBeInstanceOf(UpdatePropertyCommand); + expect(out.updates[0].propertyKey).toBe('strength'); + expect(out.updates[0].propertyValue).toBe(80); + }); + + it('preserves per-item class identity across a mixed batch', () => { + // A clobbered name→constructor mapping (two classes sharing a name) would + // surface here as an item deserializing to the wrong class. + const batch = [ + new ServerAnnouncement('a'), + new MoveActionCommand([0, 1], 5), + new Circle([1, 2], 3), + ]; + const out = deserialize(serialize(batch)); + expect(out[0]).toBeInstanceOf(ServerAnnouncement); + expect(out[1]).toBeInstanceOf(MoveActionCommand); + expect(out[2]).toBeInstanceOf(Circle); + }); +}); + +describe('networked entity round-trips (toArray ↔ constructor contract)', () => { + it('round-trips a ProjectileBase', () => { + const out = deserialize(serialize(new ProjectileBase(7, [10, 20], 30, 'blue', 40))); + expect(out).toBeInstanceOf(ProjectileBase); + expect(out.id).toBe(7); + expect(out.center[0]).toBeCloseTo(10, 5); + expect(out.center[1]).toBeCloseTo(20, 5); + expect(out.radius).toBe(30); + expect(out.team).toBe('blue'); + expect(out.strength).toBe(40); + }); + + it('round-trips a CharacterBase with its nested body Circles', () => { + const out = deserialize( + serialize( + new CharacterBase( + 8, + 'Bob', + 2, + 1, + 'red', + 100, + new Circle([1, 2], 50), + new Circle([3, 4], 20), + new Circle([5, 6], 20), + ), + ), + ); + expect(out).toBeInstanceOf(CharacterBase); + expect(out.id).toBe(8); + expect(out.name).toBe('Bob'); + expect(out.killCount).toBe(2); + expect(out.deathCount).toBe(1); + expect(out.team).toBe('red'); + expect(out.health).toBe(100); + expect(out.head).toBeInstanceOf(Circle); + expect(out.head.center[0]).toBeCloseTo(1, 5); + expect(out.head.radius).toBe(50); + }); + + it('round-trips a LampBase', () => { + const out = deserialize(serialize(new LampBase(9, [7, 8], [1, 0.5, 0.2], 0.8))); + expect(out).toBeInstanceOf(LampBase); + expect(out.center[1]).toBeCloseTo(8, 5); + expect(out.color[2]).toBeCloseTo(0.2, 5); + expect(out.lightness).toBeCloseTo(0.8, 5); + }); + + it('round-trips a PlanetBase (derived centre/radius recomputed from vertices)', () => { + const out = deserialize( + serialize(new PlanetBase(10, [[0, 0], [100, 0], [50, 100]], 0.7, true)), + ); + expect(out).toBeInstanceOf(PlanetBase); + expect(out.id).toBe(10); + expect(out.vertices).toHaveLength(3); + expect(out.ownership).toBeCloseTo(0.7, 5); + expect(out.isKeystone).toBe(true); + expect(out.center[0]).toBeCloseTo(50, 5); // recomputed by the constructor + }); +}); + +describe('shared character geometry — single source of truth', () => { + it('matches the known body layout', () => { + expect(headRadius).toBe(50); + expect(feetRadius).toBe(20); + expect(boundRadius).toBe((headRadius + feetRadius * 2) * 2); // 180 + }); + + it('posture offsets are measured from the centre of mass (they sum to ~0)', () => { + const sx = headOffset[0] + leftFootOffset[0] + rightFootOffset[0]; + const sy = headOffset[1] + leftFootOffset[1] + rightFootOffset[1]; + expect(sx).toBeCloseTo(0, 4); + expect(sy).toBeCloseTo(0, 4); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..4b59984 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "es6", + "esModuleInterop": true, + "strict": true, + "experimentalDecorators": true, + "downlevelIteration": true, + "moduleResolution": "node", + "module": "commonjs", + "composite": true, + "lib": ["dom", "es2017"] + } +}