Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions examples/platformer/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
dist
node_modules
73 changes: 73 additions & 0 deletions examples/platformer/README.md
Original file line number Diff line number Diff line change
@@ -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.
148 changes: 148 additions & 0 deletions examples/platformer/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#10182e" />
<title>Orbit Courier — Effect Machine Platformer</title>
</head>
<body>
<main class="app-shell">
<header class="hero">
<div>
<p class="eyebrow">@typeonce/effect-machine · playable example</p>
<h1>Orbit Courier</h1>
<p class="lede">A tiny platformer where every pose is a typed state.</p>
</div>
<div class="status-light"><span></span> machine online</div>
</header>

<section class="demo-grid">
<div class="game-card">
<svg id="game" viewBox="0 0 640 360" role="img" aria-label="Playable platformer scene">
<defs>
<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#10182e" />
<stop offset="1" stop-color="#213751" />
</linearGradient>
</defs>
<rect width="640" height="360" fill="url(#sky)" />
<g class="stars" fill="#d9fff7">
<circle cx="72" cy="58" r="1.5" />
<circle cx="158" cy="112" r="1" />
<circle cx="282" cy="52" r="1" />
<circle cx="390" cy="92" r="1.5" />
<circle cx="520" cy="48" r="1" />
<circle cx="584" cy="138" r="1.5" />
</g>
<path
class="skyline"
d="M0 324v-38h40v-18h34v27h38v-46h46v37h48v-24h38v34h42v-52h50v42h44v-30h44v40h45v-64h44v54h40v-28h40v36h47v40z"
/>
<rect class="ground" y="324" width="640" height="36" />
<rect class="ground-top" y="324" width="640" height="5" />
<rect class="wall" y="112" width="6" height="212" />
<rect class="wall" x="634" y="112" width="6" height="212" />

<g id="player" data-mode="Standing">
<g id="player-pose">
<rect class="body" width="30" height="30" rx="5" />
<rect class="face" x="5" y="6" width="20" height="13" rx="2" />
<circle class="eye" cx="11" cy="12" r="2" />
<circle class="eye" cx="20" cy="12" r="2" />
<path class="scarf" d="M2 20h27v5H17l-7 5 2-5H2z" />
</g>
</g>
</svg>
<div class="controls" aria-label="Controls">
<span><kbd>A</kbd><kbd>D</kbd> move</span>
<span><kbd>W</kbd>/<kbd>Space</kbd> jump ×2</span>
<span><kbd>S</kbd> duck / dive</span>
<span><kbd>R</kbd> reset</span>
</div>
</div>

<aside class="debug-card">
<div class="panel-heading">
<span>live statechart</span>
</div>

<div class="chart" id="chart">
<div class="chart-root"><span>Character</span><small>parallel</small></div>
<div class="regions">
<section>
<h2>locomotion</h2>
<div class="branch">
<h3 data-node="Grounded">Grounded</h3>
<div class="state-row">
<span data-node="Standing">Standing</span>
<span data-node="Running">Running</span>
<span data-node="Ducking">Ducking</span>
<span data-node="Landing">Landing</span>
</div>
</div>
<div class="branch">
<h3 data-node="Airborne">Airborne</h3>
<div class="parallel-label">parallel regions</div>
<div class="nested-region">
<small>motion</small>
<div class="state-row">
<span data-node="Jumping">Jumping</span>
<span data-node="Falling">Falling</span>
<span data-node="Diving">Diving</span>
</div>
</div>
<div class="nested-region">
<small>air jump</small>
<div class="state-row">
<span data-node="AirJumpGroundLock">ground lock</span>
<span data-node="AirJumpWallLock">wall lock</span>
<span data-node="AirJumpReady">ready</span>
<span data-node="AirJumpSpent">spent</span>
</div>
</div>
</div>
</section>
<section>
<h2>facing</h2>
<div class="state-row facing-row">
<span data-node="Left">Left</span>
<span data-node="Right">Right</span>
</div>
</section>
<section>
<h2>wall contact</h2>
<div class="state-row">
<span data-node="NoWall">No wall</span>
<span data-node="LeftWall">Left wall</span>
<span data-node="RightWall">Right wall</span>
</div>
</section>
</div>
</div>

<dl class="telemetry">
<div>
<dt>active</dt>
<dd id="active-mode">Standing · NoWall · Right</dd>
</div>
<div>
<dt>state-local data</dt>
<dd><code id="state-data">{}</code></dd>
</div>
<div>
<dt>last protocol event</dt>
<dd><code id="last-event">machine started</code></dd>
</div>
</dl>
</aside>
</section>

<p class="note">
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.
</p>
</main>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
25 changes: 25 additions & 0 deletions examples/platformer/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
Loading