From dd273295f209d43c141149683ca726f9f19c1f66 Mon Sep 17 00:00:00 2001 From: JasonAtClockwork Date: Wed, 27 May 2026 11:00:00 -0700 Subject: [PATCH 1/2] Adding initial Blackholio TS client --- demo/Blackholio/README.md | 1 + demo/Blackholio/client-ts/.gitignore | 5 + demo/Blackholio/client-ts/.npmrc | 1 + demo/Blackholio/client-ts/README.md | 75 ++ demo/Blackholio/client-ts/index.html | 41 + demo/Blackholio/client-ts/package.json | 28 + demo/Blackholio/client-ts/pnpm-lock.yaml | 1033 +++++++++++++++++ demo/Blackholio/client-ts/pnpm-workspace.yaml | 4 + .../client-ts/src/game/BlackholioScene.ts | 65 ++ .../client-ts/src/game/CameraController.ts | 37 + .../client-ts/src/game/CircleController.ts | 50 + .../client-ts/src/game/EntityController.ts | 77 ++ .../client-ts/src/game/FoodController.ts | 17 + .../client-ts/src/game/GameManager.ts | 252 ++++ .../client-ts/src/game/PlayerController.ts | 111 ++ demo/Blackholio/client-ts/src/game/input.ts | 10 + .../client-ts/src/game/leaderboard.ts | 21 + demo/Blackholio/client-ts/src/game/math.ts | 36 + demo/Blackholio/client-ts/src/main.ts | 15 + .../src/module_bindings/circle_table.ts | 25 + .../src/module_bindings/config_table.ts | 16 + .../consume_entity_event_table.ts | 16 + .../src/module_bindings/enter_game_reducer.ts | 15 + .../src/module_bindings/entity_table.ts | 23 + .../src/module_bindings/food_table.ts | 15 + .../client-ts/src/module_bindings/index.ts | 194 ++++ .../module_bindings/player_split_reducer.ts | 13 + .../src/module_bindings/player_table.ts | 17 + .../src/module_bindings/respawn_reducer.ts | 13 + .../src/module_bindings/suicide_reducer.ts | 13 + .../client-ts/src/module_bindings/types.ts | 95 ++ .../src/module_bindings/types/procedures.ts | 10 + .../src/module_bindings/types/reducers.ts | 20 + .../update_player_input_reducer.ts | 21 + demo/Blackholio/client-ts/src/style.css | 136 +++ .../client-ts/src/ui/DeathScreen.ts | 16 + .../client-ts/src/ui/LeaderboardController.ts | 30 + demo/Blackholio/client-ts/src/ui/StatusHud.ts | 17 + .../client-ts/src/ui/UsernameChooser.ts | 29 + demo/Blackholio/client-ts/tests/game.test.ts | 54 + demo/Blackholio/client-ts/tsconfig.json | 20 + demo/Blackholio/client-ts/vite.config.ts | 7 + 42 files changed, 2694 insertions(+) create mode 100644 demo/Blackholio/client-ts/.gitignore create mode 100644 demo/Blackholio/client-ts/.npmrc create mode 100644 demo/Blackholio/client-ts/README.md create mode 100644 demo/Blackholio/client-ts/index.html create mode 100644 demo/Blackholio/client-ts/package.json create mode 100644 demo/Blackholio/client-ts/pnpm-lock.yaml create mode 100644 demo/Blackholio/client-ts/pnpm-workspace.yaml create mode 100644 demo/Blackholio/client-ts/src/game/BlackholioScene.ts create mode 100644 demo/Blackholio/client-ts/src/game/CameraController.ts create mode 100644 demo/Blackholio/client-ts/src/game/CircleController.ts create mode 100644 demo/Blackholio/client-ts/src/game/EntityController.ts create mode 100644 demo/Blackholio/client-ts/src/game/FoodController.ts create mode 100644 demo/Blackholio/client-ts/src/game/GameManager.ts create mode 100644 demo/Blackholio/client-ts/src/game/PlayerController.ts create mode 100644 demo/Blackholio/client-ts/src/game/input.ts create mode 100644 demo/Blackholio/client-ts/src/game/leaderboard.ts create mode 100644 demo/Blackholio/client-ts/src/game/math.ts create mode 100644 demo/Blackholio/client-ts/src/main.ts create mode 100644 demo/Blackholio/client-ts/src/module_bindings/circle_table.ts create mode 100644 demo/Blackholio/client-ts/src/module_bindings/config_table.ts create mode 100644 demo/Blackholio/client-ts/src/module_bindings/consume_entity_event_table.ts create mode 100644 demo/Blackholio/client-ts/src/module_bindings/enter_game_reducer.ts create mode 100644 demo/Blackholio/client-ts/src/module_bindings/entity_table.ts create mode 100644 demo/Blackholio/client-ts/src/module_bindings/food_table.ts create mode 100644 demo/Blackholio/client-ts/src/module_bindings/index.ts create mode 100644 demo/Blackholio/client-ts/src/module_bindings/player_split_reducer.ts create mode 100644 demo/Blackholio/client-ts/src/module_bindings/player_table.ts create mode 100644 demo/Blackholio/client-ts/src/module_bindings/respawn_reducer.ts create mode 100644 demo/Blackholio/client-ts/src/module_bindings/suicide_reducer.ts create mode 100644 demo/Blackholio/client-ts/src/module_bindings/types.ts create mode 100644 demo/Blackholio/client-ts/src/module_bindings/types/procedures.ts create mode 100644 demo/Blackholio/client-ts/src/module_bindings/types/reducers.ts create mode 100644 demo/Blackholio/client-ts/src/module_bindings/update_player_input_reducer.ts create mode 100644 demo/Blackholio/client-ts/src/style.css create mode 100644 demo/Blackholio/client-ts/src/ui/DeathScreen.ts create mode 100644 demo/Blackholio/client-ts/src/ui/LeaderboardController.ts create mode 100644 demo/Blackholio/client-ts/src/ui/StatusHud.ts create mode 100644 demo/Blackholio/client-ts/src/ui/UsernameChooser.ts create mode 100644 demo/Blackholio/client-ts/tests/game.test.ts create mode 100644 demo/Blackholio/client-ts/tsconfig.json create mode 100644 demo/Blackholio/client-ts/vite.config.ts diff --git a/demo/Blackholio/README.md b/demo/Blackholio/README.md index ee915bdd42b..6579bc505a4 100644 --- a/demo/Blackholio/README.md +++ b/demo/Blackholio/README.md @@ -66,6 +66,7 @@ You should be prompted for a username and you should be able to move around, eat Blackholio/ ├── client-unity/ # Unity client project ├── client-unreal/ # Unreal Engine client project +├── client-ts/ # Browser client using Phaser and TypeScript ├── server-csharp/ # SpacetimeDB server module (C# implementation) ├── server-rust/ # SpacetimeDB server module (Rust implementation) ├── DEVELOP.md # Development guidelines diff --git a/demo/Blackholio/client-ts/.gitignore b/demo/Blackholio/client-ts/.gitignore new file mode 100644 index 00000000000..318e99ada5d --- /dev/null +++ b/demo/Blackholio/client-ts/.gitignore @@ -0,0 +1,5 @@ +node_modules +dist +*.log + +.DS_Store diff --git a/demo/Blackholio/client-ts/.npmrc b/demo/Blackholio/client-ts/.npmrc new file mode 100644 index 00000000000..44bdf80d1df --- /dev/null +++ b/demo/Blackholio/client-ts/.npmrc @@ -0,0 +1 @@ +minimum-release-age=1440 diff --git a/demo/Blackholio/client-ts/README.md b/demo/Blackholio/client-ts/README.md new file mode 100644 index 00000000000..0b491c5a5de --- /dev/null +++ b/demo/Blackholio/client-ts/README.md @@ -0,0 +1,75 @@ +# Blackholio Phaser Client + +Browser client for the existing Blackholio Rust SpacetimeDB module, built with +TypeScript and Phaser. + +## Requirements + +- Node.js 18 or later +- pnpm 10.16.0 or later +- A locally available SpacetimeDB CLI/server + +This is a standalone pnpm project. Its local `pnpm-workspace.yaml` prevents +pnpm from selecting the repository workspace, and its local dependency +configuration enforces a 1440-minute minimum release age. + +The checked-in dependency configuration links the TypeScript SDK from this +repository during development: + +```json +"spacetimedb": "link:../../../crates/bindings-typescript" +``` + +When publishing this demo outside the SpacetimeDB repository, replace the local +link with the current published npm package version: + +```json +"spacetimedb": "" +``` + +`package.json` does not support commented dependency alternatives, so the +published form is documented here rather than left as an invalid commented +entry in the manifest. + +## Run Locally + +Start the existing Rust server using its normal development workflow, then +from this directory: + +```bash +pnpm install +pnpm dev +``` + +To regenerate this client's checked-in TypeScript bindings without changing +the server project: + +```bash +spacetime generate --lang typescript --out-dir src/module_bindings --module-path ../server-rust +``` + +## Source Layout + +The client follows the same controller boundaries as the Unity example: + +- `GameManager.ts`: SpacetimeDB connection, subscriptions, and entity/player registries +- `PlayerController.ts`: local input and owned-circle state +- `EntityController.ts`, `CircleController.ts`, and `FoodController.ts`: rendered entities +- `CameraController.ts`: center-of-mass following and zoom +- `ui/`: username chooser, death screen, leaderboard, and browser HUD + +The client connects to `ws://localhost:3000` and database `blackholio` by +default. Override either value when starting Vite: + +```bash +VITE_SPACETIMEDB_HOST=ws://localhost:3000 \ +VITE_SPACETIMEDB_DB_NAME=blackholio \ +pnpm dev +``` + +## Controls + +- Pointer: steer +- `Space`: split +- `Q`: lock or unlock steering direction +- `S`: self-destruct diff --git a/demo/Blackholio/client-ts/index.html b/demo/Blackholio/client-ts/index.html new file mode 100644 index 00000000000..549b8a36220 --- /dev/null +++ b/demo/Blackholio/client-ts/index.html @@ -0,0 +1,41 @@ + + + + + + Blackholio - Phaser Client + + +
+
+
+
Status: Connecting
+ +
+ + + +
+ Move with the pointer. Space split. Q lock + direction. S self-destruct. +
+
+ + + diff --git a/demo/Blackholio/client-ts/package.json b/demo/Blackholio/client-ts/package.json new file mode 100644 index 00000000000..dae40ac5845 --- /dev/null +++ b/demo/Blackholio/client-ts/package.json @@ -0,0 +1,28 @@ +{ + "name": "@clockworklabs/blackholio-client-ts", + "private": true, + "version": "0.0.1", + "type": "module", + "packageManager": "pnpm@10.16.0", + "engines": { + "node": ">=18", + "pnpm": ">=10.16.0" + }, + "scripts": { + "dev": "vite", + "build": "pnpm run typecheck && vite build", + "lint": "pnpm run typecheck", + "test": "vitest run", + "typecheck": "tsc --noEmit", + "spacetime:generate": "spacetime generate --lang typescript --out-dir src/module_bindings --module-path ../server-rust" + }, + "dependencies": { + "phaser": "4.1.0", + "spacetimedb": "link:../../../crates/bindings-typescript" + }, + "devDependencies": { + "typescript": "~5.6.2", + "vite": "^7.1.5", + "vitest": "^3.2.4" + } +} diff --git a/demo/Blackholio/client-ts/pnpm-lock.yaml b/demo/Blackholio/client-ts/pnpm-lock.yaml new file mode 100644 index 00000000000..7449289d838 --- /dev/null +++ b/demo/Blackholio/client-ts/pnpm-lock.yaml @@ -0,0 +1,1033 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + phaser: + specifier: 4.1.0 + version: 4.1.0 + spacetimedb: + specifier: link:../../../crates/bindings-typescript + version: link:../../../crates/bindings-typescript + devDependencies: + typescript: + specifier: ~5.6.2 + version: 5.6.3 + vite: + specifier: ^7.1.5 + version: 7.3.3 + vitest: + specifier: ^3.2.4 + version: 3.2.4 + +packages: + + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@rollup/rollup-android-arm-eabi@4.60.4': + resolution: {integrity: sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.60.4': + resolution: {integrity: sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.60.4': + resolution: {integrity: sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.60.4': + resolution: {integrity: sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.60.4': + resolution: {integrity: sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.60.4': + resolution: {integrity: sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.60.4': + resolution: {integrity: sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.60.4': + resolution: {integrity: sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.60.4': + resolution: {integrity: sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.60.4': + resolution: {integrity: sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.60.4': + resolution: {integrity: sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.60.4': + resolution: {integrity: sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.60.4': + resolution: {integrity: sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.60.4': + resolution: {integrity: sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.60.4': + resolution: {integrity: sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.60.4': + resolution: {integrity: sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.60.4': + resolution: {integrity: sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.60.4': + resolution: {integrity: sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.60.4': + resolution: {integrity: sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.60.4': + resolution: {integrity: sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.60.4': + resolution: {integrity: sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.60.4': + resolution: {integrity: sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.60.4': + resolution: {integrity: sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.60.4': + resolution: {integrity: sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.60.4': + resolution: {integrity: sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==} + cpu: [x64] + os: [win32] + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@vitest/expect@3.2.4': + resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} + + '@vitest/mocker@3.2.4': + resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.2.4': + resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} + + '@vitest/runner@3.2.4': + resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==} + + '@vitest/snapshot@3.2.4': + resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} + + '@vitest/spy@3.2.4': + resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} + + '@vitest/utils@3.2.4': + resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + engines: {node: '>=18'} + hasBin: true + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + phaser@4.1.0: + resolution: {integrity: sha512-ZXv5Bhyg2BqJGAAxNI2xvmzGXW9q+TwUG1RLri5ZDBYGGtcma6aWUO/eJ7EbozeqRd5fKdpo4ycNMQt+Bi5iYg==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + postcss@8.5.15: + resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + engines: {node: ^10 || ^12 || >=14} + + rollup@4.60.4: + resolution: {integrity: sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@4.0.4: + resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} + engines: {node: '>=14.0.0'} + + typescript@5.6.3: + resolution: {integrity: sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==} + engines: {node: '>=14.17'} + hasBin: true + + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + + vite@7.3.3: + resolution: {integrity: sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@3.2.4: + resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.4 + '@vitest/ui': 3.2.4 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + +snapshots: + + '@esbuild/aix-ppc64@0.27.7': + optional: true + + '@esbuild/android-arm64@0.27.7': + optional: true + + '@esbuild/android-arm@0.27.7': + optional: true + + '@esbuild/android-x64@0.27.7': + optional: true + + '@esbuild/darwin-arm64@0.27.7': + optional: true + + '@esbuild/darwin-x64@0.27.7': + optional: true + + '@esbuild/freebsd-arm64@0.27.7': + optional: true + + '@esbuild/freebsd-x64@0.27.7': + optional: true + + '@esbuild/linux-arm64@0.27.7': + optional: true + + '@esbuild/linux-arm@0.27.7': + optional: true + + '@esbuild/linux-ia32@0.27.7': + optional: true + + '@esbuild/linux-loong64@0.27.7': + optional: true + + '@esbuild/linux-mips64el@0.27.7': + optional: true + + '@esbuild/linux-ppc64@0.27.7': + optional: true + + '@esbuild/linux-riscv64@0.27.7': + optional: true + + '@esbuild/linux-s390x@0.27.7': + optional: true + + '@esbuild/linux-x64@0.27.7': + optional: true + + '@esbuild/netbsd-arm64@0.27.7': + optional: true + + '@esbuild/netbsd-x64@0.27.7': + optional: true + + '@esbuild/openbsd-arm64@0.27.7': + optional: true + + '@esbuild/openbsd-x64@0.27.7': + optional: true + + '@esbuild/openharmony-arm64@0.27.7': + optional: true + + '@esbuild/sunos-x64@0.27.7': + optional: true + + '@esbuild/win32-arm64@0.27.7': + optional: true + + '@esbuild/win32-ia32@0.27.7': + optional: true + + '@esbuild/win32-x64@0.27.7': + optional: true + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@rollup/rollup-android-arm-eabi@4.60.4': + optional: true + + '@rollup/rollup-android-arm64@4.60.4': + optional: true + + '@rollup/rollup-darwin-arm64@4.60.4': + optional: true + + '@rollup/rollup-darwin-x64@4.60.4': + optional: true + + '@rollup/rollup-freebsd-arm64@4.60.4': + optional: true + + '@rollup/rollup-freebsd-x64@4.60.4': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.60.4': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.60.4': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-x64-musl@4.60.4': + optional: true + + '@rollup/rollup-openbsd-x64@4.60.4': + optional: true + + '@rollup/rollup-openharmony-arm64@4.60.4': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.60.4': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.60.4': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.60.4': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.60.4': + optional: true + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.8': {} + + '@types/estree@1.0.9': {} + + '@vitest/expect@3.2.4': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.2.4(vite@7.3.3)': + dependencies: + '@vitest/spy': 3.2.4 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.3 + + '@vitest/pretty-format@3.2.4': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.2.4': + dependencies: + '@vitest/utils': 3.2.4 + pathe: 2.0.3 + strip-literal: 3.1.0 + + '@vitest/snapshot@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@3.2.4': + dependencies: + tinyspy: 4.0.4 + + '@vitest/utils@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + + assertion-error@2.0.1: {} + + cac@6.7.14: {} + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + check-error@2.1.3: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-eql@5.0.2: {} + + es-module-lexer@1.7.0: {} + + esbuild@0.27.7: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + eventemitter3@5.0.4: {} + + expect-type@1.3.0: {} + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + fsevents@2.3.3: + optional: true + + js-tokens@9.0.1: {} + + loupe@3.2.1: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + ms@2.1.3: {} + + nanoid@3.3.12: {} + + pathe@2.0.3: {} + + pathval@2.0.1: {} + + phaser@4.1.0: + dependencies: + eventemitter3: 5.0.4 + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + postcss@8.5.15: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + rollup@4.60.4: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.60.4 + '@rollup/rollup-android-arm64': 4.60.4 + '@rollup/rollup-darwin-arm64': 4.60.4 + '@rollup/rollup-darwin-x64': 4.60.4 + '@rollup/rollup-freebsd-arm64': 4.60.4 + '@rollup/rollup-freebsd-x64': 4.60.4 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.4 + '@rollup/rollup-linux-arm-musleabihf': 4.60.4 + '@rollup/rollup-linux-arm64-gnu': 4.60.4 + '@rollup/rollup-linux-arm64-musl': 4.60.4 + '@rollup/rollup-linux-loong64-gnu': 4.60.4 + '@rollup/rollup-linux-loong64-musl': 4.60.4 + '@rollup/rollup-linux-ppc64-gnu': 4.60.4 + '@rollup/rollup-linux-ppc64-musl': 4.60.4 + '@rollup/rollup-linux-riscv64-gnu': 4.60.4 + '@rollup/rollup-linux-riscv64-musl': 4.60.4 + '@rollup/rollup-linux-s390x-gnu': 4.60.4 + '@rollup/rollup-linux-x64-gnu': 4.60.4 + '@rollup/rollup-linux-x64-musl': 4.60.4 + '@rollup/rollup-openbsd-x64': 4.60.4 + '@rollup/rollup-openharmony-arm64': 4.60.4 + '@rollup/rollup-win32-arm64-msvc': 4.60.4 + '@rollup/rollup-win32-ia32-msvc': 4.60.4 + '@rollup/rollup-win32-x64-gnu': 4.60.4 + '@rollup/rollup-win32-x64-msvc': 4.60.4 + fsevents: 2.3.3 + + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + + tinyspy@4.0.4: {} + + typescript@5.6.3: {} + + vite-node@3.2.4: + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.3.3 + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite@7.3.3: + dependencies: + esbuild: 0.27.7 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.15 + rollup: 4.60.4 + tinyglobby: 0.2.16 + optionalDependencies: + fsevents: 2.3.3 + + vitest@3.2.4: + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(vite@7.3.3) + '@vitest/pretty-format': 3.2.4 + '@vitest/runner': 3.2.4 + '@vitest/snapshot': 3.2.4 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.16 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.3.3 + vite-node: 3.2.4 + why-is-node-running: 2.3.0 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 diff --git a/demo/Blackholio/client-ts/pnpm-workspace.yaml b/demo/Blackholio/client-ts/pnpm-workspace.yaml new file mode 100644 index 00000000000..4a28b5fd1d9 --- /dev/null +++ b/demo/Blackholio/client-ts/pnpm-workspace.yaml @@ -0,0 +1,4 @@ +packages: + - '.' + +minimumReleaseAge: 1440 diff --git a/demo/Blackholio/client-ts/src/game/BlackholioScene.ts b/demo/Blackholio/client-ts/src/game/BlackholioScene.ts new file mode 100644 index 00000000000..929ff54d8cc --- /dev/null +++ b/demo/Blackholio/client-ts/src/game/BlackholioScene.ts @@ -0,0 +1,65 @@ +import Phaser from 'phaser'; +import { CameraController } from './CameraController'; +import { GameManager } from './GameManager'; +import { DeathScreen } from '../ui/DeathScreen'; +import { LeaderboardController } from '../ui/LeaderboardController'; +import { StatusHud } from '../ui/StatusHud'; +import { UsernameChooser } from '../ui/UsernameChooser'; + +export class BlackholioScene extends Phaser.Scene { + private gameManager?: GameManager; + private cameraController?: CameraController; + private leaderboard?: LeaderboardController; + private statusHud?: StatusHud; + + constructor() { + super('blackholio'); + } + + create(): void { + this.cameras.main.setBackgroundColor('#050817'); + + let manager: GameManager; + const usernameChooser = new UsernameChooser(name => manager.enterGame(name)); + const deathScreen = new DeathScreen(() => manager.respawn()); + const statusHud = new StatusHud(); + const leaderboard = new LeaderboardController(); + + manager = new GameManager( + this, + deathScreen, + usernameChooser, + statusHud, + worldSize => this.setupArena(worldSize) + ); + this.gameManager = manager; + this.cameraController = new CameraController(this, manager); + this.leaderboard = leaderboard; + this.statusHud = statusHud; + manager.connect(); + } + + update(time: number, delta: number): void { + const manager = this.gameManager; + if (!manager) { + return; + } + manager.update(time, delta); + this.cameraController?.update(delta); + this.leaderboard?.update(manager.players.values(), manager.localPlayer); + this.statusHud?.update(manager.localPlayer); + } + + private setupArena(worldSize: number): void { + const graphics = this.add.graphics().setDepth(-10); + graphics.fillStyle(0x070e24, 1); + graphics.fillRect(0, 0, worldSize, worldSize); + graphics.lineStyle(5, 0xdda63e, 1); + graphics.strokeRect(0, 0, worldSize, worldSize); + this.cameras.main.centerOn(worldSize / 2, worldSize / 2); + this.cameras.main.setZoom( + Math.min(this.scale.width, this.scale.height) / worldSize + ); + this.cameraController?.setWorldSize(worldSize); + } +} diff --git a/demo/Blackholio/client-ts/src/game/CameraController.ts b/demo/Blackholio/client-ts/src/game/CameraController.ts new file mode 100644 index 00000000000..67f7023f45b --- /dev/null +++ b/demo/Blackholio/client-ts/src/game/CameraController.ts @@ -0,0 +1,37 @@ +import Phaser from 'phaser'; +import { cameraSize } from './math'; +import type { GameManager } from './GameManager'; + +export class CameraController { + private worldSize = 1000; + + constructor( + private readonly scene: Phaser.Scene, + private readonly gameManager: GameManager + ) {} + + setWorldSize(worldSize: number): void { + this.worldSize = worldSize; + } + + update(delta: number): void { + const localPlayer = this.gameManager.localPlayer; + const center = localPlayer?.centerOfMass() ?? { + x: this.worldSize / 2, + y: this.worldSize / 2, + }; + const camera = this.scene.cameras.main; + camera.centerOn(center.x, center.y); + if (!localPlayer || localPlayer.numberOfOwnedCircles === 0) { + return; + } + const targetSize = cameraSize( + localPlayer.totalMass(), + localPlayer.numberOfOwnedCircles + ); + const zoom = this.scene.scale.height / (targetSize * 2); + camera.setZoom( + Phaser.Math.Linear(camera.zoom, zoom, Math.min(1, (delta / 1000) * 2)) + ); + } +} diff --git a/demo/Blackholio/client-ts/src/game/CircleController.ts b/demo/Blackholio/client-ts/src/game/CircleController.ts new file mode 100644 index 00000000000..3cb305290bb --- /dev/null +++ b/demo/Blackholio/client-ts/src/game/CircleController.ts @@ -0,0 +1,50 @@ +import Phaser from 'phaser'; +import type { Circle, Entity } from '../module_bindings/types'; +import { EntityController } from './EntityController'; +import type { PlayerController } from './PlayerController'; + +const COLOR_PALETTE = [ + 0xaf9f31, 0xaf7431, 0x702ffc, 0x335bfc, 0xb03636, 0xb06d36, 0x8d2b63, + 0x02bcfa, 0x0732fb, 0x021c92, +]; +const LABEL_TEXTURE_FONT_SIZE = 24; +const LABEL_WORLD_SIZE_FACTOR = 0.4; + +export class CircleController extends EntityController { + private readonly label: Phaser.GameObjects.Text; + + constructor( + scene: Phaser.Scene, + entity: Entity, + circle: Circle, + private readonly owner: PlayerController + ) { + super( + scene, + entity, + COLOR_PALETTE[Math.abs(circle.playerId) % COLOR_PALETTE.length] + ); + this.label = scene.add + .text(0, 0, owner.username, { + color: '#ffffff', + fontSize: `${LABEL_TEXTURE_FONT_SIZE}px`, + fontStyle: 'bold', + }) + .setOrigin(0.5, 0.5) + .setResolution(2); + this.container.add(this.label); + owner.onCircleSpawned(this); + } + + updateUsername(): void { + this.label.setText(this.owner.username); + } + + protected override draw(): void { + super.draw(); + this.shape.lineStyle(0.6, 0xffffff, 0.5); + this.shape.strokeCircle(0, 0, this.radius); + const worldFontSize = Math.max(1, this.radius * LABEL_WORLD_SIZE_FACTOR); + this.label.setScale(worldFontSize / LABEL_TEXTURE_FONT_SIZE); + } +} diff --git a/demo/Blackholio/client-ts/src/game/EntityController.ts b/demo/Blackholio/client-ts/src/game/EntityController.ts new file mode 100644 index 00000000000..fc467322b75 --- /dev/null +++ b/demo/Blackholio/client-ts/src/game/EntityController.ts @@ -0,0 +1,77 @@ +import Phaser from 'phaser'; +import type { Entity } from '../module_bindings/types'; +import { massToRadius, type Vec2 } from './math'; + +const LERP_DURATION_MS = 100; + +export abstract class EntityController { + public readonly entityId: number; + public consuming = false; + + protected readonly shape: Phaser.GameObjects.Graphics; + protected readonly container: Phaser.GameObjects.Container; + protected radius = 0; + + private readonly target: Phaser.Math.Vector2; + private targetRadius: number; + + protected constructor( + protected readonly scene: Phaser.Scene, + entity: Entity, + protected readonly color: number + ) { + this.entityId = entity.entityId; + this.shape = scene.add.graphics(); + this.container = scene.add.container(entity.position.x, entity.position.y, [ + this.shape, + ]); + this.target = new Phaser.Math.Vector2(entity.position.x, entity.position.y); + this.targetRadius = massToRadius(entity.mass); + } + + get position(): Vec2 { + return { x: this.container.x, y: this.container.y }; + } + + onEntityUpdated(entity: Entity): void { + if (this.consuming) { + return; + } + this.target.set(entity.position.x, entity.position.y); + this.targetRadius = massToRadius(entity.mass); + } + + onDelete(): void { + this.container.destroy(true); + } + + despawnToward(target: EntityController): void { + this.consuming = true; + this.scene.tweens.add({ + targets: this.container, + duration: 200, + x: target.container.x, + y: target.container.y, + scale: 0, + onComplete: () => this.container.destroy(true), + }); + } + + update(delta: number): void { + if (this.consuming) { + return; + } + const positionT = Math.min(1, delta / LERP_DURATION_MS); + const radiusT = Math.min(1, (delta / 1000) * 8); + this.container.x = Phaser.Math.Linear(this.container.x, this.target.x, positionT); + this.container.y = Phaser.Math.Linear(this.container.y, this.target.y, positionT); + this.radius = Phaser.Math.Linear(this.radius, this.targetRadius, radiusT); + this.draw(); + } + + protected draw(): void { + this.shape.clear(); + this.shape.fillStyle(this.color, 1); + this.shape.fillCircle(0, 0, this.radius); + } +} diff --git a/demo/Blackholio/client-ts/src/game/FoodController.ts b/demo/Blackholio/client-ts/src/game/FoodController.ts new file mode 100644 index 00000000000..1908d3a8eed --- /dev/null +++ b/demo/Blackholio/client-ts/src/game/FoodController.ts @@ -0,0 +1,17 @@ +import Phaser from 'phaser'; +import type { Entity, Food } from '../module_bindings/types'; +import { EntityController } from './EntityController'; + +const COLOR_PALETTE = [ + 0x77fcad, 0x4cfa92, 0x23f678, 0x77fbc9, 0x4cf9b8, 0x23f5a5, +]; + +export class FoodController extends EntityController { + constructor(scene: Phaser.Scene, entity: Entity, food: Food) { + super( + scene, + entity, + COLOR_PALETTE[Math.abs(food.entityId) % COLOR_PALETTE.length] + ); + } +} diff --git a/demo/Blackholio/client-ts/src/game/GameManager.ts b/demo/Blackholio/client-ts/src/game/GameManager.ts new file mode 100644 index 00000000000..e350c108083 --- /dev/null +++ b/demo/Blackholio/client-ts/src/game/GameManager.ts @@ -0,0 +1,252 @@ +import type Phaser from 'phaser'; +import type { Identity } from 'spacetimedb'; +import { DbConnection, type ErrorContext } from '../module_bindings'; +import type { + Circle, + ConsumeEntityEvent, + Entity, + Food, + Player, +} from '../module_bindings/types'; +import type { DeathScreen } from '../ui/DeathScreen'; +import type { StatusHud } from '../ui/StatusHud'; +import type { UsernameChooser } from '../ui/UsernameChooser'; +import { CircleController } from './CircleController'; +import { EntityController } from './EntityController'; +import { FoodController } from './FoodController'; +import { PlayerController } from './PlayerController'; + +const HOST = import.meta.env.VITE_SPACETIMEDB_HOST ?? 'ws://localhost:3000'; +const DB_NAME = import.meta.env.VITE_SPACETIMEDB_DB_NAME ?? 'blackholio'; +const TOKEN_KEY = `${HOST}/${DB_NAME}/auth_token`; + +export class GameManager { + public connection?: DbConnection; + public identity?: Identity; + public readonly entities = new Map(); + public readonly players = new Map(); + + private readonly pendingConsumeAnimations = new Set(); + + constructor( + public readonly scene: Phaser.Scene, + public readonly deathScreen: DeathScreen, + private readonly usernameChooser: UsernameChooser, + private readonly statusHud: StatusHud, + private readonly setupArena: (worldSize: number) => void + ) {} + + get localPlayer(): PlayerController | undefined { + return Array.from(this.players.values()).find(player => player.isLocalPlayer); + } + + connect(): void { + const storedToken = localStorage.getItem(TOKEN_KEY) || undefined; + const builder = DbConnection.builder() + .withUri(HOST) + .withDatabaseName(DB_NAME) + .onConnect((connection: DbConnection, identity: Identity, token: string) => { + this.connection = connection; + this.identity = identity; + localStorage.setItem(TOKEN_KEY, token); + this.statusHud.setStatus('Connected', 'connected'); + this.registerCallbacks(connection); + connection + .subscriptionBuilder() + .onApplied(() => this.handleSubscriptionApplied()) + .subscribeToAllTables(); + }) + .onDisconnect(() => { + this.statusHud.setStatus('Disconnected', 'error'); + }) + .onConnectError((_ctx: ErrorContext, error: Error) => { + this.statusHud.setStatus(`Error: ${error.message}`, 'error'); + }); + if (storedToken) { + builder.withToken(storedToken); + } + builder.build(); + } + + enterGame(name: string): void { + void this.connection?.reducers.enterGame({ name }); + this.usernameChooser.show(false); + this.deathScreen.setVisible(false); + } + + respawn(): void { + void this.connection?.reducers.respawn({}); + this.deathScreen.setVisible(false); + } + + findEntity(entityId: number): Entity | undefined { + return this.connection?.db.entity.entityId.find(entityId) ?? undefined; + } + + update(time: number, delta: number): void { + this.entities.forEach(entity => entity.update(delta)); + this.localPlayer?.update(time); + } + + private registerCallbacks(connection: DbConnection): void { + connection.db.entity.onInsert((_ctx, entity) => this.syncEntity(entity.entityId)); + connection.db.entity.onUpdate((_ctx, _oldEntity, entity) => { + this.entities.get(entity.entityId)?.onEntityUpdated(entity); + }); + connection.db.entity.onDelete((_ctx, entity) => this.entityOnDelete(entity)); + connection.db.circle.onInsert((_ctx, circle) => this.circleOnInsert(circle)); + connection.db.circle.onDelete((_ctx, circle) => this.circleOnDelete(circle)); + connection.db.food.onInsert((_ctx, food) => this.foodOnInsert(food)); + connection.db.player.onInsert((_ctx, player) => this.playerOnInsert(player)); + connection.db.player.onUpdate((_ctx, _oldPlayer, player) => + this.playerOnUpdate(player) + ); + connection.db.player.onDelete((_ctx, player) => { + this.players.delete(player.playerId); + }); + connection.db.consume_entity_event.onInsert((_ctx, event) => + this.consumeEntityEventOnInsert(event) + ); + } + + private handleSubscriptionApplied(): void { + const connection = this.connection; + if (!connection || !this.identity) { + return; + } + const config = connection.db.config.id.find(0); + if (config) { + this.setupArena(Number(config.worldSize)); + } + this.syncSubscribedState(); + const player = connection.db.player.identity.find(this.identity); + if (!player || !player.name) { + this.usernameChooser.show(true); + return; + } + const hasCircle = Array.from(connection.db.circle.iter()).some( + circle => circle.playerId === player.playerId + ); + if (hasCircle) { + this.usernameChooser.show(false); + } else { + void connection.reducers.enterGame({ name: player.name }); + } + } + + private syncSubscribedState(): void { + const connection = this.connection; + if (!connection) { + return; + } + for (const player of connection.db.player.iter()) { + this.playerOnUpdate(player); + } + for (const circle of connection.db.circle.iter()) { + this.circleOnInsert(circle); + } + for (const food of connection.db.food.iter()) { + this.foodOnInsert(food); + } + } + + private circleOnInsert(circle: Circle): void { + if (this.entities.has(circle.entityId)) { + return; + } + const entity = this.findEntity(circle.entityId); + const player = this.getOrCreatePlayer(circle.playerId); + if (!entity || !player) { + return; + } + this.entities.set( + circle.entityId, + new CircleController(this.scene, entity, circle, player) + ); + } + + private foodOnInsert(food: Food): void { + if (this.entities.has(food.entityId)) { + return; + } + const entity = this.findEntity(food.entityId); + if (entity) { + this.entities.set( + food.entityId, + new FoodController(this.scene, entity, food) + ); + } + } + + private circleOnDelete(circle: Circle): void { + this.players.get(circle.playerId)?.onCircleDeleted(circle.entityId); + } + + private syncEntity(entityId: number): void { + const connection = this.connection; + if (!connection || this.entities.has(entityId)) { + return; + } + const circle = connection.db.circle.entityId.find(entityId); + if (circle) { + this.circleOnInsert(circle); + return; + } + const food = connection.db.food.entityId.find(entityId); + if (food) { + this.foodOnInsert(food); + } + } + + private entityOnDelete(entity: Entity): void { + const entityController = this.entities.get(entity.entityId); + if (!entityController) { + return; + } + this.entities.delete(entity.entityId); + if (this.pendingConsumeAnimations.delete(entity.entityId)) { + return; + } + entityController.onDelete(); + } + + private consumeEntityEventOnInsert(event: ConsumeEntityEvent): void { + const consumedEntity = this.entities.get(event.consumedEntityId); + const consumerEntity = this.entities.get(event.consumerEntityId); + if (!consumedEntity || !consumerEntity) { + return; + } + this.pendingConsumeAnimations.add(event.consumedEntityId); + consumedEntity.despawnToward(consumerEntity); + } + + private playerOnInsert(player: Player): void { + this.getOrCreatePlayer(player.playerId); + for (const circle of this.connection?.db.circle.iter() ?? []) { + if (circle.playerId === player.playerId) { + this.circleOnInsert(circle); + } + } + } + + private playerOnUpdate(player: Player): void { + const controller = this.getOrCreatePlayer(player.playerId); + controller?.updatePlayer(player); + } + + private getOrCreatePlayer(playerId: number): PlayerController | undefined { + const existing = this.players.get(playerId); + if (existing) { + return existing; + } + const player = Array.from(this.connection?.db.player.iter() ?? []).find( + value => value.playerId === playerId + ); + if (!player) { + return undefined; + } + const controller = new PlayerController(this, player); + this.players.set(playerId, controller); + return controller; + } +} diff --git a/demo/Blackholio/client-ts/src/game/PlayerController.ts b/demo/Blackholio/client-ts/src/game/PlayerController.ts new file mode 100644 index 00000000000..5e96ee3483b --- /dev/null +++ b/demo/Blackholio/client-ts/src/game/PlayerController.ts @@ -0,0 +1,111 @@ +import Phaser from 'phaser'; +import type { Player } from '../module_bindings/types'; +import { pointerDirection } from './input'; +import { centerOfMass, type Vec2, type WeightedPosition } from './math'; +import type { CircleController } from './CircleController'; +import type { GameManager } from './GameManager'; + +const SEND_UPDATES_FREQUENCY_MS = 1000 / 20; + +export class PlayerController { + private readonly ownedCircles: CircleController[] = []; + private readonly splitKey?: Phaser.Input.Keyboard.Key; + private readonly lockKey?: Phaser.Input.Keyboard.Key; + private readonly suicideKey?: Phaser.Input.Keyboard.Key; + private lastMovementSendTimestamp = 0; + private lockedPointer?: Vec2; + + constructor( + private readonly gameManager: GameManager, + private player: Player + ) { + const keyboard = gameManager.scene.input.keyboard; + this.splitKey = keyboard?.addKey(Phaser.Input.Keyboard.KeyCodes.SPACE, false); + this.lockKey = keyboard?.addKey(Phaser.Input.Keyboard.KeyCodes.Q, false); + this.suicideKey = keyboard?.addKey(Phaser.Input.Keyboard.KeyCodes.S, false); + } + + get playerId(): number { + return this.player.playerId; + } + + get username(): string { + return this.player.name; + } + + get isLocalPlayer(): boolean { + return this.gameManager.identity?.isEqual(this.player.identity) ?? false; + } + + get numberOfOwnedCircles(): number { + return this.ownedCircles.length; + } + + updatePlayer(player: Player): void { + this.player = player; + this.ownedCircles.forEach(circle => circle.updateUsername()); + } + + onCircleSpawned(circle: CircleController): void { + this.ownedCircles.push(circle); + } + + onCircleDeleted(entityId: number): void { + const index = this.ownedCircles.findIndex(circle => circle.entityId === entityId); + if (index !== -1) { + this.ownedCircles.splice(index, 1); + if (this.isLocalPlayer && this.ownedCircles.length === 0) { + this.gameManager.deathScreen.setVisible(true); + } + } + } + + totalMass(): number { + return this.ownedCircles.reduce( + (sum, circle) => sum + (this.gameManager.findEntity(circle.entityId)?.mass ?? 0), + 0 + ); + } + + centerOfMass(): Vec2 | undefined { + const entities: WeightedPosition[] = this.ownedCircles.flatMap(circle => { + const entity = this.gameManager.findEntity(circle.entityId); + return entity ? [{ mass: entity.mass, position: circle.position }] : []; + }); + return centerOfMass(entities); + } + + update(time: number): void { + if (!this.isLocalPlayer || this.ownedCircles.length === 0) { + return; + } + if (this.splitKey && Phaser.Input.Keyboard.JustDown(this.splitKey)) { + void this.gameManager.connection?.reducers.playerSplit({}); + } + if (this.lockKey && Phaser.Input.Keyboard.JustDown(this.lockKey)) { + this.lockedPointer = this.lockedPointer + ? undefined + : this.currentPointerPosition(); + } + if (this.suicideKey && Phaser.Input.Keyboard.JustDown(this.suicideKey)) { + void this.gameManager.connection?.reducers.suicide({}); + } + if (time - this.lastMovementSendTimestamp < SEND_UPDATES_FREQUENCY_MS) { + return; + } + this.lastMovementSendTimestamp = time; + const direction = pointerDirection( + this.lockedPointer ?? this.currentPointerPosition(), + { + x: this.gameManager.scene.scale.width, + y: this.gameManager.scene.scale.height, + } + ); + void this.gameManager.connection?.reducers.updatePlayerInput({ direction }); + } + + private currentPointerPosition(): Vec2 { + const pointer = this.gameManager.scene.input.activePointer; + return { x: pointer.x, y: pointer.y }; + } +} diff --git a/demo/Blackholio/client-ts/src/game/input.ts b/demo/Blackholio/client-ts/src/game/input.ts new file mode 100644 index 00000000000..07b62b02cd1 --- /dev/null +++ b/demo/Blackholio/client-ts/src/game/input.ts @@ -0,0 +1,10 @@ +import type { Vec2 } from './math'; + +export function pointerDirection(pointer: Vec2, viewport: Vec2): Vec2 { + const center = { x: viewport.x / 2, y: viewport.y / 2 }; + const scale = viewport.y / 3; + return { + x: (pointer.x - center.x) / scale, + y: (pointer.y - center.y) / scale, + }; +} diff --git a/demo/Blackholio/client-ts/src/game/leaderboard.ts b/demo/Blackholio/client-ts/src/game/leaderboard.ts new file mode 100644 index 00000000000..8c63dfabdb7 --- /dev/null +++ b/demo/Blackholio/client-ts/src/game/leaderboard.ts @@ -0,0 +1,21 @@ +export type PlayerScore = { + id: number; + name: string; + mass: number; + local: boolean; +}; + +export function leaderboardRows( + players: readonly PlayerScore[], + limit = 10 +): PlayerScore[] { + const live = players.filter(player => player.mass > 0); + const leaders = [...live] + .sort((a, b) => b.mass - a.mass) + .slice(0, limit); + const local = live.find(player => player.local); + if (local && !leaders.some(player => player.id === local.id)) { + leaders.push(local); + } + return leaders; +} diff --git a/demo/Blackholio/client-ts/src/game/math.ts b/demo/Blackholio/client-ts/src/game/math.ts new file mode 100644 index 00000000000..cc0ed1a5790 --- /dev/null +++ b/demo/Blackholio/client-ts/src/game/math.ts @@ -0,0 +1,36 @@ +export type Vec2 = { + x: number; + y: number; +}; + +export type WeightedPosition = { + mass: number; + position: Vec2; +}; + +export function massToRadius(mass: number): number { + return Math.sqrt(mass); +} + +export function cameraSize(totalMass: number, circleCount: number): number { + return ( + 50 + + Math.min(50, totalMass / 5) + + Math.min(Math.max(circleCount - 1, 0), 1) * 30 + ); +} + +export function centerOfMass(entities: readonly WeightedPosition[]): Vec2 | undefined { + let totalMass = 0; + let x = 0; + let y = 0; + for (const entity of entities) { + totalMass += entity.mass; + x += entity.position.x * entity.mass; + y += entity.position.y * entity.mass; + } + if (totalMass <= 0) { + return undefined; + } + return { x: x / totalMass, y: y / totalMass }; +} diff --git a/demo/Blackholio/client-ts/src/main.ts b/demo/Blackholio/client-ts/src/main.ts new file mode 100644 index 00000000000..97f2ceb47e9 --- /dev/null +++ b/demo/Blackholio/client-ts/src/main.ts @@ -0,0 +1,15 @@ +import Phaser from 'phaser'; +import './style.css'; +import { BlackholioScene } from './game/BlackholioScene'; + +new Phaser.Game({ + type: Phaser.AUTO, + parent: 'game', + backgroundColor: '#050817', + scale: { + mode: Phaser.Scale.RESIZE, + width: window.innerWidth, + height: window.innerHeight, + }, + scene: BlackholioScene, +}); diff --git a/demo/Blackholio/client-ts/src/module_bindings/circle_table.ts b/demo/Blackholio/client-ts/src/module_bindings/circle_table.ts new file mode 100644 index 00000000000..4bb3bb8706a --- /dev/null +++ b/demo/Blackholio/client-ts/src/module_bindings/circle_table.ts @@ -0,0 +1,25 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; +import { + DbVector2, +} from "./types"; + + +export default __t.row({ + entityId: __t.i32().primaryKey().name("entity_id"), + playerId: __t.i32().name("player_id"), + get direction() { + return DbVector2; + }, + speed: __t.f32(), + lastSplitTime: __t.timestamp().name("last_split_time"), +}); diff --git a/demo/Blackholio/client-ts/src/module_bindings/config_table.ts b/demo/Blackholio/client-ts/src/module_bindings/config_table.ts new file mode 100644 index 00000000000..0ed56dfb5a4 --- /dev/null +++ b/demo/Blackholio/client-ts/src/module_bindings/config_table.ts @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + id: __t.i32().primaryKey(), + worldSize: __t.i64().name("world_size"), +}); diff --git a/demo/Blackholio/client-ts/src/module_bindings/consume_entity_event_table.ts b/demo/Blackholio/client-ts/src/module_bindings/consume_entity_event_table.ts new file mode 100644 index 00000000000..65997b120ab --- /dev/null +++ b/demo/Blackholio/client-ts/src/module_bindings/consume_entity_event_table.ts @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + consumedEntityId: __t.i32().name("consumed_entity_id"), + consumerEntityId: __t.i32().name("consumer_entity_id"), +}); diff --git a/demo/Blackholio/client-ts/src/module_bindings/enter_game_reducer.ts b/demo/Blackholio/client-ts/src/module_bindings/enter_game_reducer.ts new file mode 100644 index 00000000000..ce493ee8574 --- /dev/null +++ b/demo/Blackholio/client-ts/src/module_bindings/enter_game_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + name: __t.string(), +}; diff --git a/demo/Blackholio/client-ts/src/module_bindings/entity_table.ts b/demo/Blackholio/client-ts/src/module_bindings/entity_table.ts new file mode 100644 index 00000000000..cd90e141beb --- /dev/null +++ b/demo/Blackholio/client-ts/src/module_bindings/entity_table.ts @@ -0,0 +1,23 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; +import { + DbVector2, +} from "./types"; + + +export default __t.row({ + entityId: __t.i32().primaryKey().name("entity_id"), + get position() { + return DbVector2; + }, + mass: __t.i32(), +}); diff --git a/demo/Blackholio/client-ts/src/module_bindings/food_table.ts b/demo/Blackholio/client-ts/src/module_bindings/food_table.ts new file mode 100644 index 00000000000..cff1d8c8e5f --- /dev/null +++ b/demo/Blackholio/client-ts/src/module_bindings/food_table.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + entityId: __t.i32().primaryKey().name("entity_id"), +}); diff --git a/demo/Blackholio/client-ts/src/module_bindings/index.ts b/demo/Blackholio/client-ts/src/module_bindings/index.ts new file mode 100644 index 00000000000..d07cfc7f579 --- /dev/null +++ b/demo/Blackholio/client-ts/src/module_bindings/index.ts @@ -0,0 +1,194 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +// This was generated using spacetimedb cli version 2.2.0 (commit d62295d89cb41be71b927b62522b8a405ae08a21). + +/* eslint-disable */ +/* tslint:disable */ +import { + DbConnectionBuilder as __DbConnectionBuilder, + DbConnectionImpl as __DbConnectionImpl, + SubscriptionBuilderImpl as __SubscriptionBuilderImpl, + TypeBuilder as __TypeBuilder, + Uuid as __Uuid, + convertToAccessorMap as __convertToAccessorMap, + makeQueryBuilder as __makeQueryBuilder, + procedureSchema as __procedureSchema, + procedures as __procedures, + reducerSchema as __reducerSchema, + reducers as __reducers, + schema as __schema, + t as __t, + table as __table, + type AlgebraicTypeType as __AlgebraicTypeType, + type DbConnectionConfig as __DbConnectionConfig, + type ErrorContextInterface as __ErrorContextInterface, + type Event as __Event, + type EventContextInterface as __EventContextInterface, + type Infer as __Infer, + type QueryBuilder as __QueryBuilder, + type ReducerEventContextInterface as __ReducerEventContextInterface, + type RemoteModule as __RemoteModule, + type SubscriptionEventContextInterface as __SubscriptionEventContextInterface, + type SubscriptionHandleImpl as __SubscriptionHandleImpl, +} from "spacetimedb"; + +// Import all reducer arg schemas +import EnterGameReducer from "./enter_game_reducer"; +import PlayerSplitReducer from "./player_split_reducer"; +import RespawnReducer from "./respawn_reducer"; +import SuicideReducer from "./suicide_reducer"; +import UpdatePlayerInputReducer from "./update_player_input_reducer"; + +// Import all procedure arg schemas + +// Import all table schema definitions +import CircleRow from "./circle_table"; +import ConfigRow from "./config_table"; +import ConsumeEntityEventRow from "./consume_entity_event_table"; +import EntityRow from "./entity_table"; +import FoodRow from "./food_table"; +import PlayerRow from "./player_table"; + +/** Type-only namespace exports for generated type groups. */ + +/** The schema information for all tables in this module. This is defined the same was as the tables would have been defined in the server. */ +const tablesSchema = __schema({ + circle: __table({ + name: 'circle', + indexes: [ + { accessor: 'entity_id', name: 'circle_entity_id_idx_btree', algorithm: 'btree', columns: [ + 'entityId', + ] }, + { accessor: 'player_id', name: 'circle_player_id_idx_btree', algorithm: 'btree', columns: [ + 'playerId', + ] }, + ], + constraints: [ + { name: 'circle_entity_id_key', constraint: 'unique', columns: ['entityId'] }, + ], + }, CircleRow), + config: __table({ + name: 'config', + indexes: [ + { accessor: 'id', name: 'config_id_idx_btree', algorithm: 'btree', columns: [ + 'id', + ] }, + ], + constraints: [ + { name: 'config_id_key', constraint: 'unique', columns: ['id'] }, + ], + }, ConfigRow), + consume_entity_event: __table({ + name: 'consume_entity_event', + indexes: [ + ], + constraints: [ + ], + event: true, + }, ConsumeEntityEventRow), + entity: __table({ + name: 'entity', + indexes: [ + { accessor: 'entity_id', name: 'entity_entity_id_idx_btree', algorithm: 'btree', columns: [ + 'entityId', + ] }, + ], + constraints: [ + { name: 'entity_entity_id_key', constraint: 'unique', columns: ['entityId'] }, + ], + }, EntityRow), + food: __table({ + name: 'food', + indexes: [ + { accessor: 'entity_id', name: 'food_entity_id_idx_btree', algorithm: 'btree', columns: [ + 'entityId', + ] }, + ], + constraints: [ + { name: 'food_entity_id_key', constraint: 'unique', columns: ['entityId'] }, + ], + }, FoodRow), + player: __table({ + name: 'player', + indexes: [ + { accessor: 'identity', name: 'player_identity_idx_btree', algorithm: 'btree', columns: [ + 'identity', + ] }, + { accessor: 'player_id', name: 'player_player_id_idx_btree', algorithm: 'btree', columns: [ + 'playerId', + ] }, + ], + constraints: [ + { name: 'player_identity_key', constraint: 'unique', columns: ['identity'] }, + { name: 'player_player_id_key', constraint: 'unique', columns: ['playerId'] }, + ], + }, PlayerRow), +}); + +/** The schema information for all reducers in this module. This is defined the same way as the reducers would have been defined in the server, except the body of the reducer is omitted in code generation. */ +const reducersSchema = __reducers( + __reducerSchema("enter_game", EnterGameReducer), + __reducerSchema("player_split", PlayerSplitReducer), + __reducerSchema("respawn", RespawnReducer), + __reducerSchema("suicide", SuicideReducer), + __reducerSchema("update_player_input", UpdatePlayerInputReducer), +); + +/** The schema information for all procedures in this module. This is defined the same way as the procedures would have been defined in the server. */ +const proceduresSchema = __procedures( +); + +/** The remote SpacetimeDB module schema, both runtime and type information. */ +const REMOTE_MODULE = { + versionInfo: { + cliVersion: "2.2.0" as const, + }, + tables: tablesSchema.schemaType.tables, + reducers: reducersSchema.reducersType.reducers, + ...proceduresSchema, +} satisfies __RemoteModule< + typeof tablesSchema.schemaType, + typeof reducersSchema.reducersType, + typeof proceduresSchema +>; + +/** The tables available in this remote SpacetimeDB module. Each table reference doubles as a query builder. */ +export const tables: __QueryBuilder = __makeQueryBuilder(tablesSchema.schemaType); + +/** The reducers available in this remote SpacetimeDB module. */ +export const reducers = __convertToAccessorMap(reducersSchema.reducersType.reducers); + +/** The procedures available in this remote SpacetimeDB module. */ +export const procedures = __convertToAccessorMap(proceduresSchema.procedures); + +/** The context type returned in callbacks for all possible events. */ +export type EventContext = __EventContextInterface; +/** The context type returned in callbacks for reducer events. */ +export type ReducerEventContext = __ReducerEventContextInterface; +/** The context type returned in callbacks for subscription events. */ +export type SubscriptionEventContext = __SubscriptionEventContextInterface; +/** The context type returned in callbacks for error events. */ +export type ErrorContext = __ErrorContextInterface; +/** The subscription handle type to manage active subscriptions created from a {@link SubscriptionBuilder}. */ +export type SubscriptionHandle = __SubscriptionHandleImpl; + +/** Builder class to configure a new subscription to the remote SpacetimeDB instance. */ +export class SubscriptionBuilder extends __SubscriptionBuilderImpl {} + +/** Builder class to configure a new database connection to the remote SpacetimeDB instance. */ +export class DbConnectionBuilder extends __DbConnectionBuilder {} + +/** The typed database connection to manage connections to the remote SpacetimeDB instance. This class has type information specific to the generated module. */ +export class DbConnection extends __DbConnectionImpl { + /** Creates a new {@link DbConnectionBuilder} to configure and connect to the remote SpacetimeDB instance. */ + static builder = (): DbConnectionBuilder => { + return new DbConnectionBuilder(REMOTE_MODULE, (config: __DbConnectionConfig) => new DbConnection(config)); + }; + + /** Creates a new {@link SubscriptionBuilder} to configure a subscription to the remote SpacetimeDB instance. */ + override subscriptionBuilder = (): SubscriptionBuilder => { + return new SubscriptionBuilder(this); + }; +} + diff --git a/demo/Blackholio/client-ts/src/module_bindings/player_split_reducer.ts b/demo/Blackholio/client-ts/src/module_bindings/player_split_reducer.ts new file mode 100644 index 00000000000..e18fbc0a086 --- /dev/null +++ b/demo/Blackholio/client-ts/src/module_bindings/player_split_reducer.ts @@ -0,0 +1,13 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default {}; diff --git a/demo/Blackholio/client-ts/src/module_bindings/player_table.ts b/demo/Blackholio/client-ts/src/module_bindings/player_table.ts new file mode 100644 index 00000000000..51aa446511d --- /dev/null +++ b/demo/Blackholio/client-ts/src/module_bindings/player_table.ts @@ -0,0 +1,17 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + identity: __t.identity().primaryKey(), + playerId: __t.i32().name("player_id"), + name: __t.string(), +}); diff --git a/demo/Blackholio/client-ts/src/module_bindings/respawn_reducer.ts b/demo/Blackholio/client-ts/src/module_bindings/respawn_reducer.ts new file mode 100644 index 00000000000..e18fbc0a086 --- /dev/null +++ b/demo/Blackholio/client-ts/src/module_bindings/respawn_reducer.ts @@ -0,0 +1,13 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default {}; diff --git a/demo/Blackholio/client-ts/src/module_bindings/suicide_reducer.ts b/demo/Blackholio/client-ts/src/module_bindings/suicide_reducer.ts new file mode 100644 index 00000000000..e18fbc0a086 --- /dev/null +++ b/demo/Blackholio/client-ts/src/module_bindings/suicide_reducer.ts @@ -0,0 +1,13 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default {}; diff --git a/demo/Blackholio/client-ts/src/module_bindings/types.ts b/demo/Blackholio/client-ts/src/module_bindings/types.ts new file mode 100644 index 00000000000..3a73452ba8f --- /dev/null +++ b/demo/Blackholio/client-ts/src/module_bindings/types.ts @@ -0,0 +1,95 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export const Circle = __t.object("Circle", { + entityId: __t.i32(), + playerId: __t.i32(), + get direction() { + return DbVector2; + }, + speed: __t.f32(), + lastSplitTime: __t.timestamp(), +}); +export type Circle = __Infer; + +export const CircleDecayTimer = __t.object("CircleDecayTimer", { + scheduledId: __t.u64(), + scheduledAt: __t.scheduleAt(), +}); +export type CircleDecayTimer = __Infer; + +export const CircleRecombineTimer = __t.object("CircleRecombineTimer", { + scheduledId: __t.u64(), + scheduledAt: __t.scheduleAt(), + playerId: __t.i32(), +}); +export type CircleRecombineTimer = __Infer; + +export const Config = __t.object("Config", { + id: __t.i32(), + worldSize: __t.i64(), +}); +export type Config = __Infer; + +export const ConsumeEntityEvent = __t.object("ConsumeEntityEvent", { + consumedEntityId: __t.i32(), + consumerEntityId: __t.i32(), +}); +export type ConsumeEntityEvent = __Infer; + +export const ConsumeEntityTimer = __t.object("ConsumeEntityTimer", { + scheduledId: __t.u64(), + scheduledAt: __t.scheduleAt(), + consumedEntityId: __t.i32(), + consumerEntityId: __t.i32(), +}); +export type ConsumeEntityTimer = __Infer; + +export const DbVector2 = __t.object("DbVector2", { + x: __t.f32(), + y: __t.f32(), +}); +export type DbVector2 = __Infer; + +export const Entity = __t.object("Entity", { + entityId: __t.i32(), + get position() { + return DbVector2; + }, + mass: __t.i32(), +}); +export type Entity = __Infer; + +export const Food = __t.object("Food", { + entityId: __t.i32(), +}); +export type Food = __Infer; + +export const MoveAllPlayersTimer = __t.object("MoveAllPlayersTimer", { + scheduledId: __t.u64(), + scheduledAt: __t.scheduleAt(), +}); +export type MoveAllPlayersTimer = __Infer; + +export const Player = __t.object("Player", { + identity: __t.identity(), + playerId: __t.i32(), + name: __t.string(), +}); +export type Player = __Infer; + +export const SpawnFoodTimer = __t.object("SpawnFoodTimer", { + scheduledId: __t.u64(), + scheduledAt: __t.scheduleAt(), +}); +export type SpawnFoodTimer = __Infer; + diff --git a/demo/Blackholio/client-ts/src/module_bindings/types/procedures.ts b/demo/Blackholio/client-ts/src/module_bindings/types/procedures.ts new file mode 100644 index 00000000000..d5ac825c9ab --- /dev/null +++ b/demo/Blackholio/client-ts/src/module_bindings/types/procedures.ts @@ -0,0 +1,10 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { type Infer as __Infer } from "spacetimedb"; + +// Import all procedure arg schemas + + diff --git a/demo/Blackholio/client-ts/src/module_bindings/types/reducers.ts b/demo/Blackholio/client-ts/src/module_bindings/types/reducers.ts new file mode 100644 index 00000000000..a1cdd1d4eed --- /dev/null +++ b/demo/Blackholio/client-ts/src/module_bindings/types/reducers.ts @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { type Infer as __Infer } from "spacetimedb"; + +// Import all reducer arg schemas +import EnterGameReducer from "../enter_game_reducer"; +import PlayerSplitReducer from "../player_split_reducer"; +import RespawnReducer from "../respawn_reducer"; +import SuicideReducer from "../suicide_reducer"; +import UpdatePlayerInputReducer from "../update_player_input_reducer"; + +export type EnterGameParams = __Infer; +export type PlayerSplitParams = __Infer; +export type RespawnParams = __Infer; +export type SuicideParams = __Infer; +export type UpdatePlayerInputParams = __Infer; + diff --git a/demo/Blackholio/client-ts/src/module_bindings/update_player_input_reducer.ts b/demo/Blackholio/client-ts/src/module_bindings/update_player_input_reducer.ts new file mode 100644 index 00000000000..22be8557313 --- /dev/null +++ b/demo/Blackholio/client-ts/src/module_bindings/update_player_input_reducer.ts @@ -0,0 +1,21 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + DbVector2, +} from "./types"; + +export default { + get direction() { + return DbVector2; + }, +}; diff --git a/demo/Blackholio/client-ts/src/style.css b/demo/Blackholio/client-ts/src/style.css new file mode 100644 index 00000000000..9099fe28496 --- /dev/null +++ b/demo/Blackholio/client-ts/src/style.css @@ -0,0 +1,136 @@ +:root { + color: #f5f7ff; + font-family: Inter, system-ui, sans-serif; + background: #050817; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + overflow: hidden; +} + +#app, +#game, +canvas { + height: 100vh; + width: 100vw; +} + +.hud, +.leaderboard, +.controls { + position: fixed; + z-index: 2; + background: rgb(3 7 24 / 72%); + border: 1px solid rgb(221 166 62 / 40%); + backdrop-filter: blur(4px); +} + +.hud { + display: flex; + gap: 1.5rem; + left: 1rem; + top: 1rem; + padding: 0.65rem 0.85rem; + border-radius: 0.5rem; +} + +#status.connected { + color: #55e29a; +} + +#status.error { + color: #f77777; +} + +.leaderboard { + right: 1rem; + top: 1rem; + min-width: 13rem; + padding: 0.8rem 1rem; + border-radius: 0.5rem; +} + +.leaderboard h2 { + font-size: 1rem; + margin: 0 0 0.55rem; +} + +.leaderboard ol { + display: grid; + gap: 0.28rem; + margin: 0; + padding-left: 1.4rem; +} + +.leaderboard li.local { + color: #ffd76c; + font-weight: 700; +} + +.controls { + bottom: 1rem; + left: 50%; + padding: 0.55rem 0.85rem; + border-radius: 0.5rem; + transform: translateX(-50%); +} + +kbd { + background: #18213c; + border-radius: 0.25rem; + padding: 0.1rem 0.35rem; +} + +.overlay { + align-items: center; + background: rgb(1 4 14 / 68%); + display: flex; + inset: 0; + justify-content: center; + position: fixed; + z-index: 3; +} + +.panel { + background: #0d1430; + border: 1px solid #dda63e; + border-radius: 0.7rem; + display: grid; + gap: 0.85rem; + min-width: min(23rem, calc(100vw - 2rem)); + padding: 1.5rem; +} + +.panel h1, +.panel p { + margin: 0; +} + +.panel input, +.panel button { + border-radius: 0.35rem; + font: inherit; + padding: 0.7rem; +} + +.panel input { + background: #f7f8ff; + border: 0; +} + +.panel button { + background: #dda63e; + border: 0; + color: #091026; + cursor: pointer; + font-weight: 700; +} + +.hidden { + display: none; +} diff --git a/demo/Blackholio/client-ts/src/ui/DeathScreen.ts b/demo/Blackholio/client-ts/src/ui/DeathScreen.ts new file mode 100644 index 00000000000..cfb7a2bd5d5 --- /dev/null +++ b/demo/Blackholio/client-ts/src/ui/DeathScreen.ts @@ -0,0 +1,16 @@ +export class DeathScreen { + private readonly overlay = document.querySelector( + '#death-overlay' + ) as HTMLElement; + private readonly respawnButton = document.querySelector( + '#respawn-button' + ) as HTMLButtonElement; + + constructor(onRespawn: () => void) { + this.respawnButton.addEventListener('click', onRespawn); + } + + setVisible(visible: boolean): void { + this.overlay.classList.toggle('hidden', !visible); + } +} diff --git a/demo/Blackholio/client-ts/src/ui/LeaderboardController.ts b/demo/Blackholio/client-ts/src/ui/LeaderboardController.ts new file mode 100644 index 00000000000..5581b5388b9 --- /dev/null +++ b/demo/Blackholio/client-ts/src/ui/LeaderboardController.ts @@ -0,0 +1,30 @@ +import type { PlayerController } from '../game/PlayerController'; +import { leaderboardRows } from '../game/leaderboard'; + +export class LeaderboardController { + private readonly root = document.querySelector('#leaderboard') as HTMLElement; + private readonly list = this.root.querySelector('ol') as HTMLOListElement; + + update( + players: Iterable, + localPlayer?: PlayerController + ): void { + const rows = leaderboardRows( + Array.from(players, player => ({ + id: player.playerId, + name: player.username, + mass: player.totalMass(), + local: player === localPlayer, + })) + ); + this.root.classList.toggle('hidden', rows.length === 0); + this.list.replaceChildren( + ...rows.map(player => { + const item = document.createElement('li'); + item.classList.toggle('local', player.local); + item.textContent = `${player.name} - ${player.mass}`; + return item; + }) + ); + } +} diff --git a/demo/Blackholio/client-ts/src/ui/StatusHud.ts b/demo/Blackholio/client-ts/src/ui/StatusHud.ts new file mode 100644 index 00000000000..e4528c6d6a0 --- /dev/null +++ b/demo/Blackholio/client-ts/src/ui/StatusHud.ts @@ -0,0 +1,17 @@ +import type { PlayerController } from '../game/PlayerController'; + +export class StatusHud { + private readonly status = document.querySelector('#status') as HTMLElement; + private readonly mass = document.querySelector('#mass') as HTMLElement; + private readonly massValue = this.mass.querySelector('strong') as HTMLElement; + + setStatus(message: string, state: 'pending' | 'connected' | 'error'): void { + this.status.textContent = message; + this.status.className = state === 'pending' ? '' : state; + } + + update(localPlayer?: PlayerController): void { + this.mass.classList.toggle('hidden', !localPlayer); + this.massValue.textContent = String(localPlayer?.totalMass() ?? 0); + } +} diff --git a/demo/Blackholio/client-ts/src/ui/UsernameChooser.ts b/demo/Blackholio/client-ts/src/ui/UsernameChooser.ts new file mode 100644 index 00000000000..a2408f5373c --- /dev/null +++ b/demo/Blackholio/client-ts/src/ui/UsernameChooser.ts @@ -0,0 +1,29 @@ +export function submittedUsername(input: string): string { + return input.trim() || ''; +} + +export class UsernameChooser { + private readonly overlay = document.querySelector( + '#join-overlay' + ) as HTMLElement; + private readonly form = document.querySelector( + '#join-form' + ) as HTMLFormElement; + private readonly input = document.querySelector( + '#name-input' + ) as HTMLInputElement; + + constructor(onPlay: (name: string) => void) { + this.form.addEventListener('submit', event => { + event.preventDefault(); + onPlay(submittedUsername(this.input.value)); + }); + } + + show(visible: boolean): void { + this.overlay.classList.toggle('hidden', !visible); + if (visible) { + this.input.focus(); + } + } +} diff --git a/demo/Blackholio/client-ts/tests/game.test.ts b/demo/Blackholio/client-ts/tests/game.test.ts new file mode 100644 index 00000000000..d58c4c86d5b --- /dev/null +++ b/demo/Blackholio/client-ts/tests/game.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; +import { pointerDirection } from '../src/game/input'; +import { leaderboardRows } from '../src/game/leaderboard'; +import { cameraSize, centerOfMass, massToRadius } from '../src/game/math'; +import { submittedUsername } from '../src/ui/UsernameChooser'; + +describe('Blackholio gameplay helpers', () => { + it('converts server mass into a rendered radius', () => { + expect(massToRadius(25)).toBe(5); + }); + + it('calculates a weighted local camera center', () => { + expect( + centerOfMass([ + { mass: 10, position: { x: 0, y: 10 } }, + { mass: 30, position: { x: 20, y: 10 } }, + ]) + ).toEqual({ x: 15, y: 10 }); + expect(centerOfMass([])).toBeUndefined(); + }); + + it('matches the Unity camera size increase after splitting', () => { + expect(cameraSize(15, 1)).toBe(53); + expect(cameraSize(100, 1)).toBe(70); + expect(cameraSize(100, 2)).toBe(100); + expect(cameraSize(100, 5)).toBe(100); + }); + + it('normalizes pointer movement relative to viewport height', () => { + expect(pointerDirection({ x: 800, y: 300 }, { x: 800, y: 600 })).toEqual({ + x: 2, + y: 0, + }); + }); + + it('adds a living local player below the top-ten cutoff', () => { + const rows = leaderboardRows([ + ...Array.from({ length: 11 }, (_, i) => ({ + id: i, + name: `P${i}`, + mass: 100 - i, + local: false, + })), + { id: 99, name: 'Local', mass: 1, local: true }, + ]); + expect(rows).toHaveLength(11); + expect(rows.at(-1)?.id).toBe(99); + }); + + it('submits the same default username as the Unity chooser', () => { + expect(submittedUsername(' Alice ')).toBe('Alice'); + expect(submittedUsername(' ')).toBe(''); + }); +}); diff --git a/demo/Blackholio/client-ts/tsconfig.json b/demo/Blackholio/client-ts/tsconfig.json new file mode 100644 index 00000000000..63a9c13e3b0 --- /dev/null +++ b/demo/Blackholio/client-ts/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["vite/client"], + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src", "tests", "vite.config.ts"] +} diff --git a/demo/Blackholio/client-ts/vite.config.ts b/demo/Blackholio/client-ts/vite.config.ts new file mode 100644 index 00000000000..3f380a0ba8d --- /dev/null +++ b/demo/Blackholio/client-ts/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite'; + +export default defineConfig({ + server: { + port: 5173, + }, +}); From c889bb4ad7efe492fa48f3cb43bcd80b6e3717f5 Mon Sep 17 00:00:00 2001 From: JasonAtClockwork Date: Thu, 28 May 2026 10:21:11 -0700 Subject: [PATCH 2/2] Added Blackholio TS server --- demo/Blackholio/README.md | 1 + .../client-ts/src/module_bindings/index.ts | 4 +- demo/Blackholio/server-ts/.gitignore | 4 + demo/Blackholio/server-ts/.npmrc | 1 + demo/Blackholio/server-ts/README.md | 33 + demo/Blackholio/server-ts/generate.sh | 5 + demo/Blackholio/server-ts/logs.sh | 5 + demo/Blackholio/server-ts/package.json | 23 + demo/Blackholio/server-ts/pnpm-lock.yaml | 28 + demo/Blackholio/server-ts/pnpm-workspace.yaml | 4 + demo/Blackholio/server-ts/publish.sh | 5 + demo/Blackholio/server-ts/src/index.ts | 725 ++++++++++++++++++ demo/Blackholio/server-ts/src/math.ts | 40 + demo/Blackholio/server-ts/tsconfig.json | 14 + 14 files changed, 890 insertions(+), 2 deletions(-) create mode 100644 demo/Blackholio/server-ts/.gitignore create mode 100644 demo/Blackholio/server-ts/.npmrc create mode 100644 demo/Blackholio/server-ts/README.md create mode 100755 demo/Blackholio/server-ts/generate.sh create mode 100755 demo/Blackholio/server-ts/logs.sh create mode 100644 demo/Blackholio/server-ts/package.json create mode 100644 demo/Blackholio/server-ts/pnpm-lock.yaml create mode 100644 demo/Blackholio/server-ts/pnpm-workspace.yaml create mode 100755 demo/Blackholio/server-ts/publish.sh create mode 100644 demo/Blackholio/server-ts/src/index.ts create mode 100644 demo/Blackholio/server-ts/src/math.ts create mode 100644 demo/Blackholio/server-ts/tsconfig.json diff --git a/demo/Blackholio/README.md b/demo/Blackholio/README.md index 6579bc505a4..85635e9059e 100644 --- a/demo/Blackholio/README.md +++ b/demo/Blackholio/README.md @@ -69,6 +69,7 @@ Blackholio/ ├── client-ts/ # Browser client using Phaser and TypeScript ├── server-csharp/ # SpacetimeDB server module (C# implementation) ├── server-rust/ # SpacetimeDB server module (Rust implementation) +├── server-ts/ # SpacetimeDB server module (TypeScript implementation) ├── DEVELOP.md # Development guidelines └── README.md # This file ``` diff --git a/demo/Blackholio/client-ts/src/module_bindings/index.ts b/demo/Blackholio/client-ts/src/module_bindings/index.ts index d07cfc7f579..6120bf25542 100644 --- a/demo/Blackholio/client-ts/src/module_bindings/index.ts +++ b/demo/Blackholio/client-ts/src/module_bindings/index.ts @@ -1,7 +1,7 @@ // THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE // WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. -// This was generated using spacetimedb cli version 2.2.0 (commit d62295d89cb41be71b927b62522b8a405ae08a21). +// This was generated using spacetimedb cli version 2.3.0 (commit aa73d1c35b4b346b98eeba10a3d756b4ae72162f). /* eslint-disable */ /* tslint:disable */ @@ -142,7 +142,7 @@ const proceduresSchema = __procedures( /** The remote SpacetimeDB module schema, both runtime and type information. */ const REMOTE_MODULE = { versionInfo: { - cliVersion: "2.2.0" as const, + cliVersion: "2.3.0" as const, }, tables: tablesSchema.schemaType.tables, reducers: reducersSchema.reducersType.reducers, diff --git a/demo/Blackholio/server-ts/.gitignore b/demo/Blackholio/server-ts/.gitignore new file mode 100644 index 00000000000..ff5bbf2102e --- /dev/null +++ b/demo/Blackholio/server-ts/.gitignore @@ -0,0 +1,4 @@ +node_modules +*.log + +.DS_Store diff --git a/demo/Blackholio/server-ts/.npmrc b/demo/Blackholio/server-ts/.npmrc new file mode 100644 index 00000000000..44bdf80d1df --- /dev/null +++ b/demo/Blackholio/server-ts/.npmrc @@ -0,0 +1 @@ +minimum-release-age=1440 diff --git a/demo/Blackholio/server-ts/README.md b/demo/Blackholio/server-ts/README.md new file mode 100644 index 00000000000..89ba8b2806c --- /dev/null +++ b/demo/Blackholio/server-ts/README.md @@ -0,0 +1,33 @@ +# Blackholio TypeScript Server + +SpacetimeDB TypeScript implementation of the Blackholio server module. This +module is intended to match `../server-rust` so the browser client can target +either server after regenerating bindings. + +This is a standalone pnpm project. Its local `pnpm-workspace.yaml` prevents pnpm +from selecting the repository workspace, and both pnpm config files enforce a +1440-minute minimum release age. + +The checked-in dependency configuration links the TypeScript SDK from this +repository during development: + +```json +"spacetimedb": "link:../../../crates/bindings-typescript" +``` + +When publishing this demo outside the SpacetimeDB repository, replace the local +link with the current published npm package version. + +## Commands + +```bash +pnpm install +pnpm run typecheck +spacetime build +./generate.sh +./publish.sh +``` + +If `pnpm install` is interrupted while fetching packages, treat the generated +`node_modules` directory as disposable and rerun the install when the registry is +available. Do not work around minimum release age with `resolution-mode`. diff --git a/demo/Blackholio/server-ts/generate.sh b/demo/Blackholio/server-ts/generate.sh new file mode 100755 index 00000000000..593795bf0b0 --- /dev/null +++ b/demo/Blackholio/server-ts/generate.sh @@ -0,0 +1,5 @@ +#!/bin/bash + +set -euo pipefail + +spacetime generate --lang typescript --out-dir ../client-ts/src/module_bindings --module-path . $@ diff --git a/demo/Blackholio/server-ts/logs.sh b/demo/Blackholio/server-ts/logs.sh new file mode 100755 index 00000000000..9da9123e77a --- /dev/null +++ b/demo/Blackholio/server-ts/logs.sh @@ -0,0 +1,5 @@ +#!/bin/bash + +set -euo pipefail + +spacetime logs -s local blackholio diff --git a/demo/Blackholio/server-ts/package.json b/demo/Blackholio/server-ts/package.json new file mode 100644 index 00000000000..1b208fb57b1 --- /dev/null +++ b/demo/Blackholio/server-ts/package.json @@ -0,0 +1,23 @@ +{ + "name": "@clockworklabs/blackholio-server-ts", + "private": true, + "version": "0.0.1", + "type": "module", + "packageManager": "pnpm@10.16.0", + "engines": { + "node": ">=18", + "pnpm": ">=10.16.0" + }, + "scripts": { + "build": "spacetime build", + "publish": "./publish.sh", + "spacetime:generate": "./generate.sh", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "spacetimedb": "link:../../../crates/bindings-typescript" + }, + "devDependencies": { + "typescript": "~5.6.2" + } +} diff --git a/demo/Blackholio/server-ts/pnpm-lock.yaml b/demo/Blackholio/server-ts/pnpm-lock.yaml new file mode 100644 index 00000000000..20db07f6294 --- /dev/null +++ b/demo/Blackholio/server-ts/pnpm-lock.yaml @@ -0,0 +1,28 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + spacetimedb: + specifier: link:../../../crates/bindings-typescript + version: link:../../../crates/bindings-typescript + devDependencies: + typescript: + specifier: ~5.6.2 + version: 5.6.3 + +packages: + + typescript@5.6.3: + resolution: {integrity: sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==} + engines: {node: '>=14.17'} + hasBin: true + +snapshots: + + typescript@5.6.3: {} diff --git a/demo/Blackholio/server-ts/pnpm-workspace.yaml b/demo/Blackholio/server-ts/pnpm-workspace.yaml new file mode 100644 index 00000000000..4a28b5fd1d9 --- /dev/null +++ b/demo/Blackholio/server-ts/pnpm-workspace.yaml @@ -0,0 +1,4 @@ +packages: + - '.' + +minimumReleaseAge: 1440 diff --git a/demo/Blackholio/server-ts/publish.sh b/demo/Blackholio/server-ts/publish.sh new file mode 100755 index 00000000000..439d96eeb37 --- /dev/null +++ b/demo/Blackholio/server-ts/publish.sh @@ -0,0 +1,5 @@ +#!/bin/bash + +set -euo pipefail + +spacetime publish -s local blackholio --module-path . --delete-data -y diff --git a/demo/Blackholio/server-ts/src/index.ts b/demo/Blackholio/server-ts/src/index.ts new file mode 100644 index 00000000000..cfde077ea35 --- /dev/null +++ b/demo/Blackholio/server-ts/src/index.ts @@ -0,0 +1,725 @@ +import { ScheduleAt } from 'spacetimedb'; +import { + schema, + table, + t, + type InferSchema, + type InferTypeOfRow, + type ReducerCtx, +} from 'spacetimedb/server'; +import { + add, + DbVector2, + magnitude, + mul, + normalized, + sqrMagnitude, + sub, + vec, + type DbVector2 as DbVector2Type, +} from './math'; + +const START_PLAYER_MASS = 15; +const START_PLAYER_SPEED = 10; +const FOOD_MASS_MIN = 2; +const FOOD_MASS_MAX = 4; +const TARGET_FOOD_COUNT = 600n; +const MINIMUM_SAFE_MASS_RATIO = 0.85; + +const MIN_MASS_TO_SPLIT = START_PLAYER_MASS * 2; +const MAX_CIRCLES_PER_PLAYER = 16; +const SPLIT_RECOMBINE_DELAY_SEC = 5; +const SPLIT_GRAV_PULL_BEFORE_RECOMBINE_SEC = 2; +const ALLOWED_SPLIT_CIRCLE_OVERLAP_PCT = 0.9; +const SELF_COLLISION_SPEED = 0.05; + +const MICROS_PER_SECOND = 1_000_000n; + +const configRow = t.row('Config', { + id: t.i32().primaryKey(), + world_size: t.i64().name('world_size'), +}); + +const entityRow = t.row('Entity', { + entity_id: t.i32().primaryKey().autoInc().name('entity_id'), + position: DbVector2, + mass: t.i32(), +}); +type Entity = InferTypeOfRow; + +const circleRow = t.row('Circle', { + entity_id: t.i32().primaryKey().name('entity_id'), + player_id: t.i32().index().name('player_id'), + direction: DbVector2, + speed: t.f32(), + last_split_time: t.timestamp().name('last_split_time'), +}); +type Circle = InferTypeOfRow; + +const playerRow = t.row('Player', { + identity: t.identity().primaryKey(), + player_id: t.i32().unique().autoInc().name('player_id'), + name: t.string(), +}); +type Player = InferTypeOfRow; + +const foodRow = t.row('Food', { + entity_id: t.i32().primaryKey().name('entity_id'), +}); + +const moveAllPlayersTimerRow = t.row('MoveAllPlayersTimer', { + scheduled_id: t.u64().primaryKey().autoInc().name('scheduled_id'), + scheduled_at: t.scheduleAt().name('scheduled_at'), +}); +type MoveAllPlayersTimer = InferTypeOfRow; + +const spawnFoodTimerRow = t.row('SpawnFoodTimer', { + scheduled_id: t.u64().primaryKey().autoInc().name('scheduled_id'), + scheduled_at: t.scheduleAt().name('scheduled_at'), +}); +type SpawnFoodTimer = InferTypeOfRow; + +const circleDecayTimerRow = t.row('CircleDecayTimer', { + scheduled_id: t.u64().primaryKey().autoInc().name('scheduled_id'), + scheduled_at: t.scheduleAt().name('scheduled_at'), +}); +type CircleDecayTimer = InferTypeOfRow; + +const circleRecombineTimerRow = t.row('CircleRecombineTimer', { + scheduled_id: t.u64().primaryKey().autoInc().name('scheduled_id'), + scheduled_at: t.scheduleAt().name('scheduled_at'), + player_id: t.i32().name('player_id'), +}); +type CircleRecombineTimer = InferTypeOfRow; + +const consumeEntityEventRow = t.row('ConsumeEntityEvent', { + consumed_entity_id: t.i32().name('consumed_entity_id'), + consumer_entity_id: t.i32().name('consumer_entity_id'), +}); + +const consumeEntityTimerRow = t.row('ConsumeEntityTimer', { + scheduled_id: t.u64().primaryKey().autoInc().name('scheduled_id'), + scheduled_at: t.scheduleAt().name('scheduled_at'), + consumed_entity_id: t.i32().name('consumed_entity_id'), + consumer_entity_id: t.i32().name('consumer_entity_id'), +}); +type ConsumeEntityTimer = InferTypeOfRow; + +const spacetimedb = schema({ + config: table({ public: true }, configRow), + entity: table({ public: true }, entityRow), + circle: table({ public: true }, circleRow), + player: table({ public: true }, playerRow), + food: table({ public: true }, foodRow), + consume_entity_event: table( + { public: true, event: true }, + consumeEntityEventRow + ), + logged_out_entity: table({ name: 'logged_out_entity' }, entityRow), + logged_out_circle: table({ name: 'logged_out_circle' }, circleRow), + logged_out_player: table({ name: 'logged_out_player' }, playerRow), + move_all_players_timer: table( + { + name: 'move_all_players_timer', + scheduled: (): any => move_all_players, + }, + moveAllPlayersTimerRow + ), + spawn_food_timer: table( + { + name: 'spawn_food_timer', + scheduled: (): any => spawn_food, + }, + spawnFoodTimerRow + ), + circle_decay_timer: table( + { + name: 'circle_decay_timer', + scheduled: (): any => circle_decay, + }, + circleDecayTimerRow + ), + circle_recombine_timer: table( + { + name: 'circle_recombine_timer', + scheduled: (): any => circle_recombine, + }, + circleRecombineTimerRow + ), + consume_entity_timer: table( + { + name: 'consume_entity_timer', + scheduled: (): any => consume_entity, + }, + consumeEntityTimerRow + ), +}); +export default spacetimedb; + +type BlackholioCtx = ReducerCtx>; + +export const init = spacetimedb.init(ctx => { + ctx.db.config.insert({ id: 0, world_size: 1000n }); + ctx.db.circle_decay_timer.insert({ + scheduled_id: 0n, + scheduled_at: ScheduleAt.interval(5n * MICROS_PER_SECOND), + }); + ctx.db.spawn_food_timer.insert({ + scheduled_id: 0n, + scheduled_at: ScheduleAt.interval(500_000n), + }); + ctx.db.move_all_players_timer.insert({ + scheduled_id: 0n, + scheduled_at: ScheduleAt.interval(50_000n), + }); +}); + +export const connect = spacetimedb.clientConnected(ctx => { + const loggedOutPlayer = ctx.db.logged_out_player.identity.find(ctx.sender); + if (loggedOutPlayer) { + ctx.db.player.insert(loggedOutPlayer); + ctx.db.logged_out_player.identity.delete(loggedOutPlayer.identity); + + for (const circle of ctx.db.logged_out_circle.player_id.filter( + loggedOutPlayer.player_id + )) { + ctx.db.logged_out_circle.entity_id.delete(circle.entity_id); + ctx.db.circle.insert(circle); + const entity = ctx.db.logged_out_entity.entity_id.find(circle.entity_id); + if (!entity) { + throw new Error('Logged out circle has no entity'); + } + ctx.db.logged_out_entity.entity_id.delete(circle.entity_id); + ctx.db.entity.insert(entity); + } + } else { + ctx.db.player.insert({ + identity: ctx.sender, + player_id: 0, + name: '', + }); + } +}); + +export const disconnect = spacetimedb.clientDisconnected(ctx => { + const player = ctx.db.player.identity.find(ctx.sender); + if (!player) { + throw new Error('Player not found'); + } + const player_id = player.player_id; + ctx.db.logged_out_player.insert(player); + ctx.db.player.identity.delete(ctx.sender); + + for (const circle of ctx.db.circle.player_id.filter(player_id)) { + const entity = ctx.db.entity.entity_id.find(circle.entity_id); + if (!entity) { + throw new Error('Circle has no entity'); + } + ctx.db.logged_out_entity.insert(entity); + ctx.db.entity.entity_id.delete(circle.entity_id); + ctx.db.logged_out_circle.insert(circle); + ctx.db.circle.entity_id.delete(circle.entity_id); + } +}); + +export const enter_game = spacetimedb.reducer( + { name: t.string() }, + (ctx, { name }) => { + console.info(`Creating player with name ${name}`); + const player = ctx.db.player.identity.find(ctx.sender); + if (!player) { + throw new Error(''); + } + ctx.db.player.identity.update({ ...player, name }); + spawnPlayerInitialCircle(ctx, player.player_id); + } +); + +export const respawn = spacetimedb.reducer(ctx => { + const player = ctx.db.player.identity.find(ctx.sender); + if (!player) { + throw new Error('No such player found'); + } + spawnPlayerInitialCircle(ctx, player.player_id); +}); + +export const suicide = spacetimedb.reducer(ctx => { + const player = ctx.db.player.identity.find(ctx.sender); + if (!player) { + throw new Error('No such player found'); + } + for (const circle of ctx.db.circle.player_id.filter(player.player_id)) { + destroyEntity(ctx, circle.entity_id); + } +}); + +export const update_player_input = spacetimedb.reducer( + { direction: DbVector2 }, + (ctx, { direction }) => { + const player = ctx.db.player.identity.find(ctx.sender); + if (!player) { + throw new Error('Player not found'); + } + for (const circle of ctx.db.circle.player_id.filter(player.player_id)) { + const inputMagnitude = magnitude(direction); + ctx.db.circle.entity_id.update({ + ...circle, + direction: normalized(direction), + speed: Math.min(1, Math.max(0, inputMagnitude)), + }); + } + } +); + +export const move_all_players = spacetimedb.reducer( + { arg: moveAllPlayersTimerRow }, + (ctx, { arg: _timer }: { arg: MoveAllPlayersTimer }) => { + const config = ctx.db.config.id.find(0); + if (!config) { + throw new Error('Config not found'); + } + const world_size = Number(config.world_size); + const circleDirections = new Map(); + for (const circle of ctx.db.circle.iter()) { + circleDirections.set(circle.entity_id, mul(circle.direction, circle.speed)); + } + + for (const player of ctx.db.player.iter()) { + const circles = Array.from(ctx.db.circle.player_id.filter(player.player_id)); + const playerEntities = circles.map(circle => { + const entity = ctx.db.entity.entity_id.find(circle.entity_id); + if (!entity) { + throw new Error('Circle has no entity'); + } + return { ...entity }; + }); + if (playerEntities.length <= 1) { + continue; + } + applySplitMovement( + ctx.timestamp.microsSinceUnixEpoch, + circles, + playerEntities, + circleDirections + ); + } + + for (const circle of ctx.db.circle.iter()) { + const circleEntity = ctx.db.entity.entity_id.find(circle.entity_id); + if (!circleEntity) { + continue; + } + const circleRadius = massToRadius(circleEntity.mass); + const direction = circleDirections.get(circle.entity_id); + if (!direction) { + continue; + } + const newPos = add( + circleEntity.position, + mul(direction, massToMaxMoveSpeed(circleEntity.mass)) + ); + const min = circleRadius; + const max = world_size - circleRadius; + ctx.db.entity.entity_id.update({ + ...circleEntity, + position: { + x: clamp(newPos.x, min, max), + y: clamp(newPos.y, min, max), + }, + }); + } + + const entities = new Map(); + for (const entity of ctx.db.entity.iter()) { + entities.set(entity.entity_id, entity); + } + for (const circle of ctx.db.circle.iter()) { + const circleEntity = entities.get(circle.entity_id); + if (!circleEntity) { + continue; + } + for (const otherEntity of entities.values()) { + if (otherEntity.entity_id === circleEntity.entity_id) { + continue; + } + if (!isOverlapping(circleEntity, otherEntity)) { + continue; + } + const otherCircle = ctx.db.circle.entity_id.find(otherEntity.entity_id); + if (otherCircle) { + if (otherCircle.player_id !== circle.player_id) { + const massRatio = otherEntity.mass / circleEntity.mass; + if (massRatio < MINIMUM_SAFE_MASS_RATIO) { + scheduleConsumeEntity( + ctx, + circleEntity.entity_id, + otherEntity.entity_id + ); + } + } + } else { + scheduleConsumeEntity(ctx, circleEntity.entity_id, otherEntity.entity_id); + } + } + } + } +); + +export const consume_entity = spacetimedb.reducer( + { arg: consumeEntityTimerRow }, + (ctx, { arg }: { arg: ConsumeEntityTimer }) => { + const consumedEntity = ctx.db.entity.entity_id.find(arg.consumed_entity_id); + const consumerEntity = ctx.db.entity.entity_id.find(arg.consumer_entity_id); + if (!consumedEntity) { + throw new Error("Consumed entity doesn't exist"); + } + if (!consumerEntity) { + throw new Error("Consumer entity doesn't exist"); + } + ctx.db.consume_entity_event.insert({ + consumed_entity_id: consumedEntity.entity_id, + consumer_entity_id: consumerEntity.entity_id, + }); + destroyEntity(ctx, consumedEntity.entity_id); + ctx.db.entity.entity_id.update({ + ...consumerEntity, + mass: consumerEntity.mass + consumedEntity.mass, + }); + } +); + +export const player_split = spacetimedb.reducer(ctx => { + const player = ctx.db.player.identity.find(ctx.sender); + if (!player) { + throw new Error('Sender has no player'); + } + const circles = Array.from(ctx.db.circle.player_id.filter(player.player_id)); + let circleCount = circles.length; + if (circleCount >= MAX_CIRCLES_PER_PLAYER) { + return; + } + + for (const circle of circles) { + const circleEntity = ctx.db.entity.entity_id.find(circle.entity_id); + if (!circleEntity) { + throw new Error('Circle has no entity'); + } + if (circleEntity.mass >= MIN_MASS_TO_SPLIT * 2) { + const halfMass = Math.trunc(circleEntity.mass / 2); + spawnCircleAt( + ctx, + circle.player_id, + halfMass, + add(circleEntity.position, circle.direction), + ctx.timestamp + ); + ctx.db.entity.entity_id.update({ + ...circleEntity, + mass: circleEntity.mass - halfMass, + }); + ctx.db.circle.entity_id.update({ + ...circle, + last_split_time: ctx.timestamp, + }); + circleCount += 1; + if (circleCount >= MAX_CIRCLES_PER_PLAYER) { + break; + } + } + } + + ctx.db.circle_recombine_timer.insert({ + scheduled_id: 0n, + scheduled_at: ScheduleAt.time( + ctx.timestamp.microsSinceUnixEpoch + + BigInt(SPLIT_RECOMBINE_DELAY_SEC) * MICROS_PER_SECOND + ), + player_id: player.player_id, + }); + + console.warn('Player split!'); +}); + +export const spawn_food = spacetimedb.reducer( + { arg: spawnFoodTimerRow }, + (ctx, { arg: _timer }: { arg: SpawnFoodTimer }) => { + if (ctx.db.player.count() === 0n) { + return; + } + const config = ctx.db.config.id.find(0); + if (!config) { + throw new Error('Config not found'); + } + const world_size = Number(config.world_size); + let foodCount = ctx.db.food.count(); + while (foodCount < TARGET_FOOD_COUNT) { + const foodMass = ctx.random.integerInRange( + FOOD_MASS_MIN, + FOOD_MASS_MAX - 1 + ); + const foodRadius = massToRadius(foodMass); + const entity = ctx.db.entity.insert({ + entity_id: 0, + position: { + x: randomRange(ctx.random, foodRadius, world_size - foodRadius), + y: randomRange(ctx.random, foodRadius, world_size - foodRadius), + }, + mass: foodMass, + }); + ctx.db.food.insert({ entity_id: entity.entity_id }); + foodCount += 1n; + console.info(`Spawned food! ${entity.entity_id}`); + } + } +); + +export const circle_decay = spacetimedb.reducer( + { arg: circleDecayTimerRow }, + (ctx, { arg: _timer }: { arg: CircleDecayTimer }) => { + for (const circle of ctx.db.circle.iter()) { + const circleEntity = ctx.db.entity.entity_id.find(circle.entity_id); + if (!circleEntity) { + throw new Error('Entity not found'); + } + if (circleEntity.mass <= START_PLAYER_MASS) { + continue; + } + ctx.db.entity.entity_id.update({ + ...circleEntity, + mass: Math.trunc(circleEntity.mass * 0.99), + }); + } + } +); + +export const circle_recombine = spacetimedb.reducer( + { arg: circleRecombineTimerRow }, + (ctx, { arg }: { arg: CircleRecombineTimer }) => { + const circles = Array.from(ctx.db.circle.player_id.filter(arg.player_id)); + const recombiningEntities = circles + .filter( + circle => + secondsSince( + ctx.timestamp.microsSinceUnixEpoch, + circle.last_split_time.microsSinceUnixEpoch + ) >= SPLIT_RECOMBINE_DELAY_SEC + ) + .map(circle => { + const entity = ctx.db.entity.entity_id.find(circle.entity_id); + if (!entity) { + throw new Error('Circle has no entity'); + } + return entity; + }); + if (recombiningEntities.length <= 1) { + return; + } + const baseEntityId = recombiningEntities[0].entity_id; + for (let i = 1; i < recombiningEntities.length; i++) { + scheduleConsumeEntity( + ctx, + baseEntityId, + recombiningEntities[i].entity_id + ); + } + } +); + +function spawnPlayerInitialCircle( + ctx: BlackholioCtx, + player_id: number +): Entity { + const config = ctx.db.config.id.find(0); + if (!config) { + throw new Error('Config not found'); + } + const world_size = Number(config.world_size); + const playerStartRadius = massToRadius(START_PLAYER_MASS); + return spawnCircleAt( + ctx, + player_id, + START_PLAYER_MASS, + { + x: randomRange( + ctx.random, + playerStartRadius, + world_size - playerStartRadius + ), + y: randomRange( + ctx.random, + playerStartRadius, + world_size - playerStartRadius + ), + }, + ctx.timestamp + ); +} + +function spawnCircleAt( + ctx: BlackholioCtx, + player_id: number, + mass: number, + position: DbVector2Type, + timestamp: BlackholioCtx['timestamp'] +): Entity { + const entity = ctx.db.entity.insert({ + entity_id: 0, + position, + mass, + }); + ctx.db.circle.insert({ + entity_id: entity.entity_id, + player_id, + direction: vec(0, 1), + speed: 0, + last_split_time: timestamp, + }); + return entity; +} + +function destroyEntity(ctx: BlackholioCtx, entity_id: number): void { + ctx.db.food.entity_id.delete(entity_id); + ctx.db.circle.entity_id.delete(entity_id); + ctx.db.entity.entity_id.delete(entity_id); +} + +function scheduleConsumeEntity( + ctx: BlackholioCtx, + consumer_entity_id: number, + consumed_entity_id: number +): void { + ctx.db.consume_entity_timer.insert({ + scheduled_id: 0n, + scheduled_at: ScheduleAt.time(ctx.timestamp.microsSinceUnixEpoch), + consumer_entity_id, + consumed_entity_id, + }); +} + +function applySplitMovement( + nowMicros: bigint, + circles: Circle[], + playerEntities: Entity[], + circleDirections: Map +): void { + const count = playerEntities.length; + for (let i = 0; i < playerEntities.length; i++) { + const circleI = circles[i]; + const timeSinceSplit = secondsSince( + nowMicros, + circleI.last_split_time.microsSinceUnixEpoch + ); + const timeBeforeRecombining = Math.max( + 0, + SPLIT_RECOMBINE_DELAY_SEC - timeSinceSplit + ); + if (timeBeforeRecombining > SPLIT_GRAV_PULL_BEFORE_RECOMBINE_SEC) { + continue; + } + const entityI = playerEntities[i]; + for (let j = 0; j < playerEntities.length; j++) { + if (i === j) { + continue; + } + const entityJ = playerEntities[j]; + let diff = sub(entityI.position, entityJ.position); + let distanceSqr = sqrMagnitude(diff); + if (distanceSqr <= 0.0001) { + diff = vec(1, 0); + distanceSqr = 1; + } + const radiusSum = massToRadius(entityI.mass) + massToRadius(entityJ.mass); + if (distanceSqr > radiusSum * radiusSum) { + const gravityMultiplier = + 1 - + timeBeforeRecombining / SPLIT_GRAV_PULL_BEFORE_RECOMBINE_SEC; + const adjustment = mul( + normalized(diff), + ((radiusSum - Math.sqrt(distanceSqr)) * gravityMultiplier * 0.05) / + count + ); + addDirection( + circleDirections, + entityI.entity_id, + mul(adjustment, 0.5) + ); + addDirection( + circleDirections, + entityJ.entity_id, + mul(adjustment, -0.5) + ); + } + } + } + + for (let i = 0; i < playerEntities.length; i++) { + const entityI = playerEntities[i]; + for (let j = i + 1; j < playerEntities.length; j++) { + const entityJ = playerEntities[j]; + let diff = sub(entityI.position, entityJ.position); + let distanceSqr = sqrMagnitude(diff); + if (distanceSqr <= 0.0001) { + diff = vec(1, 0); + distanceSqr = 1; + } + const radiusSum = massToRadius(entityI.mass) + massToRadius(entityJ.mass); + const radiusSumMultiplied = radiusSum * ALLOWED_SPLIT_CIRCLE_OVERLAP_PCT; + if (distanceSqr < radiusSumMultiplied * radiusSumMultiplied) { + const adjustment = mul( + normalized(diff), + (radiusSum - Math.sqrt(distanceSqr)) * SELF_COLLISION_SPEED + ); + addDirection( + circleDirections, + entityI.entity_id, + mul(adjustment, 0.5) + ); + addDirection( + circleDirections, + entityJ.entity_id, + mul(adjustment, -0.5) + ); + } + } + } +} + +function addDirection( + directions: Map, + entity_id: number, + delta: DbVector2Type +): void { + directions.set(entity_id, add(directions.get(entity_id) ?? vec(0, 0), delta)); +} + +function isOverlapping(a: Entity, b: Entity): boolean { + const dx = a.position.x - b.position.x; + const dy = a.position.y - b.position.y; + const distanceSq = dx * dx + dy * dy; + const maxRadius = Math.max(massToRadius(a.mass), massToRadius(b.mass)); + return distanceSq <= maxRadius * maxRadius; +} + +function massToRadius(mass: number): number { + return Math.sqrt(mass); +} + +function massToMaxMoveSpeed(mass: number): number { + return (2 * START_PLAYER_SPEED) / (1 + Math.sqrt(mass / START_PLAYER_MASS)); +} + +function secondsSince(nowMicros: bigint, thenMicros: bigint): number { + return Number(nowMicros - thenMicros) / Number(MICROS_PER_SECOND); +} + +function randomRange( + random: { (): number }, + minInclusive: number, + maxExclusive: number +): number { + return minInclusive + random() * (maxExclusive - minInclusive); +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} diff --git a/demo/Blackholio/server-ts/src/math.ts b/demo/Blackholio/server-ts/src/math.ts new file mode 100644 index 00000000000..8f442760830 --- /dev/null +++ b/demo/Blackholio/server-ts/src/math.ts @@ -0,0 +1,40 @@ +import { t, type Infer } from 'spacetimedb/server'; + +export const DbVector2 = t.object('DbVector2', { + x: t.f32(), + y: t.f32(), +}); + +export type DbVector2 = Infer; + +export function vec(x: number, y: number): DbVector2 { + return { x, y }; +} + +export function add(a: DbVector2, b: DbVector2): DbVector2 { + return { x: a.x + b.x, y: a.y + b.y }; +} + +export function sub(a: DbVector2, b: DbVector2): DbVector2 { + return { x: a.x - b.x, y: a.y - b.y }; +} + +export function mul(a: DbVector2, scalar: number): DbVector2 { + return { x: a.x * scalar, y: a.y * scalar }; +} + +export function div(a: DbVector2, scalar: number): DbVector2 { + return scalar === 0 ? { x: 0, y: 0 } : { x: a.x / scalar, y: a.y / scalar }; +} + +export function sqrMagnitude(a: DbVector2): number { + return a.x * a.x + a.y * a.y; +} + +export function magnitude(a: DbVector2): number { + return Math.sqrt(sqrMagnitude(a)); +} + +export function normalized(a: DbVector2): DbVector2 { + return div(a, magnitude(a)); +} diff --git a/demo/Blackholio/server-ts/tsconfig.json b/demo/Blackholio/server-ts/tsconfig.json new file mode 100644 index 00000000000..824514ec33a --- /dev/null +++ b/demo/Blackholio/server-ts/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "strict": true, + "skipLibCheck": true, + "moduleResolution": "bundler", + "jsx": "react-jsx", + "target": "ESNext", + "lib": ["ES2021", "dom"], + "module": "ESNext", + "isolatedModules": true, + "noEmit": true + }, + "include": ["./**/*"] +}