From 7e659b86bf2d3325a157189e7ae402a992b4f68d Mon Sep 17 00:00:00 2001 From: Stein Helge Riise Date: Mon, 23 Feb 2026 10:42:49 +0100 Subject: [PATCH] feat: initial pong game with online lobby --- .gitignore | 4 + AGENTS.md | 47 ++ Dockerfile | 14 + Dockerfile.lobby | 10 + docker-compose.yaml | 41 + index.html | 12 + nginx.conf | 63 ++ package-lock.json | 1681 +++++++++++++++++++++++++++++++++++++++++ package.json | 22 + server/lobby.js | 455 +++++++++++ site/spill/index.html | 38 + site/spill/style.css | 152 ++++ src/App.jsx | 377 +++++++++ src/lobbyClient.js | 315 ++++++++ src/main.jsx | 5 + src/pongGame.js | 881 +++++++++++++++++++++ src/style.css | 460 +++++++++++ vite.config.js | 10 + 18 files changed, 4587 insertions(+) create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 Dockerfile create mode 100644 Dockerfile.lobby create mode 100644 docker-compose.yaml create mode 100644 index.html create mode 100644 nginx.conf create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 server/lobby.js create mode 100644 site/spill/index.html create mode 100644 site/spill/style.css create mode 100644 src/App.jsx create mode 100644 src/lobbyClient.js create mode 100644 src/main.jsx create mode 100644 src/pongGame.js create mode 100644 src/style.css create mode 100644 vite.config.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dbc6217 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +.DS_Store +.env diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..003d3d2 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,47 @@ +# Repository Guidelines + +## Project Structure & Module Organization +This repository is currently an empty scaffold (`/home/steinhelge/Gitea/demo`) with no source, test, or asset directories yet. As the project grows, use a consistent layout: + +- `src/` for application code +- `tests/` for automated tests +- `docs/` for design notes and contributor docs +- `assets/` for static files (images, sample data) + +Keep modules focused and grouped by feature (for example, `src/auth/`, `src/api/`). + +## Build, Test, and Development Commands +No build or test tooling is configured yet. When tooling is added, document the exact commands here and in the project README. Prefer simple, standard entry points such as: + +- `make test` or `npm test` to run all tests +- `make lint` or `npm run lint` for static checks +- `make dev` or `npm run dev` for local development + +If you introduce a new command, include a short description in the PR. + +## Coding Style & Naming Conventions +Use consistent formatting and enforce it with tooling (for example, `prettier`, `eslint`, `black`, or `ruff`, depending on the language stack adopted). Until a language is chosen: + +- Use 2 or 4 spaces consistently (no tabs) +- Prefer descriptive names (`user_service`, `fetchOrders`) +- Name files by language convention (`kebab-case`, `snake_case`, or `PascalCase` where appropriate) + +Keep functions small and modules cohesive. + +## Testing Guidelines +Add tests alongside new features. Place unit tests in `tests/` or next to code if the chosen framework prefers co-location. Use clear names that describe behavior, e.g., `test_login_rejects_invalid_password`. + +Aim for meaningful coverage of core logic before merging. + +## Commit & Pull Request Guidelines +Git history is not available in this directory, so no local commit convention can be inferred. Use clear, imperative commit messages (prefer Conventional Commits, e.g., `feat: add user validation`). + +For pull requests: + +- Describe the change and why it is needed +- Link related issues/tasks +- Include test evidence (command output or summary) +- Add screenshots for UI changes + +## Agent-Specific Notes +When using coding agents, keep changes small, reviewable, and scoped to one concern per PR. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f71b402 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +FROM node:20-alpine AS build +WORKDIR /app + +COPY package.json package-lock.json* ./ +RUN npm install + +COPY . . +RUN npm run build + +FROM nginx:1.27-alpine +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build /app/dist/ /usr/share/nginx/html/pong/ +COPY site/spill/ /usr/share/nginx/html/spill/ +EXPOSE 80 diff --git a/Dockerfile.lobby b/Dockerfile.lobby new file mode 100644 index 0000000..040c4c7 --- /dev/null +++ b/Dockerfile.lobby @@ -0,0 +1,10 @@ +FROM node:20-alpine +WORKDIR /app + +COPY package.json package-lock.json* ./ +RUN npm install --omit=dev + +COPY server ./server + +EXPOSE 8787 +CMD ["node", "server/lobby.js"] diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..87543eb --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,41 @@ +services: + spill: + build: + context: . + dockerfile: Dockerfile + container_name: spill + restart: unless-stopped + networks: + - edge + labels: + - "traefik.enable=true" + - "traefik.docker.network=edge" + - "traefik.http.routers.spill.rule=Host(`spill.theriise.net`)" + - "traefik.http.routers.spill.entrypoints=websecure" + - "traefik.http.routers.spill.tls.certresolver=le" + - "traefik.http.routers.pong.rule=Host(`pong.theriise.net`)" + - "traefik.http.routers.pong.entrypoints=websecure" + - "traefik.http.routers.pong.tls.certresolver=le" + - "traefik.http.services.spill.loadbalancer.server.port=80" + - "com.centurylinklabs.watchtower.enable=false" + depends_on: + - lobby + + lobby: + build: + context: . + dockerfile: Dockerfile.lobby + container_name: spill-lobby + restart: unless-stopped + networks: + - edge + environment: + - HOST=0.0.0.0 + - PORT=8787 + labels: + - "traefik.enable=false" + - "com.centurylinklabs.watchtower.enable=false" + +networks: + edge: + external: true diff --git a/index.html b/index.html new file mode 100644 index 0000000..a1fae33 --- /dev/null +++ b/index.html @@ -0,0 +1,12 @@ + + + + + + Pong | pong.theriise.net + + +
+ + + diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..f3a4eba --- /dev/null +++ b/nginx.conf @@ -0,0 +1,63 @@ +server { + listen 80; + server_name spill.theriise.net; + + root /usr/share/nginx/html/spill; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } + + location = /healthz { + access_log off; + return 200 "ok\n"; + add_header Content-Type text/plain; + } +} + +server { + listen 80; + server_name pong.theriise.net; + + root /usr/share/nginx/html/pong; + index index.html; + + location /ws { + proxy_pass http://lobby:8787/ws; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location / { + try_files $uri $uri/ /index.html; + } + + location = /healthz { + access_log off; + return 200 "ok\n"; + add_header Content-Type text/plain; + } +} + +server { + listen 80 default_server; + server_name _; + + root /usr/share/nginx/html/spill; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } + + location = /healthz { + access_log off; + return 200 "ok\n"; + add_header Content-Type text/plain; + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..3e8997a --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1681 @@ +{ + "name": "spill-pong", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "spill-pong", + "version": "1.0.0", + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@vitejs/plugin-react": "^5.0.0", + "vite": "^5.4.14" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.4.tgz", + "integrity": "sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", + "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001774", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001774.tgz", + "integrity": "sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.302", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz", + "integrity": "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..f7ce980 --- /dev/null +++ b/package.json @@ -0,0 +1,22 @@ +{ + "name": "spill-pong", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --host 0.0.0.0 --port 5173", + "lobby": "node server/lobby.js", + "build": "vite build", + "preview": "vite preview --host 0.0.0.0 --port 4173", + "dev:all": "sh -c 'node server/lobby.js & vite --host 0.0.0.0 --port 5173'" + }, + "devDependencies": { + "@vitejs/plugin-react": "^5.0.0", + "vite": "^5.4.14" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "ws": "^8.18.3" + } +} diff --git a/server/lobby.js b/server/lobby.js new file mode 100644 index 0000000..8d187a6 --- /dev/null +++ b/server/lobby.js @@ -0,0 +1,455 @@ +import http from "node:http"; +import { randomUUID } from "node:crypto"; +import { WebSocketServer } from "ws"; + +const PORT = Number(process.env.PORT || 8787); +const HOST = process.env.HOST || "0.0.0.0"; + +const clients = new Map(); // clientId -> { ws, name, status, matchId } +const socketToClientId = new WeakMap(); +const invites = new Map(); // inviteId -> { id, fromId, toId, status, createdAt } +const matches = new Map(); // matchId -> { id, aId, bId, createdAt, startedAt, ready: { [clientId]: boolean } } + +function nowIso() { + return new Date().toISOString(); +} + +function send(ws, payload) { + if (ws.readyState !== 1) return; + ws.send(JSON.stringify(payload)); +} + +function getUserView(clientId) { + const client = clients.get(clientId); + if (!client) return null; + return { + id: clientId, + name: client.name, + status: client.status, + matchId: client.matchId || null + }; +} + +function broadcastLobbyState() { + const users = [...clients.keys()] + .map(getUserView) + .filter(Boolean) + .sort((a, b) => a.name.localeCompare(b.name, "no", { sensitivity: "base" })); + + for (const { ws } of clients.values()) { + send(ws, { type: "lobby_state", users, ts: nowIso() }); + } +} + +function sanitizeName(input) { + const text = String(input || "").trim().replace(/\s+/g, " "); + return text.slice(0, 24); +} + +function isNameTaken(name, exceptClientId = null) { + const lower = name.toLocaleLowerCase("no"); + for (const [id, client] of clients) { + if (id === exceptClientId) continue; + if (client.name.toLocaleLowerCase("no") === lower) return true; + } + return false; +} + +function resetBusyStatuses(clientIds) { + for (const clientId of clientIds) { + const client = clients.get(clientId); + if (!client) continue; + if (client.status !== "in_match") { + client.status = "idle"; + client.matchId = null; + } + } +} + +function cancelInvitesForClient(clientId) { + const affectedClientIds = new Set([clientId]); + + for (const [inviteId, invite] of invites) { + if (invite.status !== "pending") continue; + if (invite.fromId !== clientId && invite.toId !== clientId) continue; + + invite.status = "canceled"; + const otherId = invite.fromId === clientId ? invite.toId : invite.fromId; + affectedClientIds.add(otherId); + + const other = clients.get(otherId); + if (other) { + send(other.ws, { + type: "invite_canceled", + inviteId, + by: clientId, + ts: nowIso() + }); + } + } + + resetBusyStatuses(affectedClientIds); +} + +function endMatch(matchId, reason = "left") { + const match = matches.get(matchId); + if (!match) return; + matches.delete(matchId); + + for (const id of [match.aId, match.bId]) { + const client = clients.get(id); + if (!client) continue; + client.matchId = null; + client.status = "idle"; + send(client.ws, { type: "match_ended", matchId, reason, ts: nowIso() }); + } +} + +function getMatchView(match, forClientId) { + const opponentId = match.aId === forClientId ? match.bId : match.aId; + const opponent = getUserView(opponentId); + return { + matchId: match.id, + opponent: opponent ? { id: opponent.id, name: opponent.name } : null, + role: match.aId === forClientId ? "host" : "guest", + ready: { + self: Boolean(match.ready?.[forClientId]), + opponent: Boolean(match.ready?.[opponentId]) + }, + started: Boolean(match.startedAt) + }; +} + +function cleanupClient(clientId) { + const client = clients.get(clientId); + if (!client) return; + + cancelInvitesForClient(clientId); + if (client.matchId) endMatch(client.matchId, "disconnect"); + clients.delete(clientId); + broadcastLobbyState(); +} + +function requireClient(ws) { + const clientId = socketToClientId.get(ws); + const client = clientId ? clients.get(clientId) : null; + return clientId && client ? { clientId, client } : null; +} + +function onHello(ws, message) { + const name = sanitizeName(message.name); + if (!name) { + send(ws, { type: "error", code: "invalid_name", message: "Ugyldig navn" }); + return; + } + + const existing = requireClient(ws); + if (existing) { + if (isNameTaken(name, existing.clientId)) { + send(ws, { type: "error", code: "name_taken", message: "Navnet er opptatt" }); + return; + } + existing.client.name = name; + send(ws, { type: "hello_ok", selfId: existing.clientId, name }); + broadcastLobbyState(); + return; + } + + if (isNameTaken(name)) { + send(ws, { type: "error", code: "name_taken", message: "Navnet er opptatt" }); + return; + } + + const clientId = randomUUID(); + clients.set(clientId, { + ws, + name, + status: "idle", + matchId: null + }); + socketToClientId.set(ws, clientId); + + send(ws, { type: "hello_ok", selfId: clientId, name }); + broadcastLobbyState(); +} + +function onInviteSend(ws, message) { + const ctx = requireClient(ws); + if (!ctx) { + send(ws, { type: "error", code: "not_authenticated", message: "Send hello først" }); + return; + } + + const { clientId, client } = ctx; + const toId = String(message.toId || ""); + if (!toId || toId === clientId) { + send(ws, { type: "error", code: "invalid_target", message: "Ugyldig motstander" }); + return; + } + + const target = clients.get(toId); + if (!target) { + send(ws, { type: "error", code: "target_offline", message: "Motstander er ikke pålogget" }); + return; + } + + if (client.status !== "idle" || target.status !== "idle") { + send(ws, { type: "error", code: "target_busy", message: "En av spillerne er opptatt" }); + return; + } + + const duplicatePending = [...invites.values()].find( + (invite) => + invite.status === "pending" && + ((invite.fromId === clientId && invite.toId === toId) || + (invite.fromId === toId && invite.toId === clientId)) + ); + if (duplicatePending) { + send(ws, { type: "error", code: "invite_exists", message: "Invitasjon finnes allerede" }); + return; + } + + const inviteId = randomUUID(); + invites.set(inviteId, { + id: inviteId, + fromId: clientId, + toId, + status: "pending", + createdAt: nowIso() + }); + + client.status = "inviting"; + target.status = "invited"; + + send(client.ws, { type: "invite_sent", inviteId, toId, toName: target.name, ts: nowIso() }); + send(target.ws, { + type: "invite_received", + inviteId, + fromId: clientId, + fromName: client.name, + ts: nowIso() + }); + + broadcastLobbyState(); +} + +function onInviteReply(ws, message) { + const ctx = requireClient(ws); + if (!ctx) { + send(ws, { type: "error", code: "not_authenticated", message: "Send hello først" }); + return; + } + + const inviteId = String(message.inviteId || ""); + const accept = Boolean(message.accept); + const invite = invites.get(inviteId); + if (!invite || invite.status !== "pending") { + send(ws, { type: "error", code: "invite_missing", message: "Invitasjonen finnes ikke" }); + return; + } + + if (invite.toId !== ctx.clientId) { + send(ws, { type: "error", code: "not_invited", message: "Du kan ikke svare på denne" }); + return; + } + + const fromClient = clients.get(invite.fromId); + const toClient = clients.get(invite.toId); + if (!fromClient || !toClient) { + invite.status = "canceled"; + resetBusyStatuses([invite.fromId, invite.toId]); + broadcastLobbyState(); + return; + } + + if (!accept) { + invite.status = "declined"; + fromClient.status = "idle"; + toClient.status = "idle"; + send(fromClient.ws, { + type: "invite_declined", + inviteId, + byId: invite.toId, + byName: toClient.name, + ts: nowIso() + }); + send(toClient.ws, { type: "invite_replied", inviteId, accept: false, ts: nowIso() }); + broadcastLobbyState(); + return; + } + + invite.status = "accepted"; + const matchId = randomUUID(); + matches.set(matchId, { + id: matchId, + aId: invite.fromId, + bId: invite.toId, + createdAt: nowIso(), + startedAt: null, + ready: { + [invite.fromId]: false, + [invite.toId]: false + } + }); + + fromClient.status = "in_match"; + toClient.status = "in_match"; + fromClient.matchId = matchId; + toClient.matchId = matchId; + + // Cancel any other pending invites involving these two players. + for (const otherInvite of invites.values()) { + if (otherInvite.id === inviteId || otherInvite.status !== "pending") continue; + if ( + [otherInvite.fromId, otherInvite.toId].includes(invite.fromId) || + [otherInvite.fromId, otherInvite.toId].includes(invite.toId) + ) { + otherInvite.status = "canceled"; + } + } + + const match = matches.get(matchId); + send(fromClient.ws, { type: "match_created", ...getMatchView(match, invite.fromId), ts: nowIso() }); + send(toClient.ws, { type: "match_created", ...getMatchView(match, invite.toId), ts: nowIso() }); + + broadcastLobbyState(); +} + +function onLeaveMatch(ws) { + const ctx = requireClient(ws); + if (!ctx) return; + if (!ctx.client.matchId) return; + endMatch(ctx.client.matchId, "left"); + broadcastLobbyState(); +} + +function onMatchReady(ws, message) { + const ctx = requireClient(ws); + if (!ctx) return; + if (!ctx.client.matchId) { + send(ws, { type: "error", code: "no_match", message: "Du er ikke i en match" }); + return; + } + + const match = matches.get(ctx.client.matchId); + if (!match) return; + + match.ready[ctx.clientId] = Boolean(message.ready); + + const a = clients.get(match.aId); + const b = clients.get(match.bId); + if (!a || !b) return; + + send(a.ws, { type: "match_ready_state", ...getMatchView(match, match.aId), ts: nowIso() }); + send(b.ws, { type: "match_ready_state", ...getMatchView(match, match.bId), ts: nowIso() }); + + if (!match.startedAt && match.ready[match.aId] && match.ready[match.bId]) { + match.startedAt = nowIso(); + send(a.ws, { type: "match_started", ...getMatchView(match, match.aId), ts: nowIso() }); + send(b.ws, { type: "match_started", ...getMatchView(match, match.bId), ts: nowIso() }); + } +} + +function onMatchSignal(ws, message) { + const ctx = requireClient(ws); + if (!ctx) return; + if (!ctx.client.matchId) return; + + const match = matches.get(ctx.client.matchId); + if (!match || !match.startedAt) return; + + const opponentId = match.aId === ctx.clientId ? match.bId : match.aId; + const opponent = clients.get(opponentId); + if (!opponent) return; + + send(opponent.ws, { + type: "match_signal", + matchId: match.id, + fromId: ctx.clientId, + payload: message.payload ?? null, + ts: nowIso() + }); +} + +function onMessage(ws, raw) { + let message; + try { + message = JSON.parse(String(raw)); + } catch { + send(ws, { type: "error", code: "bad_json", message: "Ugyldig JSON" }); + return; + } + + if (!message || typeof message !== "object") { + send(ws, { type: "error", code: "bad_message", message: "Ugyldig melding" }); + return; + } + + switch (message.type) { + case "hello": + onHello(ws, message); + break; + case "invite_send": + onInviteSend(ws, message); + break; + case "invite_reply": + onInviteReply(ws, message); + break; + case "leave_match": + onLeaveMatch(ws); + break; + case "match_ready": + onMatchReady(ws, message); + break; + case "match_signal": + onMatchSignal(ws, message); + break; + case "ping": + send(ws, { type: "pong", ts: nowIso() }); + break; + default: + send(ws, { type: "error", code: "unknown_type", message: "Ukjent meldingstype" }); + } +} + +const server = http.createServer((req, res) => { + if (req.url === "/healthz") { + res.writeHead(200, { "content-type": "text/plain" }); + res.end("ok\n"); + return; + } + + res.writeHead(404, { "content-type": "application/json" }); + res.end(JSON.stringify({ error: "not_found" })); +}); + +const wss = new WebSocketServer({ noServer: true }); + +server.on("upgrade", (req, socket, head) => { + if (!req.url || !req.url.startsWith("/ws")) { + socket.destroy(); + return; + } + + wss.handleUpgrade(req, socket, head, (ws) => { + wss.emit("connection", ws, req); + }); +}); + +wss.on("connection", (ws) => { + send(ws, { type: "connected", ts: nowIso() }); + + ws.on("message", (data) => onMessage(ws, data)); + ws.on("close", () => { + const clientId = socketToClientId.get(ws); + if (clientId) cleanupClient(clientId); + }); + ws.on("error", () => { + const clientId = socketToClientId.get(ws); + if (clientId) cleanupClient(clientId); + }); +}); + +server.listen(PORT, HOST, () => { + console.log(`Lobby listening on http://${HOST}:${PORT}`); +}); diff --git a/site/spill/index.html b/site/spill/index.html new file mode 100644 index 0000000..65a5328 --- /dev/null +++ b/site/spill/index.html @@ -0,0 +1,38 @@ + + + + + + Spill | Theriise + + + +
+
+

theriise.net

+

Spill

+

+ En liten samling nettleserspill. Foreløpig er det bare Pong, men flere spill kan legges til her etter hvert. +

+
+ +
+
+

Tilgjengelige spill

+ 1 spill +
+ + +
+

Arkade

+

Pong

+

+ Klassisk Pong i nettleseren med CPU/2P, touch-styring og mobilvennlig startknapp. +

+
+ Åpne +
+
+
+ + diff --git a/site/spill/style.css b/site/spill/style.css new file mode 100644 index 0000000..5e38995 --- /dev/null +++ b/site/spill/style.css @@ -0,0 +1,152 @@ +:root { + color-scheme: dark; + --bg: #061018; + --bg-2: #0a1d2a; + --panel: rgba(255, 255, 255, 0.06); + --panel-border: rgba(255, 255, 255, 0.12); + --text: #edf6ff; + --muted: #b5c9db; + --accent: #7bd3ff; + --accent-2: #00e2aa; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + color: var(--text); + background: + radial-gradient(circle at 14% 12%, rgba(123, 211, 255, 0.2), transparent 46%), + radial-gradient(circle at 88% 18%, rgba(0, 226, 170, 0.16), transparent 42%), + linear-gradient(180deg, var(--bg) 0%, var(--bg-2) 100%); +} + +.hub { + width: min(100%, 960px); + margin: 0 auto; + padding: 1.25rem; + display: grid; + gap: 1rem; +} + +.hero, +.games { + background: var(--panel); + border: 1px solid var(--panel-border); + border-radius: 18px; + backdrop-filter: blur(12px); + padding: 1rem; +} + +.eyebrow { + margin: 0; + color: var(--muted); + font-size: 0.85rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +h1 { + margin: 0.25rem 0 0; + font-size: clamp(2rem, 5vw, 3rem); + line-height: 0.95; +} + +.lead { + margin: 0.85rem 0 0; + color: var(--muted); + max-width: 58ch; + line-height: 1.45; +} + +.section-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + margin-bottom: 0.75rem; +} + +h2 { + margin: 0; + font-size: 1.1rem; +} + +.pill { + border: 1px solid rgba(123, 211, 255, 0.35); + background: rgba(123, 211, 255, 0.14); + color: #dff4ff; + border-radius: 999px; + padding: 0.2rem 0.55rem; + font-size: 0.8rem; +} + +.game-card { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 0.8rem; + align-items: center; + padding: 0.95rem; + border-radius: 14px; + border: 1px solid rgba(255, 255, 255, 0.1); + background: linear-gradient(180deg, rgba(255,255,255,0.04), rgba(255,255,255,0.02)); + color: inherit; + text-decoration: none; + transition: transform 120ms ease, border-color 120ms ease, background 120ms ease; +} + +.game-card:hover, +.game-card:focus-visible { + transform: translateY(-2px); + border-color: rgba(123, 211, 255, 0.35); + background: linear-gradient(180deg, rgba(123,211,255,0.08), rgba(0,226,170,0.06)); + outline: none; +} + +.game-tag { + margin: 0; + color: var(--accent); + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +h3 { + margin: 0.2rem 0 0; + font-size: 1.35rem; +} + +.game-desc { + margin: 0.35rem 0 0; + color: var(--muted); + line-height: 1.35; +} + +.cta { + border-radius: 999px; + border: 1px solid rgba(0, 226, 170, 0.35); + background: rgba(0, 226, 170, 0.12); + color: #d7fff4; + padding: 0.38rem 0.75rem; + font-weight: 700; + white-space: nowrap; +} + +@media (max-width: 640px) { + .hub { + padding: 0.8rem; + } + + .game-card { + grid-template-columns: 1fr; + align-items: start; + } + + .cta { + justify-self: start; + } +} diff --git a/src/App.jsx b/src/App.jsx new file mode 100644 index 0000000..588843c --- /dev/null +++ b/src/App.jsx @@ -0,0 +1,377 @@ +import { useEffect, useRef, useState } from "react"; +import { createLobbyClient, getDefaultLobbyState, getSavedLobbyName } from "./lobbyClient.js"; +import { createPongGame, getInitialUiState } from "./pongGame.js"; + +export default function App() { + const canvasRef = useRef(null); + const gameRef = useRef(null); + const lobbyRef = useRef(null); + const [ui, setUi] = useState(getInitialUiState); + const [lobby, setLobby] = useState(getDefaultLobbyState); + const [lobbyName, setLobbyName] = useState(() => getSavedLobbyName()); + const onlineGameSessionRef = useRef(null); + + useEffect(() => { + if (!canvasRef.current) return undefined; + + const game = createPongGame({ + canvas: canvasRef.current, + onUiChange(nextUi) { + setUi((prev) => ({ ...prev, ...nextUi })); + } + }); + gameRef.current = game; + + return () => { + gameRef.current?.destroy(); + gameRef.current = null; + }; + }, []); + + useEffect(() => { + const client = createLobbyClient({ + onState(next) { + setLobby(next); + }, + onMatchSignal(message) { + gameRef.current?.handleOnlineSignal(message.payload); + } + }); + lobbyRef.current = client; + + return () => { + client.disconnect(); + lobbyRef.current = null; + }; + }, []); + + useEffect(() => { + const match = lobby.currentMatch; + const activeSession = onlineGameSessionRef.current; + + if (!match || !match.started) { + if (activeSession) { + gameRef.current?.endOnlineMatch(); + onlineGameSessionRef.current = null; + } + return; + } + + if (activeSession?.matchId === match.matchId) return; + if (!gameRef.current || !lobbyRef.current) return; + + gameRef.current.startOnlineMatch(match, (payload) => { + lobbyRef.current?.sendMatchSignal(payload); + }); + onlineGameSessionRef.current = { matchId: match.matchId }; + }, [lobby.currentMatch]); + + const modeLabel = + ui.mode === "cpu" ? "1P vs CPU" : ui.mode === "2p" ? "2P lokal" : "Online 2P"; + const soundLabel = ui.soundEnabled ? "På" : "Av"; + const otherUsers = lobby.users.filter((user) => user.id !== lobby.selfId); + const availableUsers = otherUsers.filter((user) => user.status === "idle"); + const onlineRole = lobby.currentMatch?.role || null; + const onlineOpponentName = lobby.currentMatch?.opponent?.name || "Motstander"; + const leftPlayerLabel = + ui.mode === "online" ? (onlineRole === "guest" ? onlineOpponentName : "Deg") : "Deg"; + const rightPlayerLabel = + ui.mode === "online" ? (onlineRole === "guest" ? "Deg" : onlineOpponentName) : "CPU"; + + return ( +
+
+

Pong

+

Spill mot CPU i nettleseren

+
+
+ {leftPlayerLabel} + {ui.scores.player} + + Treff: {ui.hits.player} + +
+
+ {rightPlayerLabel} + {ui.scores.cpu} + + Treff: {ui.hits.cpu} + +
+
+
+

+ Modus {modeLabel} +

+

+ Beste score {ui.bestScore} +

+

+ Lyd {soundLabel} +

+

+ Volum + gameRef.current?.setVolumePercent(event.currentTarget.value)} + /> + {ui.volumePercent}% +

+
+
+

+ W/S eller / +

+

+ I/K for spiller 2 (2P-modus) +

+

+ M bytt modus, O lyd av/på +

+

+ Space start/pause, R restart +

+

Mobil: dra fingeren opp/ned på hver banehalvdel

+
+ + + + +
+
+ +
+
+

Online 2P (beta)

+ + {lobby.connected + ? "Tilkoblet" + : lobby.reconnecting + ? "Rekobler..." + : lobby.connecting + ? "Kobler til..." + : "Frakoblet"} + +
+ +

+ Lobby med navn, online-liste og invitasjoner er på plass. Nettkamp-synk kommer neste steg. +

+ +
+ + setLobbyName(event.currentTarget.value)} + placeholder="Ditt navn" + /> +
+ +
+ + +
+ + {lobby.error ?

{lobby.error}

: null} + + {lobby.incomingInvite ? ( +
+

+ Invitasjon fra {lobby.incomingInvite.fromName} +

+
+ + +
+
+ ) : null} + + {lobby.outgoingInvite ? ( +
+

+ Invitasjon sendt til {lobby.outgoingInvite.toName} +

+
+ ) : null} + + {lobby.currentMatch ? ( +
+

+ Match mot {lobby.currentMatch.opponent.name} ({lobby.currentMatch.role}) +

+ {!lobby.currentMatch.started ? ( + <> +

+ Trykk klar. Kampen starter når begge er klare. +

+
+ + Du: {lobby.currentMatch.ready?.self ? "Klar" : "Ikke klar"} + + + Motstander: {lobby.currentMatch.ready?.opponent ? "Klar" : "Ikke klar"} + +
+ + ) : ( +

Nettkamp er aktiv. Host styrer spillets fysikk.

+ )} +
+ {!lobby.currentMatch.started ? ( + + ) : null} + +
+
+ ) : null} + +
+
+ Spillere online + {otherUsers.length} +
+
    + {otherUsers.length === 0 ? ( +
  • Ingen andre online akkurat nå
  • + ) : ( + otherUsers.map((user) => { + const canInvite = + lobby.connected && + user.status === "idle" && + !lobby.outgoingInvite && + !lobby.incomingInvite && + !lobby.currentMatch; + return ( +
  • +
    + {user.name} + + {user.status === "idle" + ? "Ledig" + : user.status === "in_match" + ? "I kamp" + : user.status === "inviting" + ? "Inviterer" + : "Har invitasjon"} + +
    + +
  • + ); + }) + )} +
+ {availableUsers.length > 0 ? ( +

Ledige nå: {availableUsers.map((u) => u.name).join(", ")}

+ ) : null} +
+ + {lobby.notices.length > 0 ? ( +
    + {lobby.notices.map((notice) => ( +
  • + {notice.text} +
  • + ))} +
+ ) : null} +
+
+ +
+ +
+
+
+ ); +} diff --git a/src/lobbyClient.js b/src/lobbyClient.js new file mode 100644 index 0000000..6e83b32 --- /dev/null +++ b/src/lobbyClient.js @@ -0,0 +1,315 @@ +const NAME_KEY = "spill.pong.lobbyName"; + +export function getSavedLobbyName() { + try { + return window.localStorage.getItem(NAME_KEY) || ""; + } catch { + return ""; + } +} + +export function saveLobbyName(name) { + try { + window.localStorage.setItem(NAME_KEY, name); + } catch { + // Ignore storage failures. + } +} + +export function getDefaultLobbyState() { + return { + connected: false, + connecting: false, + selfId: null, + selfName: "", + users: [], + error: "", + notices: [], + reconnecting: false, + incomingInvite: null, + outgoingInvite: null, + currentMatch: null + }; +} + +function wsUrlFromWindow() { + const { protocol, host } = window.location; + const wsProtocol = protocol === "https:" ? "wss:" : "ws:"; + return `${wsProtocol}//${host}/ws`; +} + +export function createLobbyClient({ onState, onNotice, onError, onMatchSignal } = {}) { + let ws = null; + let state = getDefaultLobbyState(); + let reconnectTimer = null; + let manualDisconnect = false; + let suppressNextCloseReconnect = false; + let lastRequestedName = ""; + let reconnectAttempt = 0; + let pingTimer = null; + + function clearReconnectTimer() { + if (reconnectTimer) { + window.clearTimeout(reconnectTimer); + reconnectTimer = null; + } + } + + function clearPingTimer() { + if (pingTimer) { + window.clearInterval(pingTimer); + pingTimer = null; + } + } + + function emit(patch) { + state = { ...state, ...patch }; + onState?.(state); + } + + function addNotice(text, kind = "info") { + const item = { id: crypto.randomUUID(), text, kind, ts: Date.now() }; + const notices = [item, ...state.notices].slice(0, 6); + emit({ notices }); + onNotice?.(item); + } + + function send(payload) { + if (!ws || ws.readyState !== WebSocket.OPEN) return false; + ws.send(JSON.stringify(payload)); + return true; + } + + function startPingLoop() { + clearPingTimer(); + pingTimer = window.setInterval(() => { + send({ type: "ping" }); + }, 15000); + } + + function scheduleReconnect() { + if (manualDisconnect) return; + if (!lastRequestedName) return; + clearReconnectTimer(); + + reconnectAttempt += 1; + const delayMs = Math.min(5000, 600 * 2 ** Math.min(reconnectAttempt, 3)); + emit({ reconnecting: true, connecting: true, connected: false }); + addNotice(`Tilkobling mistet. Prøver igjen om ${Math.round(delayMs / 1000)}s`, "warn"); + + reconnectTimer = window.setTimeout(() => { + connect(lastRequestedName); + }, delayMs); + } + + function handleMessage(message) { + switch (message.type) { + case "connected": + return; + case "hello_ok": + emit({ + connected: true, + connecting: false, + reconnecting: false, + selfId: message.selfId, + selfName: message.name, + error: "" + }); + reconnectAttempt = 0; + addNotice(`Pålogget som ${message.name}`); + return; + case "lobby_state": + emit({ users: Array.isArray(message.users) ? message.users : [] }); + return; + case "invite_sent": + emit({ + outgoingInvite: { + inviteId: message.inviteId, + toId: message.toId, + toName: message.toName + } + }); + addNotice(`Invitasjon sendt til ${message.toName}`); + return; + case "invite_received": + emit({ + incomingInvite: { + inviteId: message.inviteId, + fromId: message.fromId, + fromName: message.fromName + } + }); + addNotice(`Invitasjon fra ${message.fromName}`); + return; + case "invite_replied": + emit({ incomingInvite: null }); + return; + case "invite_declined": + emit({ outgoingInvite: null }); + addNotice(`${message.byName || "Motstander"} avslo invitasjonen`, "warn"); + return; + case "invite_canceled": + if (state.incomingInvite?.inviteId === message.inviteId) { + emit({ incomingInvite: null }); + addNotice("Invitasjonen ble trukket tilbake", "warn"); + } + if (state.outgoingInvite?.inviteId === message.inviteId) { + emit({ outgoingInvite: null }); + } + return; + case "match_started": + case "match_created": + case "match_ready_state": + emit({ + incomingInvite: null, + outgoingInvite: null, + currentMatch: { + matchId: message.matchId, + opponent: message.opponent, + role: message.role, + ready: message.ready || { self: false, opponent: false }, + started: Boolean(message.started) + } + }); + if (message.type === "match_created") { + addNotice(`Match opprettet mot ${message.opponent?.name || "motstander"}`); + } + if (message.type === "match_started") { + addNotice(`Kamp startet mot ${message.opponent?.name || "motstander"}`); + } + return; + case "match_ended": + emit({ currentMatch: null }); + addNotice( + message.reason === "disconnect" + ? "Motstander mistet forbindelsen" + : "Kamp avsluttet", + "warn" + ); + return; + case "match_signal": + onMatchSignal?.(message); + return; + case "error": + emit({ error: message.message || "Feil" }); + onError?.(message); + return; + default: + return; + } + } + + function connect(name, url = wsUrlFromWindow()) { + const cleanName = String(name || "").trim(); + if (!cleanName) { + emit({ error: "Skriv inn et navn først" }); + return; + } + + if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) { + suppressNextCloseReconnect = true; + ws.close(); + } + + manualDisconnect = false; + lastRequestedName = cleanName; + clearReconnectTimer(); + emit({ + connecting: true, + reconnecting: reconnectAttempt > 0, + connected: false, + error: "", + selfName: cleanName + }); + saveLobbyName(cleanName); + + ws = new WebSocket(url); + + ws.addEventListener("open", () => { + startPingLoop(); + send({ type: "hello", name: cleanName }); + }); + + ws.addEventListener("message", (event) => { + try { + handleMessage(JSON.parse(event.data)); + } catch { + emit({ error: "Ugyldig svar fra server" }); + } + }); + + ws.addEventListener("close", () => { + clearPingTimer(); + const lostActiveMatch = Boolean(state.currentMatch); + emit({ + connecting: false, + connected: false, + selfId: null, + users: [], + reconnecting: false, + incomingInvite: null, + outgoingInvite: null, + currentMatch: null + }); + if (lostActiveMatch) { + addNotice("Match avsluttet fordi forbindelsen ble brutt", "warn"); + } + if (suppressNextCloseReconnect) { + suppressNextCloseReconnect = false; + return; + } + scheduleReconnect(); + }); + + ws.addEventListener("error", () => { + emit({ error: "Kunne ikke koble til lobby-server" }); + }); + } + + function disconnect() { + manualDisconnect = true; + reconnectAttempt = 0; + suppressNextCloseReconnect = false; + clearReconnectTimer(); + clearPingTimer(); + ws?.close(); + ws = null; + emit({ reconnecting: false }); + } + + function invite(toId) { + emit({ error: "" }); + send({ type: "invite_send", toId }); + } + + function replyInvite(inviteId, accept) { + emit({ error: "" }); + send({ type: "invite_reply", inviteId, accept }); + if (!accept) emit({ incomingInvite: null }); + } + + function leaveMatch() { + send({ type: "leave_match" }); + } + + function setReady(ready) { + send({ type: "match_ready", ready: Boolean(ready) }); + } + + function sendMatchSignal(payload) { + return send({ type: "match_signal", payload }); + } + + return { + connect, + disconnect, + invite, + replyInvite, + leaveMatch, + setReady, + sendMatchSignal, + getState() { + return state; + } + }; +} diff --git a/src/main.jsx b/src/main.jsx new file mode 100644 index 0000000..2696122 --- /dev/null +++ b/src/main.jsx @@ -0,0 +1,5 @@ +import ReactDOM from "react-dom/client"; +import App from "./App.jsx"; +import "./style.css"; + +ReactDOM.createRoot(document.getElementById("root")).render(); diff --git a/src/pongGame.js b/src/pongGame.js new file mode 100644 index 0000000..57cfaa2 --- /dev/null +++ b/src/pongGame.js @@ -0,0 +1,881 @@ +const BEST_SCORE_KEY = "spill.pong.bestScore"; +const VOLUME_KEY = "spill.pong.volume"; + +export function getInitialUiState() { + return { + scores: { player: 0, cpu: 0 }, + hits: { player: 0, cpu: 0 }, + mode: "cpu", + bestScore: 0, + soundEnabled: true, + volumePercent: 60, + overlayVisible: true, + overlayHtml: "Trykk Space eller Start for å starte", + startButtonLabel: "Start" + }; +} + +export function createPongGame({ canvas, onUiChange }) { + if (!canvas) { + throw new Error("createPongGame requires a canvas element"); + } + + const ctx = canvas.getContext("2d"); + if (!ctx) { + throw new Error("2D context is not available for the game canvas"); + } + + const audio = { ctx: null }; + let rafId = null; + let destroyed = false; + + const state = { + running: false, + paused: true, + winner: null, + keys: new Set(), + scores: { player: 0, cpu: 0 }, + hits: { player: 0, cpu: 0 }, + mode: "cpu", + bestScore: 0, + soundEnabled: true, + volume: 0.6, + overlay: { + visible: true, + html: "" + }, + touches: { + leftId: null, + rightId: null + }, + online: { + active: false, + role: null, // "host" | "guest" + matchId: null, + opponentName: "", + sendSignal: null, + remoteInput: { + up: false, + down: false, + pointerNormY: null + }, + guestSentInput: { + up: false, + down: false + }, + lastSnapshotSentAt: 0 + }, + paddle: { + width: 14, + height: 110, + speed: 430, + margin: 24, + playerY: canvas.height / 2 - 55, + cpuY: canvas.height / 2 - 55 + }, + ball: { + x: canvas.width / 2, + y: canvas.height / 2, + radius: 9, + speedX: 360, + speedY: 220, + maxSpeed: 780 + }, + winScore: 7, + lastTs: 0 + }; + + function clamp(value, min, max) { + return Math.max(min, Math.min(max, value)); + } + + function lerp(a, b, t) { + return a + (b - a) * t; + } + + function isOnlineHost() { + return state.online.active && state.online.role === "host"; + } + + function isOnlineGuest() { + return state.online.active && state.online.role === "guest"; + } + + function sendMatchSignal(payload) { + state.online.sendSignal?.(payload); + } + + function getStartButtonLabel() { + if (state.winner) return "Ny runde"; + if (!state.running) return "Start"; + return state.paused ? "Fortsett" : "Pause"; + } + + function serializeSnapshot() { + return { + running: state.running, + paused: state.paused, + winner: state.winner, + mode: state.mode, + scores: { ...state.scores }, + hits: { ...state.hits }, + paddle: { + playerY: state.paddle.playerY, + cpuY: state.paddle.cpuY + }, + ball: { + x: state.ball.x, + y: state.ball.y, + speedX: state.ball.speedX, + speedY: state.ball.speedY + }, + overlay: { + visible: state.overlay.visible, + html: state.overlay.html + } + }; + } + + function applySnapshot(snapshot, options = {}) { + if (!snapshot || typeof snapshot !== "object") return; + const smoothPositions = Boolean(options.smoothPositions); + + if (snapshot.scores) { + state.scores.player = Number(snapshot.scores.player || 0); + state.scores.cpu = Number(snapshot.scores.cpu || 0); + } + if (snapshot.hits) { + state.hits.player = Number(snapshot.hits.player || 0); + state.hits.cpu = Number(snapshot.hits.cpu || 0); + } + if (snapshot.paddle) { + const nextPlayerY = clamp( + Number(snapshot.paddle.playerY ?? state.paddle.playerY), + 0, + canvas.height - state.paddle.height + ); + const nextCpuY = clamp( + Number(snapshot.paddle.cpuY ?? state.paddle.cpuY), + 0, + canvas.height - state.paddle.height + ); + if (smoothPositions) { + state.paddle.playerY = lerp(state.paddle.playerY, nextPlayerY, 0.5); + state.paddle.cpuY = lerp(state.paddle.cpuY, nextCpuY, 0.5); + } else { + state.paddle.playerY = nextPlayerY; + state.paddle.cpuY = nextCpuY; + } + } + if (snapshot.ball) { + const nextBallX = Number(snapshot.ball.x ?? state.ball.x); + const nextBallY = Number(snapshot.ball.y ?? state.ball.y); + if (smoothPositions) { + state.ball.x = lerp(state.ball.x, nextBallX, 0.45); + state.ball.y = lerp(state.ball.y, nextBallY, 0.45); + } else { + state.ball.x = nextBallX; + state.ball.y = nextBallY; + } + state.ball.speedX = Number(snapshot.ball.speedX ?? state.ball.speedX); + state.ball.speedY = Number(snapshot.ball.speedY ?? state.ball.speedY); + } + state.running = Boolean(snapshot.running); + state.paused = Boolean(snapshot.paused); + state.winner = snapshot.winner ?? null; + if (snapshot.mode) state.mode = snapshot.mode; + if (snapshot.overlay) { + state.overlay.visible = Boolean(snapshot.overlay.visible); + state.overlay.html = String(snapshot.overlay.html || ""); + } + emitUi(); + } + + function maybeBroadcastSnapshot(force = false) { + if (!isOnlineHost()) return; + const now = performance.now(); + if (!force && now - state.online.lastSnapshotSentAt < 33) return; + state.online.lastSnapshotSentAt = now; + sendMatchSignal({ kind: "snapshot", snapshot: serializeSnapshot() }); + } + + function emitUi() { + if (destroyed) return; + onUiChange?.({ + scores: { ...state.scores }, + hits: { ...state.hits }, + mode: state.mode, + bestScore: state.bestScore, + soundEnabled: state.soundEnabled, + volumePercent: Math.round(state.volume * 100), + overlayVisible: state.overlay.visible, + overlayHtml: state.overlay.html, + startButtonLabel: getStartButtonLabel() + }); + } + + function setOverlay(html) { + state.overlay.html = html; + state.overlay.visible = true; + emitUi(); + } + + function hideOverlay() { + state.overlay.visible = false; + emitUi(); + } + + function ensureAudioContext() { + const AudioCtx = window.AudioContext || window.webkitAudioContext; + if (!AudioCtx) return null; + if (!audio.ctx) audio.ctx = new AudioCtx(); + if (audio.ctx.state === "suspended") { + audio.ctx.resume().catch(() => {}); + } + return audio.ctx; + } + + function playSound(kind) { + if (!state.soundEnabled) return; + const audioCtx = ensureAudioContext(); + if (!audioCtx) return; + + const osc = audioCtx.createOscillator(); + const gain = audioCtx.createGain(); + const now = audioCtx.currentTime; + let freq = 440; + let endFreq = 440; + let duration = 0.05; + + if (kind === "paddle") { + freq = 620; + endFreq = 760; + duration = 0.045; + } else if (kind === "wall") { + freq = 300; + endFreq = 250; + duration = 0.03; + } else if (kind === "score") { + freq = 220; + endFreq = 150; + duration = 0.13; + } else if (kind === "win") { + freq = 380; + endFreq = 840; + duration = 0.18; + } + + osc.type = kind === "wall" ? "square" : "triangle"; + osc.frequency.setValueAtTime(freq, now); + osc.frequency.exponentialRampToValueAtTime(Math.max(60, endFreq), now + duration); + gain.gain.setValueAtTime(0.0001, now); + gain.gain.exponentialRampToValueAtTime(Math.max(0.0001, 0.06 * state.volume), now + 0.01); + gain.gain.exponentialRampToValueAtTime(0.0001, now + duration); + + osc.connect(gain); + gain.connect(audioCtx.destination); + osc.start(now); + osc.stop(now + duration); + } + + function loadBestScore() { + try { + const value = Number(window.localStorage.getItem(BEST_SCORE_KEY) || 0); + return Number.isFinite(value) ? value : 0; + } catch { + return 0; + } + } + + function saveBestScore() { + try { + window.localStorage.setItem(BEST_SCORE_KEY, String(state.bestScore)); + } catch { + // Ignore storage failures. + } + } + + function loadVolume() { + try { + const value = Number(window.localStorage.getItem(VOLUME_KEY)); + if (!Number.isFinite(value)) return 0.6; + return clamp(value, 0, 1); + } catch { + return 0.6; + } + } + + function saveVolume() { + try { + window.localStorage.setItem(VOLUME_KEY, String(state.volume)); + } catch { + // Ignore storage failures. + } + } + + function resetBall(direction = Math.random() > 0.5 ? 1 : -1) { + state.ball.x = canvas.width / 2; + state.ball.y = canvas.height / 2; + const speed = 360 + Math.random() * 90; + const angle = (Math.random() * 0.8 - 0.4) * Math.PI; + state.ball.speedX = Math.cos(angle) * speed * direction; + state.ball.speedY = Math.sin(angle) * speed; + } + + function syncBestScore() { + const candidate = + state.mode === "2p" + ? Math.max(state.scores.player, state.scores.cpu) + : state.scores.player; + + if (candidate > state.bestScore) { + state.bestScore = candidate; + saveBestScore(); + emitUi(); + } + } + + function resetGame() { + state.scores.player = 0; + state.scores.cpu = 0; + state.hits.player = 0; + state.hits.cpu = 0; + state.paddle.playerY = canvas.height / 2 - state.paddle.height / 2; + state.paddle.cpuY = canvas.height / 2 - state.paddle.height / 2; + state.winner = null; + state.paused = true; + state.running = false; + state.lastTs = 0; + resetBall(); + if (state.online.active) { + setOverlay( + `Online kamp mot ${state.online.opponentName || "motstander"}
Trykk Space eller Start for å starte` + ); + } else { + setOverlay( + `Trykk Space eller Start for å starte
M bytter mellom 1P og 2P` + ); + } + emitUi(); + maybeBroadcastSnapshot(true); + } + + function togglePause() { + if (isOnlineGuest()) { + sendMatchSignal({ kind: "command", command: "toggle_pause" }); + return; + } + if (state.winner) { + resetGame(); + return; + } + + if (!state.running) { + state.running = true; + state.paused = false; + hideOverlay(); + return; + } + + state.paused = !state.paused; + if (state.paused) { + setOverlay("Pause"); + } else { + hideOverlay(); + } + maybeBroadcastSnapshot(true); + } + + function scorePoint(side) { + state.scores[side] += 1; + emitUi(); + syncBestScore(); + + if (state.scores[side] >= state.winScore) { + state.winner = side; + state.paused = true; + state.running = false; + setOverlay( + `${side === "player" ? "Du vant!" : "CPU vant!"}
Trykk R eller Start for ny runde` + ); + playSound("win"); + maybeBroadcastSnapshot(true); + return; + } + + state.paused = true; + setOverlay("Poeng! Trykk Space eller Start for serve"); + resetBall(side === "player" ? -1 : 1); + playSound("score"); + maybeBroadcastSnapshot(true); + } + + function handleInput(dt) { + if (isOnlineGuest()) { + const guestUp = state.keys.has("ArrowUp") || state.keys.has("KeyI"); + const guestDown = state.keys.has("ArrowDown") || state.keys.has("KeyK"); + let guestDir = 0; + if (guestUp) guestDir -= 1; + if (guestDown) guestDir += 1; + state.paddle.cpuY = clamp( + state.paddle.cpuY + guestDir * state.paddle.speed * dt, + 0, + canvas.height - state.paddle.height + ); + if ( + guestUp !== state.online.guestSentInput.up || + guestDown !== state.online.guestSentInput.down + ) { + state.online.guestSentInput.up = guestUp; + state.online.guestSentInput.down = guestDown; + sendMatchSignal({ kind: "guest_input", up: guestUp, down: guestDown }); + } + return; + } + + const moveUp = state.keys.has("ArrowUp") || state.keys.has("KeyW"); + const moveDown = state.keys.has("ArrowDown") || state.keys.has("KeyS"); + let dir = 0; + if (moveUp) dir -= 1; + if (moveDown) dir += 1; + + state.paddle.playerY = clamp( + state.paddle.playerY + dir * state.paddle.speed * dt, + 0, + canvas.height - state.paddle.height + ); + + if (isOnlineHost()) { + const remote = state.online.remoteInput; + if (remote.pointerNormY != null) { + const y = remote.pointerNormY * canvas.height - state.paddle.height / 2; + state.paddle.cpuY = clamp(y, 0, canvas.height - state.paddle.height); + } else { + let p2Dir = 0; + if (remote.up) p2Dir -= 1; + if (remote.down) p2Dir += 1; + state.paddle.cpuY = clamp( + state.paddle.cpuY + p2Dir * state.paddle.speed * dt, + 0, + canvas.height - state.paddle.height + ); + } + return; + } + + if (state.mode === "2p") { + const p2Up = state.keys.has("KeyI"); + const p2Down = state.keys.has("KeyK"); + let p2Dir = 0; + if (p2Up) p2Dir -= 1; + if (p2Down) p2Dir += 1; + state.paddle.cpuY = clamp( + state.paddle.cpuY + p2Dir * state.paddle.speed * dt, + 0, + canvas.height - state.paddle.height + ); + } + } + + function updateCpu(dt) { + if (state.online.active) return; + const center = state.paddle.cpuY + state.paddle.height / 2; + const target = state.ball.y + (state.ball.speedX > 0 ? state.ball.speedY * 0.08 : 0); + const diff = target - center; + const reaction = 0.9; + const speed = state.paddle.speed * reaction; + const step = clamp(diff, -speed * dt, speed * dt); + + state.paddle.cpuY = clamp(state.paddle.cpuY + step, 0, canvas.height - state.paddle.height); + } + + function collideWithPaddle(paddleX, paddleY, isPlayer) { + const { ball, paddle } = state; + const withinY = + ball.y + ball.radius >= paddleY && ball.y - ball.radius <= paddleY + paddle.height; + const withinX = isPlayer + ? ball.x - ball.radius <= paddleX + paddle.width && ball.x > paddleX + : ball.x + ball.radius >= paddleX && ball.x < paddleX + paddle.width; + + if (!withinX || !withinY) return false; + + const hitPos = (ball.y - (paddleY + paddle.height / 2)) / (paddle.height / 2); + const speed = Math.min(Math.hypot(ball.speedX, ball.speedY) * 1.05, ball.maxSpeed); + const angle = hitPos * 1.05; + + ball.speedX = (isPlayer ? 1 : -1) * Math.cos(angle) * speed; + ball.speedY = Math.sin(angle) * speed; + ball.x = isPlayer ? paddleX + paddle.width + ball.radius : paddleX - ball.radius; + state.hits[isPlayer ? "player" : "cpu"] += 1; + emitUi(); + playSound("paddle"); + return true; + } + + function updateBall(dt) { + const { ball, paddle } = state; + + ball.x += ball.speedX * dt; + ball.y += ball.speedY * dt; + + if (ball.y - ball.radius <= 0) { + ball.y = ball.radius; + ball.speedY *= -1; + playSound("wall"); + } else if (ball.y + ball.radius >= canvas.height) { + ball.y = canvas.height - ball.radius; + ball.speedY *= -1; + playSound("wall"); + } + + const playerX = paddle.margin; + const cpuX = canvas.width - paddle.margin - paddle.width; + + collideWithPaddle(playerX, paddle.playerY, true); + collideWithPaddle(cpuX, paddle.cpuY, false); + + if (ball.x + ball.radius < 0) scorePoint("cpu"); + if (ball.x - ball.radius > canvas.width) scorePoint("player"); + } + + function drawBackground() { + const gradient = ctx.createLinearGradient(0, 0, canvas.width, canvas.height); + gradient.addColorStop(0, "#031d3a"); + gradient.addColorStop(1, "#0b4b3a"); + ctx.fillStyle = gradient; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + ctx.strokeStyle = "rgba(255,255,255,0.22)"; + ctx.lineWidth = 4; + ctx.setLineDash([14, 12]); + ctx.beginPath(); + ctx.moveTo(canvas.width / 2, 0); + ctx.lineTo(canvas.width / 2, canvas.height); + ctx.stroke(); + ctx.setLineDash([]); + } + + function drawPaddles() { + const { paddle } = state; + ctx.fillStyle = "#f4f7ff"; + ctx.fillRect(paddle.margin, paddle.playerY, paddle.width, paddle.height); + ctx.fillRect( + canvas.width - paddle.margin - paddle.width, + paddle.cpuY, + paddle.width, + paddle.height + ); + } + + function drawBall() { + const { ball } = state; + const glow = ctx.createRadialGradient(ball.x, ball.y, 1, ball.x, ball.y, 18); + glow.addColorStop(0, "#ffffff"); + glow.addColorStop(1, "#7bd3ff"); + ctx.fillStyle = glow; + ctx.beginPath(); + ctx.arc(ball.x, ball.y, ball.radius, 0, Math.PI * 2); + ctx.fill(); + } + + function render() { + drawBackground(); + drawPaddles(); + drawBall(); + } + + function pointerYToCanvas(clientY) { + const rect = canvas.getBoundingClientRect(); + const normalized = (clientY - rect.top) / rect.height; + return clamp(normalized * canvas.height, 0, canvas.height); + } + + function movePaddleToPointer(side, clientY) { + const y = pointerYToCanvas(clientY) - state.paddle.height / 2; + if (side === "left") { + state.paddle.playerY = clamp(y, 0, canvas.height - state.paddle.height); + } else { + state.paddle.cpuY = clamp(y, 0, canvas.height - state.paddle.height); + } + } + + function frame(ts) { + if (destroyed) return; + + if (!state.lastTs) state.lastTs = ts; + const dt = Math.min((ts - state.lastTs) / 1000, 0.033); + state.lastTs = ts; + + handleInput(dt); + if (state.running && !state.paused && !state.winner) { + if (isOnlineHost()) { + updateBall(dt); + } else if (!isOnlineGuest()) { + if (state.mode === "cpu") updateCpu(dt); + updateBall(dt); + } + } + render(); + maybeBroadcastSnapshot(); + + rafId = requestAnimationFrame(frame); + } + + function preventGameKeys(event) { + if ( + [ + "ArrowUp", + "ArrowDown", + "Space", + "KeyW", + "KeyS", + "KeyR", + "KeyI", + "KeyK", + "KeyM", + "KeyO" + ].includes(event.code) + ) { + event.preventDefault(); + } + } + + function isEditableTarget(target) { + if (!(target instanceof Element)) return false; + if (target instanceof HTMLInputElement) return true; + if (target instanceof HTMLTextAreaElement) return true; + if (target instanceof HTMLSelectElement) return true; + if (target.isContentEditable) return true; + return Boolean(target.closest("input, textarea, select, [contenteditable='true']")); + } + + function onKeyDown(event) { + if (isEditableTarget(event.target)) return; + preventGameKeys(event); + + if (event.repeat) return; + ensureAudioContext(); + + if (event.code === "Space") { + togglePause(); + return; + } + + if (event.code === "KeyR") { + if (isOnlineGuest()) { + sendMatchSignal({ kind: "command", command: "reset" }); + return; + } + resetGame(); + return; + } + + if (event.code === "KeyO") { + state.soundEnabled = !state.soundEnabled; + emitUi(); + if (state.soundEnabled) playSound("paddle"); + return; + } + + if (event.code === "KeyM") { + if (state.online.active) return; + state.mode = state.mode === "cpu" ? "2p" : "cpu"; + resetGame(); + emitUi(); + return; + } + + state.keys.add(event.code); + } + + function onKeyUp(event) { + if (isEditableTarget(event.target)) return; + state.keys.delete(event.code); + } + + function onTouchStart(event) { + event.preventDefault(); + ensureAudioContext(); + for (const touch of event.changedTouches) { + const rect = canvas.getBoundingClientRect(); + const x = touch.clientX - rect.left; + const side = x < rect.width / 2 ? "left" : "right"; + if (isOnlineHost() && side === "right") continue; + if (isOnlineGuest() && side === "left") continue; + if (side === "left" && state.touches.leftId == null) { + state.touches.leftId = touch.identifier; + } + if (side === "right" && state.touches.rightId == null) { + state.touches.rightId = touch.identifier; + } + movePaddleToPointer(side, touch.clientY); + if (isOnlineGuest() && side === "right") { + const normalized = pointerYToCanvas(touch.clientY) / canvas.height; + state.online.remoteInput.pointerNormY = normalized; + sendMatchSignal({ kind: "guest_pointer", y: normalized }); + } + } + } + + function onTouchMove(event) { + event.preventDefault(); + for (const touch of event.touches) { + if (touch.identifier === state.touches.leftId) { + movePaddleToPointer("left", touch.clientY); + } + if (touch.identifier === state.touches.rightId) { + movePaddleToPointer("right", touch.clientY); + if (isOnlineGuest()) { + const normalized = pointerYToCanvas(touch.clientY) / canvas.height; + state.online.remoteInput.pointerNormY = normalized; + sendMatchSignal({ kind: "guest_pointer", y: normalized }); + } + } + } + } + + function clearTouchIds(event) { + for (const touch of event.changedTouches) { + if (touch.identifier === state.touches.leftId) state.touches.leftId = null; + if (touch.identifier === state.touches.rightId) { + state.touches.rightId = null; + if (isOnlineGuest()) { + state.online.remoteInput.pointerNormY = null; + sendMatchSignal({ kind: "guest_pointer", y: null }); + } + } + } + } + + function setVolumePercent(value) { + state.volume = clamp(Number(value) / 100, 0, 1); + saveVolume(); + emitUi(); + if (state.soundEnabled) { + ensureAudioContext(); + playSound("wall"); + } + } + + function toggleSound() { + state.soundEnabled = !state.soundEnabled; + emitUi(); + if (state.soundEnabled) { + ensureAudioContext(); + playSound("paddle"); + } + } + + function toggleMode() { + if (state.online.active) return; + state.mode = state.mode === "cpu" ? "2p" : "cpu"; + resetGame(); + } + + function startOrPause() { + ensureAudioContext(); + togglePause(); + } + + function applyOnlineDefaults(match) { + state.online.active = true; + state.online.role = match.role; + state.online.matchId = match.matchId; + state.online.opponentName = match.opponent?.name || "motstander"; + state.mode = "online"; + state.online.lastSnapshotSentAt = 0; + state.online.remoteInput = { up: false, down: false, pointerNormY: null }; + state.online.guestSentInput = { up: false, down: false }; + } + + function startOnlineMatch(match, sendSignalFn) { + state.online.sendSignal = sendSignalFn; + applyOnlineDefaults(match); + resetGame(); + if (isOnlineGuest()) { + setOverlay( + `Venter på host (${state.online.opponentName}) for kampdata...` + ); + } + emitUi(); + } + + function endOnlineMatch() { + state.online.active = false; + state.online.role = null; + state.online.matchId = null; + state.online.opponentName = ""; + state.online.sendSignal = null; + state.online.remoteInput = { up: false, down: false, pointerNormY: null }; + state.mode = "cpu"; + resetGame(); + } + + function handleOnlineSignal(payload) { + if (!payload || typeof payload !== "object") return; + + if (isOnlineHost()) { + if (payload.kind === "guest_input") { + state.online.remoteInput.up = Boolean(payload.up); + state.online.remoteInput.down = Boolean(payload.down); + return; + } + if (payload.kind === "guest_pointer") { + state.online.remoteInput.pointerNormY = + payload.y == null ? null : clamp(Number(payload.y), 0, 1); + return; + } + if (payload.kind === "command") { + if (payload.command === "toggle_pause") { + togglePause(); + } else if (payload.command === "reset") { + resetGame(); + } + } + return; + } + + if (isOnlineGuest() && payload.kind === "snapshot") { + const shouldSmooth = + Boolean(payload.snapshot?.running) && + !Boolean(payload.snapshot?.paused) && + !payload.snapshot?.winner; + applySnapshot(payload.snapshot, { smoothPositions: shouldSmooth }); + } + } + + window.addEventListener("keydown", onKeyDown); + window.addEventListener("keyup", onKeyUp); + canvas.addEventListener("touchstart", onTouchStart, { passive: false }); + canvas.addEventListener("touchmove", onTouchMove, { passive: false }); + canvas.addEventListener("touchend", clearTouchIds); + canvas.addEventListener("touchcancel", clearTouchIds); + + state.bestScore = loadBestScore(); + state.volume = loadVolume(); + resetGame(); + render(); + rafId = requestAnimationFrame(frame); + + return { + startOrPause, + resetGame, + toggleMode, + toggleSound, + setVolumePercent, + startOnlineMatch, + endOnlineMatch, + handleOnlineSignal, + destroy() { + destroyed = true; + if (rafId != null) cancelAnimationFrame(rafId); + window.removeEventListener("keydown", onKeyDown); + window.removeEventListener("keyup", onKeyUp); + canvas.removeEventListener("touchstart", onTouchStart); + canvas.removeEventListener("touchmove", onTouchMove); + canvas.removeEventListener("touchend", clearTouchIds); + canvas.removeEventListener("touchcancel", clearTouchIds); + state.keys.clear(); + } + }; +} diff --git a/src/style.css b/src/style.css new file mode 100644 index 0000000..6f31edf --- /dev/null +++ b/src/style.css @@ -0,0 +1,460 @@ +:root { + font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + color: #eef6ff; + background: #03111f; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + background: + radial-gradient(circle at 15% 10%, rgba(92, 181, 255, 0.18), transparent 45%), + radial-gradient(circle at 90% 15%, rgba(0, 226, 170, 0.2), transparent 42%), + linear-gradient(180deg, #03111f 0%, #061a2b 100%); +} + +.app { + min-height: 100vh; + display: grid; + grid-template-columns: minmax(260px, 320px) minmax(0, 900px); + gap: 1rem; + align-items: center; + justify-content: center; + padding: 1rem; +} + +.panel { + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 16px; + padding: 1rem; + backdrop-filter: blur(10px); +} + +.panel h1 { + margin: 0; + font-size: 2rem; +} + +.subtitle { + margin: 0.25rem 0 1rem; + color: #b9cbe0; +} + +.scoreboard { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.5rem; +} + +.scoreboard > div { + background: rgba(255, 255, 255, 0.05); + border-radius: 12px; + padding: 0.75rem; + display: grid; + gap: 0.15rem; +} + +.label { + display: block; + color: #bfd0e5; + font-size: 0.9rem; +} + +.scoreboard strong { + font-size: 2rem; +} + +.statline { + color: #bfd0e5; + font-size: 0.9rem; +} + +.meta { + display: grid; + gap: 0.25rem; + margin-top: 0.75rem; + padding: 0.7rem 0.75rem; + background: rgba(255, 255, 255, 0.04); + border-radius: 12px; +} + +.meta p { + margin: 0; + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 1rem; +} + +.volume-row { + align-items: center; +} + +.volume-row input[type="range"] { + flex: 1; + accent-color: #7bd3ff; +} + +.controls { + margin-top: 1rem; + color: #d1dff0; + line-height: 1.35; +} + +.start-button { + margin-top: 0.65rem; + width: 100%; + border: 1px solid rgba(123, 211, 255, 0.45); + background: linear-gradient(180deg, rgba(123, 211, 255, 0.28), rgba(0, 226, 170, 0.2)); + color: #eef6ff; + border-radius: 10px; + padding: 0.7rem 0.9rem; + font: inherit; + font-weight: 700; + cursor: pointer; +} + +.start-button:hover { + background: linear-gradient(180deg, rgba(123, 211, 255, 0.35), rgba(0, 226, 170, 0.28)); +} + +.start-button:focus-visible { + outline: 2px solid #7bd3ff; + outline-offset: 2px; +} + +.control-actions { + display: grid; + gap: 0.55rem; + margin-top: 0.65rem; +} + +.control-actions .start-button { + margin-top: 0; +} + +.ghost-button { + width: 100%; + border: 1px solid rgba(255, 255, 255, 0.16); + background: rgba(255, 255, 255, 0.04); + color: #dcecff; + border-radius: 10px; + padding: 0.55rem 0.75rem; + font: inherit; + font-weight: 600; + cursor: pointer; +} + +.ghost-button:hover { + background: rgba(255, 255, 255, 0.08); + border-color: rgba(255, 255, 255, 0.22); +} + +.ghost-button:focus-visible { + outline: 2px solid #7bd3ff; + outline-offset: 2px; +} + +kbd { + font: inherit; + font-weight: 700; + background: rgba(255, 255, 255, 0.12); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 6px; + padding: 0.05rem 0.4rem; +} + +.game-shell { + position: relative; + width: min(100%, 900px); +} + +canvas { + width: 100%; + height: auto; + display: block; + border-radius: 18px; + border: 1px solid rgba(255, 255, 255, 0.2); + box-shadow: 0 22px 50px rgba(0, 0, 0, 0.35); +} + +.overlay { + position: absolute; + inset: 0; + display: grid; + place-items: center; + text-align: center; + font-size: clamp(1rem, 2vw, 1.35rem); + background: rgba(3, 17, 31, 0.38); + color: white; + border-radius: 18px; + padding: 1rem; +} + +.overlay small { + color: #d3e8ff; +} + +.overlay.hidden { + display: none; +} + +.mobile-note { + color: #b8d2ea; + margin-top: 0.35rem; +} + +.lobby-panel { + margin-top: 1rem; + padding-top: 0.9rem; + border-top: 1px solid rgba(255, 255, 255, 0.08); + display: grid; + gap: 0.7rem; +} + +.lobby-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; +} + +.lobby-head h2 { + margin: 0; + font-size: 1rem; +} + +.status-pill { + border-radius: 999px; + padding: 0.18rem 0.55rem; + font-size: 0.75rem; + font-weight: 700; + border: 1px solid rgba(255, 255, 255, 0.16); + background: rgba(255, 255, 255, 0.05); + color: #dcecff; +} + +.status-pill.online { + border-color: rgba(0, 226, 170, 0.35); + background: rgba(0, 226, 170, 0.12); + color: #d7fff4; +} + +.status-pill.offline { + border-color: rgba(255, 255, 255, 0.18); +} + +.lobby-copy { + margin: 0; + color: #bfd0e5; + font-size: 0.9rem; + line-height: 1.35; +} + +.lobby-row { + display: grid; + gap: 0.35rem; +} + +.text-input { + width: 100%; + border-radius: 10px; + border: 1px solid rgba(255, 255, 255, 0.18); + background: rgba(255, 255, 255, 0.04); + color: #eef6ff; + padding: 0.55rem 0.7rem; + font: inherit; +} + +.text-input::placeholder { + color: #9fb4cb; +} + +.text-input:focus-visible { + outline: 2px solid #7bd3ff; + outline-offset: 2px; +} + +.lobby-actions { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.5rem; +} + +.ghost-button:disabled, +.start-button:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.lobby-error { + margin: 0; + color: #ffc7c7; + background: rgba(255, 88, 88, 0.12); + border: 1px solid rgba(255, 88, 88, 0.22); + border-radius: 10px; + padding: 0.55rem 0.65rem; + font-size: 0.9rem; +} + +.invite-box { + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 12px; + padding: 0.65rem; + display: grid; + gap: 0.5rem; +} + +.invite-box p { + margin: 0; +} + +.ready-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.45rem; +} + +.ready-badge { + border-radius: 999px; + padding: 0.28rem 0.55rem; + font-size: 0.78rem; + text-align: center; + border: 1px solid rgba(255, 255, 255, 0.14); + color: #d7e7f8; + background: rgba(255, 255, 255, 0.04); +} + +.ready-badge.yes { + border-color: rgba(0, 226, 170, 0.3); + background: rgba(0, 226, 170, 0.1); + color: #d4fff2; +} + +.ready-badge.no { + border-color: rgba(255, 255, 255, 0.12); +} + +.lobby-list-wrap { + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 12px; + padding: 0.65rem; + display: grid; + gap: 0.45rem; +} + +.lobby-list-head { + display: flex; + justify-content: space-between; + align-items: center; + color: #d7e7f8; + font-size: 0.9rem; +} + +.lobby-list { + margin: 0; + padding: 0; + list-style: none; + display: grid; + gap: 0.45rem; +} + +.lobby-empty { + color: #b5c7da; + font-size: 0.9rem; +} + +.lobby-user { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 0.5rem; + align-items: center; + background: rgba(255, 255, 255, 0.03); + border-radius: 10px; + padding: 0.45rem 0.5rem; +} + +.lobby-user > div { + display: grid; + min-width: 0; +} + +.lobby-user strong { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.user-status { + font-size: 0.8rem; + color: #bfd0e5; +} + +.status-idle { + color: #aef5df; +} + +.status-in_match { + color: #ffd8a8; +} + +.status-inviting, +.status-invited { + color: #b7dfff; +} + +.ghost-button.compact { + width: auto; + min-width: 78px; + padding: 0.38rem 0.6rem; +} + +.notice-list { + margin: 0; + padding: 0; + list-style: none; + display: grid; + gap: 0.35rem; +} + +.notice-item { + border-radius: 8px; + padding: 0.38rem 0.5rem; + font-size: 0.82rem; + color: #d8e9fb; + background: rgba(123, 211, 255, 0.08); + border: 1px solid rgba(123, 211, 255, 0.14); +} + +.notice-item.warn { + background: rgba(255, 204, 102, 0.08); + border-color: rgba(255, 204, 102, 0.16); + color: #ffe8ba; +} + +@media (max-width: 980px) { + .app { + grid-template-columns: 1fr; + grid-template-rows: auto auto; + } + + .panel { + width: min(100%, 900px); + justify-self: center; + } + + .lobby-actions { + grid-template-columns: 1fr; + } + + .ready-row { + grid-template-columns: 1fr; + } +} diff --git a/vite.config.js b/vite.config.js new file mode 100644 index 0000000..ee648eb --- /dev/null +++ b/vite.config.js @@ -0,0 +1,10 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + server: { + host: true, + port: 5173 + } +});