diff --git a/README.md b/README.md index db9dc83..e5db57d 100644 --- a/README.md +++ b/README.md @@ -401,6 +401,12 @@ TypeScript consumer with `skipLibCheck: false`. ## Examples +The [platformer statechart example](./examples/platformer) is a playable SVG +demo centered on a schema-first character machine. It demonstrates nested +compound locomotion, parallel airborne motion and air-jump regions, independent +facing and wall-contact regions, typed protocol events, state-scoped timers, +and state-driven SVG transforms. + The [Pokémon statechart example](./examples/pokemon) is a standalone React and Vite project demonstrating compound and parallel states, state-scoped invokes, invoked child statecharts, typed emissions, and Atom reactivity. It uses a local diff --git a/examples/platformer/.gitignore b/examples/platformer/.gitignore new file mode 100644 index 0000000..de4d1f0 --- /dev/null +++ b/examples/platformer/.gitignore @@ -0,0 +1,2 @@ +dist +node_modules diff --git a/examples/platformer/README.md b/examples/platformer/README.md new file mode 100644 index 0000000..9075310 --- /dev/null +++ b/examples/platformer/README.md @@ -0,0 +1,73 @@ +# Platformer statechart example + +A small, standalone SVG and Vite demo in which `@typeonce/effect-machine` owns +a platformer character's legal behavior. One compact adapter provides keyboard +input, gravity, a floor, and visible SVG transforms. + +```sh +pnpm install --frozen-lockfile +pnpm dev +``` + +Run the production verification with: + +```sh +pnpm check +``` + +## Controls + +- **A/D** or **arrow keys** — move +- **W**, **up**, or **Space** — jump; press again once in the air for a double jump +- Touch either wall and jump — turn and kick away; repeat after returning to a wall +- **S** or **down** — duck while grounded; dive while airborne +- **R** — reset + +## Statechart + +`Character` is parallel: `locomotion`, `facing`, and `contact` update +independently. The locomotion region is compound and makes `Grounded` and +`Airborne` mutually exclusive. Each branch is compound again: + +```text +Character (parallel) +├─ locomotion +│ ├─ Grounded: Standing | Running | Ducking | Landing +│ └─ Airborne (parallel) +│ ├─ motion: Jumping | Falling | Diving +│ └─ airJump: GroundLock | WallLock | Ready | Spent +├─ facing: Left | Right +└─ contact: NoWall | LeftWall | RightWall +``` + +State payloads live only where they are valid: `Landing` owns impact and resume +direction, while `Airborne` owns only the jump origin. Air-jump availability is +modeled entirely as state: lock states own cancellable readiness timers, +`Ready` is the only state that authorizes a double jump, and `Spent` makes a +second one unrepresentable. Entering `Airborne` exercises a complete nested +parallel target by selecting both `motion` and `airJump` regions. + +Wall contact is an independent top-level region, so the live chart can show +`Grounded + LeftWall` at a floor corner without confusing that combination with +an airborne wall jump. The `Grounded` handler always produces an ordinary jump; +only `Airborne` interprets the wall sample as a wall jump. It turns and pushes +away, refreshes the air jump through `WallLock`, and the same wall may be used +again after physically returning to it. Movement phases own their timestamps, +and both landing and capability locks demonstrate state-scoped +`Machine.after` timers. + +Keyboard commands and physics facts share a typed `Schema.TaggedUnion` +protocol. The adapter executes velocity and floor collision, then reports +`ApexReached`, `Landed`, and `WallContact`. `JumpPressed` includes the current +wall sample, but the active `Grounded` or `Airborne` branch decides its meaning. +Typed internal events coordinate orthogonal regions: `TryAirJump` is accepted +only by `Ready`, while `DoubleJump` and `WallJump` update motion, capability, +and facing without shared flags. The SVG box only reflects the active snapshot; +it never decides behavior. + +## Visuals + +The character is a few inline SVG shapes. Each state maps to one typed transform +and body color in `src/game.ts`; after the double jump is spent, a purple accent +persists across falling and diving. This keeps the example focused on the +machine rather than an art or rendering pipeline. diff --git a/examples/platformer/index.html b/examples/platformer/index.html new file mode 100644 index 0000000..555ce7d --- /dev/null +++ b/examples/platformer/index.html @@ -0,0 +1,148 @@ + + + + + + + Orbit Courier — Effect Machine Platformer + + +
+
+
+

@typeonce/effect-machine · playable example

+

Orbit Courier

+

A tiny platformer where every pose is a typed state.

+
+
machine online
+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ AD move + W/Space jump ×2 + S duck / dive + R reset +
+
+ + +
+ +

+ A small adapter owns coordinates and gravity. The machine owns legal behavior, and the box simply transforms to + show its active state. Wall contact is tracked explicitly, so floor-corner jumps stay ordinary. +

+
+ + + diff --git a/examples/platformer/package.json b/examples/platformer/package.json new file mode 100644 index 0000000..a885a3d --- /dev/null +++ b/examples/platformer/package.json @@ -0,0 +1,25 @@ +{ + "name": "@typeonce/effect-machine-example-platformer", + "version": "0.0.0", + "private": true, + "description": "Playable platformer statechart example using @typeonce/effect-machine", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "preview": "vite preview", + "check": "pnpm build" + }, + "dependencies": { + "@typeonce/effect-machine": "file:../..", + "effect": "4.0.0-beta.102" + }, + "devDependencies": { + "typescript": "6.0.3", + "vite": "8.1.5" + }, + "packageManager": "pnpm@10.17.1", + "engines": { + "node": "^20.19.0 || >=22.12.0" + } +} diff --git a/examples/platformer/pnpm-lock.yaml b/examples/platformer/pnpm-lock.yaml new file mode 100644 index 0000000..4846d66 --- /dev/null +++ b/examples/platformer/pnpm-lock.yaml @@ -0,0 +1,745 @@ +lockfileVersion: "9.0" + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +overrides: + effect: 4.0.0-beta.102 + +importers: + .: + dependencies: + "@typeonce/effect-machine": + specifier: file:../.. + version: file:../..(effect@4.0.0-beta.102) + effect: + specifier: 4.0.0-beta.102 + version: 4.0.0-beta.102 + devDependencies: + typescript: + specifier: 6.0.3 + version: 6.0.3 + vite: + specifier: 8.1.5 + version: 8.1.5(yaml@2.9.0) + +packages: + "@emnapi/core@1.11.1": + resolution: + { integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ== } + + "@emnapi/runtime@1.11.1": + resolution: + { integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw== } + + "@emnapi/wasi-threads@1.2.2": + resolution: + { integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA== } + + "@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4": + resolution: + { integrity: sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ== } + cpu: [arm64] + os: [darwin] + + "@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4": + resolution: + { integrity: sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w== } + cpu: [x64] + os: [darwin] + + "@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4": + resolution: + { integrity: sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw== } + cpu: [arm64] + os: [linux] + + "@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4": + resolution: + { integrity: sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw== } + cpu: [arm] + os: [linux] + + "@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4": + resolution: + { integrity: sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ== } + cpu: [x64] + os: [linux] + + "@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4": + resolution: + { integrity: sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ== } + cpu: [x64] + os: [win32] + + "@napi-rs/wasm-runtime@1.2.1": + resolution: + { integrity: sha512-KjZdi8Q1wh89gsVmghvbrMgWl6ZWmRmHV6wjB7/g4Zf0dyO+hH3neZUtuDNPO00qq5YE5RITVWvrIZKRaAmzGQ== } + engines: { node: ^20.19.0 || ^22.13.0 || >=23.5.0 } + peerDependencies: + "@emnapi/core": ^1.7.1 || ^2.0.0-alpha.3 + "@emnapi/runtime": ^1.7.1 || ^2.0.0-alpha.3 + + "@oxc-project/types@0.139.0": + resolution: + { integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw== } + + "@rolldown/binding-android-arm64@1.1.5": + resolution: + { integrity: sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ== } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [arm64] + os: [android] + + "@rolldown/binding-darwin-arm64@1.1.5": + resolution: + { integrity: sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw== } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [arm64] + os: [darwin] + + "@rolldown/binding-darwin-x64@1.1.5": + resolution: + { integrity: sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g== } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [x64] + os: [darwin] + + "@rolldown/binding-freebsd-x64@1.1.5": + resolution: + { integrity: sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA== } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [x64] + os: [freebsd] + + "@rolldown/binding-linux-arm-gnueabihf@1.1.5": + resolution: + { integrity: sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw== } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [arm] + os: [linux] + + "@rolldown/binding-linux-arm64-gnu@1.1.5": + resolution: + { integrity: sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q== } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [arm64] + os: [linux] + libc: [glibc] + + "@rolldown/binding-linux-arm64-musl@1.1.5": + resolution: + { integrity: sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA== } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [arm64] + os: [linux] + libc: [musl] + + "@rolldown/binding-linux-ppc64-gnu@1.1.5": + resolution: + { integrity: sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg== } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [ppc64] + os: [linux] + libc: [glibc] + + "@rolldown/binding-linux-s390x-gnu@1.1.5": + resolution: + { integrity: sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA== } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [s390x] + os: [linux] + libc: [glibc] + + "@rolldown/binding-linux-x64-gnu@1.1.5": + resolution: + { integrity: sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ== } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [x64] + os: [linux] + libc: [glibc] + + "@rolldown/binding-linux-x64-musl@1.1.5": + resolution: + { integrity: sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg== } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [x64] + os: [linux] + libc: [musl] + + "@rolldown/binding-openharmony-arm64@1.1.5": + resolution: + { integrity: sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw== } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [arm64] + os: [openharmony] + + "@rolldown/binding-wasm32-wasi@1.1.5": + resolution: + { integrity: sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA== } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [wasm32] + + "@rolldown/binding-win32-arm64-msvc@1.1.5": + resolution: + { integrity: sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw== } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [arm64] + os: [win32] + + "@rolldown/binding-win32-x64-msvc@1.1.5": + resolution: + { integrity: sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA== } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [x64] + os: [win32] + + "@rolldown/pluginutils@1.0.1": + resolution: + { integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw== } + + "@standard-schema/spec@1.1.0": + resolution: + { integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w== } + + "@tybys/wasm-util@0.10.3": + resolution: + { integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg== } + + "@typeonce/effect-machine@file:../..": + resolution: { directory: ../.., type: directory } + engines: { node: ">=20" } + peerDependencies: + effect: 4.0.0-beta.102 + + detect-libc@2.1.2: + resolution: + { integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ== } + engines: { node: ">=8" } + + effect@4.0.0-beta.102: + resolution: + { integrity: sha512-z8Y+Q76Hh/kjLFZrXu8tGn6e+tDsg45R+UHhxd190pXxD53OGwf/G/zDxXTkse4HJ5mobNZfitLfUCp4fMvu6w== } + + fast-check@4.9.0: + resolution: + { integrity: sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg== } + engines: { node: ">=12.17.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 + + find-my-way-ts@0.1.6: + resolution: + { integrity: sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA== } + + fsevents@2.3.3: + resolution: + { integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== } + engines: { node: ^8.16.0 || ^10.6.0 || >=11.0.0 } + os: [darwin] + + ini@7.0.0: + resolution: + { integrity: sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w== } + engines: { node: ^22.22.2 || ^24.15.0 || >=26.0.0 } + + kubernetes-types@1.30.0: + resolution: + { integrity: sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q== } + + lightningcss-android-arm64@1.33.0: + resolution: + { integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg== } + engines: { node: ">= 12.0.0" } + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: + { integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg== } + engines: { node: ">= 12.0.0" } + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: + { integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ== } + engines: { node: ">= 12.0.0" } + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: + { integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg== } + engines: { node: ">= 12.0.0" } + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: + { integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ== } + engines: { node: ">= 12.0.0" } + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: + { integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg== } + engines: { node: ">= 12.0.0" } + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: + { integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ== } + engines: { node: ">= 12.0.0" } + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: + { integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg== } + engines: { node: ">= 12.0.0" } + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.33.0: + resolution: + { integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw== } + engines: { node: ">= 12.0.0" } + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: + { integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA== } + engines: { node: ">= 12.0.0" } + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: + { integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA== } + engines: { node: ">= 12.0.0" } + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: + { integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA== } + engines: { node: ">= 12.0.0" } + + msgpackr-extract@3.0.4: + resolution: + { integrity: sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw== } + hasBin: true + + msgpackr@2.0.5: + resolution: + { integrity: sha512-cef05H/dSYpLpqp3sj/qyZh5vhUYCalnaLO7j1yOmpsR0y/XwLVtK7r5gn+U/F7CTEfMowcGhlUQJDLcLf7jcA== } + + multipasta@0.2.8: + resolution: + { integrity: sha512-ZPWuMKyv0cSO29f7hozp+k6+crZbQijV8ipMvxNxRf2SwtYGTX1ZX89Kd20VV4H9Znonx+EQn+iy1wGQsJ+b+Q== } + + nanoid@3.3.16: + resolution: + { integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q== } + engines: { node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1 } + hasBin: true + + node-gyp-build-optional-packages@5.2.2: + resolution: + { integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw== } + hasBin: true + + picocolors@1.1.1: + resolution: + { integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== } + + picomatch@4.0.5: + resolution: + { integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A== } + engines: { node: ">=12" } + + postcss@8.5.25: + resolution: + { integrity: sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw== } + engines: { node: ^10 || ^12 || >=14 } + + pure-rand@8.4.2: + resolution: + { integrity: sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng== } + + rolldown@1.1.5: + resolution: + { integrity: sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA== } + engines: { node: ^20.19.0 || >=22.12.0 } + hasBin: true + + source-map-js@1.2.1: + resolution: + { integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA== } + engines: { node: ">=0.10.0" } + + tinyglobby@0.2.17: + resolution: + { integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g== } + engines: { node: ">=12.0.0" } + + toml@4.3.0: + resolution: + { integrity: sha512-lVb8X9BsPVuH0M4BKeS91tXAmJvCjQ5UIyAbQFaxkKGyUFK2RPkhwaFSQH8vbpl1d23eu/IBH+dwVMHWaq9A5A== } + engines: { node: ">=20" } + + tslib@2.8.1: + resolution: + { integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== } + + typescript@6.0.3: + resolution: + { integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw== } + engines: { node: ">=14.17" } + hasBin: true + + uuid@14.0.1: + resolution: + { integrity: sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew== } + hasBin: true + + vite@8.1.5: + resolution: + { integrity: sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw== } + engines: { node: ^20.19.0 || >=22.12.0 } + hasBin: true + peerDependencies: + "@types/node": ^20.19.0 || >=22.12.0 + "@vitejs/devtools": ^0.3.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: ">=1.21.0" + less: ^4.0.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 + "@vitejs/devtools": + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + yaml@2.9.0: + resolution: + { integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA== } + engines: { node: ">= 14.6" } + hasBin: true + +snapshots: + "@emnapi/core@1.11.1": + dependencies: + "@emnapi/wasi-threads": 1.2.2 + tslib: 2.8.1 + optional: true + + "@emnapi/runtime@1.11.1": + dependencies: + tslib: 2.8.1 + optional: true + + "@emnapi/wasi-threads@1.2.2": + dependencies: + tslib: 2.8.1 + optional: true + + "@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4": + optional: true + + "@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4": + optional: true + + "@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4": + optional: true + + "@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4": + optional: true + + "@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4": + optional: true + + "@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4": + optional: true + + "@napi-rs/wasm-runtime@1.2.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)": + dependencies: + "@emnapi/core": 1.11.1 + "@emnapi/runtime": 1.11.1 + "@tybys/wasm-util": 0.10.3 + optional: true + + "@oxc-project/types@0.139.0": {} + + "@rolldown/binding-android-arm64@1.1.5": + optional: true + + "@rolldown/binding-darwin-arm64@1.1.5": + optional: true + + "@rolldown/binding-darwin-x64@1.1.5": + optional: true + + "@rolldown/binding-freebsd-x64@1.1.5": + optional: true + + "@rolldown/binding-linux-arm-gnueabihf@1.1.5": + optional: true + + "@rolldown/binding-linux-arm64-gnu@1.1.5": + optional: true + + "@rolldown/binding-linux-arm64-musl@1.1.5": + optional: true + + "@rolldown/binding-linux-ppc64-gnu@1.1.5": + optional: true + + "@rolldown/binding-linux-s390x-gnu@1.1.5": + optional: true + + "@rolldown/binding-linux-x64-gnu@1.1.5": + optional: true + + "@rolldown/binding-linux-x64-musl@1.1.5": + optional: true + + "@rolldown/binding-openharmony-arm64@1.1.5": + optional: true + + "@rolldown/binding-wasm32-wasi@1.1.5": + dependencies: + "@emnapi/core": 1.11.1 + "@emnapi/runtime": 1.11.1 + "@napi-rs/wasm-runtime": 1.2.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + optional: true + + "@rolldown/binding-win32-arm64-msvc@1.1.5": + optional: true + + "@rolldown/binding-win32-x64-msvc@1.1.5": + optional: true + + "@rolldown/pluginutils@1.0.1": {} + + "@standard-schema/spec@1.1.0": {} + + "@tybys/wasm-util@0.10.3": + dependencies: + tslib: 2.8.1 + optional: true + + "@typeonce/effect-machine@file:../..(effect@4.0.0-beta.102)": + dependencies: + effect: 4.0.0-beta.102 + + detect-libc@2.1.2: {} + + effect@4.0.0-beta.102: + dependencies: + "@standard-schema/spec": 1.1.0 + fast-check: 4.9.0 + find-my-way-ts: 0.1.6 + ini: 7.0.0 + kubernetes-types: 1.30.0 + msgpackr: 2.0.5 + multipasta: 0.2.8 + toml: 4.3.0 + uuid: 14.0.1 + yaml: 2.9.0 + + fast-check@4.9.0: + dependencies: + pure-rand: 8.4.2 + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + find-my-way-ts@0.1.6: {} + + fsevents@2.3.3: + optional: true + + ini@7.0.0: {} + + kubernetes-types@1.30.0: {} + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + + msgpackr-extract@3.0.4: + dependencies: + node-gyp-build-optional-packages: 5.2.2 + optionalDependencies: + "@msgpackr-extract/msgpackr-extract-darwin-arm64": 3.0.4 + "@msgpackr-extract/msgpackr-extract-darwin-x64": 3.0.4 + "@msgpackr-extract/msgpackr-extract-linux-arm": 3.0.4 + "@msgpackr-extract/msgpackr-extract-linux-arm64": 3.0.4 + "@msgpackr-extract/msgpackr-extract-linux-x64": 3.0.4 + "@msgpackr-extract/msgpackr-extract-win32-x64": 3.0.4 + optional: true + + msgpackr@2.0.5: + optionalDependencies: + msgpackr-extract: 3.0.4 + + multipasta@0.2.8: {} + + nanoid@3.3.16: {} + + node-gyp-build-optional-packages@5.2.2: + dependencies: + detect-libc: 2.1.2 + optional: true + + picocolors@1.1.1: {} + + picomatch@4.0.5: {} + + postcss@8.5.25: + dependencies: + nanoid: 3.3.16 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + pure-rand@8.4.2: {} + + rolldown@1.1.5: + dependencies: + "@oxc-project/types": 0.139.0 + "@rolldown/pluginutils": 1.0.1 + optionalDependencies: + "@rolldown/binding-android-arm64": 1.1.5 + "@rolldown/binding-darwin-arm64": 1.1.5 + "@rolldown/binding-darwin-x64": 1.1.5 + "@rolldown/binding-freebsd-x64": 1.1.5 + "@rolldown/binding-linux-arm-gnueabihf": 1.1.5 + "@rolldown/binding-linux-arm64-gnu": 1.1.5 + "@rolldown/binding-linux-arm64-musl": 1.1.5 + "@rolldown/binding-linux-ppc64-gnu": 1.1.5 + "@rolldown/binding-linux-s390x-gnu": 1.1.5 + "@rolldown/binding-linux-x64-gnu": 1.1.5 + "@rolldown/binding-linux-x64-musl": 1.1.5 + "@rolldown/binding-openharmony-arm64": 1.1.5 + "@rolldown/binding-wasm32-wasi": 1.1.5 + "@rolldown/binding-win32-arm64-msvc": 1.1.5 + "@rolldown/binding-win32-x64-msvc": 1.1.5 + + source-map-js@1.2.1: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + toml@4.3.0: {} + + tslib@2.8.1: + optional: true + + typescript@6.0.3: {} + + uuid@14.0.1: {} + + vite@8.1.5(yaml@2.9.0): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.5 + postcss: 8.5.25 + rolldown: 1.1.5 + tinyglobby: 0.2.17 + optionalDependencies: + fsevents: 2.3.3 + yaml: 2.9.0 + + yaml@2.9.0: {} diff --git a/examples/platformer/pnpm-workspace.yaml b/examples/platformer/pnpm-workspace.yaml new file mode 100644 index 0000000..4167189 --- /dev/null +++ b/examples/platformer/pnpm-workspace.yaml @@ -0,0 +1,10 @@ +packages: + - "." + +overrides: + effect: 4.0.0-beta.102 + +minimumReleaseAge: 1440 +minimumReleaseAgeExclude: + - effect@4.0.0-beta.102 + - vite@8.1.5 diff --git a/examples/platformer/src/game.ts b/examples/platformer/src/game.ts new file mode 100644 index 0000000..8ad601f --- /dev/null +++ b/examples/platformer/src/game.ts @@ -0,0 +1,162 @@ +import type { Axis, CharacterEvent, CharacterSnapshot, LocomotionMode } from "./machine.ts" +import { airJumpMode, facingDirection, locomotionState } from "./machine.ts" + +const FLOOR = 324 +const SIZE = 30 + +const pose = { + Standing: "translate(0 0)", + Running: "rotate(6 15 30)", + Jumping: "rotate(-12 15 15)", + Falling: "rotate(12 15 15)", + Ducking: "translate(0 15) scale(1 .5)", + Diving: "rotate(90 15 15) scale(.8 1.15)", + Landing: "translate(-3 12) scale(1.2 .6)" +} as const satisfies Record + +export class GameAdapter { + private readonly position = { x: 84, y: FLOOR - SIZE, vy: 0 } + private readonly held = new Set() + private snapshot: CharacterSnapshot | undefined + private previousMode: LocomotionMode = "Standing" + private lastJumpAt = -1 + private wallPushSpeed = 0 + private wallPushUntil = 0 + private reportedWall: Axis | undefined + private grounded = true + private apexReported = false + + constructor( + private readonly send: (event: CharacterEvent) => void, + private readonly player: SVGGElement, + private readonly playerPose: SVGGElement + ) { + window.addEventListener("keydown", this.onKeyDown) + window.addEventListener("keyup", this.onKeyUp) + } + + setSnapshot(snapshot: CharacterSnapshot) { + this.snapshot = snapshot + } + + step(seconds: number) { + if (this.snapshot === undefined) return + const dt = Math.min(seconds, 1 / 30) + const state = locomotionState(this.snapshot) + const mode = state._tag + const p = this.position + + if (state._tag === "Jumping" && state.startedAt !== this.lastJumpAt) { + p.vy = -430 + this.wallPushSpeed = state.push * 330 + this.wallPushUntil = state.push === 0 ? 0 : state.startedAt + 240 + this.grounded = false + this.apexReported = false + this.lastJumpAt = state.startedAt + } + if (mode === "Diving" && this.previousMode !== "Diving") p.vy = 610 + + const inputSpeed = mode === "Ducking" || mode === "Landing" ? 0 : this.axis * (this.grounded ? 190 : 135) + const speed = performance.now() < this.wallPushUntil ? this.wallPushSpeed : inputSpeed + if (!this.grounded) p.vy += 1_180 * dt + p.x = Math.max(0, Math.min(640 - SIZE, p.x + speed * dt)) + p.y += p.vy * dt + + if (this.jumpWall !== this.reportedWall) { + this.reportedWall = this.jumpWall + this.send({ _tag: "WallContact", wall: this.reportedWall }) + } + + if (!this.apexReported && p.vy >= 0 && mode === "Jumping") { + this.apexReported = true + this.send({ _tag: "ApexReached", y: Math.round(p.y) }) + } + if (!this.grounded && p.y >= FLOOR - SIZE) { + const impact = Math.round(p.vy) + Object.assign(p, { y: FLOOR - SIZE, vy: 0 }) + this.grounded = true + this.send({ _tag: "Landed", impact, axis: this.axis, at: performance.now() }) + } + + const x = facingDirection(this.snapshot) === "Left" ? p.x + SIZE : p.x + const flip = facingDirection(this.snapshot) === "Left" ? " scale(-1 1)" : "" + this.player.setAttribute("transform", `translate(${x.toFixed(1)} ${p.y.toFixed(1)})${flip}`) + this.playerPose.setAttribute("transform", pose[mode]) + this.player.dataset.mode = mode + this.player.dataset.airJump = airJumpMode(this.snapshot) === "AirJumpSpent" ? "spent" : "ready" + if (state._tag === "Jumping") this.player.dataset.jumpKind = state.kind + else delete this.player.dataset.jumpKind + this.previousMode = mode + } + + reset() { + Object.assign(this.position, { x: 84, y: FLOOR - SIZE, vy: 0 }) + this.held.clear() + this.grounded = true + this.previousMode = "Standing" + this.lastJumpAt = -1 + this.wallPushUntil = 0 + this.reportedWall = undefined + } + + destroy() { + window.removeEventListener("keydown", this.onKeyDown) + window.removeEventListener("keyup", this.onKeyUp) + } + + private get axis(): Axis { + const left = this.held.has("KeyA") || this.held.has("ArrowLeft") + const right = this.held.has("KeyD") || this.held.has("ArrowRight") + return left === right ? 0 : left ? -1 : 1 + } + + private get wall(): Axis { + return this.position.x <= 1 ? -1 : this.position.x >= 640 - SIZE - 1 ? 1 : 0 + } + + private get jumpWall(): Axis { + return performance.now() < this.wallPushUntil ? 0 : this.wall + } + + private readonly onKeyDown = (event: KeyboardEvent) => { + if (!this.isGameKey(event.code)) return + event.preventDefault() + const previousAxis = this.axis + this.held.add(event.code) + if (previousAxis !== this.axis) this.send({ _tag: "Move", axis: this.axis, at: performance.now() }) + if (event.repeat) return + + if (event.code === "Space" || event.code === "KeyW" || event.code === "ArrowUp") { + this.send({ _tag: "JumpPressed", at: performance.now(), y: this.position.y, wall: this.jumpWall }) + } else if (event.code === "KeyS" || event.code === "ArrowDown") { + this.send({ _tag: "DownPressed", at: performance.now() }) + } else if (event.code === "KeyR") { + this.reset() + this.send({ _tag: "Reset" }) + } + } + + private readonly onKeyUp = (event: KeyboardEvent) => { + const previousAxis = this.axis + this.held.delete(event.code) + if (previousAxis !== this.axis) this.send({ _tag: "Move", axis: this.axis, at: performance.now() }) + if (event.code === "KeyS" || event.code === "ArrowDown") { + this.send({ _tag: "DownReleased", axis: this.axis, at: performance.now() }) + } + } + + private isGameKey(code: string) { + return [ + "KeyA", + "KeyD", + "KeyW", + "KeyS", + "KeyR", + "Space", + "ArrowLeft", + "ArrowRight", + "ArrowUp", + "ArrowDown" + ].includes(code) + } +} diff --git a/examples/platformer/src/machine.ts b/examples/platformer/src/machine.ts new file mode 100644 index 0000000..1ef6358 --- /dev/null +++ b/examples/platformer/src/machine.ts @@ -0,0 +1,386 @@ +import { Effect, Schema } from "effect" +import { Machine } from "@typeonce/effect-machine" + +// Domain schemas are shared by state payloads and the public physics protocol. +export const Axis = Schema.Literals([-1, 0, 1]) +export type Axis = typeof Axis.Type +const JumpKind = Schema.Literals(["Ground", "Double", "Wall"]) + +const State = Schema.TaggedUnion({ + Character: {}, + Locomotion: {}, + Grounded: {}, + Standing: {}, + Running: { startedAt: Schema.Number }, + Ducking: { startedAt: Schema.Number }, + Landing: { impact: Schema.Number, resumeAxis: Axis, landedAt: Schema.Number }, + Airborne: { originY: Schema.Number }, + Motion: {}, + Jumping: { startedAt: Schema.Number, push: Axis, kind: JumpKind }, + Falling: { apexY: Schema.Number }, + Diving: { startedAt: Schema.Number }, + AirJump: {}, + AirJumpGroundLock: {}, + AirJumpWallLock: {}, + AirJumpReady: {}, + AirJumpSpent: {}, + WallContact: {}, + NoWall: {}, + LeftWall: {}, + RightWall: {}, + Facing: {}, + Left: {}, + Right: {} +}) + +// Inputs and physics facts are one runtime-decoded, statically typed protocol. +export const Event = Schema.TaggedUnion({ + Move: { axis: Axis, at: Schema.Number }, + JumpPressed: { at: Schema.Number, y: Schema.Number, wall: Axis }, + WallContact: { wall: Axis }, + DownPressed: { at: Schema.Number }, + DownReleased: { axis: Axis, at: Schema.Number }, + ApexReached: { y: Schema.Number }, + Landed: { impact: Schema.Number, axis: Axis, at: Schema.Number }, + Reset: {} +}) + +const InternalEvent = Schema.TaggedUnion({ + LandingSettled: {}, + AirJumpUnlocked: {}, + TryAirJump: { at: Schema.Number }, + DoubleJump: { at: Schema.Number }, + WallJump: { at: Schema.Number, push: Axis } +}) + +const awayFrom = (wall: Axis): Axis => (wall === -1 ? 1 : wall === 1 ? -1 : 0) + +export const CharacterStates = Machine.defineStates({ + Character: { + schema: State.cases.Character, + type: "parallel", + states: { + locomotion: { + schema: State.cases.Locomotion, + initial: "Grounded", + states: { + Grounded: { + schema: State.cases.Grounded, + initial: "Standing", + states: { + Standing: State.cases.Standing, + Running: State.cases.Running, + Ducking: State.cases.Ducking, + Landing: State.cases.Landing + } + }, + Airborne: { + schema: State.cases.Airborne, + type: "parallel", + states: { + motion: { + schema: State.cases.Motion, + initial: "Jumping", + states: { + Jumping: State.cases.Jumping, + Falling: State.cases.Falling, + Diving: State.cases.Diving + } + }, + airJump: { + schema: State.cases.AirJump, + initial: "AirJumpGroundLock", + states: { + AirJumpGroundLock: State.cases.AirJumpGroundLock, + AirJumpWallLock: State.cases.AirJumpWallLock, + AirJumpReady: State.cases.AirJumpReady, + AirJumpSpent: State.cases.AirJumpSpent + } + } + } + } + } + }, + facing: { + schema: State.cases.Facing, + initial: "Right", + states: { + Left: State.cases.Left, + Right: State.cases.Right + } + }, + contact: { + schema: State.cases.WallContact, + initial: "NoWall", + states: { + NoWall: State.cases.NoWall, + LeftWall: State.cases.LeftWall, + RightWall: State.cases.RightWall + } + } + } + } +}) + +const initialCharacter = () => + CharacterStates.initial.Character(State.cases.Character.make({}), (character) => + character + .locomotion(State.cases.Locomotion.make({}), (locomotion) => + locomotion.Grounded(State.cases.Grounded.make({}), (grounded) => + grounded.Standing(State.cases.Standing.make({})) + ) + ) + .facing(State.cases.Facing.make({}), (facing) => facing.Right(State.cases.Right.make({}))) + .contact(State.cases.WallContact.make({}), (contact) => contact.NoWall(State.cases.NoWall.make({}))) + ) + +export const CharacterMachine = Machine.make({ + id: "PlatformerCharacter", + states: CharacterStates.states, + events: Object.values(Event.cases), + internalEvents: Object.values(InternalEvent.cases), + initial: initialCharacter +}).handle({ + Character: { + on: { + Reset: initialCharacter + }, + states: { + locomotion: { + states: { + Grounded: { + on: { + JumpPressed: ({ event, target }) => + target.branch.Character.locomotion.Airborne( + State.cases.Airborne.make({ originY: event.y }), + (airborne) => + airborne + .motion(State.cases.Motion.make({}), (motion) => + motion.Jumping( + State.cases.Jumping.make({ startedAt: event.at, push: 0, kind: "Ground" }) + ) + ) + .airJump(State.cases.AirJump.make({}), (airJump) => + airJump.AirJumpGroundLock(State.cases.AirJumpGroundLock.make({})) + ) + ) + }, + states: { + Standing: { + on: { + Move: ({ event, target }) => + event.axis === 0 + ? undefined + : target.local.Running(State.cases.Running.make({ startedAt: event.at })), + DownPressed: ({ event, target }) => + target.local.Ducking(State.cases.Ducking.make({ startedAt: event.at })) + } + }, + Running: { + on: { + Move: ({ event, target }) => + event.axis === 0 ? target.local.Standing(State.cases.Standing.make({})) : undefined, + DownPressed: ({ event, target }) => + target.local.Ducking(State.cases.Ducking.make({ startedAt: event.at })) + } + }, + Ducking: { + on: { + DownReleased: ({ event, target }) => + event.axis === 0 + ? target.local.Standing(State.cases.Standing.make({})) + : target.local.Running(State.cases.Running.make({ startedAt: event.at })) + } + }, + Landing: { + invoke: Machine.after("140 millis", InternalEvent.cases.LandingSettled.make({}), { + id: "landing-settle" + }), + on: { + Move: ({ event, state, target }) => + target.local.Landing(Machine.retag(State.cases.Landing, state, { resumeAxis: event.axis })), + LandingSettled: ({ state, target }) => + state.resumeAxis === 0 + ? target.local.Standing(State.cases.Standing.make({})) + : target.local.Running(State.cases.Running.make({ startedAt: state.landedAt + 140 })) + } + } + } + }, + Airborne: { + on: { + JumpPressed: Effect.fn(function* ({ event, runtime }) { + const machine = yield* runtime + const push = awayFrom(event.wall) + yield* machine.raise( + push === 0 + ? InternalEvent.cases.TryAirJump.make({ at: event.at }) + : InternalEvent.cases.WallJump.make({ at: event.at, push }) + ) + }), + Landed: ({ event, target }) => + target.branch.Character.locomotion.Grounded(State.cases.Grounded.make({}), (grounded) => + grounded.Landing( + State.cases.Landing.make({ + impact: event.impact, + resumeAxis: event.axis, + landedAt: event.at + }) + ) + ) + }, + states: { + motion: { + on: { + DoubleJump: ({ event, target }) => + target.local.Jumping( + State.cases.Jumping.make({ startedAt: event.at, push: 0, kind: "Double" }) + ), + WallJump: ({ event, target }) => + target.local.Jumping( + State.cases.Jumping.make({ startedAt: event.at, push: event.push, kind: "Wall" }) + ) + }, + states: { + Jumping: { + on: { + ApexReached: ({ event, target }) => + target.local.Falling(State.cases.Falling.make({ apexY: event.y })), + DownPressed: ({ event, target }) => + target.local.Diving(State.cases.Diving.make({ startedAt: event.at })) + } + }, + Falling: { + on: { + DownPressed: ({ event, target }) => + target.local.Diving(State.cases.Diving.make({ startedAt: event.at })) + } + }, + Diving: {} + } + }, + airJump: { + on: { + WallJump: { + reenter: true, + transition: ({ target }) => + target.local.AirJumpWallLock(State.cases.AirJumpWallLock.make({})) + } + }, + states: { + AirJumpGroundLock: { + invoke: Machine.after("120 millis", InternalEvent.cases.AirJumpUnlocked.make({}), { + id: "ground-air-jump-unlock" + }), + on: { + AirJumpUnlocked: ({ target }) => + target.local.AirJumpReady(State.cases.AirJumpReady.make({})) + } + }, + AirJumpWallLock: { + invoke: Machine.after("240 millis", InternalEvent.cases.AirJumpUnlocked.make({}), { + id: "wall-air-jump-unlock" + }), + on: { + AirJumpUnlocked: ({ target }) => + target.local.AirJumpReady(State.cases.AirJumpReady.make({})) + } + }, + AirJumpReady: { + on: { + TryAirJump: Effect.fn(function* ({ event, runtime, target }) { + const machine = yield* runtime + yield* machine.raise(InternalEvent.cases.DoubleJump.make({ at: event.at })) + return target.local.AirJumpSpent(State.cases.AirJumpSpent.make({})) + }) + } + }, + AirJumpSpent: {} + } + } + } + } + } + }, + facing: { + states: { + Left: { + on: { + Move: ({ event, target }) => + event.axis === 1 ? target.local.Right(State.cases.Right.make({})) : undefined, + WallJump: ({ event, target }) => + event.push === 1 ? target.local.Right(State.cases.Right.make({})) : undefined + } + }, + Right: { + on: { + Move: ({ event, target }) => + event.axis === -1 ? target.local.Left(State.cases.Left.make({})) : undefined, + WallJump: ({ event, target }) => + event.push === -1 ? target.local.Left(State.cases.Left.make({})) : undefined + } + } + } + }, + contact: { + on: { + WallContact: ({ event, target }) => + event.wall === -1 + ? target.local.LeftWall(State.cases.LeftWall.make({})) + : event.wall === 1 + ? target.local.RightWall(State.cases.RightWall.make({})) + : target.local.NoWall(State.cases.NoWall.make({})) + }, + states: { + NoWall: {}, + LeftWall: {}, + RightWall: {} + } + } + } + } +}) + +export type CharacterSnapshot = Machine.Machine.Snapshot +export type CharacterEvent = Machine.Machine.InputEvent + +export const locomotionState = (snapshot: CharacterSnapshot) => { + const locomotion = snapshot.states.locomotion.state + return locomotion.path === "Character.locomotion.Grounded" + ? locomotion.state.value + : locomotion.states.motion.state.value +} + +export type LocomotionMode = ReturnType["_tag"] + +export const locomotionMode = (snapshot: CharacterSnapshot): LocomotionMode => locomotionState(snapshot)._tag + +export const locomotionBranch = (snapshot: CharacterSnapshot) => snapshot.states.locomotion.state.value._tag + +export const airJumpMode = (snapshot: CharacterSnapshot) => { + const locomotion = snapshot.states.locomotion.state + return locomotion.path === "Character.locomotion.Airborne" + ? locomotion.states.airJump.state.value._tag + : undefined +} + +export const wallContact = (snapshot: CharacterSnapshot) => { + return snapshot.states.contact.state.value._tag +} + +export const facingDirection = (snapshot: CharacterSnapshot) => snapshot.states.facing.state.value._tag + +export const activeStateData = (snapshot: CharacterSnapshot) => { + const locomotion = snapshot.states.locomotion.state + const { _tag: _branch, ...branchData } = locomotion.value + if (locomotion.path === "Character.locomotion.Grounded") { + const { _tag: _leaf, ...leafData } = locomotion.state.value + return { ...branchData, ...leafData } + } + const { _tag: _motion, ...motionData } = locomotion.states.motion.state.value + return { + ...branchData, + ...motionData, + airJump: locomotion.states.airJump.state.value._tag + } +} diff --git a/examples/platformer/src/main.ts b/examples/platformer/src/main.ts new file mode 100644 index 0000000..b9429a7 --- /dev/null +++ b/examples/platformer/src/main.ts @@ -0,0 +1,84 @@ +import "./styles.css" +import { Effect, Fiber, Stream } from "effect" +import { Machine } from "@typeonce/effect-machine" +import { GameAdapter } from "./game.ts" +import { + activeStateData, + airJumpMode, + CharacterMachine, + facingDirection, + locomotionBranch, + locomotionMode, + wallContact, + type CharacterEvent, + type CharacterSnapshot +} from "./machine.ts" + +const requiredElement = (selector: string) => { + const element = document.querySelector(selector) + if (element === null) throw new Error(`Missing element: ${selector}`) + return element +} + +const modeLabel = requiredElement("#active-mode") +const stateData = requiredElement("#state-data") +const lastEvent = requiredElement("#last-event") + +const showEvent = (event: CharacterEvent) => { + const { _tag, ...payload } = event + const detail = Object.keys(payload).length === 0 ? "" : ` ${JSON.stringify(payload)}` + lastEvent.textContent = `${_tag}${detail}` +} + +let deliver: ((event: CharacterEvent) => void) | undefined +const pending: Array = [] +const send = (event: CharacterEvent) => { + showEvent(event) + deliver === undefined ? pending.push(event) : deliver(event) +} + +const game = new GameAdapter( + send, + requiredElement("#player"), + requiredElement("#player-pose") +) + +const publish = (next: CharacterSnapshot) => { + game.setSnapshot(next) + const mode = locomotionMode(next) + const facing = facingDirection(next) + const contact = wallContact(next) + const airJump = airJumpMode(next) + modeLabel.textContent = [mode, airJump, contact, facing].filter(Boolean).join(" · ") + stateData.textContent = JSON.stringify(activeStateData(next)) + + const active = new Set([mode, facing, contact, locomotionBranch(next)]) + if (airJump !== undefined) active.add(airJump) + document.querySelectorAll("[data-node]").forEach((node) => { + node.classList.toggle("is-active", active.has(node.dataset.node ?? "")) + }) +} + +const program = Effect.gen(function* () { + const actor = yield* Machine.start(CharacterMachine) + deliver = (event) => Effect.runFork(actor.send(event).pipe(Effect.catchTag("StoppedError", () => Effect.void))) + publish(yield* actor.state) + for (const event of pending.splice(0)) yield* actor.send(event) + yield* Stream.runForEach(actor.changes, ({ state }) => Effect.sync(() => publish(state))) +}) + +const fiber = Effect.runFork(program) + +let previous = performance.now() + +const frame = (now: number) => { + game.step((now - previous) / 1_000) + previous = now + requestAnimationFrame(frame) +} + +requestAnimationFrame(frame) +window.addEventListener("beforeunload", () => { + game.destroy() + Effect.runFork(Fiber.interrupt(fiber)) +}) diff --git a/examples/platformer/src/styles.css b/examples/platformer/src/styles.css new file mode 100644 index 0000000..fd2e7f3 --- /dev/null +++ b/examples/platformer/src/styles.css @@ -0,0 +1,366 @@ +:root { + color: #e9fff9; + background: #0a1021; + font-family: + Inter, + ui-sans-serif, + system-ui, + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + sans-serif; + font-synthesis: none; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-width: 320px; + min-height: 100vh; + background: + radial-gradient(circle at 12% 0%, rgb(46 196 182 / 16%), transparent 32rem), + radial-gradient(circle at 92% 18%, rgb(255 107 107 / 10%), transparent 28rem), #0a1021; +} + +.app-shell { + width: min(1180px, calc(100% - 32px)); + margin: 0 auto; + padding: 36px 0 50px; +} +.hero { + display: flex; + align-items: end; + justify-content: space-between; + gap: 24px; + margin-bottom: 22px; +} +.eyebrow { + margin: 0 0 8px; + color: #55d6be; + font: + 700 11px/1.2 ui-monospace, + monospace; + letter-spacing: 0.12em; + text-transform: uppercase; +} +h1 { + margin: 0; + font-size: clamp(42px, 7vw, 76px); + line-height: 0.9; + letter-spacing: -0.065em; +} +.lede { + margin: 14px 0 0; + color: #9aabc1; + font-size: 15px; +} +.status-light { + display: flex; + gap: 8px; + align-items: center; + color: #92a6bc; + font: + 650 11px ui-monospace, + monospace; + text-transform: uppercase; +} +.status-light span { + width: 7px; + height: 7px; + border-radius: 50%; + background: #55d6be; + box-shadow: 0 0 14px #55d6be; +} + +.demo-grid { + display: grid; + grid-template-columns: minmax(0, 1.65fr) minmax(310px, 0.85fr); + gap: 16px; +} +.game-card, +.debug-card { + overflow: hidden; + border: 1px solid #243452; + border-radius: 18px; + background: rgb(16 24 46 / 88%); + box-shadow: 0 24px 70px rgb(0 0 0 / 28%); +} +#game { + display: block; + width: 100%; + aspect-ratio: 16 / 9; + background: #10182e; +} +.stars { + opacity: 0.75; +} +.skyline { + fill: #263d5d; +} +.ground { + fill: #273a54; +} +.ground-top { + fill: #55d6be; +} +.wall { + fill: #55d6be; + opacity: 0.75; +} +#player-pose { + transition: transform 80ms ease-out; +} +#player .body { + fill: #2ec4b6; + stroke: #102641; + stroke-width: 3; +} +#player .face { + fill: #fff1c1; +} +#player .eye { + fill: #14213d; +} +#player .scarf { + fill: #ff6b6b; +} +#player[data-mode="Jumping"] .body { + fill: #63e6d6; +} +#player[data-jump-kind="Double"] .body { + fill: #b388ff; +} +#player[data-mode="Falling"] .body { + fill: #70a9ff; +} +#player[data-mode="Diving"] .body { + fill: #ff8b7f; +} +#player[data-mode="Landing"] .body { + fill: #ffd166; +} +#player[data-air-jump="spent"] .scarf { + fill: #c77dff; + filter: drop-shadow(0 0 3px #c77dff); +} +.controls { + display: flex; + flex-wrap: wrap; + gap: 14px 20px; + padding: 13px 16px; + border-top: 1px solid #243452; + color: #8fa2b9; + font-size: 11px; +} +.controls span { + display: flex; + align-items: center; + gap: 5px; +} +kbd { + min-width: 22px; + padding: 4px 6px; + border: 1px solid #3b506e; + border-bottom-width: 2px; + border-radius: 5px; + color: #d8fff6; + background: #1b2942; + font: + 700 10px ui-monospace, + monospace; + text-align: center; +} + +.debug-card { + padding: 16px; +} +.panel-heading { + display: flex; + justify-content: space-between; + margin-bottom: 14px; + color: #8196ad; + font: + 700 10px ui-monospace, + monospace; + letter-spacing: 0.1em; + text-transform: uppercase; +} +.chart { + padding: 12px; + border: 1px solid #273a58; + border-radius: 12px; + background: #0d162b; +} +.chart-root { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 10px; + border: 1px solid #3e5775; + border-radius: 7px; + color: #e9fff9; + background: #182742; + font: + 700 12px ui-monospace, + monospace; +} +.chart-root small { + color: #55d6be; + font-size: 9px; + text-transform: uppercase; +} +.regions { + display: grid; + gap: 9px; + margin-top: 9px; +} +.regions section { + padding: 9px; + border-left: 2px solid #334865; + background: #111d33; +} +.regions h2 { + margin: 0 0 8px; + color: #7389a1; + font: + 700 9px ui-monospace, + monospace; + letter-spacing: 0.1em; + text-transform: uppercase; +} +.branch + .branch { + margin-top: 7px; +} +.branch h3 { + display: inline-block; + margin: 0 0 5px; + padding: 3px 6px; + border-radius: 4px; + color: #7890a9; + font: + 700 9px ui-monospace, + monospace; +} +.parallel-label { + display: inline-block; + margin-left: 4px; + color: #55d6be; + font: 700 8px ui-monospace, monospace; + letter-spacing: 0.06em; + text-transform: uppercase; +} +.nested-region { + padding: 6px; + border-left: 1px solid #304766; + background: #0e192d; +} +.nested-region + .nested-region { + margin-top: 4px; +} +.nested-region small { + display: block; + margin-bottom: 4px; + color: #637c98; + font: 700 8px ui-monospace, monospace; + letter-spacing: 0.08em; + text-transform: uppercase; +} +.state-row { + display: flex; + flex-wrap: wrap; + gap: 4px; +} +.state-row span { + padding: 5px 6px; + border: 1px solid #2b3c59; + border-radius: 5px; + color: #70869e; + background: #131f35; + font: + 650 9px ui-monospace, + monospace; + transition: 120ms ease; +} +[data-node].is-active { + border-color: #55d6be !important; + color: #dffff8 !important; + background: rgb(46 196 182 / 16%) !important; + box-shadow: 0 0 0 2px rgb(46 196 182 / 8%); +} +.facing-row { + display: grid; + grid-template-columns: 1fr 1fr; +} +.facing-row span { + text-align: center; +} + +.telemetry { + display: grid; + gap: 0; + margin: 14px 0 0; +} +.telemetry div { + display: grid; + grid-template-columns: 105px 1fr; + gap: 12px; + padding: 9px 2px; + border-top: 1px solid #23334f; +} +.telemetry dt { + color: #6f849c; + font: + 700 9px ui-monospace, + monospace; + letter-spacing: 0.04em; + text-transform: uppercase; +} +.telemetry dd { + min-width: 0; + margin: 0; + overflow-wrap: anywhere; + color: #c8d8e7; + font: + 600 10px/1.4 ui-monospace, + monospace; +} +code { + color: #ff9292; + font-family: ui-monospace, monospace; +} +.note { + max-width: 790px; + margin: 18px 4px 0; + color: #72879f; + font-size: 12px; + line-height: 1.7; +} + +@media (max-width: 850px) { + .demo-grid { + grid-template-columns: 1fr; + } + .debug-card { + min-height: 0; + } +} + +@media (max-width: 540px) { + .app-shell { + width: min(100% - 16px, 1180px); + padding-top: 24px; + } + .hero { + align-items: start; + flex-direction: column; + } + .controls { + gap: 10px; + } + .telemetry div { + grid-template-columns: 90px 1fr; + } +} diff --git a/examples/platformer/src/vite-env.d.ts b/examples/platformer/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/examples/platformer/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/platformer/tsconfig.json b/examples/platformer/tsconfig.json new file mode 100644 index 0000000..ed48e42 --- /dev/null +++ b/examples/platformer/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "strict": true, + "noUncheckedIndexedAccess": true + }, + "include": ["src"] +} diff --git a/examples/platformer/vite.config.ts b/examples/platformer/vite.config.ts new file mode 100644 index 0000000..20ed627 --- /dev/null +++ b/examples/platformer/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vite" + +export default defineConfig({ + server: { + port: 5174 + } +})