Skip to content

Commit 239be48

Browse files
feat: add platformer statechart example
1 parent 607a0c4 commit 239be48

14 files changed

Lines changed: 2033 additions & 0 deletions

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -401,6 +401,12 @@ TypeScript consumer with `skipLibCheck: false`.
401401

402402
## Examples
403403

404+
The [platformer statechart example](./examples/platformer) is a playable SVG
405+
demo centered on a schema-first character machine. It demonstrates nested
406+
compound locomotion, parallel airborne motion and air-jump regions, independent
407+
facing and wall-contact regions, typed protocol events, state-scoped timers,
408+
and state-driven SVG transforms.
409+
404410
The [Pokémon statechart example](./examples/pokemon) is a standalone React and
405411
Vite project demonstrating compound and parallel states, state-scoped invokes,
406412
invoked child statecharts, typed emissions, and Atom reactivity. It uses a local

examples/platformer/.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
dist
2+
node_modules

examples/platformer/README.md

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
# Platformer statechart example
2+
3+
A small, standalone SVG and Vite demo in which `@typeonce/effect-machine` owns
4+
a platformer character's legal behavior. One compact adapter provides keyboard
5+
input, gravity, a floor, and visible SVG transforms.
6+
7+
```sh
8+
pnpm install --frozen-lockfile
9+
pnpm dev
10+
```
11+
12+
Run the production verification with:
13+
14+
```sh
15+
pnpm check
16+
```
17+
18+
## Controls
19+
20+
- **A/D** or **arrow keys** — move
21+
- **W**, **up**, or **Space** — jump; press again once in the air for a double jump
22+
- Touch either wall and jump — turn and kick away; repeat after returning to a wall
23+
- **S** or **down** — duck while grounded; dive while airborne
24+
- **R** — reset
25+
26+
## Statechart
27+
28+
`Character` is parallel: `locomotion`, `facing`, and `contact` update
29+
independently. The locomotion region is compound and makes `Grounded` and
30+
`Airborne` mutually exclusive. Each branch is compound again:
31+
32+
```text
33+
Character (parallel)
34+
├─ locomotion
35+
│ ├─ Grounded: Standing | Running | Ducking | Landing
36+
│ └─ Airborne (parallel)
37+
│ ├─ motion: Jumping | Falling | Diving
38+
│ └─ airJump: GroundLock | WallLock | Ready | Spent
39+
├─ facing: Left | Right
40+
└─ contact: NoWall | LeftWall | RightWall
41+
```
42+
43+
State payloads live only where they are valid: `Landing` owns impact and resume
44+
direction, while `Airborne` owns only the jump origin. Air-jump availability is
45+
modeled entirely as state: lock states own cancellable readiness timers,
46+
`Ready` is the only state that authorizes a double jump, and `Spent` makes a
47+
second one unrepresentable. Entering `Airborne` exercises a complete nested
48+
parallel target by selecting both `motion` and `airJump` regions.
49+
50+
Wall contact is an independent top-level region, so the live chart can show
51+
`Grounded + LeftWall` at a floor corner without confusing that combination with
52+
an airborne wall jump. The `Grounded` handler always produces an ordinary jump;
53+
only `Airborne` interprets the wall sample as a wall jump. It turns and pushes
54+
away, refreshes the air jump through `WallLock`, and the same wall may be used
55+
again after physically returning to it. Movement phases own their timestamps,
56+
and both landing and capability locks demonstrate state-scoped
57+
`Machine.after` timers.
58+
59+
Keyboard commands and physics facts share a typed `Schema.TaggedUnion`
60+
protocol. The adapter executes velocity and floor collision, then reports
61+
`ApexReached`, `Landed`, and `WallContact`. `JumpPressed` includes the current
62+
wall sample, but the active `Grounded` or `Airborne` branch decides its meaning.
63+
Typed internal events coordinate orthogonal regions: `TryAirJump` is accepted
64+
only by `Ready`, while `DoubleJump` and `WallJump` update motion, capability,
65+
and facing without shared flags. The SVG box only reflects the active snapshot;
66+
it never decides behavior.
67+
68+
## Visuals
69+
70+
The character is a few inline SVG shapes. Each state maps to one typed transform
71+
and body color in `src/game.ts`; after the double jump is spent, a purple accent
72+
persists across falling and diving. This keeps the example focused on the
73+
machine rather than an art or rendering pipeline.

examples/platformer/index.html

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+
<meta name="theme-color" content="#10182e" />
7+
<title>Orbit Courier — Effect Machine Platformer</title>
8+
</head>
9+
<body>
10+
<main class="app-shell">
11+
<header class="hero">
12+
<div>
13+
<p class="eyebrow">@typeonce/effect-machine · playable example</p>
14+
<h1>Orbit Courier</h1>
15+
<p class="lede">A tiny platformer where every pose is a typed state.</p>
16+
</div>
17+
<div class="status-light"><span></span> machine online</div>
18+
</header>
19+
20+
<section class="demo-grid">
21+
<div class="game-card">
22+
<svg id="game" viewBox="0 0 640 360" role="img" aria-label="Playable platformer scene">
23+
<defs>
24+
<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1">
25+
<stop offset="0" stop-color="#10182e" />
26+
<stop offset="1" stop-color="#213751" />
27+
</linearGradient>
28+
</defs>
29+
<rect width="640" height="360" fill="url(#sky)" />
30+
<g class="stars" fill="#d9fff7">
31+
<circle cx="72" cy="58" r="1.5" />
32+
<circle cx="158" cy="112" r="1" />
33+
<circle cx="282" cy="52" r="1" />
34+
<circle cx="390" cy="92" r="1.5" />
35+
<circle cx="520" cy="48" r="1" />
36+
<circle cx="584" cy="138" r="1.5" />
37+
</g>
38+
<path
39+
class="skyline"
40+
d="M0 324v-38h40v-18h34v27h38v-46h46v37h48v-24h38v34h42v-52h50v42h44v-30h44v40h45v-64h44v54h40v-28h40v36h47v40z"
41+
/>
42+
<rect class="ground" y="324" width="640" height="36" />
43+
<rect class="ground-top" y="324" width="640" height="5" />
44+
<rect class="wall" y="112" width="6" height="212" />
45+
<rect class="wall" x="634" y="112" width="6" height="212" />
46+
47+
<g id="player" data-mode="Standing">
48+
<g id="player-pose">
49+
<rect class="body" width="30" height="30" rx="5" />
50+
<rect class="face" x="5" y="6" width="20" height="13" rx="2" />
51+
<circle class="eye" cx="11" cy="12" r="2" />
52+
<circle class="eye" cx="20" cy="12" r="2" />
53+
<path class="scarf" d="M2 20h27v5H17l-7 5 2-5H2z" />
54+
</g>
55+
</g>
56+
</svg>
57+
<div class="controls" aria-label="Controls">
58+
<span><kbd>A</kbd><kbd>D</kbd> move</span>
59+
<span><kbd>W</kbd>/<kbd>Space</kbd> jump ×2</span>
60+
<span><kbd>S</kbd> duck / dive</span>
61+
<span><kbd>R</kbd> reset</span>
62+
</div>
63+
</div>
64+
65+
<aside class="debug-card">
66+
<div class="panel-heading">
67+
<span>live statechart</span>
68+
</div>
69+
70+
<div class="chart" id="chart">
71+
<div class="chart-root"><span>Character</span><small>parallel</small></div>
72+
<div class="regions">
73+
<section>
74+
<h2>locomotion</h2>
75+
<div class="branch">
76+
<h3 data-node="Grounded">Grounded</h3>
77+
<div class="state-row">
78+
<span data-node="Standing">Standing</span>
79+
<span data-node="Running">Running</span>
80+
<span data-node="Ducking">Ducking</span>
81+
<span data-node="Landing">Landing</span>
82+
</div>
83+
</div>
84+
<div class="branch">
85+
<h3 data-node="Airborne">Airborne</h3>
86+
<div class="parallel-label">parallel regions</div>
87+
<div class="nested-region">
88+
<small>motion</small>
89+
<div class="state-row">
90+
<span data-node="Jumping">Jumping</span>
91+
<span data-node="Falling">Falling</span>
92+
<span data-node="Diving">Diving</span>
93+
</div>
94+
</div>
95+
<div class="nested-region">
96+
<small>air jump</small>
97+
<div class="state-row">
98+
<span data-node="AirJumpGroundLock">ground lock</span>
99+
<span data-node="AirJumpWallLock">wall lock</span>
100+
<span data-node="AirJumpReady">ready</span>
101+
<span data-node="AirJumpSpent">spent</span>
102+
</div>
103+
</div>
104+
</div>
105+
</section>
106+
<section>
107+
<h2>facing</h2>
108+
<div class="state-row facing-row">
109+
<span data-node="Left">Left</span>
110+
<span data-node="Right">Right</span>
111+
</div>
112+
</section>
113+
<section>
114+
<h2>wall contact</h2>
115+
<div class="state-row">
116+
<span data-node="NoWall">No wall</span>
117+
<span data-node="LeftWall">Left wall</span>
118+
<span data-node="RightWall">Right wall</span>
119+
</div>
120+
</section>
121+
</div>
122+
</div>
123+
124+
<dl class="telemetry">
125+
<div>
126+
<dt>active</dt>
127+
<dd id="active-mode">Standing · NoWall · Right</dd>
128+
</div>
129+
<div>
130+
<dt>state-local data</dt>
131+
<dd><code id="state-data">{}</code></dd>
132+
</div>
133+
<div>
134+
<dt>last protocol event</dt>
135+
<dd><code id="last-event">machine started</code></dd>
136+
</div>
137+
</dl>
138+
</aside>
139+
</section>
140+
141+
<p class="note">
142+
A small adapter owns coordinates and gravity. The machine owns legal behavior, and the box simply transforms to
143+
show its active state. Wall contact is tracked explicitly, so floor-corner jumps stay ordinary.
144+
</p>
145+
</main>
146+
<script type="module" src="/src/main.ts"></script>
147+
</body>
148+
</html>

examples/platformer/package.json

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
{
2+
"name": "@typeonce/effect-machine-example-platformer",
3+
"version": "0.0.0",
4+
"private": true,
5+
"description": "Playable platformer statechart example using @typeonce/effect-machine",
6+
"type": "module",
7+
"scripts": {
8+
"dev": "vite",
9+
"build": "tsc --noEmit && vite build",
10+
"preview": "vite preview",
11+
"check": "pnpm build"
12+
},
13+
"dependencies": {
14+
"@typeonce/effect-machine": "file:../..",
15+
"effect": "4.0.0-beta.102"
16+
},
17+
"devDependencies": {
18+
"typescript": "6.0.3",
19+
"vite": "8.1.5"
20+
},
21+
"packageManager": "pnpm@10.17.1",
22+
"engines": {
23+
"node": "^20.19.0 || >=22.12.0"
24+
}
25+
}

0 commit comments

Comments
 (0)