From 32c7696bda50e83ab105ddabe923533dd28dae9b Mon Sep 17 00:00:00 2001 From: "martijn.diersmann@pm.me" Date: Sat, 26 Sep 2026 22:21:07 +0200 Subject: [PATCH] fitst version --- .dockerignore | 10 + .gitignore | 4 + Dockerfile | 18 + README.md | 143 ++++++ k8s/base/deployment.yaml | 57 +++ k8s/base/kustomization.yaml | 9 + k8s/base/service.yaml | 15 + k8s/ingress.yaml | 22 + k8s/kind-config.yaml | 12 + package-lock.json | 857 ++++++++++++++++++++++++++++++++++++ package.json | 19 + public/client.js | 475 ++++++++++++++++++++ public/index.html | 79 ++++ public/render.js | 431 ++++++++++++++++++ public/style.css | 261 +++++++++++ server/engine.js | 313 +++++++++++++ server/index.js | 142 ++++++ test/simulate.js | 284 ++++++++++++ 18 files changed, 3151 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 k8s/base/deployment.yaml create mode 100644 k8s/base/kustomization.yaml create mode 100644 k8s/base/service.yaml create mode 100644 k8s/ingress.yaml create mode 100644 k8s/kind-config.yaml create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 public/client.js create mode 100644 public/index.html create mode 100644 public/render.js create mode 100644 public/style.css create mode 100644 server/engine.js create mode 100644 server/index.js create mode 100644 test/simulate.js diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..63022c2 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +node_modules +npm-debug.log +test +README.md +.git +.gitignore +Dockerfile +.dockerignore +k8s +*.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8b56f7f --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +.DS_Store +*.log +.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5f7bba8 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +# Turbo Race - productie-image +FROM node:20-alpine + +ENV NODE_ENV=production +WORKDIR /app + +# eerst alleen package-files voor betere layer-caching +COPY package.json package-lock.json ./ +RUN npm ci --omit=dev + +COPY server ./server +COPY public ./public + +ENV PORT=8080 +EXPOSE 8080 + +USER node +CMD ["node", "server/index.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..2994b24 --- /dev/null +++ b/README.md @@ -0,0 +1,143 @@ +# ๐ŸŽ๏ธ Turbo Race + +Multiplayer browser-racegame voor **tot 20 spelers**, speelbaar op de telefoon, +met een neon/synthwave-look. Server-authoritatief (Node.js + WebSocket), +deploybaar op je eigen Kubernetes-node. + +## Features + +- Verticale scrollende weg, portretmodus, **eenhandig besturen** + (linkerhelft / rechterhelft van het scherm vasthouden). +- Keyboard-up / pijltjes op desktop. +- **Host** opent de kamer met `?host=` en start heimelijk met de `S`-toets + (30-seconden countdown, nieuwe aanmelders blijven welkom). +- Obstakels: **pylonen** (vertragen) en **olievlekken** (wegslijden). +- Power-ups: **โšก boost** en **๐Ÿ›ก schild** (respawnen na 4,5s). +- Live positie, snelheid, afstand, leaderboard en minimap. +- **1:30 minuten** race over een baan van 23.000 m; wie over de finish is wint, + anders wint bij aflopen van de tijd wie het verst is. Daarna **uitslag + podium** + en direct een nieuwe ronde. + +## Snel starten (lokaal) + +```bash +npm install +npm start # โ†’ http://localhost:8080 +``` + +Open `http://localhost:8080` op je laptop รฉn op je telefoon (zelfde wifi). +Vul een naam in, klik aanmelden, en stuur met je vingers. + +> ๐ŸŽฎ **De host is ook gewoon een speler.** Open als host: +> `http://localhost:8080/?host=dev-host` +> en druk op de **S**-toets om de start-telling in gang te zetten +> (pas als de kamer vol is of iedereen klaar is ๐Ÿ˜‰). + +### Besturing + +| Apparaat | Actie | +| --- | --- | +| Telefoon | Linkerhelft vasthouden = links, rechterhelft = rechts | +| Toetsenbord | `โ†` / `A` en `โ†’` / `D` | +| Host | `S` in lobby of uitslag = race (laten) starten | + +## Configuratie (omgevingsvariabelen) + +| Variabele | Standaard | Betekenis | +| --- | --- | --- | +| `PORT` | `8080` | HTTP-poort (ook WebSocket op `/ws`) | +| `MAX_PLAYERS` | `20` | Maximaal aantal coureurs per kamer | +| `ADMIN_TOKEN` | `dev-host` | Geheim token waarmee je host wordt | +| `COUNTDOWN_S` | `30` | Aftellen nadat de host op `S` drukt | +| `RACE_DURATION_S` | `90` | Maximale racetijd in seconden | +| `TRACK_LENGTH` | `23000` | Baanglengte in meter (finish = einde van de baan) | + +> โš ๏ธ Gebruik in productie een **sterk `ADMIN_TOKEN`** (random string), anders kan +> iedereen met de URL host worden. + +## Tests + +Headless simulatie: engine-unit-test **plus** live integratietest met een echte +server, 20 websocket-clients en een host die de race start en de uitslag leest. + +```bash +npm test +``` + +## Docker + +```bash +docker build -t turbo-race:latest . +docker run --rm -p 8080:8080 \ + -e ADMIN_TOKEN='moeilijk-te-raden-token' \ + turbo-race:latest +``` + +## Deployen op Kubernetes + +Manifests staan in `k8s/` (Kustomize). Bouw de image op je node (of push naar +een registry) en pas de image-tag in `k8s/base/deployment.yaml` aan. + +```bash +# 1) image bouwen (op de node zelf) +docker build -t turbo-race:latest . + +# 2) token instellen (kustomization generatiert een Secret) +# Bewerk de literal 'admin-token=...' in k8s/base/kustomization.yaml + +# 3) deployen +kubectl apply -k k8s/base +kubectl rollout status deploy/turbo-race +kubectl get svc turbo-race # -> NodePort 30090 +``` + +Speel nu via: `http://:30090` + +- Healthcheck: `curl http://:30090/healthz` โ†’ `{"ok":true,...}` +- Met een domein + ingress-controller voeg je `k8s/ingress.yaml` toe + (pas de `host` aan, WebSocket-tunnels worden lang opengehouden). + +### Lokaal testen met Kind (macOS/Linux) + +Op een **echte node** is NodePort 30090 direct bereikbaar op het node-IP. +Met **Kind** moet de poort wรฉl expliciet naar de host gemapt worden: +dienst `k8s/kind-config.yaml` mapt `30090 โ†’ localhost`. + +```bash +kind create cluster --config k8s/kind-config.yaml # alleen bij (her)aanmaken +kind load docker-image turbo-race:latest +kubectl apply -k k8s/base +# speel op: http://localhost:30090 +``` + +> โ„น๏ธ Kind hergebruikt een bestaand cluster; als NodePort niet naar `localhost` +> werkt, verwijder het cluster eerst (`kind delete cluster`) en maak het opnieuw +> aan met bovenstaande `--config` (port-mappings staan vast bij creatie). + + +> โš ๏ธ **Belangrijk:** de game staat **in-memory** (1 kamer). Houd +> `replicas: 1` โ€” meerdere replica's zouden spelers over losse kamers verspreiden. + +## Architectuur + +``` +public/ frontend (geen bundler): index.html, style.css, client.js, render.js +server/engine.js server-side spelengine: fysica, obstakels, power-ups, fases, ranking +server/index.js HTTP/static + WebSocket /ws + events + broadcasts +test/simulate.js headless test (fluweel: 20 clients ipv 1) +``` + +- Engine loop 40 Hz; **snapshots op ~16 Hz**; clients **interpoleren** de rendering. +- Baan is **deterministisch** opgebouwd uit รฉรฉn random seed โ†’ eerlijk & identiek + voor elke speler. +- De host is een normale speler; de `host_start`-melding wordt alleen geaccepteerd + van sockets die met het juiste token zijn aangemeld. + +## Flow & regels + +1. **Lobby** โ€” namen aanmelden tot max 20 (fases live zichtbaar). +2. Host drukt `S` โ†’ **30s countdown** (heen-en-weer banners, nieuwe spelers welkom). +3. **Race** โ€” rechte weg met obstakels en power-ups; finish is het einde van de + baan. Wie als eerste over de line is, wint; anders wint bij het aflopen van + de tijd wie het verst is. +4. **Uitslag** โ€” podium + volledige lijst; de host start opnieuw met `S`. diff --git a/k8s/base/deployment.yaml b/k8s/base/deployment.yaml new file mode 100644 index 0000000..0befec8 --- /dev/null +++ b/k8s/base/deployment.yaml @@ -0,0 +1,57 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: turbo-race + labels: + app: turbo-race +spec: + replicas: 1 + selector: + matchLabels: + app: turbo-race + template: + metadata: + labels: + app: turbo-race + spec: + containers: + - name: turbo-race + image: turbo-race:latest + imagePullPolicy: IfNotPresent + ports: + - containerPort: 8080 + env: + - name: PORT + value: "8080" + - name: MAX_PLAYERS + value: "20" + - name: COUNTDOWN_S + value: "30" + - name: RACE_DURATION_S + value: "90" + - name: TRACK_LENGTH + value: "23000" + - name: ADMIN_TOKEN + valueFrom: + secretKeyRef: + name: turbo-race-secret + key: admin-token + resources: + requests: + cpu: 100m + memory: 64Mi + limits: + cpu: 300m + memory: 160Mi + readinessProbe: + httpGet: + path: /healthz + port: 8080 + initialDelaySeconds: 3 + periodSeconds: 5 + livenessProbe: + httpGet: + path: /healthz + port: 8080 + initialDelaySeconds: 5 + periodSeconds: 10 diff --git a/k8s/base/kustomization.yaml b/k8s/base/kustomization.yaml new file mode 100644 index 0000000..83ea6ff --- /dev/null +++ b/k8s/base/kustomization.yaml @@ -0,0 +1,9 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - deployment.yaml + - service.yaml +secretGenerator: + - name: turbo-race-secret + literals: + - admin-token=b61e4bacba3998927b6d6fd2ee69b51cf7fee31c4370a5b0 diff --git a/k8s/base/service.yaml b/k8s/base/service.yaml new file mode 100644 index 0000000..d17d751 --- /dev/null +++ b/k8s/base/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: turbo-race + labels: + app: turbo-race +spec: + selector: + app: turbo-race + type: NodePort + ports: + - name: http + port: 8080 + targetPort: 8080 + nodePort: 30090 diff --git a/k8s/ingress.yaml b/k8s/ingress.yaml new file mode 100644 index 0000000..8acdfb6 --- /dev/null +++ b/k8s/ingress.yaml @@ -0,0 +1,22 @@ +# Optionele Ingress - pas de host aan en zet in je kustomization resources +# OF: kubectl apply -f k8s/ingress.yaml +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: turbo-race + annotations: + nginx.ingress.kubernetes.io/proxy-read-timeout: "3600" + nginx.ingress.kubernetes.io/proxy-send-timeout: "3600" +spec: + ingressClassName: nginx + rules: + - host: race.voorbeeld.nl + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: turbo-race + port: + name: http diff --git a/k8s/kind-config.yaml b/k8s/kind-config.yaml new file mode 100644 index 0000000..078da08 --- /dev/null +++ b/k8s/kind-config.yaml @@ -0,0 +1,12 @@ +# Lokale Kind-cluster-config voor Turbo Race. +# Extra port-mapping zodat NodePort 30090 bereikbaar is als http://localhost:30090 +# Aanmaken (alleen nodig bij eerste keer / recreatie): +# kind create cluster --config k8s/kind-config.yaml +kind: Cluster +apiVersion: kind.x-k8s.io/v1alpha4 +nodes: + - role: control-plane + extraPortMappings: + - containerPort: 30090 + hostPort: 30090 + protocol: TCP diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..3cb3fe8 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,857 @@ +{ + "name": "turbo-race", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "turbo-race", + "version": "1.0.0", + "dependencies": { + "express": "^4.19.2", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=18" + } + }, + "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==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "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==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.8", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz", + "integrity": "sha512-JNcyFQ64OiijEkPzUBTCe+hyPXUD/3LEldGQ6iF5LR1w00mx9o7xtDWHXBY2iItjdCFGoilOLNQbH943ut7pHA==", + "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.16.0", + "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/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "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==", + "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==", + "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/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==", + "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==", + "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==", + "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==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "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==", + "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==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "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==", + "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==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "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==", + "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==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "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==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "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==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.3", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.3.tgz", + "integrity": "sha512-Bdcs4+3qlpVlx2NRn6fgX2Ue2/gGRaPeawebgclM0ERSCqDpA+owF1fdPwjJUTAJWMTuAaxjDf+hzb0/4eKvvw==", + "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.13", + "proxy-addr": "~2.0.7", + "qs": "~6.16.0", + "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/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "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/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "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==", + "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==", + "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==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "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==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "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==", + "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/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "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==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "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==", + "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==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "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==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "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==", + "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==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "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==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "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==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "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==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/qs": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "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==", + "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==", + "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/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==", + "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==", + "license": "MIT" + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "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/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "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==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "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==", + "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==", + "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==", + "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/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "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==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "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==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "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==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "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==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/ws": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", + "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 + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..5d6d3e7 --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "name": "turbo-race", + "version": "1.0.0", + "description": "Turbo Race - multiplayer browser racegame voor tot 20 spelers (aandrijving: Node + WebSocket, deploy op Kubernetes)", + "private": true, + "main": "server/index.js", + "scripts": { + "start": "node server/index.js", + "dev": "node --watch server/index.js", + "test": "node test/simulate.js" + }, + "engines": { + "node": ">=18" + }, + "dependencies": { + "express": "^4.19.2", + "ws": "^8.18.0" + } +} diff --git a/public/client.js b/public/client.js new file mode 100644 index 0000000..41402fc --- /dev/null +++ b/public/client.js @@ -0,0 +1,475 @@ +/* Turbo Race - client: netwerk, input, rendering, HUD */ +(function () { + 'use strict'; + const painter = window.TurboPainter; + const ROAD_W = 420; + const K = 0.32; // zelfde schaal als render.js + + const $ = (id) => document.getElementById(id); + const clamp = (n, lo, hi) => (n < lo ? lo : n > hi ? hi : n); + + /* ---------- state ---------- */ + let ws = null; + const me = { id: null, host: false, name: '', color: '#ff3fa4' }; + let phase = 'lobby'; + let snap = null; // laatste snapshot + let track = null; // {obstacles, powerups} + const rp = {}; // render-state per speler {rx, rd, ...} + const input = { steer: 0 }; + let visSteer = 0; // visuele stuursmoothing (alleen voor rendering) + let fx = []; + let shake = 0; + let lastSent = 0; + + const canvas = $('game'); + const ctx = canvas.getContext('2d'); + const mm = $('minimap'); + const mmCtx = mm.getContext('2d'); + let W = 0, H = 0, DPR = 1; + + function resize() { + DPR = Math.min(window.devicePixelRatio || 1, 2); + W = window.innerWidth; H = window.innerHeight; + canvas.width = Math.round(W * DPR); + canvas.height = Math.round(H * DPR); + canvas.style.width = W + 'px'; + canvas.style.height = H + 'px'; + ctx.setTransform(DPR, 0, 0, DPR, 0, 0); + mm.width = Math.round(mm.clientWidth * DPR); + mm.height = Math.round(mm.clientHeight * DPR); + mmCtx.setTransform(DPR, 0, 0, DPR, 0, 0); + } + window.addEventListener('resize', resize); + window.addEventListener('orientationchange', () => setTimeout(resize, 200)); + + /* ---------- kleurenpalet ---------- */ + const PALETTE = ['#ff3fa4', '#00f0ff', '#ffd93d', '#3dff88', '#ff7a1a', '#8f5bff', '#ff4d6d', '#4dffd6', '#ff9ff3', '#b3ff4d']; + let chosenColor = PALETTE[0]; + (function initColors() { + const box = $('colors'); + PALETTE.forEach((c) => { + const s = document.createElement('button'); + s.type = 'button'; + s.className = 'swatch' + (c === chosenColor ? ' on' : ''); + s.style.background = c; + s.onclick = () => { + chosenColor = c; + box.querySelectorAll('.swatch').forEach((el) => el.classList.remove('on')); + s.classList.add('on'); + }; + box.appendChild(s); + }); + })(); + + /* ---------- verbinding ---------- */ + function hostToken() { + try { return new URLSearchParams(location.search).get('host') || ''; } catch { return ''; } + } + + function connect() { + const proto = location.protocol === 'https:' ? 'wss://' : 'ws://'; + ws = new WebSocket(proto + location.host + '/ws'); + ws.onopen = () => { + ws.send(JSON.stringify({ + type: 'join', name: $('nameInput').value, color: chosenColor, hostToken: hostToken(), + })); + }; + ws.onmessage = (ev) => { + let m; try { m = JSON.parse(ev.data); } catch { return; } + handle(m); + }; + ws.onclose = () => { if (phase !== 'results') $('dead').classList.remove('hidden'); }; + ws.onerror = () => {}; + } + + $('joinBtn').onclick = () => connect(); + $('nameInput').addEventListener('keydown', (e) => { if (e.key === 'Enter') connect(); }); + + /* ---------- message-handling ---------- */ + function handle(m) { + switch (m.type) { + case 'joined': + me.id = m.id; me.host = !!m.host; + me.name = m.self.name; me.color = m.self.color; + snap = m.snapshot; track = m.track; + applySnapshot(m.snapshot); + $('join').classList.add('hidden'); + $('room').classList.remove('hidden'); + break; + case 'snapshot': + applySnapshot(m); + break; + case 'track': + track = m.track; + break; + case 'fx': + onFx(m.id, m.kind); + break; + case 'announce': + toast(m.text, 'gold'); + break; + case 'error': + showJoinError(m.msg); + break; + case 'phase': // bevestiging; snapshot volgt zo + break; + } + } + + /* ---------- snapshot & interpolatie ---------- */ + let prevPhase = 'lobby'; + function applySnapshot(s) { + snap = s; + const newPhase = s.phase; + const hard = (prevPhase !== newPhase) && (newPhase === 'race' || newPhase === 'lobby'); + const seen = new Set(); + for (const p of s.players) { + seen.add(p.id); + if (!rp[p.id]) { + rp[p.id] = { rx: p.x, rd: p.d, name: p.name, color: p.color, boost: false, shield: false, slip: false, finished: false, rank: 0, speed: 0 }; + } + if (hard) { rp[p.id].rx = p.x; rp[p.id].rd = p.d; } + } + for (const key in rp) if (!seen.has(key)) delete rp[key]; + prevPhase = newPhase; + phase = newPhase; + + if (phase === 'countdown') $('countdown').classList.remove('hidden'); + if (phase === 'race') { $('countdown').classList.add('hidden'); $('results').classList.add('hidden'); } + if (phase === 'results') { $('countdown').classList.add('hidden'); buildResults(s); } + if (phase === 'lobby') { $('results').classList.add('hidden'); $('countdown').classList.add('hidden'); } + } + + /* ---------- render-loop ---------- */ + let raf = 0, lastT = performance.now(), accHud = 0, accMinimap = 0; + function frame(ts) { + const dt = Math.min(0.05, (ts - lastT) / 1000); + lastT = ts; + accHud += dt; accMinimap += dt; + + if (snap) { + for (const p of snap.players) { + const r = rp[p.id]; + if (!r) continue; + const k = (p.id === me.id) ? (1 - Math.exp(-dt * 18)) : (1 - Math.exp(-dt * 7)); + r.rx += (p.x - r.rx) * k; + r.rd += (p.d - r.rd) * k; + r.name = p.name; r.color = p.color; + r.boost = p.boost; r.shield = p.shield; r.slip = p.slip; + r.finished = p.finished; r.speed = p.speed; r.rank = p.rank; + } + } + + visSteer += (input.steer - visSteer) * Math.min(1, dt * 12); + draw(ts); + advanceFx(dt); + shake = Math.max(0, shake - dt * 40); + + if (accHud > 0.1) { accHud = 0; updateHud(); } + if (accMinimap > 0.22) { accMinimap = 0; drawMinimap(); } + + raf = requestAnimationFrame(frame); + } + + function draw(t) { + const meSnap = snap ? snap.players.find((p) => p.id === me.id) : null; + if (!meSnap) { + painter.render(ctx, { W, H, t, me: { d: 0 }, trackLength: snap ? snap.trackLength : 23000, track, cars: [], fx: [], speedFactor: 0, shake: 0 }); + return; + } + const r = rp[me.id]; + const selfD = r ? r.rd : meSnap.d; + const cars = []; + for (const key in rp) { + const o = rp[key]; + cars.push({ + x: o.rx, d: o.rd, name: o.name, color: o.color, + boost: o.boost, shield: o.shield, slip: o.slip, finished: o.finished, + isMe: key === me.id, + steer: key === me.id ? visSteer : 0, + }); + } + painter.render(ctx, { + W, H, t, me: { d: selfD }, + trackLength: snap.trackLength, track, cars, fx, + speedFactor: (meSnap.speed || 0) / 300, + shake, + }); + } + + function showJoinError(msg) { + const el = $('joinMsg'); + el.textContent = msg || 'Kon niet aanmelden'; + el.className = 'msg err'; + } + + + /* ---------- HUD & lijsten ---------- */ + let lastBoardSig = ''; + function updateHud() { + if (!snap) return; + const now = Date.now(); + const meSnap = snap.players.find((p) => p.id === me.id); + + if (phase === 'countdown') { + const rem = Math.max(0, Math.ceil((snap.countdownEndsAt - now) / 1000)); + $('cdNum').textContent = rem; + updateBoard(rem); + return; + } + + if (phase === 'race' && meSnap) { + const rem = Math.max(0, snap.raceEndsAt - now); + const tot = Math.ceil(rem / 1000); + const min = Math.floor(tot / 60), sec = tot % 60; + const tEl = $('hudTimer'); + const txt = `${String(min).padStart(2, '0')}:${String(sec).padStart(2, '0')}`; + if (tEl.textContent !== txt) tEl.textContent = txt; + tEl.classList.toggle('danger', tot <= 10); + $('hudSpeed').textContent = Math.round((meSnap.speed || 0) * 0.36) + ' km/u'; + $('hudPos').textContent = '#' + meSnap.rank + ' / ' + snap.players.length; + $('hudDist').textContent = meSnap.d + ' m'; + } + + updateBoard(0); + updateLead(); + } + + function updateBoard(remaining) { + if (!snap) return; + const inRace = phase === 'race' || phase === 'results'; + const head = inRace ? '๐Ÿ WEDSTRIJD' : `๐Ÿ‘ฅ AANMELDERS (${snap.players.length}/${snap.maxPlayers})`; + const sig = head + '|' + snap.players.map((p) => `${p.id}:${p.rank}:${p.d}`).join(',') + '|host' + me.host; + if (sig === lastBoardSig) return; + lastBoardSig = sig; + $('boardHead').textContent = head; + + const ol = $('boardList'); + ol.innerHTML = ''; + const sorted = snap.players.slice().sort((a, b) => a.rank - b.rank); + for (const p of sorted) { + const li = document.createElement('li'); + const cls = []; + if (p.id === me.id) cls.push('me'); + if (inRace && p.rank === 1) cls.push('top'); + if (cls.length) li.className = cls.join(' '); + if (inRace) { + li.innerHTML = `${p.rank}${esc(p.name)}${p.finished ? fmtTime(p.finishTime) : Math.round(p.d) + ' m'}`; + } else { + li.innerHTML = `${p.rank}${esc(p.name)}`; + } + ol.appendChild(li); + } + if (me.host && !inRace) { + const li = document.createElement('li'); + li.className = 'st'; + li.innerHTML = `๐Ÿ‘‘ Jij bent host โ€” druk op S${remaining != null ? ' (nog ' + remaining + 's voor start)' : ''}`; + ol.appendChild(li); + } + } + + let lastLead = ''; + function updateLead() { + if (!snap || phase !== 'race' || snap.players.length === 0) { + if (lastLead !== '') { lastLead = ''; $('lead').classList.add('hidden'); } + return; + } + const sorted = snap.players.slice().sort((a, b) => a.rank - b.rank); + const top = sorted[0]; + const key = top.id + top.d; + if (key === lastLead) return; + lastLead = key; + $('leadText').innerHTML = `๐Ÿ‘‘ ${esc(top.name)} ligt AAN KOP`; + $('lead').classList.remove('hidden'); + } + + function drawMinimap() { + if (!snap || !track) return; + const w = mm.clientWidth, h = mm.clientHeight; + mmCtx.clearRect(0, 0, w, h); + const total = snap.trackLength || 23000; + const yFor = (d) => h - 3 - (d / total) * (h - 8); + // finishlijn + mmCtx.fillStyle = 'rgba(255,217,61,0.85)'; + mmCtx.fillRect(1, 2, w - 2, 2); + for (const p of snap.players) { + const yy = yFor(p.d); + const isMe = p.id === me.id; + const isTop = p.rank === 1; + mmCtx.beginPath(); + mmCtx.arc(w / 2, yy, isTop ? 3 : 2.2, 0, 7); + mmCtx.fillStyle = p.color; + mmCtx.fill(); + if (isMe) { + mmCtx.strokeStyle = '#fff'; + mmCtx.lineWidth = 1; + mmCtx.beginPath(); mmCtx.arc(w / 2, yy, 4.2, 0, 7); mmCtx.stroke(); + } + } + } + + function fmtTime(ms) { + const s = ms / 1000; + const m = Math.floor(s / 60); + const ss = Math.floor(s % 60); + return `${String(m).padStart(2, '0')}:${String(ss).padStart(2, '0')}`; + } + function esc(str) { + return String(str).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); + } + + + /* ---------- in-/uitvoer ---------- */ + const touchesLeft = new Map(); + const touchesRight = new Map(); + + canvas.addEventListener('pointerdown', (e) => { + canvas.setPointerCapture(e.pointerId); + const side = e.clientX < W / 2 ? 'L' : 'R'; + (side === 'L' ? touchesLeft : touchesRight).set(e.pointerId, true); + updateSteerFromTouches(); + }); + canvas.addEventListener('pointermove', (e) => { + if (!touchesLeft.has(e.pointerId) && !touchesRight.has(e.pointerId)) return; + touchesLeft.delete(e.pointerId); + touchesRight.delete(e.pointerId); + const side = e.clientX < W / 2 ? 'L' : 'R'; + (side === 'L' ? touchesLeft : touchesRight).set(e.pointerId, true); + updateSteerFromTouches(); + }); + const endTouch = (e) => { touchesLeft.delete(e.pointerId); touchesRight.delete(e.pointerId); updateSteerFromTouches(); }; + canvas.addEventListener('pointerup', endTouch); + canvas.addEventListener('pointercancel', endTouch); + + function updateSteerFromTouches() { + if (touchesRight.size > 0 && touchesLeft.size === 0) input.steer = 1; + else if (touchesLeft.size > 0 && touchesRight.size === 0) input.steer = -1; + else input.steer = 0; + } + + window.addEventListener('keydown', (e) => { + if (e.key === 'ArrowLeft' || e.key === 'a' || e.key === 'A') input.steer = -1; + if (e.key === 'ArrowRight' || e.key === 'd' || e.key === 'D') input.steer = 1; + if (e.key.toLowerCase() === 's' && me.host && (phase === 'lobby' || phase === 'results') + && document.activeElement !== $('nameInput')) { + sendHostStart(); + } + }); + window.addEventListener('keyup', (e) => { + if (e.key === 'ArrowLeft' || e.key === 'a' || e.key === 'A' || + e.key === 'ArrowRight' || e.key === 'd' || e.key === 'D') { + if (touchesLeft.size === 0 && touchesRight.size === 0) input.steer = 0; + } + }); + window.addEventListener('blur', () => { input.steer = 0; }); + + function sendHostStart() { + if (ws && ws.readyState === 1) ws.send(JSON.stringify({ type: 'host_start' })); + } + + // input naar server + setInterval(() => { + if (ws && ws.readyState === 1 && me.id) { + ws.send(JSON.stringify({ type: 'input', steer: input.steer })); + } + }, 33); + + /* ---------- fx & toasts ---------- */ + function onFx(id, kind) { + const r = rp[id]; + if (!r) return; + const meSnap = snap && snap.players.find((p) => p.id === me.id); + const selfD = meSnap ? rp[me.id] ? rp[me.id].rd : meSnap.d : 0; + const roadW = Math.min(W * 0.88, 460); + const scaleX = roadW / ROAD_W; + const carY = Math.min(H * 0.62, H - 140); + const sx = (W - roadW) / 2 + r.rx * scaleX; + const sy = carY - (r.rd - selfD) * K; + const col = kind.indexOf('boost') >= 0 ? '#00f0ff' : kind.indexOf('shield') >= 0 ? '#3dff88' + : kind.indexOf('pylon') >= 0 ? '#ffd93d' : '#ff4d6d'; + + if (kind.indexOf('hit') === 0) { + burst(sx, sy, col, 14); + burst(sx, sy, '#fff', 6); + } else { + pulseFx(sx, sy, col); + burst(sx, sy, col, 12); + } + + if (id === me.id) { + if (kind === 'hit:pylon') toast('๐Ÿ’ฅ Je raakt een pylon!', ''); + else if (kind === 'hit:oil') toast('๐ŸŒ€ Olie! Je glijdt wegโ€ฆ', ''); + else if (kind === 'pickup:boost') toast('โšก BOOST!', 'gold'); + else if (kind === 'pickup:shield') toast('๐Ÿ›ก Schild actief!', 'gold'); + if (kind.indexOf('hit') === 0) shake = 14; + } + } + + function burst(sx, sy, color, n) { + for (let i = 0; i < n; i++) { + const a = Math.random() * Math.PI * 2; + const sp = 40 + Math.random() * 160; + fx.push({ + kind: 'dot', sx, sy, + vx: Math.cos(a) * sp, vy: Math.sin(a) * sp, + size: 2 + Math.random() * 4, + color, life: 0, maxLife: 0.4 + Math.random() * 0.4, + }); + } + } + function pulseFx(sx, sy, color) { + fx.push({ kind: 'pulse', sx, sy, vx: 0, vy: 0, size: 14, color, life: 0, maxLife: 0.5 }); + } + function advanceFx(dt) { + for (let i = fx.length - 1; i >= 0; i--) { + const f = fx[i]; + f.life += dt; + if (f.life >= f.maxLife) { fx.splice(i, 1); continue; } + if (f.kind === 'dot') { f.sx += f.vx * dt; f.sy += f.vy * dt; f.vx *= 0.94; f.vy *= 0.94; } + } + } + + function toast(text, cls) { + const box = $('toasts'); + const el = document.createElement('div'); + el.className = 'toast' + (cls ? ' ' + cls : ''); + el.textContent = text; + box.appendChild(el); + setTimeout(() => el.remove(), 2600); + while (box.children.length > 3) box.firstChild.remove(); + } + + /* ---------- uitslag ---------- */ + function buildResults(s) { + $('results').classList.remove('hidden'); + const sorted = s.players.slice().sort((a, b) => a.rank - b.rank); + const podium = $('podium'); + podium.innerHTML = ''; + const medals = { 1: '๐Ÿฅ‡', 2: '๐Ÿฅˆ', 3: '๐Ÿฅ‰' }; + for (const p of sorted.slice(0, 3)) { + const d = document.createElement('div'); + d.className = 'pod g' + p.rank; + d.innerHTML = `${medals[p.rank]}
${esc(p.name)}
`; + podium.appendChild(d); + } + const ol = $('resultsList'); + ol.innerHTML = ''; + sorted.forEach((p) => { + const li = document.createElement('li'); + if (p.id === me.id) li.className = 'me'; + li.innerHTML = `${p.rank}${esc(p.name)}${p.finished ? fmtTime(p.finishTime) : 'niet gefinisht ยท ' + Math.round(p.d) + ' m'}`; + ol.appendChild(li); + }); + $('resSub').textContent = me.host + ? 'Druk op S om een nieuwe race te starten (of gebruik de knop).' + : 'De host kan een nieuwe race starten.'; + } + + $('againBtn').onclick = () => { + if (ws && ws.readyState === 1) ws.send(JSON.stringify({ type: 'ready_again' })); + }; + + /* ---------- start ---------- */ + resize(); + requestAnimationFrame(frame); +})(); diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..4590fd0 --- /dev/null +++ b/public/index.html @@ -0,0 +1,79 @@ + + + + + + +Turbo Race + + + +
+ + + +
+
+

TURBORACE

+

Tot 20 coureurs ยท 1 baan ยท max 1:30 min

+ +
+ +

+

๐Ÿ•น๏ธ Hou links of rechts van het scherm vast om te sturen, ontwijk pylonen & olie, pak power-ups en word de snelste!

+
+
+ + + + + + + + + + + + + + + + + diff --git a/public/render.js b/public/render.js new file mode 100644 index 0000000..697b25b --- /dev/null +++ b/public/render.js @@ -0,0 +1,431 @@ +/* Turbo Race - canvas painter (neon synthwave) */ +window.TurboPainter = (function () { + 'use strict'; + + const ROAD_W = 420; // wereldbreedte (px) + const K = 0.32; // verticale schaal: px per eenheid afstand + const C = { + cyan: '#00f0ff', pink: '#ff3fa4', gold: '#ffd93d', green: '#3dff88', red: '#ff4d6d', + }; + + function rgba(hex, a) { + const h = hex.replace('#', ''); + return `rgba(${parseInt(h.slice(0, 2), 16)},${parseInt(h.slice(2, 4), 16)},${parseInt(h.slice(4, 6), 16)},${a})`; + } + function clamp(n, lo, hi) { return n < lo ? lo : (n > hi ? hi : n); } + function rrect(ctx, x, y, w, h, r) { + r = Math.min(r, w / 2, h / 2); + ctx.beginPath(); + ctx.moveTo(x + r, y); + ctx.arcTo(x + w, y, x + w, y + h, r); + ctx.arcTo(x + w, y + h, x, y + h, r); + ctx.arcTo(x, y + h, x, y, r); + ctx.arcTo(x, y, x + w, y, r); + ctx.closePath(); + } + + // werelds-afstand -> scherm-y + function syFor(v, d) { return v.carY - (d - v.me.d) * K; } + + /* ---------------- ACHTERGROND ---------------- */ + function drawBackground(ctx, W, H, t) { + const g = ctx.createLinearGradient(0, 0, 0, H); + g.addColorStop(0, '#070a20'); + g.addColorStop(0.45, '#120c30'); + g.addColorStop(1, '#1b0f38'); + ctx.fillStyle = g; + ctx.fillRect(0, 0, W, H); + + const hx = W / 2 + Math.sin(t * 0.5) * 8; + const rad = ctx.createRadialGradient(hx, H * 0.42, 40, hx, H * 0.42, W * 0.75); + rad.addColorStop(0, 'rgba(255,63,164,0.20)'); + rad.addColorStop(0.5, 'rgba(0,240,255,0.10)'); + rad.addColorStop(1, 'rgba(0,0,0,0)'); + ctx.fillStyle = rad; + ctx.fillRect(0, 0, W, H); + + // synthwave grid + const gridY = H * 0.5; + ctx.lineWidth = 1; + for (let i = 1; i < 10; i++) { + const y = gridY + (H - gridY) * Math.pow(i / 10, 1.7); + ctx.strokeStyle = rgba(C.cyan, 0.06 + i * 0.006); + ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke(); + } + for (let i = -7; i <= 7; i++) { + const sway = Math.sin(t * 0.7 + i) * (W * 0.008); + ctx.strokeStyle = rgba(C.pink, 0.08); + ctx.beginPath(); + ctx.moveTo(hx + i * 22 + sway, gridY); + ctx.lineTo(hx + i * (W / 6), H); + ctx.stroke(); + } + } + + /* ---------------- WEG ---------------- */ + function drawRoad(ctx, v) { + const { H, roadLeft, roadW, me, trackLength } = v; + const rl = roadLeft, rr = roadLeft + roadW; + + const ag = ctx.createLinearGradient(rl, 0, rr, 0); + ag.addColorStop(0, '#181e38'); + ag.addColorStop(0.18, '#222a4e'); + ag.addColorStop(0.5, '#2a3460'); + ag.addColorStop(0.82, '#202747'); + ag.addColorStop(1, '#171c34'); + ctx.fillStyle = ag; + ctx.fillRect(rl, 0, roadW, H); + + // horizontale textuur-stroken (scollen) + const stripeH = 26; + const off = me.d * K; + ctx.fillStyle = 'rgba(255,255,255,0.025)'; + for (let y = -(off % stripeH); y < H; y += stripeH) { + ctx.fillRect(rl + 5, y, roadW - 10, stripeH * 0.55); + } + + // midden-streepjes + const period = 74, dashLen = 32; + const cOff = (me.d * K) % period; + ctx.fillStyle = rgba(C.gold, 0.55); + for (let y = -period + cOff; y < H; y += period) { + rrect(ctx, rl + roadW / 2 - 2.5, y, 5, dashLen, 2); + ctx.fill(); + } + ctx.globalAlpha = 1; + + // kantlijnen neon + ctx.save(); + ctx.shadowBlur = 14; + ctx.shadowColor = C.cyan; + ctx.strokeStyle = C.cyan; + ctx.lineWidth = 3; + ctx.beginPath(); ctx.moveTo(rl + 2, 0); ctx.lineTo(rl + 2, H); ctx.stroke(); + ctx.shadowColor = C.pink; + ctx.strokeStyle = C.pink; + ctx.beginPath(); ctx.moveTo(rr - 2, 0); ctx.lineTo(rr - 2, H); ctx.stroke(); + ctx.restore(); + + // stroken net buiten de weg + ctx.fillStyle = 'rgba(255,255,255,0.04)'; + ctx.fillRect(rl - 14, 0, 12, H); + ctx.fillRect(rr + 2, 0, 12, H); + + // start- en finishlijn + roadLine(ctx, v, 0, false); + roadLine(ctx, v, trackLength, true); + } + + function roadLine(ctx, v, worldD, checkered) { + const sy = syFor(v, worldD); + if (sy < -16 || sy > v.H + 16) return; + const { roadLeft, roadW } = v; + const h = 16; + if (checkered) { + const sq = 8; + for (let x = 0; x * sq < roadW; x++) { + for (let y = 0; y * sq < h; y++) { + ctx.fillStyle = ((x + y) % 2 === 0) ? '#fff' : '#0b0f2a'; + ctx.fillRect(roadLeft + x * sq, sy + y * sq, sq, sq); + } + } + } else { + ctx.fillStyle = 'rgba(255,255,255,0.75)'; + ctx.fillRect(roadLeft, sy, roadW, h); + } + } + + /* ---------------- AUTO'S ---------------- */ + function drawCar(ctx, v, p, t) { + const w = Math.max(20, v.roadW * 0.062); + const h = w * 1.42; + const sx = v.roadLeft + p.x * v.scaleX; + const sy = syFor(v, p.d); + if (sy < -90 || sy > v.H + 90) return; + + ctx.save(); + ctx.translate(sx, sy); + if (p.slip) ctx.rotate(Math.sin(t * 40 + p.x) * 0.08); + else ctx.rotate((p.steer || 0) * -0.08); + + // boost-vlam + if (p.boost) { + const fl = 8 + Math.random() * 10; + const fg = ctx.createLinearGradient(0, h * 0.4, 0, h * 0.4 + fl); + fg.addColorStop(0, rgba(C.cyan, 0.9)); + fg.addColorStop(1, rgba(C.cyan, 0)); + ctx.fillStyle = fg; + ctx.beginPath(); + ctx.moveTo(-w * 0.28, h * 0.38); + ctx.lineTo(0, h * 0.38 + fl); + ctx.lineTo(w * 0.28, h * 0.38); + ctx.closePath(); ctx.fill(); + } + + // schild-bubbel + if (p.shield) { + ctx.strokeStyle = rgba(C.green, 0.65); + ctx.lineWidth = 2; + ctx.shadowBlur = 12; + ctx.shadowColor = C.green; + ctx.beginPath(); ctx.ellipse(0, 0, w * 0.8, h * 0.75, 0, 0, 7); ctx.stroke(); + ctx.shadowBlur = 0; + } + + // schaduw + ctx.fillStyle = 'rgba(0,0,0,0.35)'; + ctx.beginPath(); ctx.ellipse(2, 2, w * 0.55, h * 0.55, 0, 0, 7); ctx.fill(); + + // carrosserie met glow + ctx.shadowColor = p.color; + ctx.shadowBlur = p.isMe ? 22 : 12; + const body = ctx.createLinearGradient(0, -h / 2, w, h / 2); + body.addColorStop(0, lighten(p.color, 18)); + body.addColorStop(0.5, p.color); + body.addColorStop(1, darken(p.color, 12)); + ctx.fillStyle = body; + rrect(ctx, -w / 2, -h / 2, w, h, w * 0.32); + ctx.fill(); + ctx.shadowBlur = 0; + ctx.strokeStyle = 'rgba(255,255,255,0.35)'; + ctx.lineWidth = 1; + rrect(ctx, -w / 2, -h / 2, w, h, w * 0.32); + ctx.stroke(); + + // voorruit + ctx.fillStyle = 'rgba(10,16,46,0.9)'; + rrect(ctx, -w * 0.34, -h * 0.16, w * 0.68, h * 0.34, 3); + ctx.fill(); + ctx.fillStyle = 'rgba(255,255,255,0.25)'; + rrect(ctx, -w * 0.34, -h * 0.16, w * 0.68, 2.5, 2); + ctx.fill(); + + // zijwielen + ctx.fillStyle = '#0a0d1c'; + rrect(ctx, -w * 0.52, -h * 0.34, w * 0.16, h * 0.28, 2); ctx.fill(); + rrect(ctx, w * 0.36, -h * 0.34, w * 0.16, h * 0.28, 2); ctx.fill(); + rrect(ctx, -w * 0.52, h * 0.1, w * 0.16, h * 0.3, 2); ctx.fill(); + rrect(ctx, w * 0.36, h * 0.1, w * 0.16, h * 0.3, 2); ctx.fill(); + + // vlag bij gefinisht + if (p.finished) { + ctx.fillStyle = '#dfe7ff'; + ctx.fillRect(w * 0.3, -h * 0.72, 3, h * 0.7); + const sq = 3; + for (let i = 0; i < 3; i++) { + for (let j = 0; j < 2; j++) { + ctx.fillStyle = ((i + j) % 2 === 0) ? '#fff' : '#111'; + ctx.fillRect(w * 0.3 + 3 + i * sq, -h * 0.72 + j * sq, sq, sq); + } + } + } + + // naamlabel + ctx.font = '600 10px system-ui, sans-serif'; + ctx.textAlign = 'center'; + ctx.fillStyle = p.isMe ? '#fff' : rgba('#eaf4ff', 0.92); + ctx.shadowColor = '#000'; + ctx.shadowBlur = 4; + ctx.fillText(p.name + (p.isMe ? ' โ€ข jij' : ''), 0, -h * 0.62); + ctx.restore(); + } + + function hexToRgb(hex) { + const h = hex.replace('#', ''); + return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)]; + } + function lighten(hex, amt) { + const [r, g, b] = hexToRgb(hex); + const f = (x) => Math.round(clamp(x + amt, 0, 255)); + return `rgb(${f(r)},${f(g)},${f(b)})`; + } + function darken(hex, amt) { + const [r, g, b] = hexToRgb(hex); + const f = (x) => Math.round(clamp(x - amt, 0, 255)); + return `rgb(${f(r)},${f(g)},${f(b)})`; + } + + /* ---------------- FX / DEELTJES ---------------- */ + function drawFx(ctx, fx, t) { + for (let i = fx.length - 1; i >= 0; i--) { + const f = fx[i]; + if (!f) continue; + const lifeF = 1 - f.life / f.maxLife; + if (f.kind === 'pulse') { + ctx.strokeStyle = rgba(f.color, lifeF * 0.7); + ctx.lineWidth = 2.5; + ctx.beginPath(); + ctx.arc(f.sx, f.sy, f.size * (1 + (1 - lifeF) * 3), 0, 7); + ctx.stroke(); + } else { + ctx.globalAlpha = lifeF; + ctx.fillStyle = f.color; + ctx.shadowBlur = f.size * 2; + ctx.shadowColor = f.color; + ctx.beginPath(); + ctx.arc(f.sx, f.sy, f.size * (0.3 + lifeF * 0.7), 0, 7); + ctx.fill(); + ctx.shadowBlur = 0; + } + } + ctx.globalAlpha = 1; + } + function drawPylon(ctx, v, sx, sy, t) { + const s = Math.max(13, v.roadW * 0.05); + ctx.save(); + ctx.translate(sx, sy); + ctx.shadowBlur = 14; + ctx.shadowColor = C.gold; + const grad = ctx.createLinearGradient(0, -s, 0, s); + grad.addColorStop(0, '#ff7a1a'); + grad.addColorStop(0.5, '#20264a'); + grad.addColorStop(1, '#ffd93d'); + ctx.fillStyle = grad; + ctx.beginPath(); + ctx.moveTo(-s * 0.42, -s); + ctx.lineTo(s * 0.42, -s); + ctx.lineTo(s * 0.2, s); + ctx.lineTo(-s * 0.2, s); + ctx.closePath(); + ctx.fill(); + ctx.fillStyle = C.red; + ctx.shadowColor = C.red; + ctx.shadowBlur = 8 + Math.sin(t * 10) * 5; + ctx.beginPath(); ctx.arc(0, -s - 4, 3, 0, 7); ctx.fill(); + ctx.restore(); + } + + function drawOil(ctx, v, sx, sy) { + const rx = Math.max(24, v.roadW * 0.10); + ctx.save(); + ctx.translate(sx, sy); + const og = ctx.createRadialGradient(0, 0, 2, 0, 0, rx); + og.addColorStop(0, 'rgba(40,20,60,0.9)'); + og.addColorStop(0.7, 'rgba(60,30,80,0.75)'); + og.addColorStop(1, 'rgba(80,40,110,0.45)'); + ctx.fillStyle = og; + ctx.beginPath(); ctx.ellipse(0, 0, rx, rx * 0.42, 0, 0, 7); ctx.fill(); + ctx.strokeStyle = rgba(C.pink, 0.35); + ctx.lineWidth = 1.5; + ctx.beginPath(); ctx.ellipse(0, 0, rx * 0.6, rx * 0.24, 0, 0, 7); ctx.stroke(); + ctx.restore(); + } + + function drawPickup(ctx, v, sx, sy, kind, t) { + const s = Math.max(11, v.roadW * 0.042); + const pulse = 1 + Math.sin(t * 4 + sx * 0.01) * 0.12; + ctx.save(); + ctx.translate(sx, sy + Math.sin(t * 3) * 4); + ctx.scale(pulse, pulse); + const col = kind === 'boost' ? C.cyan : C.green; + ctx.shadowBlur = 18; + ctx.shadowColor = col; + ctx.strokeStyle = col; + ctx.fillStyle = rgba(col, 0.18); + ctx.lineWidth = 3; + ctx.rotate(t * 1.5); + ctx.beginPath(); + if (kind === 'boost') { + ctx.moveTo(0, -s); ctx.lineTo(s, 0); ctx.lineTo(0, s); ctx.lineTo(-s, 0); ctx.closePath(); + } else { + for (let i = 0; i < 6; i++) { + const a = (i / 6) * Math.PI * 2; + const r = (i % 2 === 0) ? s : s * 0.62; + ctx.lineTo(Math.cos(a) * r, Math.sin(a) * r); + if (i === 0) { ctx.moveTo(Math.cos(a) * r, Math.sin(a) * r); ctx.lineTo(Math.cos(a) * r, Math.sin(a) * r); } + } + ctx.closePath(); + } + ctx.fill(); ctx.stroke(); + ctx.rotate(-t * 1.5); + ctx.fillStyle = '#fff'; + if (kind === 'boost') { + ctx.beginPath(); + ctx.moveTo(s * 0.12, -s * 0.55); + ctx.lineTo(-s * 0.45, s * 0.25); + ctx.lineTo(-s * 0.02, s * 0.25); + ctx.lineTo(-s * 0.12, s * 0.55); + ctx.lineTo(s * 0.45, -s * 0.25); + ctx.lineTo(s * 0.02, -s * 0.25); + ctx.closePath(); ctx.fill(); + } else { + ctx.beginPath(); + ctx.moveTo(0, -s * 0.55); + ctx.lineTo(s * 0.5, -s * 0.3); + ctx.lineTo(s * 0.5, s * 0.15); + ctx.lineTo(0, s * 0.55); + ctx.lineTo(-s * 0.5, s * 0.15); + ctx.lineTo(-s * 0.5, -s * 0.3); + ctx.closePath(); ctx.fill(); + ctx.strokeStyle = '#0b0f2a'; + ctx.stroke(); + } + ctx.restore(); + } + + + /* ---------------- HOOFD-RENDER ---------------- */ + function render(ctx, o) { + const t = o.t / 1000; + const W = o.W, H = o.H; + const roadW = Math.min(W * 0.88, 460); + const roadLeft = (W - roadW) / 2; + const scaleX = roadW / ROAD_W; + const carY = Math.min(H * 0.62, H - 140); + + const v = { + W, H, roadLeft, roadW, scaleX, carY, t, + me: { d: o.me.d }, + trackLength: o.trackLength, + }; + + ctx.save(); + if (o.shake) { + ctx.translate((Math.random() - 0.5) * o.shake, (Math.random() - 0.5) * o.shake); + } + drawBackground(ctx, W, H, t); + drawRoad(ctx, v); + + // obstakels + power-ups (alleen zichtbare) + const top = -80, bot = H + 80; + if (o.track) { + for (const ob of o.track.obstacles) { + const sy = syFor(v, ob.d); + if (sy < top || sy > bot) continue; + const sx = roadLeft + ob.x * scaleX; + if (ob.type === 'oil') drawOil(ctx, v, sx, sy); + else drawPylon(ctx, v, sx, sy, t); + } + for (const pu of o.track.powerups) { + const sy = syFor(v, pu.d); + if (sy < top || sy > bot) continue; + const sx = roadLeft + pu.x * scaleX; + drawPickup(ctx, v, sx, sy, pu.kind, t); + } + } + + // auto's (achterste eerst) + const cars = (o.cars || []).slice().sort((a, b) => a.d - b.d); + for (const p of cars) drawCar(ctx, v, p, t); + + drawFx(ctx, o.fx || [], t); + ctx.restore(); + + // snelheidsstrepen bij boost + if (o.speedFactor > 1.15) { + ctx.strokeStyle = 'rgba(255,255,255,0.12)'; + ctx.lineWidth = 2; + for (let i = 0; i < 8; i++) { + const x = (Math.sin(t * 3 + i * 1.7) * 0.5 + 0.5) * W; + const len = 40 + ((t * 300) % 40); + ctx.beginPath(); + ctx.moveTo(x, H); + ctx.lineTo(x, H - len - ((t * 400) % 60)); + ctx.stroke(); + } + } + } + + return { render }; +})(); + diff --git a/public/style.css b/public/style.css new file mode 100644 index 0000000..ecd9ff0 --- /dev/null +++ b/public/style.css @@ -0,0 +1,261 @@ +:root { + --bg1: #0b0f2a; + --bg2: #150f33; + --neon: #00f0ff; + --neon2: #ff3fa4; + --gold: #ffd93d; + --ok: #3dff88; + --red: #ff4d6d; + --card: rgba(13, 16, 44, 0.82); + --line: rgba(0, 240, 255, 0.35); +} + +* { box-sizing: border-box; margin: 0; padding: 0; -webkit-tap-highlight-color: transparent; } + +html, body { + height: 100%; + overflow: hidden; + touch-action: none; + overscroll-behavior: none; + background: radial-gradient(120% 90% at 50% 0%, var(--bg1), var(--bg2)); + font-family: "Segoe UI", system-ui, -apple-system, Roboto, sans-serif; + color: #eaf4ff; + user-select: none; + -webkit-user-select: none; + position: fixed; inset: 0; +} + +#stars { + position: fixed; inset: 0; + background-image: + radial-gradient(1.5px 1.5px at 12% 22%, rgba(255,255,255,.8), transparent 60%), + radial-gradient(1px 1px at 68% 8%, rgba(255,255,255,.7), transparent 60%), + radial-gradient(1.5px 1.5px at 82% 66%, rgba(0,240,255,.7), transparent 60%), + radial-gradient(1px 1px at 34% 84%, rgba(255,255,255,.55), transparent 60%), + radial-gradient(2px 2px at 44% 12%, rgba(255,63,164,.6), transparent 60%), + radial-gradient(1px 1px at 92% 40%, rgba(255,255,255,.6), transparent 60%); + pointer-events: none; + z-index: 0; +} + +#game { + position: fixed; inset: 0; + width: 100%; height: 100%; + display: block; + z-index: 1; +} + +/* ---- Overlays ---- */ +.overlay { + position: fixed; inset: 0; + z-index: 5; + display: flex; align-items: center; justify-content: center; + padding: 16px; + background: radial-gradient(100% 100% at 50% 20%, rgba(11,15,42,.45), rgba(11,15,42,.9)); + backdrop-filter: blur(6px); +} +.hidden { display: none !important; } + +.card { + width: min(420px, 100%); + background: var(--card); + border: 1px solid var(--line); + border-radius: 22px; + padding: 26px 24px; + box-shadow: 0 0 40px rgba(0, 240, 255, 0.15), 0 20px 60px rgba(0, 0, 0, 0.5); + text-align: center; + animation: pop .35s cubic-bezier(.2, 1.4, .4, 1); +} +@keyframes pop { from { transform: scale(.85); opacity: 0; } to { transform: scale(1); opacity: 1; } } + +.logo { + font-size: 46px; font-weight: 900; letter-spacing: 3px; line-height: 1; + color: var(--neon); + text-shadow: 0 0 18px rgba(0,240,255,.8), 0 4px 0 rgba(255,63,164,.7); + margin-bottom: 6px; +} +.logo span { color: var(--neon2); text-shadow: 0 0 18px rgba(255,63,164,.9); } +.tagline { color: #99a8d8; font-size: 13px; letter-spacing: 1px; margin-bottom: 20px; } + +input[type=text], input:not([type]) { + width: 100%; + padding: 13px 16px; + font-size: 17px; + border-radius: 12px; + border: 1px solid rgba(0,240,255,.4); + background: rgba(8, 12, 32, 0.8); + color: #fff; + outline: none; + margin-bottom: 14px; + text-align: center; +} +input:focus { border-color: var(--neon); box-shadow: 0 0 14px rgba(0,240,255,.45); } +input::placeholder { color: #667; } + +.colors { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; margin-bottom: 16px; } +.swatch { + width: 34px; height: 34px; border-radius: 50%; + border: 2px solid transparent; + cursor: pointer; + box-shadow: 0 0 8px rgba(0,0,0,.4); +} +.swatch.on { border-color: #fff; box-shadow: 0 0 12px rgba(255,255,255,.8); transform: scale(1.12); } + +.btn { + width: 100%; + padding: 14px; + font-size: 18px; font-weight: 800; letter-spacing: 1px; + color: #02131a; + background: linear-gradient(90deg, var(--neon), #45e0ff); + border: none; border-radius: 14px; + box-shadow: 0 0 22px rgba(0,240,255,.55), 0 6px 0 rgba(0,120,170,.9); + cursor: pointer; + transition: transform .08s; +} +.btn:active { transform: translateY(4px); box-shadow: 0 0 16px rgba(0,240,255,.5), 0 2px 0 rgba(0,120,170,.9); } +.btn.ghost { + background: linear-gradient(90deg, var(--neon2), #ff7ac0); color: #25010f; + box-shadow: 0 0 22px rgba(255,63,164,.5), 0 6px 0 rgba(150,20,90,.9); + margin-top: 10px; +} + +.msg { min-height: 20px; margin-top: 12px; font-size: 14px; color: var(--gold); } +.msg.err { color: var(--red); } +.hint { margin-top: 14px; font-size: 12.5px; color: #7d8ab8; line-height: 1.5; } + + +/* ---- Kamer / HUD ---- */ +#room { position: fixed; inset: 0; z-index: 2; pointer-events: none; } + +#hud { + position: absolute; top: calc(10px + env(safe-area-inset-top)); left: 0; right: 0; + display: flex; justify-content: space-between; align-items: flex-start; + padding: 0 12px; pointer-events: none; + text-shadow: 0 2px 8px rgba(0,0,0,.8); +} +.hud-timer { + font-size: 30px; font-weight: 900; color: #fff; + font-variant-numeric: tabular-nums; +} +.hud-timer.danger { color: var(--red); animation: blink 1s infinite; } +.hud-speed { font-size: 12px; color: var(--neon); letter-spacing: 1px; margin-top: 2px; } +.hud-pos { text-align: right; font-size: 20px; font-weight: 800; color: var(--gold); } +.hud-dist { text-align: right; font-size: 12px; color: #b9c6f0; letter-spacing: 1px; margin-top: 2px; } +@keyframes blink { 50% { opacity: .35; } } + +#lead { + position: absolute; top: calc(118px + env(safe-area-inset-top)); left: 50%; transform: translateX(-50%); + padding: 6px 16px; border-radius: 999px; + background: linear-gradient(90deg, rgba(255,63,164,.25), rgba(0,240,255,.25)); + background-color: rgba(10,14,40,.6); + border: 1px solid var(--gold); + color: #fff; font-size: 13px; font-weight: 700; letter-spacing: .5px; + text-shadow: 0 0 10px rgba(255,217,61,.9); + display: flex; gap: 6px; align-items: center; + white-space: nowrap; +} +#lead .crown { color: var(--gold); } + +/* Leaderboard / aanmelderslijst */ +.board { + position: absolute; + top: calc(20px + env(safe-area-inset-top)); right: 12px; + width: min(180px, 44vw); + background: var(--card); + border: 1px solid var(--line); + border-radius: 14px; + padding: 8px 10px; + pointer-events: none; +} +.board-head { font-size: 10.5px; letter-spacing: 1.5px; color: var(--neon); margin-bottom: 5px; text-transform: uppercase; } +#boardList { list-style: none; max-height: 42vh; overflow: hidden; } +#boardList li { + display: flex; gap: 6px; align-items: center; + font-size: 12px; padding: 3px 4px; border-radius: 6px; + color: #dce7ff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +#boardList li .dot { width: 9px; height: 9px; border-radius: 50%; flex: none; box-shadow: 0 0 8px currentColor; } +#boardList li .nm { flex: 1; overflow: hidden; text-overflow: ellipsis; } +#boardList li.me { background: rgba(0,240,255,.18); box-shadow: inset 0 0 0 1px rgba(0,240,255,.5); } +#boardList li.top { background: rgba(255,217,61,.16); box-shadow: inset 0 0 0 1px rgba(255,217,61,.55); } +#boardList li .rk { width: 14px; text-align: right; color: #7d8ab8; font-weight: 700; } +#boardList li .st { font-size: 10px; color: #9fb0dd; } + +/* Minimap */ +#minimapWrap { + position: absolute; + right: 12px; bottom: calc(84px + env(safe-area-inset-bottom)); + width: 22px; height: 34vh; + border-radius: 10px; + border: 1px solid var(--line); + background: rgba(8,12,32,.7); + overflow: hidden; + pointer-events: auto; + cursor: pointer; +} +#minimap { width: 100%; height: 100%; display: block; } + +/* Toasts */ +#toasts { position: absolute; top: calc(170px + env(safe-area-inset-top)); left: 50%; transform: translateX(-50%); display: flex; flex-direction: column; gap: 6px; align-items: center; pointer-events: none; width: 86%; } +.toast { + background: rgba(10,14,40,.85); + border: 1px solid rgba(0,240,255,.4); + padding: 7px 14px; border-radius: 999px; + font-size: 13px; font-weight: 600; color: #fff; + box-shadow: 0 0 18px rgba(0,240,255,.25); + animation: toastIn .25s ease-out; + text-align: center; +} +.toast.gold { border-color: var(--gold); box-shadow: 0 0 18px rgba(255,217,61,.3); } +@keyframes toastIn { from { transform: translateY(-12px); opacity: 0; } to { transform: none; opacity: 1; } } + +/* Touch hints */ +#controls { + position: absolute; left: 0; right: 0; bottom: calc(10px + env(safe-area-inset-bottom)); + display: flex; justify-content: space-between; align-items: center; + padding: 0 18px; pointer-events: none; +} +.ctrl { color: rgba(234,244,255,.5); font-size: 12px; letter-spacing: 1px; text-shadow: 0 1px 6px #000; } +.arrow { color: rgba(0,240,255,.8); font-size: 16px; } +.tipzoom { color: rgba(234,244,255,.4); font-size: 14px; } + +/* Countdown */ +#countdown { flex-direction: column; gap: 8px; background: radial-gradient(90% 90% at 50% 50%, rgba(11,15,42,.25), rgba(11,15,42,.88)); } +.cd-label { font-size: 15px; letter-spacing: 5px; text-transform: uppercase; color: var(--neon); text-shadow: 0 0 14px rgba(0,240,255,.8); } +.cd-num { + font-size: 120px; font-weight: 900; line-height: 1; + color: #fff; + font-variant-numeric: tabular-nums; + text-shadow: 0 0 40px var(--neon), 0 0 80px var(--neon2); + animation: pulse 0.5s ease-in-out infinite alternate; +} +@keyframes pulse { from { transform: scale(1); } to { transform: scale(1.06); } } +.cd-sub { color: #9fb0dd; font-size: 14px; } + +/* Uitslag */ +.results-card { max-height: 92vh; overflow: auto; } +.res-title { font-size: 26px; letter-spacing: 3px; color: var(--gold); text-shadow: 0 0 18px rgba(255,217,61,.7); margin-bottom: 12px; } +.podium { display: flex; justify-content: center; align-items: flex-end; gap: 10px; margin-bottom: 14px; } +.pod { + width: 30%; border-radius: 12px 12px 0 0; padding: 6px 4px 10px; + background: rgba(255,255,255,.06); border: 1px solid rgba(255,255,255,.15); + font-size: 11px; text-align: center; +} +.pod.g1 { height: 96px; background: linear-gradient(180deg, rgba(255,217,61,.45), rgba(255,217,61,.08)); border-color: var(--gold); order: 2; } +.pod.g2 { height: 72px; background: linear-gradient(180deg, rgba(200,214,255,.4), rgba(200,214,255,.06)); order: 1; } +.pod.g3 { height: 56px; background: linear-gradient(180deg, rgba(255,150,90,.4), rgba(255,150,90,.06)); order: 3; } +.pod .medal { font-size: 22px; display: block; margin-bottom: 4px; } +.pod .pname { font-weight: 700; color: #fff; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +.results-list { list-style: none; text-align: left; margin: 0 0 16px; max-height: 32vh; overflow: auto; } +.results-list li { display: flex; gap: 10px; padding: 6px 8px; border-bottom: 1px solid rgba(255,255,255,.07); font-size: 14px; } +.results-list li.me { background: rgba(0,240,255,.16); border-radius: 8px; } +.results-list .rk { width: 28px; color: var(--gold); font-weight: 800; } +.results-list .nm { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.results-list .tm { color: #b9c6f0; font-variant-numeric: tabular-nums; } + +@media (max-width: 480px) { + .logo { font-size: 40px; } + .hud-timer { font-size: 26px; } +} + diff --git a/server/engine.js b/server/engine.js new file mode 100644 index 0000000..aae906f --- /dev/null +++ b/server/engine.js @@ -0,0 +1,313 @@ +'use strict'; +/* + * Turbo Race - spelengine (server-authoritatief) + * 20 spelers, verticaal scrollende weg, obstakels + power-ups, + * race max 120s, countdown na host-start. + */ +const { EventEmitter } = require('events'); +const crypto = require('crypto'); + +const ROAD_W = 420; // wegbreedte in wereld-eenheden (= px) +const CAR_W = 24; // breedte auto +const CAR_HALF_X = CAR_W / 2; +const CAR_HALF_Y = 13; // halve verticale hitbox +const CRUISE = 300; // kruissnelheid (u/s) +const BOOST_BONUS = 175; // extra snelheid tijdens boost +const LATERAL = 165; // laterale stuursnelheid (u/s) +const STEER_RESPONSE = 7; // hoe snel stuuruitslag inslingert (hoger = directer, lager = vloeiender) +const OBJ_VH = 12; // verticale halve hoogte van obstakels +const PICKUP_VH = 14; // verticale halve hoogte van power-ups +const PICKUP_RADIUS = 16; // horizontale straal power-up + +function clamp(n, lo, hi) { return n < lo ? lo : (n > hi ? hi : n); } + +// Deterministische PRNG zodat de baan eerlijk (identiek) is voor iedereen +function mulberry32(a) { + return function () { + a |= 0; a = (a + 0x6D2B79F5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +class Game extends EventEmitter { + constructor(opts = {}) { + super(); + this.maxPlayers = opts.maxPlayers || 20; + this.countdownS = opts.countdownS || 30; + this.raceDurationS = opts.raceDurationS || 90; // race max 1:30 + this.trackLength = opts.trackLength || 23000; // finish = einde van de baan + + this.players = new Map(); // id -> player + this.order = []; // aanmeld-volgorde + this.phase = 'lobby'; // lobby | countdown | race | results + this.countdownEndsAt = 0; + this.raceEndsAt = 0; + this.raceStartedAt = 0; + this.track = null; // { seed, obstacles[], powerups[] } + this._idc = 1; + } + + _randomColor() { + const hue = Math.floor(Math.random() * 360); + return `hsl(${hue} 90% 62%)`; + } + + _newId() { return 'p' + (this._idc++); } + + addPlayer(nameRaw, color, hostToken, adminToken) { + if (this.players.size >= this.maxPlayers) { + const e = new Error(`Vol zit: max ${this.maxPlayers} spelers`); + e.code = 'FULL'; throw e; + } + if (this.phase === 'race') { + const e = new Error('De race is al bezig'); + e.code = 'LATE'; throw e; + } + const name = String(nameRaw || '').trim().slice(0, 14) || + ('Anoniem-' + Math.floor(2 + Math.random() * 98)); + const player = { + id: this._newId(), + name, + color: /^#[0-9a-fA-F]{6}$/.test(String(color)) ? color : this._randomColor(), + host: !!(hostToken && adminToken && String(hostToken) === String(adminToken)), + x: clamp(40 + Math.random() * (ROAD_W - 80), CAR_HALF_X, ROAD_W - CAR_HALF_X), + d: 0, + speed: 0, + finished: false, + finishTime: 0, + rank: 0, + boostUntil: 0, shieldUntil: 0, slipUntil: 0, slowUntil: 0, + steer: 0, steerSm: 0, + joinedAt: Date.now(), + }; + this.players.set(player.id, player); + this.order.push(player.id); + this.emit('join', player); + return player; + } + + removePlayer(id) { + if (!this.players.delete(id)) return; + this.order = this.order.filter((x) => x !== id); + this.emit('leave', id); + } + + hostStart() { + if (this.phase !== 'lobby' && this.phase !== 'results') return false; + this.phase = 'countdown'; + this.countdownEndsAt = Date.now() + this.countdownS * 1000; + this.emit('countdown'); + return true; + } + + readyAgain() { + if (this.phase !== 'results') return false; + this._resetRaceState(); + this.phase = 'lobby'; + this.emit('lobby_reset'); + return true; + } + + _resetRaceState() { + for (const p of this.players.values()) { + p.x = clamp(40 + Math.random() * (ROAD_W - 80), CAR_HALF_X, ROAD_W - CAR_HALF_X); + p.d = 0; p.speed = 0; p.finished = false; p.finishTime = 0; + p.boostUntil = p.shieldUntil = p.slipUntil = p.slowUntil = 0; + p.steerSm = 0; + } + this.track = null; + } + + update(dtMs) { + const now = Date.now(); + if (this.phase === 'countdown' && now >= this.countdownEndsAt) { + this._startRace(now); + } else if (this.phase === 'race') { + this._updatePhysics(dtMs / 1000, now); + const ended = now >= this.raceEndsAt || this._allFinished(); + if (ended) { + this._rank(); + this.phase = 'results'; + this.emit('results'); + } + } + } + + _allFinished() { + if (this.players.size === 0) return false; + for (const p of this.players.values()) if (!p.finished) return false; + return true; + } + + _startRace(now) { + this.phase = 'race'; + this.raceStartedAt = now; + this.raceEndsAt = now + this.raceDurationS * 1000; + this._generateTrack(); + // Startgrid: posities verdeeld van links naar rechts + const arr = [...this.players.values()]; + const slots = ROAD_W / (arr.length + 1); + arr.forEach((p, i) => { + p.x = slots * (i + 1); + p.d = 0; p.speed = 0; + p.finished = false; p.finishTime = 0; + p.boostUntil = p.shieldUntil = p.slipUntil = p.slowUntil = 0; + p.steerSm = 0; + }); + this.emit('race_start', this.track); + } + + _generateTrack() { + const seed = crypto.randomBytes(4).readUInt32LE(0) || 1; + const rng = mulberry32(seed); + const obstacles = []; + const powerups = []; + const lanes = [70, 210, 350]; + let oid = 0, pid = 0; + let d = 700; + let puD = 1500; + while (d < this.trackLength - 900) { + const step = 250 + rng() * 190; + // aantal geblokkeerde banen: nooit alle 3, zodat er altijd een doorgang is + const blockCount = rng() < 0.18 ? 1 : 2; + const idx = [0, 1, 2].sort(() => rng() - 0.5).slice(0, blockCount).sort((a, b) => a - b); + for (const li of idx) { + const type = rng() < 0.72 ? 'pylon' : 'oil'; + obstacles.push({ + id: 'o' + (oid++), + type, + d: Math.round(d + (rng() - 0.5) * 10), + x: clamp(lanes[li] + (rng() - 0.5) * 26, 30, ROAD_W - 30), + hh: type === 'oil' ? 26 : 14, + }); + } + // power-ups + if (d >= puD && d + 500 < this.trackLength - 900) { + const kind = pid % 2 === 0 ? 'boost' : 'shield'; + const x = lanes[Math.floor(rng() * 3)] + (rng() - 0.5) * 34; + powerups.push({ + id: 'p' + (pid++), + kind, + d: Math.round(puD), + x: clamp(x, 36, ROAD_W - 36), + respawnAt: 0, + }); + puD += 700 + rng() * 240; + } + d += step; + } + if (powerups.length === 0) { + powerups.push({ id: 'p0', kind: 'boost', d: 1500, x: 210, respawnAt: 0 }); + } + this.track = { seed, obstacles, powerups }; + return this.track; + } + + _updatePhysics(dt, now) { + this._rank(); + for (const p of this.players.values()) { + if (p.finished) { p.speed = 0; continue; } + + // Snelheidsdoel + let target = CRUISE + (p.boostUntil > now ? BOOST_BONUS : 0); + if (p.slowUntil > now) target = Math.min(target, 70); + p.speed += (target - p.speed) * Math.min(1, dt * 2.2); + + // Sturen: geleidelijk inslingeren (smoother) i.p.v. direct volle uitslag. + // p.steer is het ruwe doel van de client, p.steerSm de opgerampte waarde. + p.steerSm += (p.steer - p.steerSm) * Math.min(1, dt * STEER_RESPONSE); + + // Sturen (olie = wegglijden) + let mv = p.steerSm * LATERAL * dt; + if (p.slipUntil > now) mv = p.steerSm * LATERAL * 0.3 * dt + (Math.random() - 0.5) * 260 * dt; + p.x = clamp(p.x + mv + (p.slipUntil > now ? (Math.random() - 0.5) * 90 * dt : 0), + CAR_HALF_X, ROAD_W - CAR_HALF_X); + + p.d += p.speed * dt; + if (p.d >= this.trackLength && !p.finished) { + p.finished = true; + p.finishTime = Math.max(1, now - this.raceStartedAt); + this.emit('finish', { id: p.id, name: p.name, slot: p.rank }); + } + } + this._collisions(now); + } + + _collisions(now) { + if (!this.track) return; + for (const p of this.players.values()) { + if (p.finished) continue; + for (const o of this.track.obstacles) { + if (Math.abs(p.d - o.d) < OBJ_VH + CAR_HALF_Y && + Math.abs(p.x - o.x) < o.hh + CAR_HALF_X) { + if (p.shieldUntil > now) continue; + if (o.type === 'pylon' && p.slowUntil <= now) { + p.slowUntil = now + 900; + p.speed = Math.min(p.speed, 70); + this.emit('hit', { id: p.id, kind: 'pylon' }); + } else if (o.type === 'oil' && p.slipUntil <= now) { + p.slipUntil = now + 1300; + p.speed *= 0.8; + this.emit('hit', { id: p.id, kind: 'oil' }); + } + } + } + for (const pu of this.track.powerups) { + if (pu.respawnAt > now) continue; + if (Math.abs(p.d - pu.d) < PICKUP_VH + CAR_HALF_Y && + Math.abs(p.x - pu.x) < PICKUP_RADIUS + CAR_HALF_X) { + if (pu.kind === 'boost') p.boostUntil = now + 2600; + else p.shieldUntil = now + 5000; + pu.respawnAt = now + 4500; + this.emit('pickup', { id: p.id, kind: pu.kind }); + } + } + } + } + + _rank() { + const arr = [...this.players.values()]; + arr.sort((a, b) => { + if (a.finished && b.finished) return a.finishTime - b.finishTime; + if (a.finished) return -1; + if (b.finished) return 1; + return b.d - a.d; + }); + arr.forEach((p, i) => { p.rank = i + 1; }); + return arr; + } + + snapshot() { + const now = Date.now(); + this._rank(); + const players = [...this.players.values()].map((p) => ({ + id: p.id, + name: p.name, + color: p.color, + x: +p.x.toFixed(1), + d: Math.round(p.d), + speed: Math.round(p.speed), + finished: p.finished, + finishTime: p.finishTime, + rank: p.rank, + boost: p.boostUntil > now, + shield: p.shieldUntil > now, + slip: p.slipUntil > now, + })); + return { + type: 'snapshot', + phase: this.phase, + countdownEndsAt: this.countdownEndsAt, + raceEndsAt: this.raceEndsAt, + raceDuration: this.raceDurationS, + trackLength: this.trackLength, + maxPlayers: this.maxPlayers, + players, + }; + } +} + +module.exports = Game; + diff --git a/server/index.js b/server/index.js new file mode 100644 index 0000000..a7c7311 --- /dev/null +++ b/server/index.js @@ -0,0 +1,142 @@ +'use strict'; +/* + * Turbo Race - HTTP + WebSocket server + * Serveert de statische frontend en draait de spelengine. + * Host-eindpunt: verbind met ?host= en druk op "s". + */ +const path = require('path'); +const http = require('http'); +const express = require('express'); +const { WebSocketServer } = require('ws'); +const Game = require('./engine'); + +const PORT = parseInt(process.env.PORT || '8080', 10); +const MAX_PLAYERS = parseInt(process.env.MAX_PLAYERS || '20', 10); +const ADMIN_TOKEN = process.env.ADMIN_TOKEN || 'dev-host'; +const COUNTDOWN_S = parseInt(process.env.COUNTDOWN_S || '30', 10); +const RACE_DURATION_S = parseInt(process.env.RACE_DURATION_S || '90', 10); +const TRACK_LENGTH = parseInt(process.env.TRACK_LENGTH || '23000', 10); + +const app = express(); +app.use(express.static(path.join(__dirname, '..', 'public'))); +app.get('/healthz', (req, res) => res.status(200).json({ ok: true, phase: game.phase, players: game.players.size })); + +const server = http.createServer(app); +const wss = new WebSocketServer({ server, path: '/ws' }); + +const game = new Game({ + maxPlayers: MAX_PLAYERS, + countdownS: COUNTDOWN_S, + raceDurationS: RACE_DURATION_S, + trackLength: TRACK_LENGTH, +}); + +// socket -> playerId +const sockToId = new Map(); + +function broadcast(obj, except) { + const data = JSON.stringify(obj); + for (const client of wss.clients) { + if (client.readyState === client.OPEN && client !== except) client.send(data); + } +} + +function send(ws, obj) { + if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(obj)); +} + +/* ---------------- engine-events -> naar alle clients ---------------- */ +game.on('join', (p) => + broadcast({ type: 'player_joined', player: { id: p.id, name: p.name, color: p.color } })); +game.on('leave', (id) => broadcast({ type: 'player_left', id })); +game.on('countdown', () => broadcast({ type: 'phase', phase: 'countdown' })); +game.on('race_start', (track) => { + broadcast({ type: 'track', track }); + broadcast(game.snapshot()); +}); +game.on('lobby_reset', () => broadcast({ type: 'phase', phase: 'lobby' })); +game.on('hit', (e) => broadcast({ type: 'fx', id: e.id, kind: 'hit:' + e.kind })); +game.on('pickup', (e) => broadcast({ type: 'fx', id: e.id, kind: 'pickup:' + e.kind })); +game.on('finish', (e) => + broadcast({ type: 'announce', text: `๐Ÿ ${e.name} is over de finish!` })); + +/* ---------------- websocket ---------------- */ +wss.on('connection', (ws) => { + ws.isAlive = true; + ws.on('pong', () => { ws.isAlive = true; }); + + ws.on('message', (buf) => { + let msg; + try { msg = JSON.parse(buf.toString()); } catch { return; } + switch (msg.type) { + case 'join': { + let player; + try { + player = game.addPlayer(msg.name, msg.color, msg.hostToken, ADMIN_TOKEN); + } catch (e) { + return send(ws, { type: 'error', code: e.code || 'ERR', msg: e.message }); + } + sockToId.set(ws, player.id); + send(ws, { + type: 'joined', + id: player.id, + host: player.host, + self: { name: player.name, color: player.color }, + snapshot: game.snapshot(), + track: game.track, + }); + break; + } + case 'input': { + const id = sockToId.get(ws); + if (!id) break; + const p = game.players.get(id); + if (!p) break; + p.steer = Math.max(-1, Math.min(1, Number(msg.steer) || 0)); + break; + } + case 'host_start': { + const id = sockToId.get(ws); + const p = id && game.players.get(id); + if (!p || !p.host) break; + if (game.hostStart()) broadcast({ type: 'phase', phase: 'countdown', hostStart: true }); + break; + } + case 'ready_again': { + const id = sockToId.get(ws); + const p = id && game.players.get(id); + if (!p) break; + if (game.readyAgain()) broadcast({ type: 'phase', phase: 'lobby', reset: true }); + break; + } + } + }); + + ws.on('error', () => {}); + ws.on('close', () => { + const id = sockToId.get(ws); + sockToId.delete(ws); + if (id) game.removePlayer(id); + }); +}); + +/* ---------------- heartbeat + game-loop + broadcasts ---------------- */ +setInterval(() => { + for (const client of wss.clients) { + if (!client.isAlive) { client.terminate(); continue; } + client.isAlive = false; + client.ping(); + } +}, 30000); + +setInterval(() => game.update(25), 25); + +setInterval(() => { + if (wss.clients.size > 0) broadcast(game.snapshot()); +}, 62.5); + +server.listen(PORT, () => { + console.log(`๐ŸŽ๏ธ Turbo Race draait op http://0.0.0.0:${PORT}`); + console.log(` Max spelers: ${MAX_PLAYERS} | countdown: ${COUNTDOWN_S}s | race: max ${RACE_DURATION_S}s | baan: ${TRACK_LENGTH}m`); + console.log(` Host-modus (open in browser): http:///?host=${ADMIN_TOKEN} -> druk op S om te starten`); +}); diff --git a/test/simulate.js b/test/simulate.js new file mode 100644 index 0000000..debf077 --- /dev/null +++ b/test/simulate.js @@ -0,0 +1,284 @@ +'use strict'; +/* + * Turbo Race - headless test/simulatie + * 1) engine-unit-test (fases, vol, host-token, botsingen, power-ups, ranking) + * 2) integratietest: echte server + 20 websocket-clients + host die de race start + * Run: node test/simulate.js + */ +const assert = require('assert'); +const { spawn } = require('child_process'); +const path = require('path'); +const http = require('http'); +const net = require('net'); +const WebSocket = require('ws'); + +let failures = 0; +let testChild = null; +function test(name, fn) { + try { fn(); console.log(` โœ“ ${name}`); } + catch (e) { failures++; console.error(` โœ— ${name}\n ${e.message}`); } +} +function freePort() { + return new Promise((resolve) => { + const srv = net.createServer(); + srv.listen(0, () => { + const port = srv.address().port; + srv.close(() => resolve(port)); + }); + }); +} +function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); } + +/* ---------------- DEEL 1: engine-unit-test ---------------- */ +async function engineUnit() { + console.log('\n[1] Engine-unit-test'); + const Game = require('../server/engine'); + const g = new Game({ maxPlayers: 20, countdownS: 1, raceDurationS: 12 }); + + const host = g.addPlayer('Host', '#ff0000', 'tok', 'tok'); + test('host wordt herkend met geldig token', () => assert.strictEqual(host.host, true)); + + const speler = g.addPlayer('Piet', '#00ff00', 'verkeerd', 'tok'); + test('speler met fout token is geen host', () => assert.strictEqual(speler.host, false)); + + test('join tijdens race wordt geweigerd (LATE)', () => { + g.phase = 'race'; + assert.throws(() => g.addPlayer('Laat', '#fff', '', 'tok'), (e) => e.code === 'LATE'); + g.phase = 'lobby'; + }); + + for (let i = 2; i < 20; i++) g.addPlayer('R' + i, '#00f0ff', '', 'tok'); + test('20 spelers kunnen aanmelden', () => assert.strictEqual(g.players.size, 20)); + test('21e speler wordt geweigerd (FULL)', () => { + assert.throws(() => g.addPlayer('X', '#fff', '', 'tok'), (e) => e.code === 'FULL'); + }); + + test('hostStart zet fases lobby->countdown', () => { + assert.strictEqual(g.hostStart(), true); + assert.strictEqual(g.phase, 'countdown'); + assert.strictEqual(g.hostStart(), false); // niet opnieuw tijdens countdown + }); + + const loopEnd = Date.now() + 20000; + while (g.phase !== 'results' && Date.now() < loopEnd) { + g.update(25); + await sleep(20); + } + test('race eindigt in results', () => assert.strictEqual(g.phase, 'results')); + test('baan is gegenereerd met obstakels + power-ups', () => { + assert.ok(g.track, 'track bestaat'); + assert.ok(g.track.obstacles.length > 20, 'meer dan 20 obstakels'); + const kinds = new Set(g.track.obstacles.map((o) => o.type)); + assert.ok(kinds.has('pylon') && kinds.has('oil'), 'zowel pylon als olie'); + assert.ok(g.track.powerups.some((p) => p.kind === 'boost'), 'boost-power-up aanwezig'); + assert.ok(g.track.powerups.some((p) => p.kind === 'shield'), 'shield-power-up aanwezig'); + }); + + test('rankings zijn compleet en opeenvolgend', () => { + const ranks = [...g.players.values()].map((p) => p.rank).sort((a, b) => a - b); + assert.deepStrictEqual(ranks, [...Array(20).keys()].map((i) => i + 1)); + }); + + test('snapshot bevat alle spelers + fase', () => { + const s = g.snapshot(); + assert.strictEqual(s.players.length, 20); + assert.strictEqual(s.phase, 'results'); + assert.ok(s.trackLength > 0); + }); + + test('readyAgain reset naar lobby en houdt spelers', () => { + assert.strictEqual(g.readyAgain(), true); + assert.strictEqual(g.phase, 'lobby'); + assert.strictEqual(g.players.size, 20); + }); + + test('pylon-aanrijding vertraagt (slowUntil) en power-up activeert boost', () => { + g.hostStart(); + const t0 = Date.now(); + while (g.phase !== 'race' && Date.now() < t0 + 3000) { g.update(25); } + assert.strictEqual(g.phase, 'race'); + const p = [...g.players.values()][0]; + const pylon = g.track.obstacles.find((o) => o.type === 'pylon'); + const pu = g.track.powerups.find((x) => x.kind === 'boost' && x.respawnAt <= Date.now()); + assert.ok(pylon && pu, 'pylon en boost-power-up aanwezig'); + p.d = pylon.d - 5; p.x = pylon.x; + g.update(25); + const slowHit = p.slowUntil > Date.now(); + p.d = pu.d; p.x = pu.x; + g.update(25); + assert.ok(slowHit, 'pylon vertraagt zoals verwacht'); + assert.ok(p.boostUntil > Date.now(), 'boost na power-up actief'); + }); + + console.log(' [engine] OK'); +} + +/* ---------------- DEEL 2: websocket-integratietest ---------------- */ +async function integration() { + console.log('\n[2] Websocket-integratietest (server + 20 clients + host)'); + const PORT = await freePort(); + const ADMIN = 'testhost123'; + const child = spawn(process.execPath, ['server/index.js'], { + cwd: path.join(__dirname, '..'), + env: { + ...process.env, + PORT: String(PORT), + ADMIN_TOKEN: ADMIN, + COUNTDOWN_S: '2', + RACE_DURATION_S: '8', + MAX_PLAYERS: '20', + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + testChild = child; + let out = ''; + child.stdout.on('data', (d) => { out += d.toString(); }); + child.stderr.on('data', (d) => { out += d.toString(); }); + + let up = false; + for (let i = 0; i < 100 && !up; i++) { + await sleep(100); + up = out.includes('Turbo Race draait'); + } + test('server start', () => assert.ok(up, 'server log niet gezien: ' + out)); + + const url = `ws://127.0.0.1:${PORT}/ws`; + function mkClient() { + const w = new WebSocket(url); + const q = []; + const waiters = []; + w.on('message', (b) => { + const m = JSON.parse(b.toString()); + const idx = currentWaiters.findIndex((wl) => wl.type === m.type); + if (idx >= 0) { + const wl = currentWaiters.splice(idx, 1)[0]; + wl.resolve(m); + } else { + q.push(m); + } + }); + const currentWaiters = []; + return { + w, + send: (o) => w.send(JSON.stringify(o)), + next: (type, timeout = 8000) => new Promise((resolve, reject) => { + const t = setTimeout(() => { + const i = currentWaiters.findIndex((wl) => wl.type === type); + if (i >= 0) currentWaiters.splice(i, 1); + reject(new Error('timeout wachten op ' + type)); + }, timeout); + const idx = q.findIndex((m) => m.type === type); + if (idx >= 0) { const m = q.splice(idx, 1)[0]; clearTimeout(t); resolve(m); return; } + currentWaiters.push({ type, resolve: (m) => { clearTimeout(t); resolve(m); } }); + }), + close: () => { try { w.close(); } catch {} }, + }; + } + + const clients = []; + for (let i = 0; i < 19; i++) { + const c = mkClient(); + await new Promise((res, rej) => { c.w.once('open', res); c.w.once('error', rej); }); + c.send({ type: 'join', name: 'Speler' + i, color: '#00f0ff' }); + clients.push(c); + } + for (let i = 0; i < clients.length; i++) { + try { await clients[i].next('joined'); } + catch (e) { console.error(` [debug] joined-timeout op client ${i}: ${e.message}`); throw e; } + } + test('19 clients ontvangen joined', () => true); + + const hostC = mkClient(); + await new Promise((res, rej) => { hostC.w.once('open', res); hostC.w.once('error', rej); }); + hostC.send({ type: 'join', name: 'Boss', color: '#ff3fa4', hostToken: ADMIN }); + await hostC.next('joined'); + test('host is de 20e speler', () => assert.strictEqual(clients.length + 1, 20)); + + const httpHealth = await new Promise((res) => { + http.get(`http://127.0.0.1:${PORT}/healthz`, (r) => { + let b = ''; r.on('data', (d) => b += d); r.on('end', () => res(b)); + }).on('error', () => res('')); + }); + test('healthz rapporteert 20 spelers', () => { + assert.ok(httpHealth.includes('"players":20'), 'healthz: ' + httpHealth); + }); + + // niet-host mag niet kunnen starten (inline, synchrone volgorde) + clients[0].send({ type: 'host_start' }); + await sleep(200); + const samples = []; + const grab = (b) => samples.push(JSON.parse(b.toString()).type); + for (const c of clients.slice(0, 5)) c.w.on('message', grab); + await sleep(400); + for (const c of clients.slice(0, 5)) c.w.removeListener('message', grab); + try { + assert.ok(!samples.includes('phase'), 'fase mag niet gewijzigd zijn door niet-host: ' + samples.join(',')); + console.log(' โœ“ niet-host kan geen race starten (server negeert)'); + } catch (e) { + failures++; + console.error(' โœ— niet-host kan geen race starten (server negeert)\n ' + e.message); + } + + hostC.send({ type: 'host_start' }); + const trackMsg = await hostC.next('track', 8000).catch(() => null); + test('host start -> server stuurt track-layout', () => + assert.ok(trackMsg && trackMsg.track && trackMsg.track.obstacles.length > 0)); + + clients.forEach((c, i) => c.send({ type: 'input', steer: (i % 3) - 1 })); + + // wacht op resultaten via een directe watcher (ontwijkt de bericht-queue) + function watchSnapshot(client, phaseWanted, ms) { + return new Promise((resolve) => { + const t = setTimeout(() => resolve(null), ms); + const h = (b) => { + const m = JSON.parse(b.toString()); + if (m.type === 'snapshot' && m.phase === phaseWanted) { + clearTimeout(t); client.w.removeListener('message', h); resolve(m); + } + }; + client.w.on('message', h); + }); + } + // countdown 2s + race 8s + ruime marge + const resultsSeen = await watchSnapshot(hostC, 'results', 26000).catch(() => null); + test('race loopt door naar results-fase', () => assert.ok(resultsSeen, 'geen results-fase gezien')); + if (resultsSeen) { + test('uitslag: 20 gerangschikte spelers', () => { + assert.strictEqual(resultsSeen.players.length, 20); + const ranks = resultsSeen.players.map((p) => p.rank).sort((a, b) => a - b); + assert.deepStrictEqual(ranks, [...Array(20).keys()].map((i) => i + 1)); + }); + test('er is een duidelijke koploper/winnaar', () => { + const first = resultsSeen.players.find((p) => p.rank === 1); + assert.ok(first, 'rank 1 bestaat'); + assert.ok(first.name.length > 0); + }); + } + + hostC.send({ type: 'ready_again' }); + await sleep(300); + const snap2 = await watchSnapshot(hostC, 'lobby', 4000); + test('ready_again -> terug naar lobby', () => assert.ok(snap2, 'geen lobby-fase gezien na ready_again')); + + clients.forEach((c) => c.close()); + hostC.close(); +} + +(async () => { + try { + await engineUnit(); + await integration(); + } catch (e) { + failures++; + console.error(' โœ— run-fatale fout: ' + e.message); + } finally { + if (testChild) { + try { testChild.kill('SIGKILL'); } catch {} + } + } + console.log(failures === 0 + ? '\nโœ… ALLE TESTS GESLAAGD' + : `\nโŒ ${failures} test(s) mislukt`); + process.exit(failures === 0 ? 0 : 1); +})(); +