plum

#treesitter#compiler#wasm

git clone https://git.pyrossh.dev/plum

A statically typed, imperative programming language inspired by rust, python


fd0d216 — Peter John 2026-11-03T14:21:53+05:30
Pure scene graph, phase A (std/game PureScene, Actions, SceneTree, SceneInput)
README.md CHANGED
@@ -1244,6 +1244,45 @@ The page calls `init` once after the assets are loaded, then `update` and `draw`
1244
1244
 
1245
1245
  **Immediate mode or Scene?** [`plum-examples/game.plum`](plum-examples/game.plum) is immediate mode: one state record, a pure `update`, and `draw` issues the calls each frame; it suits flat arcade games with a few kinds of thing. [`plum-examples/scene.plum`](plum-examples/scene.plum) and [`plum-examples/game_scene.plum`](plum-examples/game_scene.plum) are retained mode on `trait Actor`: a tree with transforms, z-order, culling, hit-testing and per-kind `act`/`draw`, which suits UI, layered worlds and unit crowds. `game_scene.plum` is the same Orb Run as `game.plum` rebuilt on the scene graph, so you can compare the two styles on identical rules.
1246
1246
 
1247
+ ### Pure scene graph
1248
+
1249
+ The pure scene graph is the same kind of tree as `std/game/Scene`, but every function returns a new value and nothing is changed in place. It is split into four modules: `std/game/Actions` (timed actions such as `forever(rotateBy(...))`), `std/game/PureScene` (the `Actor` trait and its leaves, `group`, `sprite`, `button`, and the draw walk), `std/game/SceneTree` (update, hit testing, paths and event routing) and `std/game/SceneInput` (`Stage`, pointer capture and `UiEvent`). A game imports them by name:
1250
+
1251
+ ```
1252
+ import std/game/Actions
1253
+ import std/game/PureScene
1254
+ import std/game/SceneTree
1255
+ import std/game/SceneInput
1256
+ ```
1257
+
1258
+ A minimal game holds a `Stage` and replaces it each frame:
1259
+
1260
+ ```
1261
+ record World(stage: Stage, wasDown: Bool)
1262
+
1263
+ export fun init() -> World =
1264
+ spr := PureScene.sprite(2, 0, 100.0, 100.0, 48.0, 48.0)
1265
+ spr.actions = List(Actions.forever(Actions.rotateBy(1.0, 1.0)))
1266
+ sp: Actor := spr
1267
+ kids := List[Actor]()
1268
+ kids.push(sp)
1269
+ root: Actor := PureScene.group(1, 0.0, 0.0, 800.0, 600.0, kids)
1270
+ World(stage: SceneInput.stage(root), wasDown: false)
1271
+
1272
+ export fun update(w: World, dt: Float) =
1273
+ inputs := SceneInput.pollPointer(w.wasDown)
1274
+ w.wasDown = isMouseDown(0)
1275
+ h := w.stage.frame(dt, inputs)
1276
+ w.stage = h.stage
1277
+
1278
+ export fun draw(w: World) =
1279
+ w.stage.draw()
1280
+ ```
1281
+
1282
+ - **Immediate mode** ([`game.plum`](plum-examples/game.plum)): one state record and a `draw` that issues the calls every frame. Use it for flat arcade games with a few kinds of thing.
1283
+ - **Scene** ([`game_scene.plum`](plum-examples/game_scene.plum)): a retained tree of mutable actors. Use it when you want objects that change in place and a familiar `act`/`draw` object model.
1284
+ - **Pure scene** ([`game_pure_scene.plum`](plum-examples/game_pure_scene.plum)): every update returns new values, pointer clicks arrive as `UiEvent`s, and the rules can be tested without a host. Use it when you want pure updates and tests that run without a game host.
1285
+
1247
1286
  **Browser check:** `cargo run --manifest-path plum-game/web-test/Cargo.toml -- plum-examples/game.plum --screenshot /tmp/orb-run.png` builds the static web version, serves it and drives headless Chromium (software WebGPU; `--browser webkit` drives Playwright's WebKit, the Safari engine, instead) through [playwright-rs](https://crates.io/crates/playwright-rs): it fails if the canvas stays blank or stops changing, or the page logs an error, and reports whether audio started. It also takes `--assets DIR`, `--require-audio`, `--measure SECS` (with `--json`, `draws` is the GPU draw calls per frame), `--headed`, `--dpr N` (a device pixel ratio of N), `--lose-device` (destroys the GPU device after a few frames and requires rendering to resume) and `--expect-pixel x,y,r,g,b[,tol]` (repeatable; the screenshot pixel at CSS (x, y)), `--expect-unsupported-webgpu` (hides `navigator.gpu` and requires the page's "WebGPU is not supported" message while plum.wasm still loads) and `--no-module-introspection` (makes `WebAssembly.Module.imports/exports/customSections` throw, as Safari does). `plum-game/web-test/fixtures/` has `interleave.plum`, `text_sizes.plum` and `rendertarget.plum` for these. One-time setup: `cargo run --manifest-path plum-game/web-test/Cargo.toml --example install-browsers`. See [`plum-game/web-test/README.md`](plum-game/web-test/README.md); `docs/game.png` above was made with `--screenshot`. [`plum-game/web-test/BENCHMARK.md`](plum-game/web-test/BENCHMARK.md) records the renderer benchmark that led to this design.
1248
1287
 
1249
1288
  **Asset root:** textures and files are read relative to the asset root. A path that escapes the root (`..` or absolute) fails like a missing file: `-1`, `""` or `false`. The headless runner's root is `--assets` or `assets` beside the wasm; it reads image headers for `textureWidth`/`textureHeight` and records sounds without decoding them.
docs/plans/2026-11-02-own-record.review.md ADDED
@@ -0,0 +1,128 @@
1
+ # Phase B: `own record` and a state/view Orb Run: review plan
2
+
3
+ **Builds on:** phase A (pure scene graph, branch `pure-scene-values`); design D2 and section 6 of `docs/plans/2026-11-02-pure-scene-graph.design.md`; benchmark verdict `docs/plans/2026-11-02-pure-scene.benchmark.md`.
4
+ **First step after approval:** commit phase A on `pure-scene-values` (local commit only, no merge, no push), so phase B builds on a clean base.
5
+
6
+ ## Summary
7
+ Phase A's pure scene graph is about 15% slower than the mutable one, and all of the gap is in update: every animated actor is cloned every frame. Phase B adds `own record` (and `own trait`), a kind of record that is never shared. The compiler checks that an owned value isn't used after it's handed on. Because nobody else can see an owned value, the compiler may update it in place:
8
+ - a spread `Sprite(..s, x: 1.0)` of an owned `s` becomes field writes on `s`;
9
+ - a function that takes `own self` may assign its fields directly.
10
+
11
+ Code stays pure at the language level (nobody can observe the change) and gets mutable-scene speed. The scene modules switch to `own`, and the benchmark is rerun.
12
+
13
+ In parallel, a second Orb Run is written in the state/view style from the Rust example you shared:
14
+ - typed game state in flat records keyed by id, changed by messages;
15
+ - a pure `view` that rebuilds a throwaway node tree each frame.
16
+
17
+ That way the three styles can be compared on code and speed: mutable Scene, PureScene, and state/view.
18
+
19
+ ## What you will get
20
+ - **Language:**
21
+ - `own record Name(...)` and `own trait Name(...)` declarations.
22
+ - `own` in front of a parameter (`fun act(own self, dt: Float) -> Actor`), meaning the function takes the value over.
23
+ - **Compile errors**, each pointing at both places:
24
+ - using an owned value after it was moved: `"s" used after it was moved at 12:9`;
25
+ - writing a field through a borrowed value;
26
+ - storing a borrowed own value anywhere.
27
+ - **In-place updates:** for owned values, spread construction and field writes need no copy.
28
+ - **`List` additions:** `List.mapInPlace(own self, f)`, `take(i)` and `put(i, v)` for lists of owned values.
29
+ - **Scene modules on `own`:** `PureScene`, `SceneTree` and `SceneInput` use `own` instead of `clone()`. `game_pure_scene.plum` and the stress fixture are updated to match.
30
+ - **State/view Orb Run:** `plum-examples/game_state_view.plum` plus a `stress_state_view.plum` fixture.
31
+ - **Benchmark:** one table with all five rows (mutable Scene, phase A, upper bound, phase B, state/view) and a verdict.
32
+
33
+ ## What will be built
34
+ | Part | What it does | Size | Built by |
35
+ |---|---|---|---|
36
+ | Upper-bound probe | A throwaway copy of the stress fixture whose update writes in place without any checks, measured once. It shows the best phase B can reach before we build it. | S | a careful helper, then me |
37
+ | Syntax | Grammar, parser, AST, formatter and editor highlighting for `own record`, `own trait` and `own` parameters | M | a careful helper |
38
+ | Ownership checker | Tracks each own-typed binding as owned, borrowed or moved through blocks, branches, loops and closures, and reports the errors above | L, split in two (bindings and moves; branches, loops, fields) | a careful helper |
39
+ | In-place codegen | Spread of an owned source becomes field writes on it, and field writes on owned bindings are allowed | M | a careful helper |
40
+ | List for owned elements | `mapInPlace`, `take` and `put`, and `at` returns a borrow | M | a careful helper |
41
+ | Scene modules on own | PureScene, SceneTree and SceneInput: actors are `own record`s and `Actor` is an `own trait`. update and act take `own`, and `clone()` is removed. | M | a careful helper |
42
+ | Example and fixture on own | game_pure_scene.plum and stress_pure_scene.plum follow the new API, and all their tests still pass | M | a careful helper |
43
+ | State/view Orb Run | Same game: id-keyed state records, a message `update`, and a pure `view(state) -> SceneNode`. The picture type is ML-style: `enum Transform = Translate \| Rotate \| Scale` (struct members), `enum SceneNode = Shape \| Sprite \| Text \| Group \| Filter` with `Group(transform, children)` and `Filter(effect, child)`. A small interpreter with a matrix stack draws it. `Shape` is limited to rect, circle and line (the host has no vector paths). `Filter` is limited to tint, alpha and blend mode, drawn into a render target and composited back. Plus its stress fixture. | M | a careful helper |
44
+ | Benchmark and docs | Five-row benchmark, verdict, README note on `own`, design doc update | S | me, then a fast helper |
45
+
46
+ ## How it fits together
47
+ ```mermaid
48
+ flowchart LR
49
+ SRC["source with own record / own trait / own param"] --> SYN[Syntax]
50
+ SYN --> CHK["Ownership checker: owned, borrowed, moved"]
51
+ CHK --> CG["Codegen: in-place spread and field writes"]
52
+ CG --> WASM[wasm-gc]
53
+ LST["List: mapInPlace, take, put"] --> SCN["PureScene, SceneTree, SceneInput on own"]
54
+ CHK --> SCN
55
+ SCN --> EX["game_pure_scene + stress fixture"]
56
+ SV["State/view Orb Run (no own needed)"] --> BM[Benchmark]
57
+ EX --> BM
58
+ UB[Upper-bound probe] --> BM
59
+ ```
60
+
61
+ ## How it gets built
62
+ ```mermaid
63
+ flowchart TB
64
+ C0[Commit phase A] --> W1
65
+ subgraph W1["Wave 1, in parallel"]
66
+ UB[Upper-bound probe]
67
+ SY[Syntax]
68
+ SV[State/view Orb Run]
69
+ end
70
+ SY --> CK1["Checker: bindings and moves"]
71
+ CK1 --> CK2["Checker: branches, loops, fields"]
72
+ SY --> CG[In-place codegen]
73
+ CK2 --> LS[List for owned]
74
+ CG --> LS
75
+ LS --> SC[Scene modules on own]
76
+ SC --> EX[Example and fixture]
77
+ EX --> BM[Benchmark and docs]
78
+ UB --> BM
79
+ SV --> BM
80
+ ```
81
+
82
+ ## Decisions you can change
83
+ - **Where ownership lives.**
84
+ - Default: on the type (`own record`, `own trait`). Every value of the type is unique, which is what makes in-place updates sound.
85
+ - Alternative: on bindings only (`own` locals and parameters of ordinary records). Simpler, but unsound: an alias stored in a list could still see the mutation.
86
+ - **How parameters behave.**
87
+ - Default: a plain parameter is a read-only borrow, and `own` takes the value over.
88
+ - Alternative: parameters are owned by default and `&` borrows (Rust-like, more annotations at call sites).
89
+ - **Own values inside ordinary records** (the host-facing `World` holds the `Stage`).
90
+ - Default: allowed. A field may be moved out only in the "swap" form `w.stage = w.stage.frame(...)`, where the same statement writes the field back.
91
+ - Alternative: forbid it, and make `World` itself `own`.
92
+ - **Existing code.** Nothing changes for ordinary `record`s (default). Alternative: also forbid assigning a field through a parameter in every record (a much larger, breaking change).
93
+ - **Phase A's `clone()`.** Removed from the scene modules (default) | kept as an explicit copy method for when a real copy is wanted.
94
+ - **Speed bar.** Same as phase A: average within 10% and p95 within 15% of the mutable scene, same session.
95
+
96
+ ## Risks
97
+ - **R1: checker complexity.** Branches, loops, closures and early returns all affect what counts as moved. The default is conservative: moved on any path means moved after the join, moving an outer owned value inside a loop is an error unless it's reassigned in the same iteration, and closures may only borrow. The rules are kept small and listed in the error messages.
98
+ - **R2: generics.** `List[T]` with an own `T` must never duplicate an element. Plum makes a separate copy of each generic per type, so the checker checks each instantiation. A std method that copies `T` (for example `List.copy`) fails only when instantiated with an own type, and the error names the method. The List part sweeps List for this.
99
+ - **R3: trait dispatch.** A trait value's field writes go through generated setters, and those must count as writes on an owned value. The upper-bound probe shows whether dispatch, rather than copying, is what remains slow.
100
+ - **R4: the probe doesn't close the gap.** If even unchecked in-place updates stay over the bar, ownership alone isn't enough. I'll stop after the probe and report what's left (likely list rebuilding or dispatch) before building the checker.
101
+ - **R5: known compiler bug.** Reusing a local name with different types in two match arms gives invalid wasm. The ownership pass must not make this worse. A test with a renamed local in a match covers it.
102
+
103
+ ## Out of scope
104
+ - Lifetimes, borrows returned from functions, borrow regions, shared mutable cycles.
105
+ - Reference counting or a linear-memory backend.
106
+ - Ownership for structs (already copied by value) and for primitives.
107
+ - Generic traits, narrowing a trait value back to its record (the state/view example sidesteps this), pointer edge cases left from phase A.
108
+ - Vector paths for `Shape` (needs host tessellation) and shader filters such as blur (needs new host support).
109
+ - Merging or pushing anything: that's your call after this phase.
110
+
111
+ ## How we will know it works
112
+ - New compile-error tests, each with exact messages:
113
+ - use after move;
114
+ - a field write through a borrow;
115
+ - storing a borrow;
116
+ - moved in one branch then used after the join;
117
+ - an outer owned value moved inside a loop;
118
+ - an owned value captured by a closure and moved;
119
+ - the swap form `w.stage = w.stage.frame(...)` accepted, and the non-swap form rejected.
120
+ - Codegen tests:
121
+ - a spread of an owned record returns the same object (identity check through a mutable probe in the test) and leaves no copy;
122
+ - a spread of a non-own record still copies.
123
+ - Every existing test still passes: the full suite (1474 today) plus phase A's 90 std/game tests and 21 example tests, unchanged in behaviour.
124
+ - The host parity test still passes, and so does the browser check for both Orb Run variants.
125
+ - The benchmark table has five rows: mutable Scene, phase A, upper bound, phase B, state/view. The verdict says whether phase B meets the bar.
126
+
127
+ ## Rough time and cost
128
+ About 10 parts. The compiler chain (syntax, then checker, then codegen and List, then scene) is sequential, while the probe and the state/view game run alongside it. Rough agent time is 60–90 minutes plus two headed benchmark runs. Rough until the machine plan is linted. The probe result comes early and can stop the compiler work (R4).
docs/plans/2026-11-02-pure-scene-graph.design.md ADDED
@@ -0,0 +1,258 @@
1
+ # Pure scene graph for Plum
2
+
3
+ **Status:** design, not started. Nothing here is implemented.
4
+ **Variant:** *value-type payloads*, on worktree branch `pure-scene-values`. Compared with the original (main checkout, same path):
5
+ - Action leaves are `struct`s (D9).
6
+ - D4 sets out generic traits and `UiEvent` ids side by side.
7
+ - Inline ADT payloads are evaluated and left out (D10).
8
+ **Goal:** a libGDX/scene2d-style scene graph whose update, input and animation logic is pure (values in, values out), written in Plum on the existing wasm-gc backend and WebGPU host, and as fast as today's mutable `std/game/Scene`.
9
+ **Reference sketch:** `docs/plans/2026-11-02-pure-scene-graph.sketch.pfl`. It's language-neutral placeholder syntax and is the fixed version of the root-level `scene_graph.pfl`. This doc maps it onto Plum.
10
+ **Background:** a design conversation about pure functional languages with reference counting or a borrow checker on wasm (Perceus, Ante, Vale/Valen, Pyret reactors), plus `plum-game/web-test/BENCHMARK.md`.
11
+
12
+ ## 1. Where Plum is today
13
+
14
+ | Concern | Today |
15
+ |---|---|
16
+ | Memory | wasm-gc. `Str`, `Array`, `List`, records and trait layout (T10 subtyping) are all GC types. No refcounts, no linear memory except the optional `Mem` primitive. |
17
+ | Data | `struct` = value type (copy on assign, flattened into wasm locals, no heap allocation). Struct fields must be `Int`, `Float`, `Bool`, `Byte` or another struct, with no enums, generics or references (`plum-checker/src/lib.rs:1527`). `record` = reference type, mutable (`p.x = 1` is seen through every alias). Spread update `Point(..p, x: 1)` makes a copy. |
18
+ | Sum types | Union enums: `enum Action = MoveBy \| Delay \| ...`, each member a separately declared struct, record or primitive. Lowered to hidden variants (`Action__MoveBy(MoveBy)`) with exhaustive `match`, nested patterns, narrowing and generics. Inline payloads (`enum S = Circle(r: Float)`) were removed by ruling in union-enums T8. |
19
+ | Traits | `trait Actor(fields) = methods`. A record embeds it (`record Ship(Actor, ...)`). Lowered to a closed-world union enum over all embedding records, so dispatch is a `match`, not a vtable. v1 has no generic traits. |
20
+ | Generics | Specialised per type. No trait bounds. |
21
+ | Scene | `plum-std/game/Scene.plum`: mutable tree with `parent: Actor?` pointers, `scratch` lists, culling and z-order, drawn through per-call `extern`s. |
22
+ | Game styles | Immediate mode (`plum-examples/game.plum`, already reactor-shaped: one `World`, pure `step`, `draw`) and retained mode (`plum-examples/game_scene.plum` on `Scene`). |
23
+ | Missing for the sketch | `own`/`&`, tuples, list patterns, `dyn`. |
24
+
25
+ ## 2. What you will get
26
+
27
+ - A scene graph module where `act`, actions, hit-testing and input routing are pure functions over immutable-by-default records, testable under `plum test` with no host.
28
+ - **No parent pointers.** The parent transform is passed down the recursion, and events bubble up a `Path` of child indices.
29
+ - **Actions as data** (`MoveBy`, `RotateBy`, `Delay`, `Sequence`, `Parallel`, `Forever`) stepped by a pure function, carrying spare time so chains and loops don't drift.
30
+ - **Pointer capture.** A press sets the target, and Move/Up go to it until release. Buttons click only when released over themselves, and can't get stuck pressed.
31
+ - **Typed UI events** instead of callbacks with side effects.
32
+ - **A language feature (`own record`, phase B)** that turns pure-looking updates into in-place writes, checked at compile time, with no refcounts.
33
+
34
+ ## 3. Decisions
35
+
36
+ Each decision has a default. The ones marked *changeable* can be flipped before work starts without affecting the others.
37
+
38
+ **D1. Keep wasm-gc, no reference counting.** Perceus-style RC needs linear memory, an allocator and rewrites of `Str`, `Array` and trait layout: effectively a second backend. What RC buys a pure language is in-place update of unique values, and D2 gets that statically. GC pauses haven't shown up in any measurement so far. Revisit only if a frame-time trace shows GC pauses.
39
+
40
+ **D2. Purity through uniqueness, in two phases.**
41
+ - *Phase A (no language change):* the new scene module is written in pure style. Every update is a spread copy (`Ship(..s, x: nx)`) and every function returns new values. Correct, but it allocates per update. We measure it.
42
+ - *Phase B (`own record`):* a new record kind whose values are never aliased. The compiler checks moves and turns a spread update of a dead owned value into in-place field writes. Run phase B only if phase A misses the performance bar (section 7). *Changeable:* run B regardless, for the guarantee.
43
+
44
+ **D3. No `Node` enum, no `dyn`.** Plum traits are already closed-world unions, so `List[Actor]` is heterogeneous with match dispatch, and a game's own records join simply by embedding `Actor`. The sketch's `Node<Msg, X>` / `Custom(X)` exists only because its placeholder language has open traits. In Plum it disappears.
45
+
46
+ **D4. How UI events reach the game: two options.** A std module can't name the game's own message type, so either the scene emits its own event type or the trait gets a type parameter.
47
+
48
+ | | **4a. `UiEvent` ids (default)** | **4b. Generic traits** |
49
+ |---|---|---|
50
+ | Shape | Scene emits `UiEvent(id: Int, kind: UiKind)` (`Click`, `Press`, `Release`, `Enter`, `Exit`). The game's `handle` matches on the id. | `trait Actor[M]`, `Button[M](Actor[M], onClick: M)`, `route` returns `Handled[M]`. Clicks are the game's own values, as in the sketch. |
51
+ | Language work | None. | Lift the traits-v1 exclusion: `lowerTraits` (`plum-core/src/traits.rs:51`) substitutes `M` into copied fields and defaults. Union membership becomes per-instantiation: `record Sprite[M](Actor[M])` belongs to every `Actor[X]`, `record Ship(Actor[GameMsg])` only to `Actor[GameMsg]` (`members(en, args, ctx)` in `plum-checker/src/unions.rs` filters by matching args). Unions are already generic, and generics are already per instantiation. |
52
+ | Type safety | An id with no case in `handle` is silently ignored. Ids are plain `Int`s and can collide. | A missing message case is an exhaustiveness error. Nothing to collide. |
53
+ | API noise | None. | `[M]` on `Group`, `Stage`, `List[Actor[M]]`, `Handled[M]`. A game writes it once (`Stage[GameMsg]`). |
54
+ | Other uses | None. | Unlocks generic traits generally (`trait Container[T]`). |
55
+ | Order | Phase A can start now. | Its own small feature, done *before* phase A so the scene API is written once. |
56
+
57
+ Recommendation: 4b if typed clicks matter to you, otherwise 4a. The rest of this design is the same under either.
58
+
59
+ **D5. Multiple results are small records, not tuples.** `Stepped(base, left, spare)`, `Handled(actor, events)`, and so on, as the sketch already does. No tuple feature is needed.
60
+
61
+ **D6. No list patterns.** Paths are `List[Int]` walked with a `depth: Int` index (`path.at(depth)`, `depth == path.length()`), not `[i, ..rest]`.
62
+
63
+ **D7. Draw stays per-call `extern`, during a read-only walk.** BENCHMARK.md (M3 Pro, headed, WebGPU): flat immediate mode is 1.64 ms per-call vs 1.77 ms command buffer, and the scene tree is 2.46 ms per-call vs 2.15 ms command buffer. The command buffer was removed by ruling. The pure draw walk calls `std/game/Game` directly, the one effect at the edge, and tests use the headless Recorder host. The sketch's `List<DrawCmd>` output isn't built. *Changeable:* if the pure scene ends up draw-bound, the scene-tree row (13% faster with a buffer) is the argument for bringing `GameCmd` back for this module only.
64
+
65
+ **D8. New module alongside, then replace.** Build as `std/game/PureScene`, port `game_scene.plum` to it as `plum-examples/game_pure_scene.plum`, and once the bar in section 7 is met, replace `std/game/Scene` and take its name. Culling and z-order move over in that step.
66
+
67
+ **D9. Value-type payloads where the language allows.** The property the pure design wants from ADTs is that payloads are immutable values, not shared references. A `struct` has that today: it's copied on assignment and lives in wasm locals with no heap allocation. So any type whose fields are all scalars or structs becomes a struct:
68
+ - `MoveBy`, `RotateBy`, `Delay` (the leaf actions)
69
+ - `Tick`
70
+ - the existing `Affine`, `Vec2`, `Color`
71
+
72
+ The rest stay `record`s, because struct fields can't be enums or references:
73
+ - types that hold a `List`, a trait value or an enum: `Sequence`, `Parallel`, `Forever`, `Stepped`, `Handled`, `Capture`, every actor
74
+ - `Pointer`, because its `kind` is an enum (`PointerKind`). That's one allocation per input event, which is negligible.
75
+
76
+ These records are treated as immutable by convention in phase A: no field assignment in `PureScene`, enforced in review. Phase B's `own record` makes that a compiler guarantee.
77
+
78
+ Effect:
79
+ - Stepping a leaf action (by far the most common case: one `MoveBy`/`RotateBy` per animated actor) allocates nothing for the action itself.
80
+ - A `match` on `Action` narrows to a struct copy, so a step can't accidentally mutate an action another actor shares, for example a template reused across actors.
81
+
82
+ *Changeable:* lift the struct-field restriction to allow tag enums and references. That makes `Pointer`, `Sequence`, `Forever` and `Stepped` value types too. Tag enums are easy (an i32 local). References need a codegen change (a flattened struct with GC-ref locals). Out of scope here.
83
+
84
+ **D10. No inline ADT payloads.** Evaluated and rejected for this design. `enum Action = | MoveBy(dx: Float, ...) | ...` would only be sugar: the loader would generate the struct or record and list it as a member. It would save about six declaration lines for `Action` and change neither semantics nor codegen.
85
+
86
+ Using a closed ADT `Node = Group | Sprite | ...` with a shared `ActorBase` struct instead of `trait Actor` would be worse:
87
+ - the trait gives shared field access (`a.x`) directly, where an ADT needs `base` plus a `match` per operation (about 50 lines of that in the sketch);
88
+ - Plum traits are already closed-world unions with `match` dispatch, which is the main thing a closed ADT would add.
89
+
90
+ Inline payloads also don't help D4: the problem there is naming the game's type, not variant syntax. *Changeable:* add the sugar anyway as a separate convenience, reversing the T8 ruling.
91
+
92
+ ## 4. How it fits together
93
+
94
+ ```mermaid
95
+ flowchart LR
96
+ HOST["JS host (rAF, input, WebGPU)"] -->|inputs, dt| FRAME["frame(world, dt, inputs) -> World"]
97
+ FRAME --> INPUT["Stage.input: findHit -> Path, capture, route"]
98
+ INPUT -->|UiEvent list| HANDLE["game handle(world, event) -> World"]
99
+ HANDLE --> UPDATE["Stage.update: act + actions, depth first"]
100
+ UPDATE --> DRAW["Stage.draw (read-only walk)"]
101
+ DRAW -->|per-call externs| HOST
102
+ ```
103
+
104
+ Every box except the host and the final `draw` is pure, and is what `plum test` exercises.
105
+
106
+ ## 5. The module in Plum (proposed syntax)
107
+
108
+ Phase A syntax only, so it compiles with today's language. Phase B adds `own` (section 6).
109
+
110
+ ```plum
111
+ module std
112
+ import std/List
113
+ import std/game/Vec2
114
+ import std/game/Color
115
+ import std/game/Game
116
+
117
+ enum Touchable = | Enabled | Disabled | ChildrenOnly
118
+
119
+ # Actions: plain data. `elapsed` counts up to `duration`.
120
+ # Leaves are structs (D9): copied values, no heap allocation per step.
121
+ # Composites hold an Action or a List, which struct fields can't, so they
122
+ # stay records, never mutated in this module.
123
+ enum Action = MoveBy | RotateBy | Delay | Sequence | Parallel | Forever
124
+ struct MoveBy(dx: Float, dy: Float, duration: Float, elapsed: Float)
125
+ struct RotateBy(radians: Float, duration: Float, elapsed: Float)
126
+ struct Delay(duration: Float, elapsed: Float)
127
+ record Sequence(steps: List[Action])
128
+ record Parallel(steps: List[Action])
129
+ record Forever(template: Action, current: Action)
130
+
131
+ # How much of a timed action one step covers (sketch: `tick`).
132
+ struct Tick(used: Float, frac: Float, done: Bool)
133
+
134
+ trait Actor(
135
+ id: Int, x: Float, y: Float, width: Float, height: Float,
136
+ originX: Float, originY: Float, scaleX: Float, scaleY: Float, rotation: Float,
137
+ color: Color, visible: Bool, touchable: Touchable, actions: List[Action]) =
138
+
139
+ # Custom behaviour; returns the new actor. Default: unchanged.
140
+ fun act(self, dt: Float) -> Actor = self
141
+ # Paints this actor only (the effect edge). Default: nothing.
142
+ fun draw(self, m: Affine, tint: Color) = return
143
+ # Point in local coordinates on this actor?
144
+ fun hit(self, p: Vec2) -> Bool =
145
+ self.touchable == Enabled && p.x >= 0.0 && p.y >= 0.0 && p.x < self.width && p.y < self.height
146
+ # Pointer routed to this actor. Default: ignore.
147
+ fun onPointer(self, e: Pointer) -> Handled = Handled(actor: self, events: List[UiEvent]())
148
+
149
+ record Group(Actor, children: List[Actor])
150
+ record Sprite(Actor, texture: Int, sx: Float, sy: Float, sw: Float, sh: Float, clickable: Bool)
151
+ record Label(Actor, text: Str, size: Float)
152
+ record Button(Actor, text: Str, upTex: Int, downTex: Int, pressed: Bool, over: Bool)
153
+
154
+ record Pointer(kind: PointerKind, x: Float, y: Float, over: Bool) # record: enum field (D9)
155
+ record UiEvent(id: Int, kind: UiKind) # D4a; under D4b the game's own M
156
+ record Handled(actor: Actor, events: List[UiEvent])
157
+ record Stepped(actor: Actor, left: Action?, spare: Float)
158
+ record Capture(path: List[Int], id: Int)
159
+ record Stage(root: Group, camera: Affine, captured: Capture?)
160
+ ```
161
+
162
+ The algorithms are the sketch's, translated:
163
+
164
+ | Sketch | Plum |
165
+ |---|---|
166
+ | `tick` / `step_action` / `step_all` | Same logic. `Stepped` works on the whole actor (the trait value) instead of a separate `ActorBase`, because trait fields *are* the base. Spread update goes through the trait: `Actor(..a, x: nx)` needs spread on trait values, see risk R3. |
167
+ | `update` | `fun update(a: Actor, dt: Float) -> Actor = runActions(a.act(dt), dt)`. `Group.act` maps `update` over children. |
168
+ | `draw_child` / `Group.draw` | `drawTree(a, parent: Affine, tint: Color)`: skip invisible, compose `parent * local(a)`, call `a.draw`, recurse into `Group` children with pattern `if`. Read-only. |
169
+ | `find_hit` | `findHit(a, world, p) -> List[Int]?`, top-most child first, `Disabled` prunes the subtree, a singular matrix means no hit. |
170
+ | `id_at` / `find_path` / `modify_at` | `idAt(a, path, depth)`, `findPath(a, id)`, `modifyAt(a, path, depth, f: fn(Actor) -> Actor)`. `modify` = `findPath` then `modifyAt`, so only the first match changes. |
171
+ | `route` | `route(a, path, depth, e) -> Handled`: the child at `path.at(depth)` first, then the group's own `onPointer`, events concatenated child-first. |
172
+ | `stage_input` + `Capture` | `Stage.input(self, in: Input) -> StageHandled`. Same capture rules: Down sets the capture, Move/Up go to it with `over`, Up clears it, a capture whose `idAt` no longer matches is dropped. |
173
+ | `Msg` + `on_click: Msg` | D4a: `UiEvent(id, Click)`. D4b: `Button[M](Actor[M], onClick: M)`, exactly the sketch. |
174
+ | `Shared<String>` for text | Not needed: `Str` is a GC reference, so passing it is a pointer copy. |
175
+ | `Never`, `Node`, `Custom` | Gone (D3). |
176
+
177
+ ### Bugs fixed relative to the original `scene_graph.pfl` (all carried into the Plum version)
178
+
179
+ 1. Use-after-move in `update`, `Forever`, `Sprite.on_input`, `Button.on_input` and `Label.draw`. Every function now reads what it needs before moving `self`.
180
+ 2. `Custom(Box<dyn Actor>)` couldn't work with `Self`-returning methods. Replaced by a type parameter, and in Plum by closed-world traits.
181
+ 3. A button could stay pressed after a release elsewhere. Fixed with pointer capture and the `over` flag (scene2d `touchFocus`, `isPressed`, `isOver`).
182
+ 4. `invert` on a singular matrix (scale 0) gave NaN hit tests. It now returns none, which means no hit.
183
+ 5. Zero-duration `MoveBy`/`RotateBy` computed 0/0. `tick` now applies the full delta at once.
184
+ 6. `Forever` and `Sequence` dropped leftover time each cycle and drifted. Spare time is now carried; `Forever` restarts at most once per frame.
185
+ 7. `touchable` didn't prune subtrees. It's now three-state (`Enabled`/`Disabled`/`ChildrenOnly`).
186
+ 8. `modify` applied `f` to every node with the id and kept walking. It now uses `find_path` + `modify_at`, so only the first match changes.
187
+ 9. A new draw buffer was allocated every frame. It's now reused (in the sketch; moot in Plum per D7).
188
+
189
+ ## 6. Phase B: `own record` (language feature)
190
+
191
+ Only if phase A misses the bar, or by ruling (D2).
192
+
193
+ **Surface.** `own record Name(fields) = methods` declares a linear record type. Everything else in the language is unchanged. Existing `record`s keep today's aliasing semantics.
194
+
195
+ **Rules (checker).**
196
+ 1. A binding of an `own record` type is either *owned* (constructed here, returned by a call, or an `own` parameter) or *borrowed* (an ordinary parameter, or a field read through a borrow).
197
+ 2. Passing an owned binding to an `own` parameter, returning it, or storing it in a field or list *moves* it. Any later use is an error: `"x" used after it was moved at L:C`.
198
+ 3. Through a borrow you can't assign fields, move the value, or store it.
199
+ 4. A struct holding an `own record` field is itself treated as owned (it can't be copied).
200
+ 5. Generic code is checked after specialisation, since Plum already specialises per type. `List[T]` with an own `T`: `at(i)` borrows; `map`, `fold`, `updateAt(i, f)` and `take(i)` consume. A list method that duplicates `T` is an error at that instantiation.
201
+
202
+ **Codegen.** `T(..x, f: v)` where `x` is owned and dead afterwards becomes `struct.set` on `x` for each changed field, and yields `x`. `List.map` on an owned list writes results back into the same backing `Array`. No refcount field, no runtime check.
203
+
204
+ **Proposed syntax on the scene:**
205
+
206
+ ```plum
207
+ own record Sprite(Actor, texture: Int, ...)
208
+
209
+ trait Actor(...) =
210
+ fun act(own self, dt: Float) -> own Actor = self
211
+ fun draw(self, m: Affine, tint: Color) = return # plain self = borrow
212
+ ```
213
+
214
+ **Explicitly not in phase B:** lifetimes, borrow regions, group borrowing (Valen) and shared-mutable cycles (Ante). A borrow is only a parameter that can't escape, which needs no lifetime inference.
215
+
216
+ ## 7. How we will know it works
217
+
218
+ - **Unit tests (`plum test`, no host)** in the new module and the ported example, one or more per item in the section 5 bug list:
219
+ - a zero-duration `MoveBy`
220
+ - a `Forever(RotateBy 1 s)` after 10 s of frames of 0.016 s: angle within 1e-6 of 10 turns
221
+ - a Sequence spilling spare time
222
+ - scale-0 hit returns none
223
+ - press on a button, release elsewhere: no click and not stuck pressed
224
+ - press, drag off and back on, release: one click
225
+ - `Disabled` group hides its subtree
226
+ - `modify` changes only the first match
227
+ - a capture dropped after the tree changes
228
+ - **Behavioural parity:** `plum-examples/game_pure_scene.plum` plays the same as `game_scene.plum` under the Recorder host. The same input script gives the same draw-call stream for 600 frames.
229
+ - **Performance bar:** a `stress_pure_scene.plum` fixture (same scene as `stress_scene.plum`) in `plum-game/web-test`, headed, uncapped. Pass if the average frame time is within 10% of `stress_scene` per-call (2.46 ms on the M3 Pro reference machine) and p95 is within 15%. Phase A failing this triggers phase B.
230
+ - **Browser check:** the existing playwright-rs `web_check` gains `pure_scene_renders_and_animates`.
231
+
232
+ ## 8. Risks
233
+
234
+ - **R1. Phase A allocation churn.** Spreading every actor every frame is about 5 000 small GC allocations per frame in the stress scene. wasm-gc nurseries are fast, but this is exactly what the bar measures. Mitigation: phase B.
235
+ - **R2. Ownership rules vs. existing std generics.** Rule 5 touches every `List` method. If a method's specialised body duplicates `T`, it fails only when someone instantiates it with an own type. Needs a sweep of `List`/`Array`/`Map` with an own-record test type.
236
+ - **R3. Spread on trait values.** `Actor(..a, x: nx)` has to dispatch to the concrete record's constructor. The trait union lowering (`lowerTraits`, `union_lower`) doesn't support spread on trait values today. Fallback: a generated `withTransform(...)` method per embedding record.
237
+ - **R4. Pure API ergonomics.** `a = a.act(dt)` and returning `Handled` records are noisier than today's mutation. The ported example is the test of whether games still read well. If they don't, keep immediate mode as the recommended simple path (it already is).
238
+ - **R6. Struct members of a recursive union.** The scene would be the first heavy user of struct members in a recursive enum (`Sequence(steps: List[Action])` holding struct leaves converted to `Action`). Add a test early in phase A for push, `match` and spread-update of a struct leaf through `List[Action]`.
239
+ - **R5. Path invalidation.** A path is only valid until the tree's shape changes. Capture guards with the id. Anything else holding a path across frames must re-resolve it with `findPath`.
240
+
241
+ ## 9. Out of scope
242
+
243
+ - Reference counting, Perceus and a linear-memory backend (D1).
244
+ - Generic traits, unless D4b is chosen. They'd then be their own feature ahead of phase A.
245
+ - Inline ADT payload sugar (D10).
246
+ - Enum or reference fields in structs (D9's changeable option).
247
+ - Tuples, list patterns, `dyn` (D3, D5, D6).
248
+ - Multi-touch (one pointer), keyboard focus, scene2d layout widgets (Table and similar), and `Forever` catching up more than one cycle per frame.
249
+ - Removing mutation from the language: `record` stays mutable, and only the scene module and `own record` types are pure.
250
+
251
+ ## 10. Phases
252
+
253
+ 0. **If D4b:** generic traits (lowering substitution, per-instantiation membership, checker and codegen tests) as its own plan.
254
+ 1. **Phase A:** `std/game/PureScene` with tests (section 7 unit list), the ported example, the stress fixture and benchmark row. Decide on phase B from the number.
255
+ 2. **Phase B** (conditional): `own record` grammar + parser, then checker move/borrow rules, then codegen in-place spread + `List` consuming methods, then switch PureScene's records to `own record` and re-run the benchmark.
256
+ 3. **Replace:** move culling/z-order over, rename to `std/game/Scene`, port remaining users, delete the old module.
257
+
258
+ Each phase gets its own superlite plan (`docs/plans/<date>-<topic>.md` with a `%% taskgraph`) when it's approved.
docs/plans/2026-11-02-pure-scene-graph.sketch.pfl ADDED
@@ -0,0 +1,763 @@
1
+ // ═══════════════════════════════════════════════════════════════════════
2
+ // 2026-11-02-pure-scene-graph.sketch.pfl — a libGDX/scene2d-style scene
3
+ // graph for a pure functional language that compiles to wasm.
4
+ //
5
+ // Reference sketch for 2026-11-02-pure-scene-graph.design.md (same
6
+ // folder). The design doc maps this onto Plum; this file stays
7
+ // language-neutral. Fixed version of the root-level scene_graph.pfl.
8
+ //
9
+ // Syntax legend (placeholder syntax — adapt to your language):
10
+ // struct immutable value type, stored inline / unboxed where possible
11
+ // class nominal type with fields + methods, heap allocated,
12
+ // implements traits; NO inheritance (compose instead)
13
+ // trait interface; generic code over traits is monomorphized
14
+ // enum tagged union (sum type)
15
+ // own T owned / unique value → the compiler may update it in place.
16
+ // Using an owned value after it was moved is a compile error.
17
+ // A field may be moved out of an owned value; the value is
18
+ // then unusable until `{ x with f = v }` refills that field.
19
+ // &T borrowed, read-only → no refcount traffic, no copies
20
+ // { x with f = v } functional update (in-place when x is unique)
21
+ // [a, ..rest] list pattern
22
+ // Shared<T> immutable shared value; .clone() is a pointer copy
23
+ // (RC: one increment, GC: free). Used for strings.
24
+ // Clone trait for values that can be duplicated explicitly
25
+ //
26
+ // Memory model: works with Perceus-style RC (own/& are then just hints
27
+ // that enable reuse + borrowing) or with a borrow checker (own/& are then
28
+ // enforced; every function below obeys the move rules). The tree has no
29
+ // parent pointers, so it is acyclic either way.
30
+ // ═══════════════════════════════════════════════════════════════════════
31
+
32
+ module scene
33
+
34
+ // ───────────────────────────────────────────────────────────────────────
35
+ // 1. Math structs (small, unboxed, copied by value)
36
+ // ───────────────────────────────────────────────────────────────────────
37
+
38
+ struct Vec2 { x: Float, y: Float }
39
+
40
+ fn vec2(x: Float, y: Float) -> Vec2 = Vec2 { x, y }
41
+ fn add(a: Vec2, b: Vec2) -> Vec2 = Vec2 { x: a.x + b.x, y: a.y + b.y }
42
+ fn mul(a: Vec2, s: Float) -> Vec2 = Vec2 { x: a.x * s, y: a.y * s }
43
+
44
+ struct Color { r: Float, g: Float, b: Float, a: Float }
45
+
46
+ let WHITE = Color { r: 1.0, g: 1.0, b: 1.0, a: 1.0 }
47
+
48
+ /// Child colour is multiplied by the parent's (scene2d's colour inheritance).
49
+ fn tint(p: Color, c: Color) -> Color =
50
+ Color { r: p.r * c.r, g: p.g * c.g, b: p.b * c.b, a: p.a * c.a }
51
+
52
+ struct Rect { x: Float, y: Float, w: Float, h: Float }
53
+
54
+ fn contains(r: Rect, p: Vec2) -> Bool =
55
+ p.x >= r.x && p.x < r.x + r.w && p.y >= r.y && p.y < r.y + r.h
56
+
57
+ /// 2D affine matrix: | a c tx |
58
+ /// | b d ty |
59
+ struct Affine { a: Float, b: Float, c: Float, d: Float, tx: Float, ty: Float }
60
+
61
+ let IDENTITY = Affine { a: 1.0, b: 0.0, c: 0.0, d: 1.0, tx: 0.0, ty: 0.0 }
62
+
63
+ /// compose(p, q) = p · q (apply q first, then p)
64
+ fn compose(p: Affine, q: Affine) -> Affine =
65
+ Affine {
66
+ a: p.a * q.a + p.c * q.b,
67
+ b: p.b * q.a + p.d * q.b,
68
+ c: p.a * q.c + p.c * q.d,
69
+ d: p.b * q.c + p.d * q.d,
70
+ tx: p.a * q.tx + p.c * q.ty + p.tx,
71
+ ty: p.b * q.tx + p.d * q.ty + p.ty,
72
+ }
73
+
74
+ fn apply(m: Affine, v: Vec2) -> Vec2 =
75
+ vec2(m.a * v.x + m.c * v.y + m.tx, m.b * v.x + m.d * v.y + m.ty)
76
+
77
+ /// None when the matrix is singular (e.g. scale 0 at the start of a
78
+ /// pop-in animation): such an actor has no area and cannot be hit.
79
+ fn invert(m: Affine) -> Option<Affine> =
80
+ let det = m.a * m.d - m.b * m.c
81
+ if abs(det) < 1e-12 then None
82
+ else
83
+ let ia = m.d / det
84
+ let ib = -m.b / det
85
+ let ic = -m.c / det
86
+ let id = m.a / det
87
+ Some(Affine { a: ia, b: ib, c: ic, d: id,
88
+ tx: -(ia * m.tx + ic * m.ty),
89
+ ty: -(ib * m.tx + id * m.ty) })
90
+
91
+ /// A world-space point in the local space of `world`, if it has one.
92
+ fn to_local(world: Affine, p: Vec2) -> Option<Vec2> =
93
+ invert(world).map(|inv| apply(inv, p))
94
+
95
+ fn translate(x: Float, y: Float) -> Affine = { IDENTITY with tx = x, ty = y }
96
+
97
+ /// scene2d-style local transform: rotate & scale around `origin`.
98
+ struct Transform { pos: Vec2, origin: Vec2, rotation: Float, scale: Vec2 }
99
+
100
+ /// M·v = pos + origin + R·S·(v − origin)
101
+ fn to_affine(t: Transform) -> Affine =
102
+ let r = radians(t.rotation)
103
+ let cs = cos(r)
104
+ let sn = sin(r)
105
+ let a = cs * t.scale.x
106
+ let b = sn * t.scale.x
107
+ let c = -sn * t.scale.y
108
+ let d = cs * t.scale.y
109
+ let o = t.origin
110
+ Affine { a, b, c, d,
111
+ tx: t.pos.x + o.x - (a * o.x + c * o.y),
112
+ ty: t.pos.y + o.y - (b * o.x + d * o.y) }
113
+
114
+ // ───────────────────────────────────────────────────────────────────────
115
+ // 2. Actions — scene2d's Actions, but as plain data + a pure step function
116
+ // ───────────────────────────────────────────────────────────────────────
117
+
118
+ enum Action: Clone {
119
+ MoveBy { delta: Vec2, duration: Float, elapsed: Float },
120
+ RotateBy { degrees: Float, duration: Float, elapsed: Float },
121
+ Delay { duration: Float, elapsed: Float },
122
+ Sequence { steps: List<Action> },
123
+ Parallel { steps: List<Action> },
124
+ Forever { template: Action, current: Action },
125
+ }
126
+
127
+ fn move_by(dx: Float, dy: Float, secs: Float) -> Action =
128
+ MoveBy { delta: vec2(dx, dy), duration: secs, elapsed: 0.0 }
129
+ fn rotate_by(deg: Float, secs: Float) -> Action =
130
+ RotateBy { degrees: deg, duration: secs, elapsed: 0.0 }
131
+ fn forever(a: own Action) -> Action = Forever { template: a.clone(), current: a }
132
+
133
+ /// How much of a timed action one step covers. A zero-length action
134
+ /// finishes at once and applies all of itself (no 0/0).
135
+ struct Tick { used: Float, frac: Float, done: Bool }
136
+
137
+ fn tick(duration: Float, elapsed: Float, dt: Float) -> Tick =
138
+ if duration <= 0.0 then Tick { used: 0.0, frac: 1.0, done: true }
139
+ else
140
+ let used = min(dt, duration - elapsed)
141
+ Tick { used, frac: used / duration, done: elapsed + used >= duration }
142
+
143
+ /// Result of stepping one action: the updated base, the action if it is
144
+ /// not finished, and the part of `dt` it did not use (`spare`, 0 unless
145
+ /// it finished). Sequences hand the spare time to their next step, so
146
+ /// chained and looping animations do not drift.
147
+ struct Stepped { base: ActorBase, left: Option<Action>, spare: Float }
148
+
149
+ fn step_action(base: own ActorBase, act: own Action, dt: Float) -> own Stepped =
150
+ match act {
151
+ MoveBy { delta, duration, elapsed } ->
152
+ let k = tick(duration, elapsed, dt)
153
+ let b = { base with transform.pos =
154
+ add(base.transform.pos, mul(delta, k.frac)) }
155
+ if k.done then Stepped { base: b, left: None, spare: dt - k.used }
156
+ else Stepped { base: b, left: Some(MoveBy { delta, duration, elapsed: elapsed + k.used }),
157
+ spare: 0.0 }
158
+
159
+ RotateBy { degrees, duration, elapsed } ->
160
+ let k = tick(duration, elapsed, dt)
161
+ let b = { base with transform.rotation =
162
+ base.transform.rotation + degrees * k.frac }
163
+ if k.done then Stepped { base: b, left: None, spare: dt - k.used }
164
+ else Stepped { base: b, left: Some(RotateBy { degrees, duration, elapsed: elapsed + k.used }),
165
+ spare: 0.0 }
166
+
167
+ Delay { duration, elapsed } ->
168
+ let k = tick(duration, elapsed, dt)
169
+ if k.done then Stepped { base, left: None, spare: dt - k.used }
170
+ else Stepped { base, left: Some(Delay { duration, elapsed: elapsed + k.used }), spare: 0.0 }
171
+
172
+ Sequence { steps } ->
173
+ match steps {
174
+ [] -> Stepped { base, left: None, spare: dt }
175
+ [first, ..rest] ->
176
+ let s = step_action(base, first, dt)
177
+ match s.left {
178
+ Some(a) -> Stepped { base: s.base, left: Some(Sequence { steps: [a, ..rest] }),
179
+ spare: 0.0 }
180
+ // The rest starts in this same frame with the time left over.
181
+ // Terminates: the list is one shorter on every call.
182
+ None -> step_action(s.base, Sequence { steps: rest }, s.spare)
183
+ }
184
+ }
185
+
186
+ Parallel { steps } ->
187
+ let s = step_all(base, steps, dt)
188
+ if s.kept.is_empty() then Stepped { base: s.base, left: None, spare: s.spare }
189
+ else Stepped { base: s.base, left: Some(Parallel { steps: s.kept }), spare: 0.0 }
190
+
191
+ // Restarts at most once per frame, with the spare time, so a
192
+ // zero-length template cannot spin forever inside one frame.
193
+ Forever { template, current } ->
194
+ let s = step_action(base, current, dt)
195
+ match s.left {
196
+ Some(a) -> Stepped { base: s.base, left: Some(Forever { template, current: a }),
197
+ spare: 0.0 }
198
+ None ->
199
+ let r = step_action(s.base, template.clone(), s.spare)
200
+ let next = match r.left { Some(a) -> a, None -> template.clone() }
201
+ Stepped { base: r.base, left: Some(Forever { template, current: next }), spare: 0.0 }
202
+ }
203
+ }
204
+
205
+ /// Result of stepping actions side by side: the unfinished ones, and the
206
+ /// smallest spare time among the finished ones (meaningful only when
207
+ /// every action finished).
208
+ struct SteppedAll { base: ActorBase, kept: List<Action>, spare: Float }
209
+
210
+ fn step_all(base: own ActorBase, acts: own List<Action>, dt: Float) -> own SteppedAll =
211
+ acts.fold(SteppedAll { base, kept: [], spare: dt }, |acc, a|
212
+ let s = step_action(acc.base, a, dt)
213
+ match s.left {
214
+ Some(x) -> SteppedAll { base: s.base, kept: acc.kept.push(x), spare: acc.spare }
215
+ None -> SteppedAll { base: s.base, kept: acc.kept, spare: min(acc.spare, s.spare) }
216
+ })
217
+
218
+ // ───────────────────────────────────────────────────────────────────────
219
+ // 3. ActorBase — the fields every actor has (composition over inheritance)
220
+ // ───────────────────────────────────────────────────────────────────────
221
+
222
+ type ActorId = Int
223
+ type Text = Shared<String>
224
+
225
+ /// scene2d's Touchable: Disabled prunes the whole subtree from hit
226
+ /// testing; ChildrenOnly lets children be hit but not the actor itself.
227
+ enum Touchable { Enabled, Disabled, ChildrenOnly }
228
+
229
+ struct ActorBase {
230
+ id: ActorId,
231
+ name: Text,
232
+ transform: Transform,
233
+ size: Vec2,
234
+ color: Color,
235
+ visible: Bool,
236
+ touchable: Touchable,
237
+ actions: List<Action>,
238
+ }
239
+
240
+ fn actor(id: ActorId, pos: Vec2, size: Vec2) -> ActorBase =
241
+ ActorBase {
242
+ id, name: Shared(""), size, color: WHITE, visible: true, touchable: Enabled,
243
+ transform: Transform { pos, origin: mul(size, 0.5), rotation: 0.0,
244
+ scale: vec2(1.0, 1.0) },
245
+ actions: [],
246
+ }
247
+
248
+ /// Step this actor's own actions (they run side by side).
249
+ fn run_actions(b: own ActorBase, dt: Float) -> own ActorBase =
250
+ let acts = b.actions // move the list out of b ...
251
+ let s = step_all({ b with actions = [] }, acts, dt) // ... and refill it
252
+ { s.base with actions = s.kept }
253
+
254
+ // ───────────────────────────────────────────────────────────────────────
255
+ // 4. Render output and input
256
+ // ───────────────────────────────────────────────────────────────────────
257
+
258
+ type TextureId = Int
259
+ type FontId = Int
260
+
261
+ /// Flat, batchable commands. The renderer merges consecutive Quads with
262
+ /// the same texture into one draw call (keeps painter's order intact).
263
+ enum DrawCmd {
264
+ Quad { texture: TextureId, region: Rect, world: Affine, size: Vec2, color: Color },
265
+ Label { font: FontId, text: Text, world: Affine, color: Color },
266
+ }
267
+
268
+ /// Raw input from the host, in screen space.
269
+ enum Input {
270
+ PointerDown(Vec2),
271
+ PointerUp(Vec2),
272
+ PointerMove(Vec2),
273
+ Key(Int),
274
+ }
275
+
276
+ fn pointer_pos(i: Input) -> Option<Vec2> =
277
+ match i {
278
+ PointerDown(p) -> Some(p), PointerUp(p) -> Some(p), PointerMove(p) -> Some(p),
279
+ Key(_) -> None,
280
+ }
281
+
282
+ enum PointerKind { Down, Up, Move }
283
+
284
+ /// What an actor receives. `over` says whether the pointer is currently
285
+ /// over the actor that captured it (the one the press landed on), so a
286
+ /// button clicks only when released over itself.
287
+ struct Pointer { kind: PointerKind, pos: Vec2, over: Bool }
288
+
289
+ /// Path of child indices from a group down to the actor that was hit.
290
+ /// Replaces parent pointers: events bubble back UP this path.
291
+ type Path = List<Int>
292
+
293
+ fn is_prefix(p: &Path, of: &Path) -> Bool =
294
+ p.len() <= of.len() && p.zip(of).all(|(a, b)| a == b)
295
+
296
+ /// An actor plus the messages it emitted while handling input.
297
+ struct Handled<Msg, A> { actor: A, msgs: List<Msg> }
298
+
299
+ // ───────────────────────────────────────────────────────────────────────
300
+ // 5. The Actor trait. `Msg` is the game's own message type, so UI
301
+ // events are typed values, not callbacks with side effects.
302
+ // Every method is static-dispatch only (generic code is
303
+ // monomorphized; heterogeneous trees use the `Node` enum below).
304
+ // ───────────────────────────────────────────────────────────────────────
305
+
306
+ trait Actor<Msg: Clone> {
307
+ fn base(&self) -> &ActorBase
308
+ fn map_base(self: own Self, f: fn(own ActorBase) -> own ActorBase) -> own Self
309
+
310
+ /// Per-frame custom behaviour (actions are handled for you). Default: none.
311
+ fn act(self: own Self, dt: Float) -> own Self = self
312
+
313
+ /// Append this actor's own draw commands. `world` already includes the
314
+ /// actor's transform. Out-list is threaded and appended in place.
315
+ fn draw(&self, world: Affine, color: Color, out: own List<DrawCmd>)
316
+ -> own List<DrawCmd>
317
+
318
+ /// Local-space hit test. Default: inside its bounds.
319
+ fn hit(&self, local: Vec2) -> Bool =
320
+ self.base().touchable == Enabled &&
321
+ contains(Rect { x: 0.0, y: 0.0, w: self.base().size.x, h: self.base().size.y }, local)
322
+
323
+ /// Find the deepest actor under point `p` (world space). Leaves return [].
324
+ fn find_hit(&self, world: Affine, p: Vec2) -> Option<Path> =
325
+ match to_local(world, p) {
326
+ Some(l) if self.hit(l) -> Some([])
327
+ _ -> None
328
+ }
329
+
330
+ /// Id of the actor at `path`, if the path still leads somewhere.
331
+ fn id_at(&self, path: &Path) -> Option<ActorId> =
332
+ if path.is_empty() then Some(self.base().id) else None
333
+
334
+ /// Path to the first actor with this id (depth first, document order).
335
+ fn find_path(&self, id: ActorId) -> Option<Path> =
336
+ if self.base().id == id then Some([]) else None
337
+
338
+ /// React to pointer input routed to this actor; may change itself and
339
+ /// emit msgs. Default: ignore.
340
+ fn on_pointer(self: own Self, e: Pointer) -> own Handled<Msg, Self> =
341
+ Handled { actor: self, msgs: [] }
342
+
343
+ /// Deliver input along a path. Leaves just handle it.
344
+ fn route(self: own Self, path: &Path, e: Pointer) -> own Handled<Msg, Self> =
345
+ self.on_pointer(e)
346
+ }
347
+
348
+ // ── Generic helpers used by every container (recursion lives here) ──────
349
+
350
+ /// Update one actor for a frame: custom logic, then its actions.
351
+ fn update<Msg, A: Actor<Msg>>(a: own A, dt: Float) -> own A =
352
+ a.act(dt).map_base(|b| run_actions(b, dt))
353
+
354
+ /// Draw a child under its parent's world transform and colour.
355
+ fn draw_child<Msg, A: Actor<Msg>>(child: &A, parent_world: Affine,
356
+ parent_color: Color, out: own List<DrawCmd>)
357
+ -> own List<DrawCmd> =
358
+ let b = child.base()
359
+ if !b.visible then out
360
+ else child.draw(compose(parent_world, to_affine(b.transform)),
361
+ tint(parent_color, b.color), out)
362
+
363
+ // ───────────────────────────────────────────────────────────────────────
364
+ // 6. Classes (concrete actors)
365
+ // ───────────────────────────────────────────────────────────────────────
366
+
367
+ class Sprite<Msg> {
368
+ base: ActorBase,
369
+ texture: TextureId,
370
+ region: Rect,
371
+ on_click: Option<Msg>,
372
+ }
373
+
374
+ impl<Msg: Clone> Actor<Msg> for Sprite<Msg> {
375
+ fn base(&self) = &self.base
376
+ fn map_base(self, f) = { self with base = f(self.base) }
377
+
378
+ fn draw(&self, world, color, out) =
379
+ out.push(Quad { texture: self.texture, region: self.region,
380
+ world, size: self.base.size, color })
381
+
382
+ // Clicks on release over the sprite. Pointer capture (see Stage) means
383
+ // the release only reaches it when the press started on it too.
384
+ fn on_pointer(self, e) =
385
+ let msgs = match (e.kind, &self.on_click) {
386
+ (Up, Some(m)) if e.over -> [m.clone()]
387
+ _ -> []
388
+ }
389
+ Handled { actor: self, msgs }
390
+ }
391
+
392
+ class TextLabel<Msg> {
393
+ base: ActorBase,
394
+ font: FontId,
395
+ text: Text,
396
+ }
397
+
398
+ impl<Msg: Clone> Actor<Msg> for TextLabel<Msg> {
399
+ fn base(&self) = &self.base
400
+ fn map_base(self, f) = { self with base = f(self.base) }
401
+ fn draw(&self, world, color, out) =
402
+ out.push(Label { font: self.font, text: self.text.clone(), world, color })
403
+ fn hit(&self, local) = false // labels are click-through
404
+ }
405
+
406
+ /// A button with its own state, like scene2d's isPressed / isOver: it
407
+ /// shows the down texture while pressed and the pointer is over it, and
408
+ /// clicks only when released over itself.
409
+ class Button<Msg> {
410
+ base: ActorBase,
411
+ font: FontId,
412
+ text: Text,
413
+ up_tex: TextureId,
414
+ down_tex: TextureId,
415
+ pressed: Bool,
416
+ over: Bool,
417
+ on_click: Msg,
418
+ }
419
+
420
+ impl<Msg: Clone> Actor<Msg> for Button<Msg> {
421
+ fn base(&self) = &self.base
422
+ fn map_base(self, f) = { self with base = f(self.base) }
423
+
424
+ fn draw(&self, world, color, out) =
425
+ let tex = if self.pressed && self.over then self.down_tex else self.up_tex
426
+ let full = Rect { x: 0.0, y: 0.0, w: 1.0, h: 1.0 }
427
+ out.push(Quad { texture: tex, region: full, world, size: self.base.size, color })
428
+ .push(Label { font: self.font, text: self.text.clone(),
429
+ world: compose(world, translate(8.0, 8.0)), color })
430
+
431
+ // Read everything needed from `self` before it is moved into the result.
432
+ fn on_pointer(self, e) =
433
+ match e.kind {
434
+ Down -> Handled { actor: { self with pressed = true, over = true }, msgs: [] }
435
+ Move -> Handled { actor: { self with over = e.over }, msgs: [] }
436
+ Up ->
437
+ let msgs = if self.pressed && e.over then [self.on_click.clone()] else []
438
+ Handled { actor: { self with pressed = false, over = false }, msgs }
439
+ }
440
+ }
441
+
442
+ /// Generic container. Group<Msg, Sprite<Msg>> is a homogeneous, fully
443
+ /// monomorphized batch (no dispatch at all); Group<Msg, Node<Msg, X>> is
444
+ /// the usual heterogeneous scene2d Group.
445
+ class Group<Msg, T: Actor<Msg>> {
446
+ base: ActorBase,
447
+ children: List<T>,
448
+ }
449
+
450
+ impl<Msg: Clone, T: Actor<Msg>> Actor<Msg> for Group<Msg, T> {
451
+ fn base(&self) = &self.base
452
+ fn map_base(self, f) = { self with base = f(self.base) }
453
+
454
+ // Recursion: update every child. `map` on a unique list runs in place.
455
+ fn act(self, dt) =
456
+ let children = self.children
457
+ { self with children = children.map(|c| update(c, dt)) }
458
+
459
+ // Recursion: draw children in order (painter's algorithm).
460
+ fn draw(&self, world, color, out) =
461
+ self.children.fold(out, |acc, c| draw_child(c, world, color, acc))
462
+
463
+ // Recursion: top-most child first; fall back to the group itself.
464
+ // Disabled prunes the subtree; ChildrenOnly skips only the group.
465
+ fn find_hit(&self, world, p) =
466
+ if self.base().touchable == Disabled then None
467
+ else
468
+ let from_children =
469
+ self.children.indexed().reverse().find_map(|(i, c)|
470
+ let cb = c.base()
471
+ if !cb.visible then None
472
+ else c.find_hit(compose(world, to_affine(cb.transform)), p)
473
+ .map(|path| [i, ..path]))
474
+ from_children.or_else(||
475
+ match to_local(world, p) {
476
+ Some(l) if self.hit(l) -> Some([])
477
+ _ -> None
478
+ })
479
+
480
+ fn id_at(&self, path) =
481
+ match path {
482
+ [] -> Some(self.base().id)
483
+ [i, ..rest] -> self.children.get(i).and_then(|c| c.id_at(rest))
484
+ }
485
+
486
+ fn find_path(&self, id) =
487
+ if self.base().id == id then Some([])
488
+ else self.children.indexed().find_map(|(i, c)|
489
+ c.find_path(id).map(|path| [i, ..path]))
490
+
491
+ // Event bubbling without parent pointers: the child handles first,
492
+ // then the group, and messages flow back up the call stack.
493
+ // A stale index (tree changed) delivers to the group only.
494
+ fn route(self, path, e) =
495
+ match path {
496
+ [] -> self.on_pointer(e)
497
+ [i, ..rest] ->
498
+ let children = self.children // move out, refill below
499
+ let r = children.update_at_with(i, |c|
500
+ let h = c.route(rest, e)
501
+ (h.actor, h.msgs))
502
+ let mine = { self with children = r.list }.on_pointer(e)
503
+ Handled { actor: mine.actor, msgs: r.out.unwrap_or([]) ++ mine.msgs }
504
+ }
505
+ }
506
+
507
+ // ───────────────────────────────────────────────────────────────────────
508
+ // 7. Node — the heterogeneous tree. Built-in variants dispatch with a
509
+ // `match` (fast, inlinable). `Custom(X)` is the extension point: a
510
+ // game puts its own actor kinds in one enum `X` that implements
511
+ // Actor. It is a type parameter, not `dyn`, because Actor's methods
512
+ // take and return `Self` by value (they are not object-safe), and
513
+ // static dispatch keeps the whole tree monomorphized.
514
+ // ───────────────────────────────────────────────────────────────────────
515
+
516
+ enum Never {} // "no custom actors"
517
+
518
+ impl<Msg: Clone> Actor<Msg> for Never {
519
+ fn base(&self) = match self {}
520
+ fn map_base(self, f) = match self {}
521
+ fn draw(&self, w, c, out) = match self {}
522
+ }
523
+
524
+ enum Node<Msg, X: Actor<Msg>> {
525
+ SpriteNode(Sprite<Msg>),
526
+ TextNode(TextLabel<Msg>),
527
+ ButtonNode(Button<Msg>),
528
+ GroupNode(Group<Msg, Node<Msg, X>>), // recursive type: groups of nodes
529
+ Custom(X), // the game's own actor kinds
530
+ }
531
+
532
+ impl<Msg: Clone, X: Actor<Msg>> Actor<Msg> for Node<Msg, X> {
533
+ fn base(&self) = match self {
534
+ SpriteNode(s) -> s.base(), TextNode(l) -> l.base(), ButtonNode(b) -> b.base(),
535
+ GroupNode(g) -> g.base(), Custom(c) -> c.base(),
536
+ }
537
+ fn map_base(self, f) = match self {
538
+ SpriteNode(s) -> SpriteNode(s.map_base(f)), TextNode(l) -> TextNode(l.map_base(f)),
539
+ ButtonNode(b) -> ButtonNode(b.map_base(f)), GroupNode(g) -> GroupNode(g.map_base(f)),
540
+ Custom(c) -> Custom(c.map_base(f)),
541
+ }
542
+ fn act(self, dt) = match self {
543
+ GroupNode(g) -> GroupNode(g.act(dt)),
544
+ Custom(c) -> Custom(c.act(dt)),
545
+ other -> other,
546
+ }
547
+ fn draw(&self, world, color, out) = match self {
548
+ SpriteNode(s) -> s.draw(world, color, out), TextNode(l) -> l.draw(world, color, out),
549
+ ButtonNode(b) -> b.draw(world, color, out), GroupNode(g) -> g.draw(world, color, out),
550
+ Custom(c) -> c.draw(world, color, out),
551
+ }
552
+ fn hit(&self, local) = match self {
553
+ SpriteNode(s) -> s.hit(local), TextNode(l) -> l.hit(local),
554
+ ButtonNode(b) -> b.hit(local), GroupNode(g) -> g.hit(local),
555
+ Custom(c) -> c.hit(local),
556
+ }
557
+ fn find_hit(&self, world, p) = match self {
558
+ SpriteNode(s) -> s.find_hit(world, p), TextNode(l) -> l.find_hit(world, p),
559
+ ButtonNode(b) -> b.find_hit(world, p), GroupNode(g) -> g.find_hit(world, p),
560
+ Custom(c) -> c.find_hit(world, p),
561
+ }
562
+ fn id_at(&self, path) = match self {
563
+ GroupNode(g) -> g.id_at(path),
564
+ Custom(c) -> c.id_at(path),
565
+ _ -> if path.is_empty() then Some(self.base().id) else None,
566
+ }
567
+ fn find_path(&self, id) = match self {
568
+ GroupNode(g) -> g.find_path(id),
569
+ Custom(c) -> c.find_path(id),
570
+ _ -> if self.base().id == id then Some([]) else None,
571
+ }
572
+ fn route(self, path, e) = match self {
573
+ SpriteNode(s) -> s.route(path, e).map_actor(SpriteNode),
574
+ TextNode(l) -> l.route(path, e).map_actor(TextNode),
575
+ ButtonNode(b) -> b.route(path, e).map_actor(ButtonNode),
576
+ GroupNode(g) -> g.route(path, e).map_actor(GroupNode),
577
+ Custom(c) -> c.route(path, e).map_actor(Custom),
578
+ }
579
+ }
580
+
581
+ /// Apply `f` to the node at `path`. A stale path leaves the tree as is.
582
+ fn modify_at<Msg: Clone, X: Actor<Msg>>(n: own Node<Msg, X>, path: &Path,
583
+ f: fn(own Node<Msg, X>) -> own Node<Msg, X>) -> own Node<Msg, X> =
584
+ match path {
585
+ [] -> f(n)
586
+ [i, ..rest] -> match n {
587
+ GroupNode(g) ->
588
+ let children = g.children
589
+ GroupNode({ g with children = children.update_at(i, |c| modify_at(c, rest, f)) })
590
+ other -> other
591
+ }
592
+ }
593
+
594
+ /// Change the first actor with this id (depth first). O(n) to find it;
595
+ /// for large scenes keep an id → Path index next to the tree.
596
+ fn modify<Msg: Clone, X: Actor<Msg>>(n: own Node<Msg, X>, id: ActorId,
597
+ f: fn(own Node<Msg, X>) -> own Node<Msg, X>) -> own Node<Msg, X> =
598
+ match n.find_path(id) {
599
+ Some(path) -> modify_at(n, &path, f)
600
+ None -> n
601
+ }
602
+
603
+ // ───────────────────────────────────────────────────────────────────────
604
+ // 8. Stage — root of the tree, camera and pointer capture; the frame
605
+ // entry points.
606
+ // ───────────────────────────────────────────────────────────────────────
607
+
608
+ /// The actor a press landed on. Move and Up go to it until the release,
609
+ /// even when the pointer leaves it (scene2d's touch focus). The id guards
610
+ /// against the tree changing under the stored path.
611
+ struct Capture { path: Path, id: ActorId }
612
+
613
+ class Stage<Msg, X> {
614
+ root: Node<Msg, X>,
615
+ camera: Affine, // world → screen
616
+ captured: Option<Capture>,
617
+ }
618
+
619
+ fn stage_update<Msg: Clone, X: Actor<Msg>>(s: own Stage<Msg, X>, dt: Float)
620
+ -> own Stage<Msg, X> =
621
+ let root = s.root
622
+ { s with root = update(root, dt) }
623
+
624
+ /// Fills `buf` (last frame's buffer, reused) with this frame's commands.
625
+ fn stage_render<Msg: Clone, X: Actor<Msg>>(s: &Stage<Msg, X>, buf: own List<DrawCmd>)
626
+ -> own List<DrawCmd> =
627
+ draw_child(&s.root, s.camera, WHITE, buf.clear())
628
+
629
+ fn deliver<Msg: Clone, X: Actor<Msg>>(s: own Stage<Msg, X>, path: &Path, e: Pointer,
630
+ captured: Option<Capture>) -> own Handled<Msg, Stage<Msg, X>> =
631
+ let root = s.root
632
+ let h = root.route(path, e)
633
+ Handled { actor: { s with root = h.actor, captured }, msgs: h.msgs }
634
+
635
+ fn stage_input<Msg: Clone, X: Actor<Msg>>(s: own Stage<Msg, X>, input: Input)
636
+ -> own Handled<Msg, Stage<Msg, X>> =
637
+ match pointer_pos(input) {
638
+ None -> Handled { actor: s, msgs: [] } // keys go to the game directly
639
+ Some(p) ->
640
+ let hit = s.root.find_hit(s.camera, p)
641
+ // A capture is valid only while its path still leads to its actor.
642
+ let cap = s.captured.clone().filter(|c| s.root.id_at(&c.path) == Some(c.id))
643
+ let over = |c: &Capture| hit.as_ref().map_or(false, |h| is_prefix(&c.path, h))
644
+ match (input, cap) {
645
+ (PointerDown(_), _) -> match hit {
646
+ None -> Handled { actor: { s with captured = None }, msgs: [] }
647
+ Some(path) ->
648
+ let id = s.root.id_at(&path).unwrap()
649
+ let c = Capture { path: path.clone(), id }
650
+ deliver(s, &path, Pointer { kind: Down, pos: p, over: true }, Some(c))
651
+ }
652
+ (PointerUp(_), Some(c)) ->
653
+ let e = Pointer { kind: Up, pos: p, over: over(&c) }
654
+ deliver(s, &c.path, e, None)
655
+ (PointerMove(_), Some(c)) ->
656
+ let e = Pointer { kind: Move, pos: p, over: over(&c) }
657
+ deliver(s, &c.path, e, Some(c))
658
+ // Hover without a press: route to whatever is under the pointer.
659
+ (PointerMove(_), None) -> match hit {
660
+ Some(path) -> deliver(s, &path, Pointer { kind: Move, pos: p, over: true }, None)
661
+ None -> Handled { actor: s, msgs: [] }
662
+ }
663
+ _ -> Handled { actor: { s with captured = None }, msgs: [] }
664
+ }
665
+ }
666
+
667
+ // ═══════════════════════════════════════════════════════════════════════
668
+ // 9. Example game: a spinning coin, a score label and a start button.
669
+ // Same shape as Pyret's reactors / the Elm architecture.
670
+ // ═══════════════════════════════════════════════════════════════════════
671
+
672
+ enum Msg: Clone { StartPressed, CoinClicked(ActorId) }
673
+
674
+ type GameNode = Node<Msg, Never>
675
+
676
+ let COIN_TEX = 1
677
+ let BTN_UP = 2
678
+ let BTN_DOWN = 3
679
+ let FONT = 1
680
+ let SCORE_ID = 2
681
+ let COIN_ID = 10
682
+
683
+ class Game {
684
+ stage: Stage<Msg, Never>,
685
+ score: Int,
686
+ started: Bool,
687
+ cmds: List<DrawCmd>, // reused draw buffer, read by the host
688
+ }
689
+
690
+ fn init() -> own Game =
691
+ let coin = SpriteNode(Sprite {
692
+ base: { actor(COIN_ID, vec2(380.0, 280.0), vec2(48.0, 48.0))
693
+ with actions = [forever(rotate_by(360.0, 1.5))] },
694
+ texture: COIN_TEX,
695
+ region: Rect { x: 0.0, y: 0.0, w: 1.0, h: 1.0 },
696
+ on_click: Some(CoinClicked(COIN_ID)),
697
+ })
698
+
699
+ let hud = GroupNode(Group {
700
+ base: actor(1, vec2(0.0, 560.0), vec2(800.0, 40.0)),
701
+ children: [
702
+ TextNode(TextLabel { base: actor(SCORE_ID, vec2(16.0, 8.0), vec2(200.0, 24.0)),
703
+ font: FONT, text: Shared("Score: 0") }),
704
+ ButtonNode(Button { base: actor(3, vec2(680.0, 4.0), vec2(104.0, 32.0)),
705
+ font: FONT, text: Shared("Start"), up_tex: BTN_UP,
706
+ down_tex: BTN_DOWN, pressed: false, over: false,
707
+ on_click: StartPressed }),
708
+ ],
709
+ })
710
+
711
+ let root = GroupNode(Group {
712
+ base: actor(0, vec2(0.0, 0.0), vec2(800.0, 600.0)),
713
+ children: [coin, hud],
714
+ })
715
+
716
+ Game { stage: Stage { root, camera: IDENTITY, captured: None },
717
+ score: 0, started: false, cmds: List.with_capacity(1024) }
718
+
719
+ /// Pure game logic: messages in, new game out.
720
+ fn handle(g: own Game, msg: Msg) -> own Game =
721
+ match msg {
722
+ StartPressed -> { g with started = true }
723
+
724
+ CoinClicked(id) if g.started ->
725
+ let score = g.score + 1
726
+ let root0 = g.stage.root // move out, refill below
727
+ let root1 = modify(root0, id, |n| n.map_base(|b|
728
+ let acts = b.actions
729
+ { b with actions = acts.push(move_by(0.0, -20.0, 0.2)) }))
730
+ let root2 = modify(root1, SCORE_ID, |n| match n {
731
+ TextNode(l) -> TextNode({ l with text = Shared("Score: " ++ show(score)) }),
732
+ other -> other,
733
+ })
734
+ { g with score, stage.root = root2 }
735
+
736
+ _ -> g
737
+ }
738
+
739
+ /// One frame: route inputs → handle messages → advance animations.
740
+ fn frame(g: own Game, dt: Float, inputs: own List<Input>) -> own Game =
741
+ let stage0 = g.stage
742
+ let routed = inputs.fold(Handled { actor: stage0, msgs: [] }, |acc, i|
743
+ let h = stage_input(acc.actor, i)
744
+ Handled { actor: h.actor, msgs: acc.msgs ++ h.msgs })
745
+ let g2 = routed.msgs.fold({ g with stage = routed.actor }, handle)
746
+ let stage2 = g2.stage
747
+ { g2 with stage = stage_update(stage2, dt) }
748
+
749
+ // ───────────────────────────────────────────────────────────────────────
750
+ // 10. wasm boundary. JS owns the requestAnimationFrame loop and holds an
751
+ // opaque handle to Game; the game stays unique, so every frame's
752
+ // updates compile to in-place writes. The draw buffer lives in Game
753
+ // and is reused, so a steady-state frame allocates nothing for it.
754
+ // ───────────────────────────────────────────────────────────────────────
755
+
756
+ export fn game_init() -> own Game = init()
757
+
758
+ export fn game_frame(g: own Game, dt: Float, inputs: own List<Input>) -> own Game =
759
+ let g2 = frame(g, dt, inputs)
760
+ let buf = g2.cmds
761
+ let cmds = stage_render(&g2.stage, buf)
762
+ { g2 with cmds } // JS reads g.cmds (packed floats in linear
763
+ // memory), uploads once, issues batched draws
docs/plans/2026-11-02-pure-scene.benchmark.md ADDED
@@ -0,0 +1,21 @@
1
+ # Pure scene graph, phase A: benchmark verdict
2
+
3
+ Machine: Apple M3 Pro, headed Chromium (Playwright), WebGPU, `--uncapped --measure 10 --frames-wait 6`, two runs per fixture, back to back, same session (2026-11-02).
4
+
5
+ | fixture | avg ms | p50 | p95 | p99 | fps | update ms | draw ms | render ms |
6
+ |---|---|---|---|---|---|---|---|---|
7
+ | stress_scene (mutable std/game/Scene) | 1.664 / 1.675 | 1.70 | 1.80 | 1.90 | 601 / 597 | 0.037 | 1.265 | 0.25 |
8
+ | stress_pure_scene (PureScene, phase A) | 1.921 / 1.983 | 1.90 | 2.30 / 2.50 | 2.50 / 2.60 | 520 / 504 | 0.318 / 0.322 | 1.25 | 0.19 |
9
+
10
+ Bar (review plan): pure avg within 10% and p95 within 15% of the mutable scene measured in the same session.
11
+
12
+ - avg: +15.4% to +18.4%: **missed**
13
+ - p95: +28% to +39%: **missed**
14
+
15
+ Where it goes: update is about 8.6x slower (0.32 vs 0.037 ms per frame for about 5,750 actors), because every animated actor is cloned every frame. Draw is unchanged. This is risk R1 as predicted. The fixture also omits the original's per-sprite frame stepping, so the true gap is, if anything, slightly larger.
16
+
17
+ In absolute terms 1.95 ms per frame is still far inside a 16.7 ms (60 Hz) budget. The old 2.46 ms figure in BENCHMARK.md came from an earlier build. This session's mutable scene measures 1.67 ms.
18
+
19
+ **Verdict:** phase A misses the bar, so per design D2, phase B (`own record`: compile-time uniqueness so spread updates of dead owned values become in-place writes) is next. It would remove the per-frame clones in `SceneTree.update` and the actors' `act`.
20
+
21
+ Not yet written into plum-game/web-test/BENCHMARK.md: the coordinator can't edit files outside docs/; add the row there when this branch is merged.
docs/plans/2026-11-02-pure-scene.md ADDED
@@ -0,0 +1,217 @@
1
+ # Pure scene graph, phase A
2
+
3
+ **Review plan:** docs/plans/2026-11-02-pure-scene.review.md
4
+ **Goal:** pure scene graph in Plum std (Actions, PureScene, SceneTree, SceneInput), Orb Run ported, parity/browser checks, docs.
5
+ **Architecture:** Actions → PureScene (trait Actor, leaves, drawTree) → SceneTree (update, hit test, paths, route) → SceneInput (Stage, capture). Pure functions; fields assigned only on a clone made in the same function. Only draw calls std/game/Game.
6
+ **Tech stack:** Rust workspace (Plum compiler), Plum std, plum-game host tests, playwright-rs.
7
+ **Spec:** docs/plans/2026-11-02-pure-scene.review.md
8
+ **Detail:** contracts
9
+ **Target-min:** 30
10
+ **Ignore:** docs/plans/2026-11-02-pure-scene.tests.md, docs/plans/2026-11-02-pure-scene-graph.sketch.pfl, docs/plans/2026-11-02-pure-scene-graph.design.md
11
+
12
+ ## Global constraints
13
+ - Test one file: `touch plum-cli/src/main.rs && cargo build --quiet -p plum-cli && ./target/debug/plum test <file>`.
14
+ - Std files: `module std` in plum-std/game/, imported as std/game/<File>; import std/Str std/Int std/Float std/List where used.
15
+ - Never run `plum format`. camelCase. Multi-letter type names in tests. Floats: `{a - b}.abs() < 1e-6`.
16
+ - Purity: never assign fields on a parameter. Scene, scene.plum, game_scene.plum keep behaviour.
17
+ - Test details and notes for T4-T9: docs/plans/2026-11-02-pure-scene.tests.md (tests.md).
18
+
19
+ ```mermaid
20
+ flowchart LR
21
+ AC[Actions] --> TR[SceneTree]
22
+ AF[Affine] --> PS[PureScene]
23
+ PS --> TR
24
+ TR --> IN[SceneInput]
25
+ IN --> EX[game_pure_scene]
26
+ EX --> HT[host parity]
27
+ EX --> WB[stress + web]
28
+ EX --> DO[README]
29
+ ```
30
+
31
+ File structure: each task's Owns. Spec coverage: What you will get (T2-T9), What will be built (T1-T9), How it fits together / How it gets built (diagrams), Decisions you can change (defaults taken), Risks (T1, T4, T7), Out of scope (not built), How we will know it works (T2-T8; benchmark by coordinator), Rough time and cost (schedule).
32
+
33
+ ## Task graph
34
+ ```mermaid
35
+ flowchart LR
36
+ T1[value check]
37
+ T2[actions]
38
+ T3[affine + core]
39
+ T4[tree ops]
40
+ T5[stage input]
41
+ T6[example]
42
+ T7[parity]
43
+ T8[stress + web]
44
+ T9[docs]
45
+ %% taskgraph
46
+ T2 --> T4
47
+ T3 --> T4
48
+ T4 --> T5
49
+ T5 --> T6
50
+ T6 --> T7
51
+ T6 --> T8
52
+ T6 --> T9
53
+ ```
54
+
55
+ ## Language check
56
+
57
+ ### T1 struct members and trait spread · Complexity 2 · Est. context 25k
58
+ **Owns:** plum-examples/struct_union_members.plum
59
+ **Components:** 1
60
+ **LOC owned:** 80
61
+ **Tests:** fast
62
+ **Kind:** mechanical
63
+ **Model:** haiku
64
+ **Consumes:** none
65
+ **Produces:** none
66
+ **Review Focus:** state: spread copy of a trait-embedding record is independent of its source.
67
+ **Test code:** module `struct_union_members`, self-contained types (`enum Step = Move | Wait | Chain`, `struct Move(dx: Float, left: Float)`, `struct Wait(left: Float)`, `record Chain(steps: List[Step])`; `trait Thing(x: Float, tag: Int)`, `record Box(Thing, size: Float)`):
68
+ - `struct_member_through_list`: `Chain(steps: [Move(1.0, 2.0), Wait(3.0)])` pushed into a `List[Step]`, read back with `match`: `Move(dx, _) => dx` gives 1.0, `Wait(l) => l` gives 3.0.
69
+ - `struct_member_spread_copy`: `m := Move(dx: 1.0, left: 2.0)`, `n := Move(..m, left: 0.5)`: n.left 0.5, m.left 2.0.
70
+ - `struct_member_match_copy_is_independent`: matching `Move` out of a list element and assigning its field leaves the list element unchanged.
71
+ - `trait_record_spread_copies_trait_fields`: `b := Box(x: 1.0, tag: 7, size: 2.0)`, `c := Box(..b, size: 3.0)`; `c.x == 1.0`, `c.tag == 7`; `c.x = 9.0` leaves `b.x == 1.0`.
72
+ - `trait_value_clone_via_method`: `Box` defines `fun clone(self) -> Thing = Box(..self)`; for `t: Thing = b`, `t.clone()` with `x` set to 5.0 leaves `t.x == 1.0`.
73
+ **Steps:** write the file, run `plum test` on it. If a case does not compile or fails, keep the test, report DONE_WITH_CONCERNS with the exact error (it is the R3/R6 evidence).
74
+
75
+ ## Actions
76
+
77
+ ### T2 actions · Complexity 4 · Est. context 32k
78
+ **Owns:** plum-std/game/Actions.plum
79
+ **Components:** 1
80
+ **LOC owned:** 170
81
+ **Tests:** fast
82
+ **Model:** sonnet
83
+ **Consumes:** none
84
+ **Produces:** Actions.stepAll(p: Pose, acts: List[Action], dt: Float) -> SteppedAll
85
+ e.g. stepAll(Pose(x: 0.0, y: 0.0, rotation: 0.0, scaleX: 1.0, scaleY: 1.0), [moveBy(10.0, 0.0, 1.0)], 0.5) -> SteppedAll(pose.x 5.0, kept length 1, spare 0.0)
86
+ e.g. stepAll(pose0, [moveBy(10.0, 0.0, 1.0), rotateBy(1.0, 0.5)], 1.0) -> SteppedAll(pose.x 10.0, pose.rotation 1.0, kept empty, spare 0.0)
87
+ e.g. stepAll(pose0, [], 0.016) -> SteppedAll(pose0, kept empty, spare 0.016)
88
+ **Produces:** Actions.stepAction(p: Pose, a: Action, dt: Float) -> Stepped
89
+ e.g. stepAction(pose0, moveBy(10.0, 0.0, 0.0), 0.016) -> Stepped(pose.x 10.0, left null, spare 0.016)
90
+ **Produces:** Actions.moveBy(dx: Float, dy: Float, secs: Float) -> Action · Actions.rotateBy(radians: Float, secs: Float) -> Action · Actions.delay(secs: Float) -> Action · Actions.sequence(steps: List[Action]) -> Action · Actions.parallel(steps: List[Action]) -> Action · Actions.forever(a: Action) -> Action
91
+ **Produces:** types `struct Pose(x: Float, y: Float, rotation: Float, scaleX: Float, scaleY: Float)`, `enum Action = MoveBy | RotateBy | Delay | Sequence | Parallel | Forever`, `struct MoveBy(dx, dy, duration, elapsed: Float)`, `struct RotateBy(radians, duration, elapsed: Float)`, `struct Delay(duration, elapsed: Float)`, `record Sequence(steps: List[Action])`, `record Parallel(steps: List[Action])`, `record Forever(template: Action, current: Action)`, `struct Tick(used: Float, frac: Float, done: Bool)`, `record Stepped(pose: Pose, left: Action?, spare: Float)`, `record SteppedAll(pose: Pose, kept: List[Action], spare: Float)`
92
+ **Review Focus:** numeric: drift over many frames, 0/0; deep: nested Sequence in Forever.
93
+ **Test code:** named tests, exact values:
94
+ - `zero_length_move_applies_at_once`: second e.g. of stepAction.
95
+ - `forever_rotate_no_drift`: `forever(rotateBy(1.0, 1.0))` stepped 625 times by 0.016: rotation within 1e-6 of 10.0, left non-null each step.
96
+ - `sequence_spills_spare_time`: `sequence([moveBy(10.0,0.0,0.5), moveBy(0.0,10.0,0.5)])` one step of 0.75: x 10.0, y 5.0, left is a Sequence of 1 step, spare 0.0.
97
+ - `empty_sequence_finishes_with_full_spare`: left null, spare = dt.
98
+ - `parallel_spare_is_min_of_finished`: `parallel([delay(0.2), delay(0.5)])` step 1.0: left null, spare 0.5.
99
+ - `forever_zero_length_template_terminates`: `forever(delay(0.0))` step 1.0 returns, left non-null.
100
+ - `step_does_not_change_input`: a MoveBy value stepped keeps its own elapsed 0.0.
101
+ **Notes:** `tick(duration, elapsed, dt)`: duration <= 0 -> Tick(0, 1, true); else used = min(dt, duration - elapsed), frac = used / duration, done = elapsed + used >= duration. Sequence recurses on the rest with spare (list shrinks). Forever restarts at most once per step with the spare, never reports spare. Design sketch section 2 is the reference.
102
+
103
+ ## Scene core
104
+
105
+ ### T3 affine split and actor core · Complexity 5 · Est. context 45k
106
+ **Owns:** plum-std/game/Affine.plum, plum-std/game/Scene.plum, plum-std/game/PureScene.plum, plum-examples/scene.plum, plum-examples/game_scene.plum, plum-game/web-test/fixtures/stress_scene.plum
107
+ **Components:** 2
108
+ **LOC owned:** 260
109
+ **Tests:** fast
110
+ **Model:** sonnet
111
+ **Consumes:** none
112
+ **Produces:** Affine.invert(self) -> Affine?
113
+ e.g. Affine(a: 0.0, b: 0.0, c: 0.0, d: 0.0, e: 1.0, f: 1.0).invert() -> null
114
+ e.g. Affine(a: 2.0, b: 0.0, c: 0.0, d: 2.0, e: 4.0, f: 0.0).invert().apply(Vec2(x: 6.0, y: 2.0)) -> Vec2(x: 1.0, y: 1.0)
115
+ **Produces:** PureScene.localMatrix(a: Actor) -> Affine
116
+ e.g. localMatrix(sprite(1, 0, 10.0, 0.0, 2.0, 2.0)) applied to Vec2(0.0, 0.0) -> Vec2(10.0, 0.0)
117
+ **Produces:** PureScene.drawTree(a: Actor, parent: Affine, tint: Color)
118
+ **Produces:** PureScene.group(id: Int, x: Float, y: Float, w: Float, h: Float, children: List[Actor]) -> Group · PureScene.sprite(id: Int, tex: Int, x: Float, y: Float, w: Float, h: Float) -> Sprite · PureScene.label(id: Int, text: Str, x: Float, y: Float, size: Float) -> Label · PureScene.button(id: Int, text: Str, upTex: Int, downTex: Int, x: Float, y: Float, w: Float, h: Float) -> Button
119
+ **Produces:** types `enum Touchable = | Enabled | Disabled | ChildrenOnly`, `enum PointerKind = | Down | Up | Move`, `record Pointer(kind: PointerKind, x: Float, y: Float, over: Bool)`, `enum UiKind = | Press | Release | Click | Enter | Exit`, `record UiEvent(id: Int, kind: UiKind)`, `record Handled(actor: Actor, events: List[UiEvent])`, `trait Actor(id: Int, x: Float, y: Float, width: Float, height: Float, originX: Float, originY: Float, scaleX: Float, scaleY: Float, rotation: Float, color: Color, visible: Bool, touchable: Touchable, actions: List[Action])` with `clone(self) -> Actor` (required), `act(self, dt: Float) -> Actor` (default self), `draw(self, m: Affine, tint: Color)` (default nothing), `hit(self, p: Vec2) -> Bool` (default: Enabled and inside width x height), `onPointer(self, e: Pointer) -> Handled` (default no events); records `Group(Actor, children: List[Actor])`, `Sprite(Actor, tex: Int, srcX: Float, srcY: Float, srcW: Float, srcH: Float)`, `Label(Actor, text: Str, size: Float)`, `Button(Actor, text: Str, upTex: Int, downTex: Int, pressed: Bool, over: Bool)`
120
+ **Review Focus:** state: Button press/over/click; numeric: singular invert.
121
+ **Test code:** in PureScene.plum unless noted:
122
+ - Affine.plum: the two invert e.g. lines as tests `invert_singular_is_null`, `invert_round_trip`.
123
+ - `clone_is_independent`: `s := sprite(1, 0, 1.0, 2.0, 4.0, 4.0)`, `c := s.clone()`, `c.x = 9.0`: `s.x == 1.0`.
124
+ - `local_matrix_translates`: localMatrix e.g. above.
125
+ - `button_click_needs_press_and_over`: onPointer Down, then Up(over true): events of the two calls together are [Press, Release, Click] with id = button id.
126
+ - `button_release_elsewhere_no_click`: Down then Up(over false): [Press, Release], resulting `pressed == false`.
127
+ - `button_drag_off_and_back_one_click`: Down, Move(over false) (over false), Move(over true), Up(over true): exactly one Click.
128
+ - `on_pointer_leaves_input_unchanged`: the button passed to onPointer still has `pressed == false` after a Down.
129
+ **Steps:** 1 move `struct Affine` and its tests from Scene.plum into Affine.plum, add `invert`; Scene.plum imports `std/game/Affine`; add that import to the three Scene users if their tests fail without it; 2 PureScene tests fail; 3 implement (draw helpers adapted from Scene.plum's drawSpriteFrame/drawLabel/drawRectBox; Button draws up/down texture by `pressed && over`, then its label); 4 pass; also run `plum test plum-std/game/Scene.plum plum-examples/scene.plum plum-examples/game_scene.plum`.
130
+ **Notes:** PureScene imports std/game/Actions only for the `Action` type in `actions`; it never steps actions. `Group.act` is the default (no recursion); recursion lives in SceneTree.update. drawTree: skip invisible; m = parent * localMatrix(a); tint multiplies colors (alpha included); draw self, then children in order.
131
+
132
+ ### T4 tree operations · Complexity 5 · Est. context 40k
133
+ **Owns:** plum-std/game/SceneTree.plum
134
+ **Components:** 1
135
+ **LOC owned:** 200
136
+ **Tests:** fast
137
+ **Model:** sonnet
138
+ **Consumes:** Actions.stepAll(p: Pose, acts: List[Action], dt: Float) -> SteppedAll (T2) · PureScene.localMatrix(a: Actor) -> Affine (T3)
139
+ **Produces:** SceneTree.update(a: Actor, dt: Float) -> Actor
140
+ e.g. update(g, 0.5) -> g's child with [moveBy(10.0,0.0,1.0)] at x 5.0
141
+ **Produces:** SceneTree.findHit(a: Actor, parent: Affine, p: Vec2) -> List[Int]?
142
+ e.g. findHit(tree, Affine.Identity, Vec2(x: 21.0, y: 1.0)) -> [1, 0]
143
+ **Produces:** SceneTree.idAt(a: Actor, path: List[Int], depth: Int) -> Int
144
+ e.g. idAt(tree, [1, 0], 0) -> 4
145
+ **Produces:** SceneTree.findPath(a: Actor, id: Int) -> List[Int]?
146
+ e.g. findPath(tree, 4) -> [1, 0]
147
+ **Produces:** SceneTree.modify(a: Actor, id: Int, f: fn(Actor) -> Actor) -> Actor
148
+ e.g. modify(tree, 2, setX9) -> sprite 2 x 9.0
149
+ **Produces:** SceneTree.route(a: Actor, path: List[Int], depth: Int, e: Pointer) -> Handled
150
+ e.g. route(g, [0], 0, Pointer(Down, 20.0, 20.0, true)) -> events [UiEvent(5, Press)]
151
+ **Review Focus:** deep, state (purity), numeric (scale 0).
152
+ **Test code:** tests.md section T4 (13 named tests).
153
+
154
+ ### T5 stage and input · Complexity 5 · Est. context 38k
155
+ **Owns:** plum-std/game/SceneInput.plum
156
+ **Components:** 1
157
+ **LOC owned:** 170
158
+ **Tests:** fast
159
+ **Model:** sonnet
160
+ **Consumes:** SceneTree.findHit(a: Actor, parent: Affine, p: Vec2) -> List[Int]? (T4) · SceneTree.idAt(a: Actor, path: List[Int], depth: Int) -> Int (T4) · SceneTree.route(a: Actor, path: List[Int], depth: Int, e: Pointer) -> Handled (T4) · SceneTree.update(a: Actor, dt: Float) -> Actor (T4)
161
+ **Produces:** SceneInput.stage(root: Actor) -> Stage
162
+ e.g. stage(r) -> Stage(root r, camera Affine.Identity, captured null)
163
+ **Produces:** Stage.frame(self, dt: Float, inputs: List[Input]) -> StageHandled
164
+ e.g. stage(r).frame(0.5, []) -> StageHandled(events [], stage.root update(r, 0.5))
165
+ **Produces:** SceneInput.pollPointer(wasDown: Bool) -> List[Input]
166
+ e.g. pollPointer(false), mouse up -> [Input(PointerMove, mx, my)]
167
+ **Produces:** Stage.input(self, in: Input) -> StageHandled · Stage.draw(self)
168
+ **Review Focus:** state (capture), protocol (event order).
169
+ **Test code:** tests.md section T5 (7 named tests; types listed there).
170
+
171
+ ## Example and checks
172
+
173
+ ### T6 Orb Run on PureScene · Complexity 6 · Est. context 50k
174
+ **Owns:** plum-examples/game_pure_scene.plum
175
+ **Components:** 2
176
+ **LOC owned:** 330
177
+ **Tests:** fast
178
+ **Model:** sonnet
179
+ **Consumes:** SceneInput.stage(root: Actor) -> Stage (T5) · SceneInput.pollPointer(wasDown: Bool) -> List[Input] (T5) · SceneTree.modify(a: Actor, id: Int, f: fn(Actor) -> Actor) -> Actor (T4)
180
+ **Produces:** none
181
+ **Review Focus:** state (restart).
182
+ **Test code:** tests.md section T6.
183
+
184
+ ### T7 host parity · Complexity 3 · Est. context 30k
185
+ **Owns:** plum-game/tests/host_test.rs
186
+ **Components:** 1
187
+ **LOC owned:** 60
188
+ **Tests:** slow
189
+ **Model:** sonnet
190
+ **Consumes:** none
191
+ **Produces:** none
192
+ **Test code:** tests.md section T7 (2 named tests).
193
+
194
+ ### T8 stress fixture and browser check · Complexity 3 · Est. context 30k
195
+ **Owns:** plum-game/web-test/fixtures/stress_pure_scene.plum, plum-game/web-test/tests/web_check.rs
196
+ **Components:** 2
197
+ **LOC owned:** 140
198
+ **Tests:** slow
199
+ **Model:** sonnet
200
+ **Consumes:** none
201
+ **Produces:** none
202
+ **Test code:** tests.md section T8.
203
+
204
+ ### T9 docs · Complexity 1 · Est. context 22k
205
+ **Owns:** README.md
206
+ **Components:** 1
207
+ **LOC owned:** 40
208
+ **Tests:** fast
209
+ **Kind:** mechanical
210
+ **Model:** haiku
211
+ **Consumes:** none
212
+ **Produces:** none
213
+ **Test code:** tests.md section T9.
214
+
215
+ ## Review Focus
216
+ std/game (library): deep, numeric, state. **Probe entry:** run=./target/debug/plum test plum-std/game
217
+ example (CLI): state, protocol. **Probe entry:** run=./target/debug/plum test plum-examples/game_pure_scene.plum
docs/plans/2026-11-02-pure-scene.report.json ADDED
@@ -0,0 +1,265 @@
1
+ {
2
+ "projection_note": "projection: fixed forecast saved at lint 2026-11-02T21:33:51 (plan edited since)",
3
+ "headline": {
4
+ "wall_min": {
5
+ "projected": 10.485,
6
+ "actual": 13.487
7
+ },
8
+ "cost_usd_agents": {
9
+ "projected": 1.188881,
10
+ "actual": 2.553586
11
+ },
12
+ "task_min": {
13
+ "projected": 16.957,
14
+ "actual": 27.485
15
+ },
16
+ "agents": {
17
+ "projected": 9,
18
+ "actual": 10
19
+ }
20
+ },
21
+ "feature": null,
22
+ "cost_split": {
23
+ "agents_usd": 2.553586,
24
+ "rework_usd": 0,
25
+ "coordinator_usd": 13.192959,
26
+ "total_usd": 15.746545
27
+ },
28
+ "concurrency": {
29
+ "peak": 4,
30
+ "projected_peak": 3
31
+ },
32
+ "critical_path": {
33
+ "actual": [
34
+ "T2",
35
+ "T4",
36
+ "T5",
37
+ "T6",
38
+ "T8"
39
+ ],
40
+ "projected": [
41
+ "T3",
42
+ "T4",
43
+ "T5",
44
+ "T6",
45
+ "T8"
46
+ ],
47
+ "projected_min": 10.485
48
+ },
49
+ "model_mix": {
50
+ "haiku": {
51
+ "tasks": 2,
52
+ "cost_usd": 0.030596
53
+ },
54
+ "sonnet": {
55
+ "tasks": 7,
56
+ "cost_usd": 2.5229899999999996
57
+ }
58
+ },
59
+ "coordinator_cost_by_model": {
60
+ "opus": 13.192959
61
+ },
62
+ "quality": {
63
+ "tests": {
64
+ "passed": 1474,
65
+ "failed": 0,
66
+ "skipped": 0,
67
+ "seconds": 114.9,
68
+ "mode": "full",
69
+ "failures": [],
70
+ "skipped_tests": []
71
+ },
72
+ "first_time_pass": 0,
73
+ "first_time_n": 0,
74
+ "rework_dispatches": 0,
75
+ "open_incidents": 71
76
+ },
77
+ "tasks": [
78
+ {
79
+ "task": "T6",
80
+ "model": "sonnet",
81
+ "pred_min": 2.349,
82
+ "actual_min": 6.383,
83
+ "delta": {
84
+ "abs": 4.0343,
85
+ "pct": 171.7
86
+ },
87
+ "cost_usd": 0.931307,
88
+ "first_time_pass": null
89
+ },
90
+ {
91
+ "task": "T2",
92
+ "model": "sonnet",
93
+ "pred_min": 1.671,
94
+ "actual_min": 5.052,
95
+ "delta": {
96
+ "abs": 3.3807,
97
+ "pct": 202.3
98
+ },
99
+ "cost_usd": 0.552461,
100
+ "first_time_pass": null
101
+ },
102
+ {
103
+ "task": "T5",
104
+ "model": "sonnet",
105
+ "pred_min": 1.671,
106
+ "actual_min": 3.468,
107
+ "delta": {
108
+ "abs": 1.7973,
109
+ "pct": 107.6
110
+ },
111
+ "cost_usd": 0.200583,
112
+ "first_time_pass": null
113
+ },
114
+ {
115
+ "task": "T3",
116
+ "model": "sonnet",
117
+ "pred_min": 2.212,
118
+ "actual_min": 3.943,
119
+ "delta": {
120
+ "abs": 1.7313,
121
+ "pct": 78.3
122
+ },
123
+ "cost_usd": 0.351361,
124
+ "first_time_pass": null
125
+ },
126
+ {
127
+ "task": "T1",
128
+ "model": "haiku",
129
+ "pred_min": 1.421,
130
+ "actual_min": 2.498,
131
+ "delta": {
132
+ "abs": 1.0773,
133
+ "pct": 75.8
134
+ },
135
+ "cost_usd": 0.013117,
136
+ "first_time_pass": null
137
+ },
138
+ {
139
+ "task": "T8",
140
+ "model": "sonnet",
141
+ "pred_min": 2.523,
142
+ "actual_min": 1.818,
143
+ "delta": {
144
+ "abs": -0.7047,
145
+ "pct": -27.9
146
+ },
147
+ "cost_usd": 0.153382,
148
+ "first_time_pass": null
149
+ },
150
+ {
151
+ "task": "T9",
152
+ "model": "haiku",
153
+ "pred_min": 1.379,
154
+ "actual_min": 0.81,
155
+ "delta": {
156
+ "abs": -0.569,
157
+ "pct": -41.3
158
+ },
159
+ "cost_usd": 0.017479,
160
+ "first_time_pass": null
161
+ },
162
+ {
163
+ "task": "T7",
164
+ "model": "sonnet",
165
+ "pred_min": 2.001,
166
+ "actual_min": 1.535,
167
+ "delta": {
168
+ "abs": -0.466,
169
+ "pct": -23.3
170
+ },
171
+ "cost_usd": 0.071706,
172
+ "first_time_pass": null
173
+ },
174
+ {
175
+ "task": "T4",
176
+ "model": "sonnet",
177
+ "pred_min": 1.73,
178
+ "actual_min": 1.977,
179
+ "delta": {
180
+ "abs": 0.2467,
181
+ "pct": 14.3
182
+ },
183
+ "cost_usd": 0.26219,
184
+ "first_time_pass": null
185
+ }
186
+ ],
187
+ "cost_table": {
188
+ "source": "est",
189
+ "usd": 2.711,
190
+ "tokens": 6911228,
191
+ "wall_min": 29893981.1099,
192
+ "rows": [
193
+ {
194
+ "agent": "a0a906a8b00a69677",
195
+ "model": "sonnet",
196
+ "wall_min": 3.9766,
197
+ "tokens": 932093,
198
+ "usd": 0.3514
199
+ },
200
+ {
201
+ "agent": "a1109cab4f51b76fb",
202
+ "model": "sonnet",
203
+ "wall_min": 2.0783,
204
+ "tokens": 569505,
205
+ "usd": 0.2438
206
+ },
207
+ {
208
+ "agent": "a236d404566ad83ee",
209
+ "model": "sonnet",
210
+ "wall_min": 2.0235,
211
+ "tokens": 522426,
212
+ "usd": 0.2622
213
+ },
214
+ {
215
+ "agent": "a2d89b1ddba2e5006",
216
+ "model": "sonnet",
217
+ "wall_min": 29893938.609,
218
+ "tokens": 0,
219
+ "usd": 0.0
220
+ },
221
+ {
222
+ "agent": "a47edc6c6ce87eefe",
223
+ "model": "sonnet",
224
+ "wall_min": 13.532,
225
+ "tokens": 2077692,
226
+ "usd": 0.6626
227
+ },
228
+ {
229
+ "agent": "a5fae794466b9b7a1",
230
+ "model": "sonnet",
231
+ "wall_min": 5.297,
232
+ "tokens": 1265972,
233
+ "usd": 0.7276
234
+ },
235
+ {
236
+ "agent": "a7e00631109ee8ffc",
237
+ "model": "opus",
238
+ "wall_min": 0.3325,
239
+ "tokens": 37010,
240
+ "usd": 0.1574
241
+ },
242
+ {
243
+ "agent": "a8d8aacaf5c406aac",
244
+ "model": "haiku",
245
+ "wall_min": 12.5239,
246
+ "tokens": 1050386,
247
+ "usd": 0.0306
248
+ },
249
+ {
250
+ "agent": "aade2d1c23c4b2b1f",
251
+ "model": "sonnet",
252
+ "wall_min": 1.1442,
253
+ "tokens": 368498,
254
+ "usd": 0.2037
255
+ },
256
+ {
257
+ "agent": "ab46667df282954cb",
258
+ "model": "sonnet",
259
+ "wall_min": 1.5929,
260
+ "tokens": 87646,
261
+ "usd": 0.0717
262
+ }
263
+ ]
264
+ }
265
+ }
docs/plans/2026-11-02-pure-scene.report.md ADDED
@@ -0,0 +1,39 @@
1
+ # superlite build report
2
+
3
+ projection: fixed forecast saved at lint 2026-11-02T21:33:51 (plan edited since)
4
+
5
+ Projected Actual Delta
6
+ wall-clock (min) 10.5 13.5 +3.0 (+29%)
7
+ agent cost (USD, +rework) 1.19 2.55 +1.36 (+115%)
8
+ task-minutes 17.0 27.5 +10.5 (+62%)
9
+ agents 9 10 +1 (+11%)
10
+ cost split: agents $2.55, rework $0.00, coordinator $13.19, total $15.75
11
+ concurrency: peak 4 at once (projected 3); critical path actual T2 T4 T5 T6 T8; projected T3 > T4 > T5 > T6 > T8 (10.5 min)
12
+ model mix: haiku 2 tasks $0.03, sonnet 7 tasks $2.52, coordinator opus $13.19
13
+ quality: tests 1474 passed, 0 failed; first-time pass n/a; rework dispatches 0; open incidents 71
14
+
15
+ task model pred actual delta cost
16
+ T6 sonnet 2.3 6.4 +4.0 (+172%) $0.931
17
+ T2 sonnet 1.7 5.1 +3.4 (+202%) $0.552
18
+ T5 sonnet 1.7 3.5 +1.8 (+108%) $0.201
19
+ T3 sonnet 2.2 3.9 +1.7 (+78%) $0.351
20
+ T1 haiku 1.4 2.5 +1.1 (+76%) $0.013
21
+ (+4 smaller misses; `superlite-cli report --json` lists all)
22
+
23
+ agent cost table:
24
+ | agent | model | wall_min | tokens | usd |
25
+ | --- | --- | --- | --- | --- |
26
+ | a0a906a8b00a69677 | sonnet | 3.9766 | 932093 | 0.3514 |
27
+ | a1109cab4f51b76fb | sonnet | 2.0783 | 569505 | 0.2438 |
28
+ | a236d404566ad83ee | sonnet | 2.0235 | 522426 | 0.2622 |
29
+ | a2d89b1ddba2e5006 | sonnet | 29893938.609 | 0 | 0.0 |
30
+ | a47edc6c6ce87eefe | sonnet | 13.532 | 2077692 | 0.6626 |
31
+ | a5fae794466b9b7a1 | sonnet | 5.297 | 1265972 | 0.7276 |
32
+ | a7e00631109ee8ffc | opus | 0.3325 | 37010 | 0.1574 |
33
+ | a8d8aacaf5c406aac | haiku | 12.5239 | 1050386 | 0.0306 |
34
+ | aade2d1c23c4b2b1f | sonnet | 1.1442 | 368498 | 0.2037 |
35
+ | ab46667df282954cb | sonnet | 1.5929 | 87646 | 0.0717 |
36
+ | totals | | 29893981.1099 | 6911228 | 2.711 |
37
+ cost source: est
38
+
39
+ takeaway: ran 29% slower than projected; cost 115% over; biggest miss T6
docs/plans/2026-11-02-pure-scene.review.md ADDED
@@ -0,0 +1,109 @@
1
+ # Pure scene graph, phase A: review plan
2
+
3
+ **Design:** `docs/plans/2026-11-02-pure-scene-graph.design.md` (value-types variant). **Sketch:** `docs/plans/2026-11-02-pure-scene-graph.sketch.pfl`.
4
+ **Rulings already made:**
5
+ - UI clicks reach the game as `UiEvent(id, kind)` ids (D4a). No generic traits.
6
+ - Wasm-gc, no refcounting (D1).
7
+ - Action leaves are structs (D9).
8
+ - No inline ADT sugar (D10).
9
+
10
+ ## Summary
11
+ A new std module, `std/game/PureScene`, gives Plum games a libGDX-style scene graph whose behaviour is pure. Each frame takes the old tree and returns a new one, and that covers animation actions, input routing, hit testing and "find this actor and change it". Nothing is changed in place except brand-new copies inside the function that made them, and there are no parent pointers. Only `draw` talks to the host. Phase A needs no compiler changes. It ends with a benchmark that decides whether phase B (`own record`, compile-time in-place updates) is needed. The current mutable `std/game/Scene` stays untouched.
12
+
13
+ ## What you will get
14
+ - `plum-std/game/PureScene.plum` with these parts:
15
+ - actors (`Group`, `Sprite`, `Label`, `Button`) on a pure `trait Actor`
16
+ - actions: `MoveBy`, `RotateBy`, `Delay`, `Sequence`, `Parallel`, `Forever`
17
+ - a `Stage` with camera, pointer capture and `UiEvent`s
18
+ - `plum test` coverage for every bug fixed in the design (section 5 there), with no host needed.
19
+ - `plum-examples/game_pure_scene.plum`: Orb Run ported from `game_scene.plum`, same rules, plus a small HUD button that uses clicks.
20
+ - A stress fixture and a benchmark row comparing it with today's scene graph, and a browser check that it renders and animates.
21
+ - `Affine` moved into its own file (`std/game/Affine`), so both scene modules share it without name clashes.
22
+
23
+ ## What will be built
24
+ | Part | What it does | Size | Built by |
25
+ |---|---|---|---|
26
+ | Affine split | Moves `Affine` and its maths out of Scene into `std/game/Affine`, and Scene imports it. No behaviour change. | S | a fast helper |
27
+ | Value check | A test that struct members (`MoveBy`) inside a recursive union (`Sequence(List[Action])`) push, match and spread-copy correctly. It guards risk R6 before anything relies on it. | S | a fast helper |
28
+ | Actions | `tick`, `stepAction` and `stepAll` with spare-time carry-over, zero-length handling, and one `Forever` restart per frame | M | a careful helper |
29
+ | Actor tree | `trait Actor` with fields, `clone`, `act`, `draw`, `hit` and `onPointer`. Groups and leaves, `update` (act + actions), the read-only draw walk, hit testing with three-state touchable and singular-matrix safety, `idAt`/`findPath`/`modifyAt` | L, split in two | a careful helper |
30
+ | Stage and input | Camera, pointer capture (press target, `over` flag, stale-capture drop), routing along a path child-first, and `UiEvent` output | M | a careful helper |
31
+ | Example game | Orb Run on PureScene, with its rules tested under `plum test` | M | a careful helper |
32
+ | Parity check | A host test that the ported example's draw calls follow the same frame-by-frame pattern as `game_scene.plum` | S | a careful helper |
33
+ | Benchmark and browser check | A `stress_pure_scene` fixture, a BENCHMARK.md row (run by me on the reference Mac), and a `web_check` case | S | a fast helper, then me |
34
+ | Docs | README games section: when to use PureScene vs Scene vs immediate mode | S | a fast helper |
35
+
36
+ ## How it fits together
37
+ ```mermaid
38
+ flowchart LR
39
+ G["game: World + handle(event)"] --> F["frame(dt, inputs)"]
40
+ F --> S["Stage.input: hit test, capture, route"]
41
+ S -->|"UiEvent list"| G
42
+ F --> U["update: act + actions"]
43
+ U --> A["Actions: step, carry spare time"]
44
+ F --> D["Stage.draw: read-only walk"]
45
+ D --> H["std/game/Game externs"]
46
+ S --> T["Actor tree: Group, Sprite, Label, Button"]
47
+ U --> T
48
+ D --> T
49
+ T --> X["std/game/Affine"]
50
+ ```
51
+
52
+ ## How it gets built
53
+ ```mermaid
54
+ flowchart TB
55
+ subgraph W1["Wave 1, in parallel"]
56
+ AF[Affine split]
57
+ VC[Value check]
58
+ AC[Actions]
59
+ TR1["Actor tree: update, draw"]
60
+ TR2["Actor tree: hit test, paths"]
61
+ ST[Stage and input]
62
+ end
63
+ W1 --> EX[Example game]
64
+ EX --> PA[Parity check]
65
+ EX --> BM[Benchmark fixture and browser check]
66
+ EX --> DO[Docs]
67
+ ```
68
+ Wave 1 parts build against agreed interfaces (stubs), so they don't wait for each other.
69
+
70
+ ## Decisions you can change
71
+ - **Copying an actor in pure code.**
72
+ - Default: each actor record defines a one-line `clone` (`Sprite(..self)`), and pure functions change only fresh clones.
73
+ - Alternative: make the compiler generate `clone` for every trait-embedding record (language work).
74
+ - **Module name.** `std/game/PureScene` (default) | replace `std/game/Scene` directly (riskier: breaks `game_scene.plum` and `scene.plum` until ported).
75
+ - **Example scope.** Full Orb Run port (default) | a small coin-and-button demo only.
76
+ - **Benchmark bar.**
77
+ - Default: average frame time within 10% of today's scene graph (2.46 ms) and p95 within 15%.
78
+ - Alternatives: any looser or tighter numbers you prefer.
79
+ - **Root `scene_graph.pfl`.** The plan's docs part deletes it (default), since the hook blocked me from doing it directly | you delete it by hand.
80
+
81
+ ## Risks
82
+ - **R1: per-frame copying.** Each animated actor gets a fresh clone every frame. The benchmark measures exactly this, and a miss is what triggers phase B.
83
+ - **R3: spread on trait values isn't supported.** The `clone` decision above works around it, as long as spread on a concrete record copies its embedded trait fields. Value check also covers that.
84
+ - **R6: struct members in recursive unions.** The Value check part runs it first. If it fails, the leaf actions become records and the rest goes on.
85
+ - **Paths go stale when the tree changes.** Capture checks the actor id at the end of the path, and `modify` re-finds the path by id.
86
+ - **Parity can drift on purpose:** the example adds a button the original doesn't have. The parity check compares the frames before the first click.
87
+
88
+ ## Out of scope
89
+ - Phase B (`own record`), generic traits, inline ADT sugar, structs with enum or reference fields.
90
+ - Culling and z-order (`toFront`, `zIndex`). They move over when PureScene replaces Scene.
91
+ - Replacing or deleting `std/game/Scene`.
92
+ - Multi-touch, keyboard focus, layout widgets.
93
+
94
+ ## How we will know it works
95
+ - `plum test plum-std/game/PureScene.plum plum-examples/game_pure_scene.plum` passes. That includes a test per design bug:
96
+ - a zero-length move;
97
+ - 10 s of `Forever(RotateBy 1 s)` at 0.016 s steps lands within 1e-6 of 10 turns;
98
+ - a scale-0 actor can't be hit;
99
+ - press on a button and release elsewhere gives no click and no stuck press;
100
+ - press, drag off and back, release gives exactly one click;
101
+ - a disabled group hides its subtree;
102
+ - `modify` changes only the first match;
103
+ - a capture is dropped after the tree changes.
104
+ - The full repo suite still passes, and `game_scene.plum` and `scene.plum` are unchanged in behaviour after the Affine split.
105
+ - `plum game plum-examples/game_pure_scene.plum` plays Orb Run in the browser, and the new `web_check` case passes.
106
+ - A BENCHMARK.md row shows the pure scene's numbers next to `stress_scene`, with a verdict: bar met (phase B optional) or missed (phase B next).
107
+
108
+ ## Rough time and cost
109
+ About 10 parts: 6 in the first wave, then 4 after the example. Rough build time is 30-45 minutes of agent work plus one headed benchmark run on your Mac. These numbers are rough until the machine plan is linted.
docs/plans/2026-11-02-pure-scene.tests.md ADDED
@@ -0,0 +1,47 @@
1
+ # Pure scene graph, phase A: test details for T4-T9
2
+
3
+ Referenced by `docs/plans/2026-11-02-pure-scene.md`. Shared fixture: `tree = group(1, 0.0, 0.0, 100.0, 100.0, [sprite(2, 0, 0.0, 0.0, 10.0, 10.0), group(3, 20.0, 0.0, 10.0, 10.0, [sprite(4, 0, 0.0, 0.0, 5.0, 5.0)])])`. A button in tests: `button(5, "Go", 0, 1, 10.0, 10.0, 100.0, 40.0)`.
4
+
5
+ Facts from T3 (PureScene as built): enum variants are bare names (`Enabled`, `Down`, `Press`), no `Type.Variant` syntax. Children are read through the trait method `kids(self) -> List[Actor]?` (null for leaves, the list for Group); don't destructure `Group(...)` (constructor patterns need every trait field). `Affine.inverse()` still exists; use `invert() -> Affine?`. Button: Down sets pressed+over and emits Press; Move emits Enter/Exit when over changes; Up emits Release if pressed, plus Click if over.
6
+
7
+ ## T4 (SceneTree)
8
+ Notes: update = a.act(dt), stepAll on its pose (Actions.stepAll), write pose and kept actions into a clone; a Group clone gets kids mapped through update (new list). findHit composes parent * localMatrix(a), prunes invisible/Disabled, children last-first, then self if hit and Enabled. route: child at path.at(depth) first (skip if out of range), then own onPointer; events child first. modify = findPath then modifyAt.
9
+ - `find_hit_nested`: findHit(tree, Affine.Identity, Vec2(x: 21.0, y: 1.0)) == [1, 0].
10
+ - `find_hit_miss`: findHit(tree, Affine.Identity, Vec2(x: 200.0, y: 200.0)) == null.
11
+ - `id_at_paths`: idAt(tree, [1, 0], 0) == 4; idAt(tree, [5], 0) == -1; idAt(tree, [], 0) == 1.
12
+ - `find_path_ids`: findPath(tree, 4) == [1, 0]; findPath(tree, 99) == null.
13
+ - `update_runs_actions_and_children`: sprite child with actions [moveBy(10.0, 0.0, 1.0)] in a group; update(root, 0.5): child x 5.0, child actions length 1.
14
+ - `update_leaves_input_unchanged`: after that update, the original root's child x is 0.0.
15
+ - `disabled_group_hides_subtree`: group 3 touchable Disabled: findHit at (21, 1) == [] (the root).
16
+ - `children_only_group_not_hit_itself`: group 3 ChildrenOnly, point (28, 8) (inside group 3, outside sprite 4): result is [] (root), not [1].
17
+ - `scale_zero_not_hit`: sprite 2 with scaleX 0.0, point (1, 1): result [] (root), no NaN.
18
+ - `topmost_child_wins`: two overlapping sprites at the same box: path is [1] (the later child).
19
+ - `modify_changes_first_match_only`: group with two sprites both id 7: modify(root, 7, set x 9.0) changes children[0].x only; children[1].x unchanged; input root unchanged.
20
+ - `route_child_first_then_group`: group containing the button, route path [0] Down then Up(over true): events [Press, Release, Click] with id 5.
21
+ - `stale_path_reaches_group_only`: route path [9] on a 1-child group: events empty, no crash.
22
+
23
+ ## T5 (SceneInput)
24
+ - `press_sets_capture`: stage(group(1, 0,0,800,600, [button])).input(Input(PointerDown, 20.0, 20.0)): events [UiEvent(5, Press)], stage.captured.id == 5.
25
+ - `release_elsewhere_no_click`: Down (20,20), Up (500,500): second call events [UiEvent(5, Release)], captured null, the button's pressed false.
26
+ - `drag_off_and_back_one_click`: Down (20,20), Move (500,500), Move (20,20), Up (20,20): exactly one Click across all events.
27
+ - `stale_capture_dropped`: Down on the button, then stage root replaced by group(1, ..., []) (no id 5), Up (20,20): no events, captured null.
28
+ - `hover_without_press_routes_move`: Move (20,20) with no capture: the button's over is true.
29
+ - `camera_maps_screen_to_world`: camera Affine(a: 2.0, b: 0.0, c: 0.0, d: 2.0, e: 0.0, f: 0.0), Down at (40, 40): Press on id 5.
30
+ - `frame_handles_inputs_then_updates`: root also holds sprite(6, ...) with [moveBy(10.0, 0.0, 1.0)]; frame(0.5, [Down (20,20), Up (20,20)]): events [Press, Release, Click], sprite 6 x == 5.0.
31
+
32
+ ## T6 (example)
33
+ - Every `test` of plum-examples/game_scene.plum, same names and asserts, ported.
34
+ - `restart_button_hidden_while_playing`: new world: the restart button's visible == false.
35
+ - `restart_button_click_restarts`: world forced to game over, a frame with Down+Up on the button: lives == StartLives, score == 0.
36
+ - `world_update_is_pure`: keep the stage before a step; after the step the kept stage's ship x is unchanged.
37
+
38
+ ## T7 (host)
39
+ - `pureSceneExampleRunsFrames`: as `sceneExampleRunsFrames`, for plum-examples/game_pure_scene.plum.
40
+ - `pureSceneMatchesSceneDrawCounts`: game_scene.plum and game_pure_scene.plum, each with Recorder::with_assets(examples_assets()), init, then 60 x (update(1/60), draw) with no input; per frame, counts of calls starting `drawTextureEx(` and `drawText(` are equal and the sets of texture ids in drawTextureEx are equal. Run: `cargo test --manifest-path plum-game/Cargo.toml --test host_test pureScene`.
41
+
42
+ ## T8 (fixture + browser)
43
+ - fixture `stress_pure_scene.plum`: same scene and counts as stress_scene.plum (5000 sprites in 50 rotating groups, 500 rects and 200 circles as sprites of a 1x1 white texture, 50 texts with a changing number) on PureScene; motion via `act` and `forever(rotateBy(...))`; one test: 10 frames of update run.
44
+ - web_check.rs: `pure_scene_renders_and_animates` calls `orb_run("plum-examples/game_pure_scene.plum")`.
45
+
46
+ ## T9 (docs)
47
+ - README Games section gains "Pure scene graph": the four imports, a 10-line example (stage, a sprite with `forever(rotateBy(...))`, frame, draw), and when to use immediate mode vs Scene vs PureScene. `grep -c "std/game/PureScene" README.md` >= 1.
plum-examples/game_pure_scene.plum ADDED
@@ -0,0 +1,872 @@
1
+ # Orb Run on the pure scene graph (`plum game plum-examples/game_pure_scene.plum`): the same
2
+ # game as game_scene.plum (same arena, rules, sprites, sounds, keys and scoring), but the
3
+ # scene is made of values. Steer the ship with the arrow keys or WASD, collect the
4
+ # drifting orbs and avoid the mines. Three lives; Enter, R or the "Again" button
5
+ # restarts after the game is over.
6
+ #
7
+ # What differs from game_scene.plum. There, actors are mutable objects that point at
8
+ # their parent and `stage.act` changes them in place. Here every actor (`Ship`, `Orb`,
9
+ # `Mine`, `Star`, `Panel` and the std `Group`, `Sprite`, `Label`, `Button`) is a value:
10
+ # `act` returns the next actor, `Stage.frame` returns a new `Stage`, and a change from the
11
+ # rules ("this orb is collected", "show the game-over panel") is a `SceneTree.modify`
12
+ # on an actor id that returns a new tree. The `World` record below is the mutable shell the
13
+ # host talks to: it keeps the current `Stage` and the run's state (score, lives, the ship's
14
+ # velocity) and replaces its `stage` every frame. Nothing that a function was given
15
+ # is ever changed; only `init`, `update` and `draw` talk to the host.
16
+ module game_pure_scene
17
+ import std/Str
18
+ import std/Int
19
+ import std/Float
20
+ import std/List
21
+ import std/Math
22
+ import std/Random
23
+ import std/game/Vec2
24
+ import std/game/Color
25
+ import std/game/Affine
26
+ import std/game/Game
27
+ import std/game/Actions
28
+ import std/game/PureScene
29
+ import std/game/SceneTree
30
+ import std/game/SceneInput
31
+
32
+ primitive Rules =
33
+ ArenaW = 800.0
34
+ ArenaH = 600.0
35
+ ShipRadius = 10.0
36
+ OrbRadius = 10.0
37
+ MineRadius = 11.0
38
+ Thrust = 900.0
39
+ Drag = 2.2
40
+ OrbCount = 3
41
+ StartLives = 3
42
+ HurtTime = 1.5
43
+ OrbPoints = 10
44
+ Cell = 16.0
45
+ Size = 48.0
46
+ Half = 24.0
47
+
48
+ # Actor ids: unique in the tree, so the rules can find an actor with `SceneTree.modify`.
49
+ primitive Id =
50
+ Root = 1
51
+ Backdrop = 2
52
+ StarLayer = 3
53
+ MineLayer = 4
54
+ OrbLayer = 5
55
+ Ship = 6
56
+ Hud = 7
57
+ Score = 8
58
+ Best = 9
59
+ Overlay = 20
60
+ OverlayPanel = 21
61
+ OverTitle = 22
62
+ OverHint = 23
63
+ Restart = 24
64
+ Icon = 30
65
+ Orb = 100
66
+ Mine = 200
67
+ Star = 1000
68
+
69
+ # A position and velocity: what a spawn returns and what drifters are bounced with.
70
+ record Motion(x: Float, y: Float, vx: Float, vy: Float)
71
+
72
+ # Which of `count` animation frames is showing after `clock` seconds at `fps` frames per second.
73
+ fun frameOf(clock: Float, count: Int, fps: Float) -> Int =
74
+ Int(clock * fps) % count
75
+
76
+ # A random spot at least `gap` away from (ax, ay), drifting at up to `speed`.
77
+ fun spawn(rng: Random, ax: Float, ay: Float, gap: Float, speed: Float) -> Motion =
78
+ margin := 30.0
79
+ b := Motion(x: 0.0, y: 0.0, vx: 0.0, vy: 0.0)
80
+ tries := 0
81
+ while tries < 20
82
+ b.x = margin + rng.nextFloat() * {Rules.ArenaW - 2.0 * margin}
83
+ b.y = margin + rng.nextFloat() * {Rules.ArenaH - 2.0 * margin}
84
+ if Math.hypot(b.x - ax, b.y - ay) >= gap
85
+ break
86
+ tries = tries + 1
87
+ heading := rng.nextFloat() * Float.Tau
88
+ pace := speed * {0.4 + 0.6 * rng.nextFloat()}
89
+ b.vx = Math.cos(heading) * pace
90
+ b.vy = Math.sin(heading) * pace
91
+ b
92
+
93
+ # `b` moved for `dt` seconds and bounced off the arena walls; `b` itself is unchanged.
94
+ fun driftOf(b: Motion, r: Float, dt: Float) -> Motion =
95
+ n := Motion(..b)
96
+ n.x = b.x + b.vx * dt
97
+ n.y = b.y + b.vy * dt
98
+ if n.x < r || n.x > Rules.ArenaW - r
99
+ n.vx = 0.0 - b.vx
100
+ n.x = n.x.max(r).min(Rules.ArenaW - r)
101
+ if n.y < r || n.y > Rules.ArenaH - r
102
+ n.vy = 0.0 - b.vy
103
+ n.y = n.y.max(r).min(Rules.ArenaH - r)
104
+ n
105
+
106
+ # How many mines are out: more as the score grows.
107
+ fun mineCount(score: Int) -> Int =
108
+ {3 + score / 50}.min(8)
109
+
110
+ # The ship's motion state. The ship actor in the tree only shows it.
111
+ record ShipState(x: Float, y: Float, vx: Float, vy: Float, rotation: Float, hurt: Float)
112
+
113
+ # Accelerates by the input axes, applies drag and keeps the ship in the arena.
114
+ fun shipStep(s: ShipState, ax: Float, ay: Float, dt: Float) -> ShipState =
115
+ n := ShipState(..s)
116
+ n.hurt = {s.hurt - dt}.max(0.0)
117
+ n.vx = s.vx + ax * Rules.Thrust * dt
118
+ n.vy = s.vy + ay * Rules.Thrust * dt
119
+ keep := {1.0 - Rules.Drag * dt}.max(0.0)
120
+ n.vx = n.vx * keep
121
+ n.vy = n.vy * keep
122
+ n.x = s.x + n.vx * dt
123
+ n.y = s.y + n.vy * dt
124
+ r := Rules.ShipRadius
125
+ if n.x < r
126
+ n.x = r
127
+ n.vx = 0.0
128
+ if n.x > Rules.ArenaW - r
129
+ n.x = Rules.ArenaW - r
130
+ n.vx = 0.0
131
+ if n.y < r
132
+ n.y = r
133
+ n.vy = 0.0
134
+ if n.y > Rules.ArenaH - r
135
+ n.y = Rules.ArenaH - r
136
+ n.vy = 0.0
137
+ if n.vx * n.vx + n.vy * n.vy > 400.0
138
+ n.rotation = Math.atan2(n.vx, 0.0 - n.vy)
139
+ n
140
+
141
+ fun newShip() -> ShipState =
142
+ ShipState(x: Rules.ArenaW / 2.0, y: Rules.ArenaH / 2.0, vx: 0.0, vy: 0.0, rotation: 0.0, hurt: 0.0)
143
+
144
+ # The centre of an actor drawn as a sprite-sized box.
145
+ fun cx(a: Actor) -> Float =
146
+ a.x + a.width / 2.0
147
+
148
+ fun cy(a: Actor) -> Float =
149
+ a.y + a.height / 2.0
150
+
151
+ # Actor kinds of this game. Each is a sprite-sized actor; `create` takes the centre.
152
+
153
+ # A filled rectangle.
154
+ record Panel(Actor) =
155
+
156
+ fun create(id: Int, x: Float, y: Float, w: Float, h: Float, c: Color) -> Panel =
157
+ Panel(
158
+ id: id, x: x, y: y, width: w, height: h, originX: 0.0, originY: 0.0,
159
+ scaleX: 1.0, scaleY: 1.0, rotation: 0.0, color: c, visible: true,
160
+ touchable: Disabled, actions: List[Action]())
161
+
162
+ fun clone(self) -> Actor = Panel(..self)
163
+
164
+ fun draw(self, m: Affine, tint: Color) =
165
+ p := m.apply(Vec2(x: 0.0, y: 0.0))
166
+ drawRect(p.x, p.y, self.width * m.scaleX(), self.height * m.scaleY(), tint)
167
+
168
+ # The player: only a picture that follows `ShipState`; `act` runs its animation clock.
169
+ record Ship(Actor, clock: Float, tex: Int) =
170
+
171
+ fun create(sheet: Int, s: ShipState) -> Ship =
172
+ Ship(
173
+ id: Id.Ship, x: s.x - Rules.Half, y: s.y - Rules.Half, width: Rules.Size, height: Rules.Size,
174
+ originX: Rules.Half, originY: Rules.Half, scaleX: 1.0, scaleY: 1.0, rotation: 0.0,
175
+ color: Color.White, visible: true, touchable: Disabled, actions: List[Action](),
176
+ clock: 0.0, tex: sheet)
177
+
178
+ fun clone(self) -> Actor = Ship(..self)
179
+
180
+ fun act(self, dt: Float) -> Actor =
181
+ c := Ship(..self)
182
+ c.clock = self.clock + dt
183
+ c
184
+
185
+ fun draw(self, m: Affine, tint: Color) =
186
+ c := m.apply(Vec2(x: self.width / 2.0, y: self.height / 2.0))
187
+ drawCircle(c.x, c.y, 17.0 * m.scaleX(), Color(r: 0.4, g: 0.8, b: 1.0, a: 0.12 * tint.a))
188
+ frame := Float(frameOf(self.clock, 4, 14.0))
189
+ pureDrawSprite(m, tint, self.width, self.height, self.tex, frame * Rules.Cell, 0.0, Rules.Cell, Rules.Cell)
190
+
191
+ # A glowing orb drifting about. `phase` offsets its animation from the other orbs.
192
+ record Orb(Actor, vx: Float, vy: Float, phase: Float, clock: Float, tex: Int) =
193
+
194
+ fun create(sheet: Int, phase: Float, id: Int, at: Motion) -> Orb =
195
+ Orb(
196
+ id: id, x: at.x - Rules.Half, y: at.y - Rules.Half, width: Rules.Size, height: Rules.Size,
197
+ originX: Rules.Half, originY: Rules.Half, scaleX: 1.0, scaleY: 1.0, rotation: 0.0,
198
+ color: Color.White, visible: true, touchable: Disabled, actions: List[Action](),
199
+ vx: at.vx, vy: at.vy, phase: phase, clock: 0.0, tex: sheet)
200
+
201
+ fun clone(self) -> Actor = Orb(..self)
202
+
203
+ fun motion(self) -> Motion =
204
+ Motion(x: self.x + Rules.Half, y: self.y + Rules.Half, vx: self.vx, vy: self.vy)
205
+
206
+ fun act(self, dt: Float) -> Actor =
207
+ m := driftOf(self.motion(), Rules.OrbRadius, dt)
208
+ c := Orb(..self)
209
+ c.clock = self.clock + dt
210
+ c.x = m.x - Rules.Half
211
+ c.y = m.y - Rules.Half
212
+ c.vx = m.vx
213
+ c.vy = m.vy
214
+ c
215
+
216
+ fun draw(self, m: Affine, tint: Color) =
217
+ c := m.apply(Vec2(x: self.width / 2.0, y: self.height / 2.0))
218
+ k := m.scaleX()
219
+ blendMode(Blend.Add)
220
+ drawCircle(c.x, c.y, 26.0 * k, Color(r: 0.35, g: 0.22, b: 0.0, a: 0.5 * tint.a))
221
+ drawCircle(c.x, c.y, 17.0 * k, Color(r: 0.6, g: 0.4, b: 0.05, a: 0.5 * tint.a))
222
+ blendMode(Blend.Normal)
223
+ frame := Float(frameOf(self.clock + self.phase, 4, 8.0))
224
+ pureDrawSprite(m, tint, self.width, self.height, self.tex, frame * Rules.Cell, Rules.Cell, Rules.Cell, Rules.Cell)
225
+
226
+ # A mine drifting about, turning `spin` radians per second (0 once the game is over).
227
+ record Mine(Actor, vx: Float, vy: Float, spin: Float, clock: Float, tex: Int) =
228
+
229
+ fun create(sheet: Int, id: Int, at: Motion, spin: Float) -> Mine =
230
+ Mine(
231
+ id: id, x: at.x - Rules.Half, y: at.y - Rules.Half, width: Rules.Size, height: Rules.Size,
232
+ originX: Rules.Half, originY: Rules.Half, scaleX: 1.0, scaleY: 1.0, rotation: 0.0,
233
+ color: Color.White, visible: true, touchable: Disabled, actions: List[Action](),
234
+ vx: at.vx, vy: at.vy, spin: spin, clock: 0.0, tex: sheet)
235
+
236
+ fun clone(self) -> Actor = Mine(..self)
237
+
238
+ fun motion(self) -> Motion =
239
+ Motion(x: self.x + Rules.Half, y: self.y + Rules.Half, vx: self.vx, vy: self.vy)
240
+
241
+ fun act(self, dt: Float) -> Actor =
242
+ m := driftOf(self.motion(), Rules.MineRadius, dt)
243
+ c := Mine(..self)
244
+ c.clock = self.clock + dt
245
+ c.x = m.x - Rules.Half
246
+ c.y = m.y - Rules.Half
247
+ c.vx = m.vx
248
+ c.vy = m.vy
249
+ c.rotation = self.rotation + self.spin * dt
250
+ c
251
+
252
+ fun draw(self, m: Affine, tint: Color) =
253
+ frame := Float(frameOf(self.clock, 4, 4.0))
254
+ pureDrawSprite(m, tint, self.width, self.height, self.tex, frame * Rules.Cell, 2.0 * Rules.Cell, Rules.Cell, Rules.Cell)
255
+
256
+ # A background star that twinkles.
257
+ record Star(Actor, seed: Float, clock: Float) =
258
+
259
+ fun create(i: Int) -> Star =
260
+ r := 1.0 + Float(i % 3) * 0.5
261
+ Star(
262
+ id: Id.Star + i, x: Float({i * 7919} % 800) - r, y: Float({i * 104729} % 600) - r,
263
+ width: r * 2.0, height: r * 2.0, originX: 0.0, originY: 0.0, scaleX: 1.0, scaleY: 1.0,
264
+ rotation: 0.0, color: Color.White, visible: true, touchable: Disabled, actions: List[Action](),
265
+ seed: Float(i), clock: 0.0)
266
+
267
+ fun clone(self) -> Actor = Star(..self)
268
+
269
+ fun act(self, dt: Float) -> Actor =
270
+ c := Star(..self)
271
+ c.clock = self.clock + dt
272
+ c
273
+
274
+ fun draw(self, m: Affine, tint: Color) =
275
+ twinkle := 0.25 + 0.2 * Math.sin(self.clock * 2.0 + self.seed)
276
+ r := self.width / 2.0
277
+ c := m.apply(Vec2(x: r, y: r))
278
+ drawCircle(c.x, c.y, r * m.scaleX(), Color(r: 0.7, g: 0.8, b: 1.0, a: twinkle * tint.a))
279
+
280
+ # --- reading and changing the tree ---
281
+
282
+ # The actor with `id`, searched depth first.
283
+ fun lookup(a: Actor, id: Int) -> Actor? =
284
+ if a.id == id
285
+ return a
286
+ if ks := a.kids()
287
+ for i := range ks.length()
288
+ r := lookup(ks.at(i), id)
289
+ if f := r
290
+ return f
291
+ null
292
+
293
+ fun find(root: Actor, id: Int) -> Actor =
294
+ lookup(root, id) ?: crash "no such actor"
295
+
296
+ fun kidsOf(a: Actor) -> List[Actor] =
297
+ a.kids() ?: crash "not a group"
298
+
299
+ fun withRoot(s: Stage, root: Actor) -> Stage =
300
+ Stage(root: root, camera: s.camera, captured: s.captured)
301
+
302
+ fun setVisible(a: Actor, v: Bool) -> Actor =
303
+ c := a.clone()
304
+ c.visible = v
305
+ c
306
+
307
+ # The group `g` with `k` added as its last child.
308
+ fun addKid(g: Actor, k: Actor) -> Actor =
309
+ ks := List[Actor]()
310
+ old := kidsOf(g)
311
+ for i := range old.length()
312
+ ks.push(old.at(i))
313
+ ks.push(k)
314
+ treeWithKids(g, ks)
315
+
316
+ fun clockOf(a: Actor) -> Float =
317
+ match a
318
+ Mine(_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, clock, _) => clock
319
+ Orb(_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, clock, _) => clock
320
+ Star(_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, clock) => clock
321
+ _ => 0.0
322
+
323
+ fun textOf(a: Actor) -> Str =
324
+ match a
325
+ Label(_, _, _, _, _, _, _, _, _, _, _, _, _, _, text, _) => text
326
+ _ => ""
327
+
328
+ fun hudLabel(id: Int, text: Str, x: Float, y: Float, size: Float, c: Color) -> Actor =
329
+ l := PureScene.label(id, text, x, y, size)
330
+ l.color = c
331
+ l
332
+
333
+ fun icon(sheet: Int, i: Int) -> Actor =
334
+ s := PureScene.sprite(Id.Icon + i, sheet, Rules.ArenaW - 40.0 - Float(i) * 36.0, 12.0, 32.0, 32.0)
335
+ s.srcX = 0.0
336
+ s.srcY = 0.0
337
+ s.srcW = Rules.Cell
338
+ s.srcH = Rules.Cell
339
+ s
340
+
341
+ # The whole scene: backdrop, stars, mines, orbs, ship and HUD layers, in drawing order.
342
+ # The layer groups are 0 x 0 so they never swallow a click; the HUD holds the
343
+ # game-over overlay with its restart button.
344
+ fun buildRoot(rng: Random, sheet: Int, best: Int) -> Actor =
345
+ ship := newShip()
346
+ stars := List[Actor]()
347
+ for i := range 40
348
+ stars.push(Star.create(i))
349
+ mines := List[Actor]()
350
+ orbs := List[Actor]()
351
+ for i := range Rules.OrbCount
352
+ orbs.push(Orb.create(sheet, Float(i) * 0.1, Id.Orb + i, spawn(rng, ship.x, ship.y, 120.0, 40.0)))
353
+ for i := range mineCount(0)
354
+ mines.push(Mine.create(sheet, Id.Mine + i, spawn(rng, ship.x, ship.y, 220.0, 90.0), 2.0))
355
+ over := List[Actor]()
356
+ over.push(Panel.create(Id.OverlayPanel, 0.0, 230.0, Rules.ArenaW, 170.0, Color(r: 0.0, g: 0.0, b: 0.0, a: 0.6)))
357
+ over.push(hudLabel(Id.OverTitle, "Game over", 300.0, 244.0, 48.0, Color.Red))
358
+ over.push(hudLabel(Id.OverHint, "Press Enter to play again", 270.0, 300.0, 24.0, Color.White))
359
+ btn := PureScene.button(Id.Restart, "Again", sheet, sheet, 340.0, 342.0, 120.0, 44.0)
360
+ btn.color = Color(r: 0.5, g: 0.8, b: 1.0, a: 0.9)
361
+ btn.visible = false
362
+ over.push(btn)
363
+ overlay := PureScene.group(Id.Overlay, 0.0, 0.0, 0.0, 0.0, over)
364
+ overlay.visible = false
365
+ hud := List[Actor]()
366
+ hud.push(hudLabel(Id.Score, "Score 0", 16.0, 12.0, 28.0, Color.White))
367
+ hud.push(hudLabel(Id.Best, "Best {best}", 16.0, 44.0, 20.0, Color.Yellow))
368
+ for i := range Rules.StartLives
369
+ hud.push(icon(sheet, i))
370
+ hud.push(overlay)
371
+ layers := List[Actor]()
372
+ layers.push(Panel.create(Id.Backdrop, 0.0, 0.0, Rules.ArenaW, Rules.ArenaH, Color.rgb(0.07, 0.08, 0.16)))
373
+ layers.push(PureScene.group(Id.StarLayer, 0.0, 0.0, 0.0, 0.0, stars))
374
+ layers.push(PureScene.group(Id.MineLayer, 0.0, 0.0, 0.0, 0.0, mines))
375
+ layers.push(PureScene.group(Id.OrbLayer, 0.0, 0.0, 0.0, 0.0, orbs))
376
+ layers.push(Ship.create(sheet, ship))
377
+ layers.push(PureScene.group(Id.Hud, 0.0, 0.0, 0.0, 0.0, hud))
378
+ PureScene.group(Id.Root, 0.0, 0.0, Rules.ArenaW, Rules.ArenaH, layers)
379
+
380
+ # --- the world ---
381
+
382
+ # The host-facing shell: the current pure `Stage` plus the run's state. It is the
383
+ # only thing that is replaced from frame to frame.
384
+ record World(
385
+ stage: Stage,
386
+ ship: ShipState,
387
+ rng: Random,
388
+ mines: Int,
389
+ score: Int,
390
+ best: Int,
391
+ lives: Int,
392
+ over: Bool,
393
+ clock: Float,
394
+ wasDown: Bool,
395
+ sheet: Int,
396
+ pickupSound: Int,
397
+ hitSound: Int,
398
+ overSound: Int,
399
+ musicSound: Int)
400
+
401
+ fun newWorld(seed: Int, sheet: Int) -> World =
402
+ rng := Random(seed: seed)
403
+ root := buildRoot(rng, sheet, 0)
404
+ World(
405
+ stage: SceneInput.stage(root), ship: newShip(), rng: rng, mines: mineCount(0), score: 0, best: 0,
406
+ lives: Rules.StartLives, over: false, clock: 0.0, wasDown: false, sheet: sheet,
407
+ pickupSound: -1, hitSound: -1, overSound: -1, musicSound: -1)
408
+
409
+ fun swap(w: World, id: Int, f: fn(Actor) -> Actor) =
410
+ w.stage = withRoot(w.stage, SceneTree.modify(w.stage.root, id, f))
411
+
412
+ # Starts a fresh game, keeping the best score, the camera and the random stream.
413
+ fun restart(w: World) =
414
+ w.best = w.best.max(w.score)
415
+ w.score = 0
416
+ w.lives = Rules.StartLives
417
+ w.over = false
418
+ w.ship = newShip()
419
+ w.mines = mineCount(0)
420
+ w.stage = Stage(root: buildRoot(w.rng, w.sheet, w.best), camera: w.stage.camera, captured: null)
421
+
422
+ # Copies the score, best score, lives and game-over panel into the HUD actors.
423
+ fun syncHud(w: World) =
424
+ score := "Score {w.score}"
425
+ best := "Best {w.best.max(w.score)}"
426
+ swap(w, Id.Score, |a| hudLabel(Id.Score, score, 16.0, 12.0, 28.0, Color.White))
427
+ swap(w, Id.Best, |a| hudLabel(Id.Best, best, 16.0, 44.0, 20.0, Color.Yellow))
428
+ for i := range Rules.StartLives
429
+ shown := i < w.lives
430
+ swap(w, Id.Icon + i, |a| setVisible(a, shown))
431
+ over := w.over
432
+ swap(w, Id.Overlay, |a| setVisible(a, over))
433
+ swap(w, Id.Restart, |a| setVisible(a, over))
434
+
435
+ # Stops every orb and mine: after game over they only animate.
436
+ fun freeze(w: World) =
437
+ for i := range Rules.OrbCount
438
+ id := Id.Orb + i
439
+ phase := Float(i) * 0.1
440
+ sheet := w.sheet
441
+ swap(w, id, |a| orbKeepClock(sheet, phase, id, Motion(x: cx(a), y: cy(a), vx: 0.0, vy: 0.0), a))
442
+ for i := range w.mines
443
+ id := Id.Mine + i
444
+ sheet := w.sheet
445
+ swap(w, id, |a| stillMine(sheet, id, a))
446
+
447
+ fun orbActor(sheet: Int, phase: Float, id: Int, at: Motion) -> Actor =
448
+ Orb.create(sheet, phase, id, at)
449
+
450
+ # A new orb that carries on `old`'s animation clock, so a respawn or freeze never jumps a frame.
451
+ fun orbKeepClock(sheet: Int, phase: Float, id: Int, at: Motion, old: Actor) -> Actor =
452
+ o := Orb.create(sheet, phase, id, at)
453
+ o.clock = clockOf(old)
454
+ o
455
+
456
+ fun mineActor(sheet: Int, id: Int, at: Motion, spin: Float) -> Actor =
457
+ Mine.create(sheet, id, at, spin)
458
+
459
+ fun stillMine(sheet: Int, id: Int, a: Actor) -> Actor =
460
+ m := Mine.create(sheet, id, Motion(x: cx(a), y: cy(a), vx: 0.0, vy: 0.0), 0.0)
461
+ m.rotation = a.rotation
462
+ m.clock = clockOf(a)
463
+ m
464
+
465
+ # Moves the ship actor to the ship state; it blinks while it is hurt, and is hidden once the game is over.
466
+ fun showShip(w: World) =
467
+ blink := w.ship.hurt > 0.0 && frameOf(w.clock, 2, 10.0) == 0
468
+ visible := !w.over && !blink
469
+ s := w.ship
470
+ swap(w, Id.Ship, |a| shipActor(a, s, visible))
471
+
472
+ fun shipActor(a: Actor, s: ShipState, visible: Bool) -> Actor =
473
+ c := a.clone()
474
+ c.x = s.x - Rules.Half
475
+ c.y = s.y - Rules.Half
476
+ c.rotation = s.rotation
477
+ c.visible = visible
478
+ c
479
+
480
+ fun overlaps(ax: Float, ay: Float, ra: Float, bx: Float, by: Float, rb: Float) -> Bool =
481
+ Math.hypot(ax - bx, ay - by) < ra + rb
482
+
483
+ # One frame: the stage takes the pointer `inputs` and advances every actor by `dt`
484
+ # (a click on the restart button restarts), then the rules look at the result. Once
485
+ # the game is over the actors only animate. Returns true when it restarted.
486
+ fun advance(w: World, ax: Float, ay: Float, dt: Float, inputs: List[Input]) -> Bool =
487
+ w.clock = w.clock + dt
488
+ h := w.stage.frame(dt, inputs)
489
+ w.stage = h.stage
490
+ for i := range h.events.length()
491
+ e := h.events.at(i)
492
+ if e.id == Id.Restart && e.kind == Click && w.over
493
+ restart(w)
494
+ return true
495
+ if w.over
496
+ return false
497
+ w.ship = shipStep(w.ship, ax, ay, dt)
498
+ ship := w.ship
499
+ changed := false
500
+ for i := range Rules.OrbCount
501
+ orb := find(w.stage.root, Id.Orb + i)
502
+ if overlaps(ship.x, ship.y, Rules.ShipRadius, cx(orb), cy(orb), Rules.OrbRadius)
503
+ w.score = w.score + Rules.OrbPoints
504
+ changed = true
505
+ at := spawn(w.rng, ship.x, ship.y, 120.0, 40.0)
506
+ sheet := w.sheet
507
+ id := Id.Orb + i
508
+ phase := Float(i) * 0.1
509
+ swap(w, id, |a| orbKeepClock(sheet, phase, id, at, a))
510
+ while w.mines < mineCount(w.score)
511
+ fresh := Mine.create(w.sheet, Id.Mine + w.mines, spawn(w.rng, ship.x, ship.y, 220.0, 90.0), 2.0)
512
+ swap(w, Id.MineLayer, |g| addKid(g, fresh))
513
+ w.mines = w.mines + 1
514
+ for i := range w.mines
515
+ mine := find(w.stage.root, Id.Mine + i)
516
+ if w.ship.hurt <= 0.0 && overlaps(ship.x, ship.y, Rules.ShipRadius, cx(mine), cy(mine), Rules.MineRadius)
517
+ w.lives = w.lives - 1
518
+ cur := w.ship
519
+ s := ShipState(..cur)
520
+ s.hurt = Rules.HurtTime
521
+ w.ship = s
522
+ changed = true
523
+ if w.lives <= 0
524
+ w.over = true
525
+ w.best = w.best.max(w.score)
526
+ if changed
527
+ syncHud(w)
528
+ if w.over
529
+ freeze(w)
530
+ showShip(w)
531
+ false
532
+
533
+ # One simulation step without pointer input.
534
+ fun step(w: World, ax: Float, ay: Float, dt: Float) -> Bool =
535
+ advance(w, ax, ay, dt, List[Input]())
536
+
537
+ # The camera that fits the arena into a screen of `sw` by `sh` pixels, centred.
538
+ fun fitCamera(sw: Float, sh: Float) -> Affine =
539
+ zoom := {sw / Rules.ArenaW}.min(sh / Rules.ArenaH)
540
+ Affine(a: zoom, b: 0.0, c: 0.0, d: zoom, e: {sw - zoom * Rules.ArenaW} / 2.0, f: {sh - zoom * Rules.ArenaH} / 2.0)
541
+
542
+ fun fit(w: World, sw: Float, sh: Float) =
543
+ s := w.stage
544
+ w.stage = Stage(root: s.root, camera: fitCamera(sw, sh), captured: s.captured)
545
+
546
+ # --- host glue: input, sound, drawing ---
547
+
548
+ fun musicVolume() -> Float =
549
+ 0.3
550
+
551
+ # Restarts the music loop from the top.
552
+ fun startMusic(w: World) =
553
+ soundStop(w.musicSound)
554
+ soundLoop(w.musicSound, musicVolume())
555
+
556
+ export fun init() -> World =
557
+ w := newWorld(2024, textureLoad("sprites.png"))
558
+ w.pickupSound = soundLoad("pickup.wav")
559
+ w.hitSound = soundLoad("hit.wav")
560
+ w.overSound = soundLoad("gameover.wav")
561
+ w.musicSound = soundLoad("music.wav")
562
+ startMusic(w)
563
+ w
564
+
565
+ fun axis(negative: Bool, positive: Bool) -> Float =
566
+ if negative && !positive
567
+ -1.0
568
+ else if positive && !negative
569
+ 1.0
570
+ else
571
+ 0.0
572
+
573
+ export fun update(w: World, dt: Float) =
574
+ if w.over && {isKeyPressed(Key.Enter) || isKeyPressed(Key.KeyR)}
575
+ restart(w)
576
+ startMusic(w)
577
+ inputs := SceneInput.pollPointer(w.wasDown)
578
+ w.wasDown = isMouseDown(0)
579
+ left := isKeyDown(Key.Left) || isKeyDown(Key.KeyA)
580
+ right := isKeyDown(Key.Right) || isKeyDown(Key.KeyD)
581
+ up := isKeyDown(Key.Up) || isKeyDown(Key.KeyW)
582
+ down := isKeyDown(Key.Down) || isKeyDown(Key.KeyS)
583
+ score := w.score
584
+ lives := w.lives
585
+ restarted := advance(w, axis(left, right), axis(up, down), dt, inputs)
586
+ if restarted
587
+ startMusic(w)
588
+ return
589
+ if w.score > score
590
+ soundPlay(w.pickupSound, 0.8)
591
+ if w.lives < lives
592
+ if w.over
593
+ soundStop(w.musicSound)
594
+ soundPlay(w.overSound, 1.0)
595
+ else
596
+ soundPlay(w.hitSound, 1.0)
597
+
598
+ export fun draw(w: World) =
599
+ fit(w, screenWidth(), screenHeight())
600
+ clear(Color.rgb(0.04, 0.05, 0.1))
601
+ w.stage.draw()
602
+
603
+ # --- tests ---
604
+
605
+ fun near(a: Float, b: Float) -> Bool =
606
+ {a - b}.abs() < 0.01
607
+
608
+ fun placeOrb(w: World, i: Int, m: Motion) =
609
+ sheet := w.sheet
610
+ id := Id.Orb + i
611
+ swap(w, id, |a| orbActor(sheet, Float(i) * 0.1, id, m))
612
+
613
+ fun placeMine(w: World, i: Int, m: Motion) =
614
+ sheet := w.sheet
615
+ id := Id.Mine + i
616
+ swap(w, id, |a| mineActor(sheet, id, m, 2.0))
617
+
618
+ fun away() -> Motion =
619
+ Motion(x: 5000.0, y: 0.0, vx: 0.0, vy: 0.0)
620
+
621
+ # A world with the orbs and mines moved out of the way.
622
+ fun emptyWorld() -> World =
623
+ w := newWorld(7, -1)
624
+ for i := range Rules.OrbCount
625
+ placeOrb(w, i, away())
626
+ for i := range w.mines
627
+ placeMine(w, i, away())
628
+ w
629
+
630
+ fun onShip(w: World) -> Motion =
631
+ Motion(x: w.ship.x, y: w.ship.y, vx: 0.0, vy: 0.0)
632
+
633
+ fun forceOver(w: World) =
634
+ w.lives = 0
635
+ w.over = true
636
+ syncHud(w)
637
+
638
+ test "steering accelerates and drag slows the ship"
639
+ s := shipStep(newShip(), 1.0, 0.0, 0.1)
640
+ assert s.vx > 0.0
641
+ assert s.x > Rules.ArenaW / 2.0
642
+ fast := s.vx
643
+ t := s
644
+ for i := range 30
645
+ t = shipStep(t, 0.0, 0.0, 0.1)
646
+ assert t.vx < fast / 4.0
647
+
648
+ test "the ship stops at the arena walls"
649
+ s := ShipState(x: 20.0, y: 20.0, vx: 0.0, vy: 0.0, rotation: 0.0, hurt: 0.0)
650
+ for i := range 20
651
+ s = shipStep(s, -1.0, -1.0, 0.1)
652
+ assert s.x == Rules.ShipRadius
653
+ assert s.y == Rules.ShipRadius
654
+ assert s.vx == 0.0
655
+
656
+ test "shipStep leaves its input unchanged"
657
+ s := newShip()
658
+ _ := shipStep(s, 1.0, 1.0, 0.1)
659
+ assert s.vx == 0.0
660
+ assert s.x == Rules.ArenaW / 2.0
661
+
662
+ test "drifters bounce off the walls"
663
+ b := driftOf(Motion(x: 795.0, y: 300.0, vx: 100.0, vy: 0.0), Rules.OrbRadius, 0.1)
664
+ assert b.vx == -100.0
665
+ assert b.x == Rules.ArenaW - 10.0
666
+ orb := Orb.create(-1, 0.0, Id.Orb, Motion(x: 795.0, y: 300.0, vx: 100.0, vy: 0.0)).act(0.1)
667
+ assert near(cx(orb), Rules.ArenaW - 10.0)
668
+
669
+ test "a mine spins as it drifts"
670
+ mine := Mine.create(-1, Id.Mine, Motion(x: 400.0, y: 300.0, vx: 10.0, vy: 0.0), 2.0)
671
+ next := mine.act(0.5)
672
+ assert near(next.rotation, 1.0)
673
+ assert near(cx(next), 405.0)
674
+ assert near(mine.rotation, 0.0)
675
+
676
+ test "the scene has a backdrop, stars, mines, orbs, ship and HUD layers"
677
+ w := newWorld(7, -1)
678
+ root := w.stage.root
679
+ assert kidsOf(root).length() == 6
680
+ assert kidsOf(kidsOf(root).at(1)).length() == 40
681
+ assert kidsOf(kidsOf(root).at(2)).length() == mineCount(0)
682
+ assert kidsOf(kidsOf(root).at(3)).length() == Rules.OrbCount
683
+ assert kidsOf(root).at(4).id == Id.Ship
684
+ assert kidsOf(root).at(2).id == Id.MineLayer
685
+ assert kidsOf(root).at(3).id == Id.OrbLayer
686
+ assert kidsOf(root).at(5).id == Id.Hud
687
+
688
+ test "collecting an orb scores and respawns it elsewhere, still in the orbs layer"
689
+ w := emptyWorld()
690
+ placeOrb(w, 0, onShip(w))
691
+ _ := step(w, 0.0, 0.0, 0.016)
692
+ orb := find(w.stage.root, Id.Orb)
693
+ assert w.score == Rules.OrbPoints
694
+ assert Math.hypot(cx(orb) - w.ship.x, cy(orb) - w.ship.y) >= 120.0
695
+ assert orb.x < 5000.0
696
+ assert kidsOf(find(w.stage.root, Id.OrbLayer)).length() == Rules.OrbCount
697
+ assert textOf(find(w.stage.root, Id.Score)) == "Score 10"
698
+
699
+ test "a mine costs one life and makes the ship flash"
700
+ w := emptyWorld()
701
+ placeMine(w, 0, onShip(w))
702
+ _ := step(w, 0.0, 0.0, 0.016)
703
+ assert w.lives == Rules.StartLives - 1
704
+ assert w.ship.hurt > 0.0
705
+ assert !find(w.stage.root, Id.Icon + 2).visible
706
+ assert find(w.stage.root, Id.Icon + 1).visible
707
+ placeMine(w, 0, away())
708
+ hidden := 0
709
+ shown := 0
710
+ for i := range 100
711
+ _ := step(w, 0.0, 0.0, 0.016)
712
+ if find(w.stage.root, Id.Ship).visible
713
+ shown = shown + 1
714
+ else
715
+ hidden = hidden + 1
716
+ assert hidden > 0
717
+ assert shown > 0
718
+ assert w.ship.hurt == 0.0
719
+ assert find(w.stage.root, Id.Ship).visible
720
+ assert w.lives == Rules.StartLives - 1
721
+
722
+ test "the ship is briefly safe after a hit"
723
+ w := emptyWorld()
724
+ placeMine(w, 0, onShip(w))
725
+ _ := step(w, 0.0, 0.0, 0.016)
726
+ _ := step(w, 0.0, 0.0, 0.016)
727
+ assert w.lives == Rules.StartLives - 1
728
+
729
+ test "the game ends at zero lives and then ignores steps"
730
+ w := emptyWorld()
731
+ w.lives = 1
732
+ w.score = 40
733
+ placeMine(w, 0, onShip(w))
734
+ _ := step(w, 0.0, 0.0, 0.016)
735
+ assert w.over
736
+ assert w.lives == 0
737
+ assert w.best == 40
738
+ assert find(w.stage.root, Id.Overlay).visible
739
+ assert !find(w.stage.root, Id.Ship).visible
740
+ x := w.ship.x
741
+ _ := step(w, 1.0, 0.0, 0.5)
742
+ assert w.ship.x == x
743
+
744
+ test "after game over nothing moves but the animations keep running"
745
+ w := emptyWorld()
746
+ w.lives = 1
747
+ placeMine(w, 0, Motion(x: w.ship.x, y: w.ship.y, vx: 10.0, vy: 0.0))
748
+ _ := step(w, 0.0, 0.0, 0.016)
749
+ assert w.over
750
+ mine := find(w.stage.root, Id.Mine)
751
+ x := mine.x
752
+ rot := mine.rotation
753
+ clock := clockOf(mine)
754
+ orbClock := clockOf(find(w.stage.root, Id.Orb))
755
+ for i := range 10
756
+ _ := step(w, 1.0, 0.0, 0.1)
757
+ after := find(w.stage.root, Id.Mine)
758
+ assert after.x == x
759
+ assert after.rotation == rot
760
+ assert w.ship.x == Rules.ArenaW / 2.0
761
+ assert !find(w.stage.root, Id.Ship).visible
762
+ assert clockOf(after) > clock + 0.9
763
+ assert clockOf(find(w.stage.root, Id.Orb)) > orbClock + 0.9
764
+ assert clockOf(find(w.stage.root, Id.Star)) > 0.9
765
+ assert w.score == 0
766
+
767
+ test "restart resets the run and keeps the best score"
768
+ w := emptyWorld()
769
+ w.score = 90
770
+ forceOver(w)
771
+ restart(w)
772
+ assert !w.over
773
+ assert !find(w.stage.root, Id.Overlay).visible
774
+ assert find(w.stage.root, Id.Ship).visible
775
+ assert w.score == 0
776
+ assert w.best == 90
777
+ assert w.lives == Rules.StartLives
778
+ assert w.ship.x == Rules.ArenaW / 2.0
779
+ assert w.mines == mineCount(0)
780
+ assert textOf(find(w.stage.root, Id.Best)) == "Best 90"
781
+
782
+ test "more mines appear as the score grows and restart removes them from the layer"
783
+ assert mineCount(0) == 3
784
+ assert mineCount(100) == 5
785
+ assert mineCount(10000) == 8
786
+ w := emptyWorld()
787
+ w.score = 100
788
+ _ := step(w, 0.0, 0.0, 0.016)
789
+ assert w.mines == 5
790
+ assert kidsOf(find(w.stage.root, Id.MineLayer)).length() == 5
791
+ restart(w)
792
+ assert w.mines == 3
793
+ assert kidsOf(find(w.stage.root, Id.MineLayer)).length() == 3
794
+
795
+ test "the same seed plays out the same way"
796
+ a := newWorld(11, -1)
797
+ b := newWorld(11, -1)
798
+ for i := range 120
799
+ _ := step(a, 1.0, -1.0, 0.016)
800
+ _ := step(b, 1.0, -1.0, 0.016)
801
+ assert a.ship.x == b.ship.x
802
+ assert a.score == b.score
803
+ assert find(a.stage.root, Id.Mine).x == find(b.stage.root, Id.Mine).x
804
+ assert newWorld(12, -1).stage.root.id == Id.Root
805
+ assert find(newWorld(12, -1).stage.root, Id.Orb).x != find(a.stage.root, Id.Orb).x
806
+
807
+ test "fit centres the arena on the screen"
808
+ w := newWorld(7, -1)
809
+ fit(w, 1600.0, 600.0)
810
+ assert near(w.stage.camera.a, 1.0)
811
+ assert near(w.stage.camera.e, 400.0)
812
+ assert near(w.stage.camera.f, 0.0)
813
+
814
+ test "animation frames cycle"
815
+ assert frameOf(0.0, 4, 8.0) == 0
816
+ assert frameOf(0.13, 4, 8.0) == 1
817
+ assert frameOf(0.5, 4, 8.0) == 0
818
+ assert axis(true, false) == -1.0
819
+ assert axis(true, true) == 0.0
820
+
821
+ test "restart_button_hidden_while_playing"
822
+ w := newWorld(7, -1)
823
+ assert !find(w.stage.root, Id.Restart).visible
824
+
825
+ test "restart_button_click_restarts"
826
+ w := emptyWorld()
827
+ w.score = 30
828
+ forceOver(w)
829
+ assert find(w.stage.root, Id.Restart).visible
830
+ down := Input(kind: PointerDown, x: 400.0, y: 364.0)
831
+ up := Input(kind: PointerUp, x: 400.0, y: 364.0)
832
+ taps := List[Input]()
833
+ taps.push(down)
834
+ taps.push(up)
835
+ restarted := advance(w, 0.0, 0.0, 0.016, taps)
836
+ assert restarted
837
+ assert w.lives == Rules.StartLives
838
+ assert w.score == 0
839
+ assert !w.over
840
+ assert !find(w.stage.root, Id.Restart).visible
841
+
842
+ test "a click on the restart button while playing does nothing"
843
+ w := emptyWorld()
844
+ w.score = 30
845
+ taps := List[Input]()
846
+ taps.push(Input(kind: PointerDown, x: 400.0, y: 364.0))
847
+ taps.push(Input(kind: PointerUp, x: 400.0, y: 364.0))
848
+ restarted := advance(w, 0.0, 0.0, 0.016, taps)
849
+ assert !restarted
850
+ assert w.score == 30
851
+
852
+ test "world_update_is_pure"
853
+ w := emptyWorld()
854
+ before := w.stage
855
+ x := find(before.root, Id.Ship).x
856
+ _ := step(w, 1.0, 0.0, 0.1)
857
+ assert find(w.stage.root, Id.Ship).x > x
858
+ assert find(before.root, Id.Ship).x == x
859
+
860
+ test "freezing at game over keeps the orb and mine animation clocks"
861
+ w := emptyWorld()
862
+ for i := range 5
863
+ _ := step(w, 0.0, 0.0, 0.1)
864
+ orbClock := clockOf(find(w.stage.root, Id.Orb))
865
+ mineClock := clockOf(find(w.stage.root, Id.Mine))
866
+ assert orbClock > 0.4
867
+ w.lives = 1
868
+ placeMine(w, 1, onShip(w))
869
+ _ := step(w, 0.0, 0.0, 0.1)
870
+ assert w.over
871
+ assert clockOf(find(w.stage.root, Id.Orb)) > orbClock
872
+ assert clockOf(find(w.stage.root, Id.Mine)) > mineClock
plum-examples/struct_union_members.plum ADDED
@@ -0,0 +1,76 @@
1
+ # Struct and record members inside enum unions, and trait-embedding records
2
+ # copied with spread. A struct is a value type, so a spread or a match-out copy
3
+ # never changes the source; a record is a reference type, so the spread builds a
4
+ # new record and leaves the source alone.
5
+ module struct_union_members
6
+ import std/Str
7
+ import std/Int
8
+ import std/Float
9
+ import std/List
10
+
11
+ enum Step = Move | Wait | Chain
12
+
13
+ struct Move(dx: Float, left: Float)
14
+
15
+ struct Wait(left: Float)
16
+
17
+ record Chain(steps: List[Step])
18
+
19
+ trait Thing(x: Float, tag: Int) =
20
+ fun clone(self) -> Thing
21
+
22
+ record Box(Thing, size: Float) =
23
+ fun clone(self) -> Thing = Box(..self)
24
+
25
+ fun dxOf(s: Step) -> Float =
26
+ match s
27
+ Move(dx, _) => dx
28
+ _ => 0.0
29
+
30
+ fun leftOf(s: Step) -> Float =
31
+ match s
32
+ Move(_, l) => l
33
+ Wait(l) => l
34
+ _ => 0.0
35
+
36
+ fun copyOut(s: Step) -> Move =
37
+ match s
38
+ Move(dx, l) => Move(dx: dx, left: l)
39
+ _ => Move(dx: 0.0, left: 0.0)
40
+
41
+ test "struct_member_through_list"
42
+ steps: List[Step] := List[Step]()
43
+ steps.push(Move(1.0, 2.0))
44
+ steps.push(Wait(3.0))
45
+ c := Chain(steps: steps)
46
+ assert dxOf(c.steps.at(0)) == 1.0
47
+ assert leftOf(c.steps.at(1)) == 3.0
48
+
49
+ test "struct_member_spread_copy"
50
+ m := Move(dx: 1.0, left: 2.0)
51
+ n := Move(..m, left: 0.5)
52
+ assert n.left == 0.5
53
+ assert m.left == 2.0
54
+
55
+ test "struct_member_match_copy_is_independent"
56
+ steps: List[Step] := List[Step]()
57
+ steps.push(Move(dx: 1.0, left: 2.0))
58
+ mv := copyOut(steps.at(0))
59
+ mv.left = 9.0
60
+ assert mv.left == 9.0
61
+ assert leftOf(steps.at(0)) == 2.0
62
+
63
+ test "trait_record_spread_copies_trait_fields"
64
+ b := Box(x: 1.0, tag: 7, size: 2.0)
65
+ c := Box(..b, size: 3.0)
66
+ assert c.x == 1.0
67
+ assert c.tag == 7
68
+ c.x = 9.0
69
+ assert b.x == 1.0
70
+
71
+ test "trait_value_clone_via_method"
72
+ b := Box(x: 1.0, tag: 7, size: 2.0)
73
+ t: Thing := b
74
+ u := t.clone()
75
+ u.x = 5.0
76
+ assert t.x == 1.0
plum-game/tests/host_test.rs CHANGED
@@ -263,6 +263,57 @@ fn sceneExampleRunsFrames() {
263
263
  assert!(g.backend().calls.iter().any(|c| c.starts_with("draw")), "{:?}", g.backend().calls);
264
264
  }
265
265
 
266
+ #[test]
267
+ fn pureSceneExampleRunsFrames() {
268
+ let wasm = compile(&root().join("plum-examples/game_pure_scene.plum"));
269
+ let mut g = PlumGame::load(&wasm, Recorder::with_assets(examples_assets())).expect("load game");
270
+ g.init().expect("init");
271
+ for _ in 0..10 {
272
+ g.update(1.0 / 60.0).expect("update");
273
+ }
274
+ g.draw().expect("draw");
275
+ assert!(g.backend().calls.iter().any(|c| c.starts_with("draw")), "{:?}", g.backend().calls);
276
+ }
277
+
278
+ #[test]
279
+ fn pureSceneMatchesSceneDrawCounts() {
280
+ let load = |name: &str| {
281
+ let wasm = compile(&root().join(name));
282
+ let mut g = PlumGame::load(&wasm, Recorder::with_assets(examples_assets())).expect("load game");
283
+ g.init().expect("init");
284
+ g
285
+ };
286
+ let mut a = load("plum-examples/game_scene.plum");
287
+ let mut b = load("plum-examples/game_pure_scene.plum");
288
+ let tex_ids = |calls: &[String]| {
289
+ let mut v: Vec<String> = calls
290
+ .iter()
291
+ .filter(|c| c.starts_with("drawTextureEx("))
292
+ .map(|c| c["drawTextureEx(".len()..].split(',').next().unwrap().to_string())
293
+ .collect();
294
+ v.sort();
295
+ v.dedup();
296
+ v
297
+ };
298
+ let count = |calls: &[String], p: &str| calls.iter().filter(|c| c.starts_with(p)).count();
299
+ for frame in 0..60 {
300
+ let mut calls = Vec::new();
301
+ for g in [&mut a, &mut b] {
302
+ g.backend_mut().calls.clear();
303
+ g.update(1.0 / 60.0).expect("update");
304
+ g.draw().expect("draw");
305
+ calls.push(g.backend().calls.clone());
306
+ }
307
+ let (ca, cb) = (&calls[0], &calls[1]);
308
+ assert!(
309
+ count(ca, "drawTextureEx(") == count(cb, "drawTextureEx(")
310
+ && count(ca, "drawText(") == count(cb, "drawText(")
311
+ && tex_ids(ca) == tex_ids(cb),
312
+ "frame {frame}\nscene: {ca:?}\npure: {cb:?}"
313
+ );
314
+ }
315
+ }
316
+
266
317
  #[test]
267
318
  fn renderTargetAndBlendCallsAreRecorded() {
268
319
  let wasm = compile(&Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/target_fixture.plum"));
plum-game/web-test/fixtures/stress_pure_scene.plum ADDED
@@ -0,0 +1,159 @@
1
+ # The stress scene of stress_scene.plum on the PURE scene graph (std/game/PureScene):
2
+ # 5000 sprites in 50 slowly rotating groups, 500 rects and 200 circles (sprites of a
3
+ # 1x1 white texture), 50 texts with a changing number, drawn through a camera. Every
4
+ # actor is a value: each frame builds a new tree with SceneTree.update. Group and
5
+ # sprite spin comes from `forever(rotateBy(...))` actions; drifting and counting
6
+ # actors do theirs in `act`.
7
+ module stress_pure_scene
8
+ import std/Str
9
+ import std/Int
10
+ import std/Float
11
+ import std/List
12
+ import std/game/Vec2
13
+ import std/game/Color
14
+ import std/game/Affine
15
+ import std/game/Game
16
+ import std/game/Actions
17
+ import std/game/PureScene
18
+ import std/game/SceneTree
19
+ import std/game/SceneInput
20
+
21
+ primitive Counts =
22
+ Sprites = 5000
23
+ Rects = 500
24
+ Circles = 200
25
+ Texts = 50
26
+ Groups = 50
27
+ WorldW = 1600
28
+ WorldH = 900
29
+
30
+ record World(sheet: Int, stage: Stage)
31
+
32
+ fun hue(i: Int) -> Color =
33
+ Color(r: Float(i % 7) / 7.0, g: Float(i % 5) / 5.0, b: 0.4 + Float(i % 3) / 5.0, a: 0.8)
34
+
35
+ fun quad(m: Affine, tint: Color, w: Float, h: Float, tex: Int, srcX: Float, srcY: Float, srcW: Float, srcH: Float) =
36
+ sx := w * m.scaleX()
37
+ sy := h * m.scaleY()
38
+ c := m.apply(Vec2(x: w / 2.0, y: h / 2.0))
39
+ drawTextureEx(tex, srcX, srcY, srcW, srcH, c.x - sx / 2.0, c.y - sy / 2.0, sx, sy, m.rotation(), false, tint)
40
+
41
+ fun caption(m: Affine, tint: Color, s: Str, size: Float) =
42
+ p := m.apply(Vec2(x: 0.0, y: 0.0))
43
+ drawText(s, p.x, p.y, size * m.scaleY(), tint)
44
+
45
+ # A square of the white texture that moves by (dx, dy) per frame and wraps.
46
+ record Drifter(Actor, tex: Int, dx: Float, dy: Float) =
47
+ fun clone(self) -> Actor = Drifter(..self)
48
+
49
+ fun act(self, dt: Float) -> Actor =
50
+ c := Drifter(..self)
51
+ c.x = self.x + self.dx
52
+ c.y = self.y + self.dy
53
+ if c.x > Float(Counts.WorldW)
54
+ c.x = c.x - Float(Counts.WorldW)
55
+ if c.y > Float(Counts.WorldH)
56
+ c.y = c.y - Float(Counts.WorldH)
57
+ me: Actor := c
58
+ me
59
+
60
+ fun draw(self, m: Affine, tint: Color) =
61
+ quad(m, tint, self.width, self.height, self.tex, 0.0, 0.0, 0.0, 0.0)
62
+
63
+ # Shows "n <count>" and counts up each frame.
64
+ record CountLabel(Actor, n: Int, text: Str) =
65
+ fun clone(self) -> Actor = CountLabel(..self)
66
+
67
+ fun act(self, dt: Float) -> Actor =
68
+ c := CountLabel(..self)
69
+ c.n = self.n + 1
70
+ c.text = "n {c.n}"
71
+ me: Actor := c
72
+ me
73
+
74
+ fun draw(self, m: Affine, tint: Color) =
75
+ caption(m, tint, self.text, 14.0)
76
+
77
+ fun drifter(id: Int, tex: Int, c: Color, x: Float, y: Float, w: Float, h: Float, dx: Float, dy: Float) -> Actor =
78
+ d := Drifter(
79
+ id: id, x: x, y: y, width: w, height: h, originX: 0.0, originY: 0.0,
80
+ scaleX: 1.0, scaleY: 1.0, rotation: 0.0, color: c, visible: true,
81
+ touchable: Disabled, actions: List[Action](), tex: tex, dx: dx, dy: dy)
82
+ me: Actor := d
83
+ me
84
+
85
+ fun countLabel(id: Int, n: Int, x: Float, y: Float) -> Actor =
86
+ l := CountLabel(
87
+ id: id, x: x, y: y, width: 60.0, height: 14.0, originX: 0.0, originY: 0.0,
88
+ scaleX: 1.0, scaleY: 1.0, rotation: 0.0, color: Color.White, visible: true,
89
+ touchable: Disabled, actions: List[Action](), n: n, text: "n {n}")
90
+ me: Actor := l
91
+ me
92
+
93
+ fun spinning(rate: Float) -> List[Action] =
94
+ acts := List[Action]()
95
+ acts.push(Actions.forever(Actions.rotateBy(rate, 1.0)))
96
+ acts
97
+
98
+ fun dancer(id: Int, sheet: Int, j: Int, i: Int) -> Actor =
99
+ s := PureScene.sprite(id, sheet, Float({j * j * 13 + j * 37} % 150), Float({j * j * 29 + j * 53} % 170), 16.0, 16.0)
100
+ s.originX = 8.0
101
+ s.originY = 8.0
102
+ s.rotation = Float(i)
103
+ s.srcX = Float(i % 4 * 16)
104
+ s.srcW = 16.0
105
+ s.srcH = 16.0
106
+ s.actions = spinning(1.2)
107
+ me: Actor := s
108
+ me
109
+
110
+ fun buildWorld(sheet: Int, white: Int) -> World =
111
+ root := List[Actor]()
112
+ per := Counts.Sprites / Counts.Groups
113
+ for gi := range Counts.Groups
114
+ kids := List[Actor]()
115
+ for j := range per
116
+ i := gi * per + j
117
+ kids.push(dancer(1000 + i, sheet, j, i))
118
+ g := PureScene.group(100 + gi, Float(gi % 10 * 160), Float(gi / 10 * 180), 150.0, 170.0, kids)
119
+ g.originX = 75.0
120
+ g.originY = 85.0
121
+ g.actions = spinning(0.24)
122
+ me: Actor := g
123
+ root.push(me)
124
+ for i := range Counts.Rects
125
+ root.push(drifter(10000 + i, white, hue(i), Float(i * 61 % Counts.WorldW), Float(i * 29 % Counts.WorldH), 12.0, 8.0, 2.0, 0.0))
126
+ for i := range Counts.Circles
127
+ root.push(drifter(20000 + i, white, hue(i + 3), Float(i * 83 % Counts.WorldW), Float(i * 47 % Counts.WorldH), 12.0, 12.0, 3.0, 1.0))
128
+ for i := range Counts.Texts
129
+ root.push(countLabel(30000 + i, i, Float(i * 30 % Counts.WorldW), Float(i * 17 % Counts.WorldH)))
130
+ top: Actor := PureScene.group(1, 0.0, 0.0, Float(Counts.WorldW), Float(Counts.WorldH), root)
131
+ cam := Affine(a: 0.8, b: 0.0, c: 0.0, d: 0.8, e: 0.0, f: 0.0)
132
+ World(sheet: sheet, stage: Stage(root: top, camera: cam, captured: null))
133
+
134
+ fun whiteTexture() -> Int =
135
+ t := textureCreate(1, 1)
136
+ renderTarget(t)
137
+ clear(Color.White)
138
+ renderTarget(-1)
139
+ t
140
+
141
+ export fun init() -> World =
142
+ buildWorld(textureLoad("sprites.png"), whiteTexture())
143
+
144
+ export fun update(w: World, dt: Float) =
145
+ w.stage = w.stage.frame(dt, List[Input]()).stage
146
+
147
+ export fun draw(w: World) =
148
+ clear(Color.rgb(0.05, 0.06, 0.1))
149
+ w.stage.draw()
150
+
151
+ test "ten frames of update run"
152
+ w := buildWorld(0, 0)
153
+ for i := range 10
154
+ update(w, 0.016)
155
+ if k := w.stage.root.kids()
156
+ assert k.length() == 50 + 500 + 200 + 50
157
+ assert {k.at(50).x - 20.0}.abs() < 0.000001
158
+ else
159
+ assert false
plum-game/web-test/tests/web_check.rs CHANGED
@@ -10,6 +10,11 @@ fn orb_run_scene_renders_and_animates() {
10
10
  orb_run("plum-examples/game_scene.plum");
11
11
  }
12
12
 
13
+ #[test]
14
+ fn pure_scene_renders_and_animates() {
15
+ orb_run("plum-examples/game_pure_scene.plum");
16
+ }
17
+
13
18
  #[test]
14
19
  fn scene_run_renders_and_animates() {
15
20
  orb_run("plum-examples/scene.plum");
plum-std/game/Actions.plum ADDED
@@ -0,0 +1,225 @@
1
+ module std
2
+ import std/List
3
+ import std/Int
4
+ import std/Float
5
+
6
+ # scene2d-style actions as plain data plus a pure step function. An action is a
7
+ # value; `Actions.stepAction(pose, action, dt)` returns a NEW pose, the action
8
+ # that is left (null when finished) and the part of `dt` it did not use. Nothing
9
+ # here mutates its inputs. Sequences hand the spare time to the next step, so
10
+ # chained and looping animations do not drift.
11
+
12
+ # The animated part of an actor's transform.
13
+ struct Pose(x: Float, y: Float, rotation: Float, scaleX: Float, scaleY: Float)
14
+
15
+ struct MoveBy(dx: Float, dy: Float, duration: Float, elapsed: Float)
16
+
17
+ struct RotateBy(radians: Float, duration: Float, elapsed: Float)
18
+
19
+ struct Delay(duration: Float, elapsed: Float)
20
+
21
+ record Sequence(steps: List[Action])
22
+
23
+ record Parallel(steps: List[Action])
24
+
25
+ # `template` is the action restarted each lap, `current` the lap in progress.
26
+ record Forever(template: Action, current: Action)
27
+
28
+ enum Action = MoveBy | RotateBy | Delay | Sequence | Parallel | Forever
29
+
30
+ # How much of a timed action one step covers. A zero-length action finishes at
31
+ # once and applies all of itself (no 0/0).
32
+ struct Tick(used: Float, frac: Float, done: Bool)
33
+
34
+ # The result of stepping one action.
35
+ record Stepped(pose: Pose, left: Action?, spare: Float)
36
+
37
+ # The result of stepping actions side by side: the unfinished ones, and the
38
+ # smallest spare time among the finished ones.
39
+ record SteppedAll(pose: Pose, kept: List[Action], spare: Float)
40
+
41
+ primitive Actions =
42
+ fun moveBy(dx: Float, dy: Float, secs: Float) -> Action =
43
+ MoveBy(dx: dx, dy: dy, duration: secs, elapsed: 0.0)
44
+
45
+ fun rotateBy(radians: Float, secs: Float) -> Action =
46
+ RotateBy(radians: radians, duration: secs, elapsed: 0.0)
47
+
48
+ fun delay(secs: Float) -> Action =
49
+ Delay(duration: secs, elapsed: 0.0)
50
+
51
+ fun sequence(steps: List[Action]) -> Action =
52
+ Sequence(steps: steps)
53
+
54
+ fun parallel(steps: List[Action]) -> Action =
55
+ Parallel(steps: steps)
56
+
57
+ fun forever(a: Action) -> Action =
58
+ Forever(template: a, current: a)
59
+
60
+ fun tick(duration: Float, elapsed: Float, dt: Float) -> Tick =
61
+ if duration <= 0.0
62
+ return Tick(used: 0.0, frac: 1.0, done: true)
63
+ used := dt.min(duration - elapsed)
64
+ Tick(used: used, frac: used / duration, done: elapsed + used >= duration)
65
+
66
+ fun stepAction(p: Pose, a: Action, dt: Float) -> Stepped =
67
+ match a
68
+ MoveBy =>
69
+ k := Actions.tick(a.duration, a.elapsed, dt)
70
+ q := Pose(x: p.x + a.dx * k.frac, y: p.y + a.dy * k.frac, rotation: p.rotation, scaleX: p.scaleX, scaleY: p.scaleY)
71
+ if k.done
72
+ return Stepped(pose: q, left: null, spare: dt - k.used)
73
+ next: Action := MoveBy(dx: a.dx, dy: a.dy, duration: a.duration, elapsed: a.elapsed + k.used)
74
+ Stepped(pose: q, left: next, spare: 0.0)
75
+ RotateBy =>
76
+ k := Actions.tick(a.duration, a.elapsed, dt)
77
+ q := Pose(x: p.x, y: p.y, rotation: p.rotation + a.radians * k.frac, scaleX: p.scaleX, scaleY: p.scaleY)
78
+ if k.done
79
+ return Stepped(pose: q, left: null, spare: dt - k.used)
80
+ next: Action := RotateBy(radians: a.radians, duration: a.duration, elapsed: a.elapsed + k.used)
81
+ Stepped(pose: q, left: next, spare: 0.0)
82
+ Delay =>
83
+ k := Actions.tick(a.duration, a.elapsed, dt)
84
+ if k.done
85
+ return Stepped(pose: p, left: null, spare: dt - k.used)
86
+ next: Action := Delay(duration: a.duration, elapsed: a.elapsed + k.used)
87
+ Stepped(pose: p, left: next, spare: 0.0)
88
+ Sequence =>
89
+ if a.steps.length() == 0
90
+ return Stepped(pose: p, left: null, spare: dt)
91
+ s := Actions.stepAction(p, a.steps.at(0), dt)
92
+ rest := a.steps.drop(1)
93
+ if cur := s.left
94
+ kept := List[Action]()
95
+ kept.push(cur)
96
+ for i := range rest.length()
97
+ kept.push(rest.at(i))
98
+ seq: Action := Sequence(steps: kept)
99
+ return Stepped(pose: s.pose, left: seq, spare: 0.0)
100
+ # The rest starts in this same frame with the time left over; the list
101
+ # is one shorter on every call, so this terminates.
102
+ tail: Action := Sequence(steps: rest)
103
+ Actions.stepAction(s.pose, tail, s.spare)
104
+ Parallel =>
105
+ all := Actions.stepAll(p, a.steps, dt)
106
+ if all.kept.length() == 0
107
+ return Stepped(pose: all.pose, left: null, spare: all.spare)
108
+ par: Action := Parallel(steps: all.kept)
109
+ Stepped(pose: all.pose, left: par, spare: 0.0)
110
+ Forever =>
111
+ # Restarts at most once per step, with the spare time, so a zero-length
112
+ # template cannot spin forever inside one step.
113
+ s := Actions.stepAction(p, a.current, dt)
114
+ if cur := s.left
115
+ again: Action := Forever(template: a.template, current: cur)
116
+ return Stepped(pose: s.pose, left: again, spare: 0.0)
117
+ r := Actions.stepAction(s.pose, a.template, s.spare)
118
+ lap := a.template
119
+ if nxt := r.left
120
+ lap = nxt
121
+ again: Action := Forever(template: a.template, current: lap)
122
+ Stepped(pose: r.pose, left: again, spare: 0.0)
123
+
124
+ fun stepAll(p: Pose, acts: List[Action], dt: Float) -> SteppedAll =
125
+ pose := p
126
+ kept := List[Action]()
127
+ spare := dt
128
+ for i := range acts.length()
129
+ s := Actions.stepAction(pose, acts.at(i), dt)
130
+ pose = s.pose
131
+ if cur := s.left
132
+ kept.push(cur)
133
+ else
134
+ spare = spare.min(s.spare)
135
+ if kept.length() > 0
136
+ spare = 0.0
137
+ SteppedAll(pose: pose, kept: kept, spare: spare)
138
+
139
+ fun actNear(a: Float, b: Float) -> Bool =
140
+ {a - b}.abs() < 0.000001
141
+
142
+ fun actSeqLen(a: Action) -> Int =
143
+ match a
144
+ Sequence => a.steps.length()
145
+ _ => 0 - 1
146
+
147
+ fun actPose0() -> Pose =
148
+ Pose(x: 0.0, y: 0.0, rotation: 0.0, scaleX: 1.0, scaleY: 1.0)
149
+
150
+ test "stepAll moves partway and keeps the action"
151
+ r := Actions.stepAll(actPose0(), List(Actions.moveBy(10.0, 0.0, 1.0)), 0.5)
152
+ assert actNear(r.pose.x, 5.0)
153
+ assert r.kept.length() == 1
154
+ assert actNear(r.spare, 0.0)
155
+
156
+ test "stepAll finishes every action and reports no spare"
157
+ r := Actions.stepAll(actPose0(), List(Actions.moveBy(10.0, 0.0, 1.0), Actions.rotateBy(1.0, 0.5)), 1.0)
158
+ assert actNear(r.pose.x, 10.0)
159
+ assert actNear(r.pose.rotation, 1.0)
160
+ assert r.kept.length() == 0
161
+ assert actNear(r.spare, 0.0)
162
+
163
+ test "stepAll with no actions is a no-op"
164
+ r := Actions.stepAll(actPose0(), List[Action](), 0.016)
165
+ assert r.kept.length() == 0
166
+ assert actNear(r.spare, 0.016)
167
+ assert actNear(r.pose.x, 0.0)
168
+
169
+ test "zero_length_move_applies_at_once"
170
+ r := Actions.stepAction(actPose0(), Actions.moveBy(10.0, 0.0, 0.0), 0.016)
171
+ assert actNear(r.pose.x, 10.0)
172
+ assert r.left == null
173
+ assert actNear(r.spare, 0.016)
174
+
175
+ test "forever_rotate_no_drift"
176
+ a := Actions.forever(Actions.rotateBy(1.0, 1.0))
177
+ p := actPose0()
178
+ for i := range 625
179
+ r := Actions.stepAction(p, a, 0.016)
180
+ p = r.pose
181
+ assert r.left != null
182
+ if nxt := r.left
183
+ a = nxt
184
+ assert actNear(p.rotation, 10.0)
185
+
186
+ test "sequence_spills_spare_time"
187
+ a := Actions.sequence(List(Actions.moveBy(10.0, 0.0, 0.5), Actions.moveBy(0.0, 10.0, 0.5)))
188
+ r := Actions.stepAction(actPose0(), a, 0.75)
189
+ assert actNear(r.pose.x, 10.0)
190
+ assert actNear(r.pose.y, 5.0)
191
+ assert actNear(r.spare, 0.0)
192
+ if nxt := r.left
193
+ assert actSeqLen(nxt) == 1
194
+ else
195
+ assert false
196
+
197
+ test "empty_sequence_finishes_with_full_spare"
198
+ r := Actions.stepAction(actPose0(), Actions.sequence(List[Action]()), 0.3)
199
+ assert r.left == null
200
+ assert actNear(r.spare, 0.3)
201
+
202
+ test "parallel_spare_is_min_of_finished"
203
+ r := Actions.stepAction(actPose0(), Actions.parallel(List(Actions.delay(0.2), Actions.delay(0.5))), 1.0)
204
+ assert r.left == null
205
+ assert actNear(r.spare, 0.5)
206
+
207
+ test "forever_zero_length_template_terminates"
208
+ r := Actions.stepAction(actPose0(), Actions.forever(Actions.delay(0.0)), 1.0)
209
+ assert r.left != null
210
+
211
+ test "forever of a nested sequence keeps looping"
212
+ a := Actions.forever(Actions.sequence(List(Actions.moveBy(1.0, 0.0, 0.1), Actions.delay(0.1))))
213
+ p := actPose0()
214
+ for i := range 100
215
+ r := Actions.stepAction(p, a, 0.05)
216
+ p = r.pose
217
+ if nxt := r.left
218
+ a = nxt
219
+ assert actNear(p.x, 25.0)
220
+
221
+ test "step_does_not_change_input"
222
+ m := MoveBy(dx: 10.0, dy: 0.0, duration: 1.0, elapsed: 0.0)
223
+ r := Actions.stepAction(actPose0(), m, 0.5)
224
+ assert actNear(m.elapsed, 0.0)
225
+ assert actNear(r.pose.x, 5.0)
plum-std/game/Affine.plum ADDED
@@ -0,0 +1,72 @@
1
+ module std
2
+ import std/Float
3
+ import std/Math
4
+ import std/game/Vec2
5
+
6
+ # A 2D affine transform: x' = a*x + c*y + e, y' = b*x + d*y + f.
7
+ struct Affine(a: Float, b: Float, c: Float, d: Float, e: Float, f: Float) =
8
+
9
+ Identity = Affine(a: 1.0, b: 0.0, c: 0.0, d: 1.0, e: 0.0, f: 0.0)
10
+
11
+ # `p * q` applies q first, then p.
12
+ fun mul(self, o: Affine) -> Affine =
13
+ Affine(
14
+ a: self.a * o.a + self.c * o.b,
15
+ b: self.b * o.a + self.d * o.b,
16
+ c: self.a * o.c + self.c * o.d,
17
+ d: self.b * o.c + self.d * o.d,
18
+ e: self.a * o.e + self.c * o.f + self.e,
19
+ f: self.b * o.e + self.d * o.f + self.f)
20
+
21
+ fun apply(self, p: Vec2) -> Vec2 =
22
+ Vec2(x: self.a * p.x + self.c * p.y + self.e, y: self.b * p.x + self.d * p.y + self.f)
23
+
24
+ # The transform that undoes this one (the identity if it is singular).
25
+ fun inverse(self) -> Affine =
26
+ det := self.a * self.d - self.b * self.c
27
+ if det == 0.0
28
+ return Affine.Identity
29
+ ia := self.d / det
30
+ ib := 0.0 - self.b / det
31
+ ic := 0.0 - self.c / det
32
+ id := self.a / det
33
+ Affine(
34
+ a: ia, b: ib, c: ic, d: id,
35
+ e: 0.0 - {ia * self.e + ic * self.f},
36
+ f: 0.0 - {ib * self.e + id * self.f})
37
+
38
+ # The transform that undoes this one, or null if it is singular.
39
+ fun invert(self) -> Affine? =
40
+ det := self.a * self.d - self.b * self.c
41
+ if det == 0.0 || {det * 0.0} != 0.0
42
+ return null
43
+ ia := self.d / det
44
+ ib := 0.0 - self.b / det
45
+ ic := 0.0 - self.c / det
46
+ id := self.a / det
47
+ Affine(
48
+ a: ia, b: ib, c: ic, d: id,
49
+ e: 0.0 - {ia * self.e + ic * self.f},
50
+ f: 0.0 - {ib * self.e + id * self.f})
51
+
52
+ fun scaleX(self) -> Float =
53
+ Math.hypot(self.a, self.b)
54
+
55
+ fun scaleY(self) -> Float =
56
+ Math.hypot(self.c, self.d)
57
+
58
+ fun rotation(self) -> Float =
59
+ Math.atan2(self.b, self.a)
60
+
61
+ test "invert_singular_is_null"
62
+ s := Affine(a: 0.0, b: 0.0, c: 0.0, d: 0.0, e: 1.0, f: 1.0)
63
+ assert s.invert() == null
64
+
65
+ test "invert_round_trip"
66
+ m := Affine(a: 2.0, b: 0.0, c: 0.0, d: 2.0, e: 4.0, f: 0.0)
67
+ if inv := m.invert()
68
+ p := inv.apply(Vec2(x: 6.0, y: 2.0))
69
+ assert {p.x - 1.0}.abs() < 1e-6
70
+ assert {p.y - 1.0}.abs() < 1e-6
71
+ else
72
+ assert false
plum-std/game/PureScene.plum ADDED
@@ -0,0 +1,274 @@
1
+ module std
2
+ import std/Int
3
+ import std/Float
4
+ import std/Str
5
+ import std/List
6
+ import std/Math
7
+ import std/game/Vec2
8
+ import std/game/Color
9
+ import std/game/Affine
10
+ import std/game/Game
11
+ import std/game/Actions
12
+
13
+ # A pure scene graph: actors are values. `Actor` is a trait that records embed
14
+ # (`Group`, `Sprite`, `Label`, `Button` here). Nothing in this file changes a
15
+ # value it was given: `onPointer` and the walkers return new actors, built from
16
+ # `clone()`, and only `drawTree` talks to the host, through `std/game/Game`.
17
+ # `act`, the actions in `actions` and tree-wide updates live in std/game/SceneTree.
18
+
19
+ enum Touchable =
20
+ | Enabled
21
+ | Disabled
22
+ | ChildrenOnly
23
+
24
+ enum PointerKind =
25
+ | Down
26
+ | Up
27
+ | Move
28
+
29
+ # A pointer event in the actor's own coordinates; `over` says it is on the actor.
30
+ record Pointer(kind: PointerKind, x: Float, y: Float, over: Bool)
31
+
32
+ enum UiKind =
33
+ | Press
34
+ | Release
35
+ | Click
36
+ | Enter
37
+ | Exit
38
+
39
+ record UiEvent(id: Int, kind: UiKind)
40
+
41
+ # The actor after handling an event, and the events it raised.
42
+ record Handled(actor: Actor, events: List[UiEvent])
43
+
44
+ trait Actor(
45
+ id: Int,
46
+ x: Float, y: Float, width: Float, height: Float,
47
+ originX: Float, originY: Float,
48
+ scaleX: Float, scaleY: Float,
49
+ rotation: Float,
50
+ color: Color,
51
+ visible: Bool,
52
+ touchable: Touchable,
53
+ actions: List[Action]) =
54
+
55
+ # A copy that can be changed without touching this value.
56
+ fun clone(self) -> Actor
57
+
58
+ # This actor after `dt` seconds of its own behaviour; the default is itself.
59
+ fun act(self, dt: Float) -> Actor =
60
+ me: Actor := self
61
+ me
62
+
63
+ # The children of a group; null for a leaf.
64
+ fun kids(self) -> List[Actor]? = null
65
+
66
+ # Paints this actor only, at its stage transform `m` with the combined `tint`.
67
+ fun draw(self, m: Affine, tint: Color) =
68
+ return
69
+
70
+ # Whether `p`, in this actor's own coordinates, is on it.
71
+ fun hit(self, p: Vec2) -> Bool =
72
+ self.touchable == Enabled && p.x >= 0.0 && p.x <= self.width && p.y >= 0.0 && p.y <= self.height
73
+
74
+ # Handles a pointer event and returns the new actor with the events it raised.
75
+ fun onPointer(self, e: Pointer) -> Handled =
76
+ me: Actor := self
77
+ Handled(actor: me, events: List[UiEvent]())
78
+
79
+ record Group(Actor, children: List[Actor]) =
80
+
81
+ fun clone(self) -> Actor = Group(..self)
82
+
83
+ fun kids(self) -> List[Actor]? = self.children
84
+
85
+ record Sprite(Actor, tex: Int, srcX: Float, srcY: Float, srcW: Float, srcH: Float) =
86
+
87
+ fun clone(self) -> Actor = Sprite(..self)
88
+
89
+ fun draw(self, m: Affine, tint: Color) =
90
+ pureDrawSprite(m, tint, self.width, self.height, self.tex, self.srcX, self.srcY, self.srcW, self.srcH)
91
+
92
+ record Label(Actor, text: Str, size: Float) =
93
+
94
+ fun clone(self) -> Actor = Label(..self)
95
+
96
+ fun draw(self, m: Affine, tint: Color) =
97
+ pureDrawText(m, tint, self.text, self.size)
98
+
99
+ record Button(Actor, text: Str, upTex: Int, downTex: Int, pressed: Bool, over: Bool) =
100
+
101
+ fun clone(self) -> Actor = Button(..self)
102
+
103
+ fun draw(self, m: Affine, tint: Color) =
104
+ t := self.upTex
105
+ if self.pressed && self.over
106
+ t = self.downTex
107
+ pureDrawSprite(m, tint, self.width, self.height, t, 0.0, 0.0, 0.0, 0.0)
108
+ size := self.height * 0.5
109
+ ox := {self.width - size * 0.6 * Float(self.text.length())} / 2.0
110
+ oy := {self.height - size} / 2.0
111
+ pureDrawText(m.mul(Affine(a: 1.0, b: 0.0, c: 0.0, d: 1.0, e: ox, f: oy)), tint, self.text, size)
112
+
113
+ fun onPointer(self, e: Pointer) -> Handled =
114
+ c := Button(..self)
115
+ ev := List[UiEvent]()
116
+ if e.kind == Down
117
+ c.pressed = true
118
+ c.over = e.over
119
+ ev.push(UiEvent(id: self.id, kind: Press))
120
+ else if e.kind == Move
121
+ c.over = e.over
122
+ if e.over && !self.over
123
+ ev.push(UiEvent(id: self.id, kind: Enter))
124
+ if !e.over && self.over
125
+ ev.push(UiEvent(id: self.id, kind: Exit))
126
+ else
127
+ c.over = e.over
128
+ if self.pressed
129
+ c.pressed = false
130
+ ev.push(UiEvent(id: self.id, kind: Release))
131
+ if e.over
132
+ ev.push(UiEvent(id: self.id, kind: Click))
133
+ me: Actor := c
134
+ Handled(actor: me, events: ev)
135
+
136
+ fun pureDrawSprite(m: Affine, tint: Color, w: Float, h: Float, tex: Int, srcX: Float, srcY: Float, srcW: Float, srcH: Float) =
137
+ sx := w * m.scaleX()
138
+ sy := h * m.scaleY()
139
+ c := m.apply(Vec2(x: w / 2.0, y: h / 2.0))
140
+ drawTextureEx(tex, srcX, srcY, srcW, srcH, c.x - sx / 2.0, c.y - sy / 2.0, sx, sy, m.rotation(), false, tint)
141
+
142
+ fun pureDrawText(m: Affine, tint: Color, s: Str, size: Float) =
143
+ p := m.apply(Vec2(x: 0.0, y: 0.0))
144
+ drawText(s, p.x, p.y, size * m.scaleY(), tint)
145
+
146
+ fun pureMulColor(a: Color, b: Color) -> Color =
147
+ Color(r: a.r * b.r, g: a.g * b.g, b: a.b * b.b, a: a.a * b.a)
148
+
149
+ primitive PureScene =
150
+
151
+ # The actor's transform into its parent's coordinates: translate to the
152
+ # position, rotate and scale about the origin.
153
+ fun localMatrix(a: Actor) -> Affine =
154
+ cs := Math.cos(a.rotation)
155
+ sn := Math.sin(a.rotation)
156
+ ma := cs * a.scaleX
157
+ mb := sn * a.scaleX
158
+ mc := 0.0 - sn * a.scaleY
159
+ md := cs * a.scaleY
160
+ Affine(
161
+ a: ma, b: mb, c: mc, d: md,
162
+ e: a.x + a.originX - {ma * a.originX + mc * a.originY},
163
+ f: a.y + a.originY - {mb * a.originX + md * a.originY})
164
+
165
+ # Draws the actor and its children given the parent's matrix and tint.
166
+ fun drawTree(a: Actor, parent: Affine, tint: Color) =
167
+ if !a.visible
168
+ return
169
+ m := parent * PureScene.localMatrix(a)
170
+ t := pureMulColor(tint, a.color)
171
+ a.draw(m, t)
172
+ if g := PureScene.childrenOf(a)
173
+ for i := range g.length()
174
+ PureScene.drawTree(g.at(i), m, t)
175
+
176
+ # The children of a group, or null for a leaf.
177
+ fun childrenOf(a: Actor) -> List[Actor]? =
178
+ a.kids()
179
+
180
+ fun group(id: Int, x: Float, y: Float, w: Float, h: Float, children: List[Actor]) -> Group =
181
+ Group(
182
+ id: id, x: x, y: y, width: w, height: h, originX: 0.0, originY: 0.0,
183
+ scaleX: 1.0, scaleY: 1.0, rotation: 0.0, color: Color.White, visible: true,
184
+ touchable: Enabled, actions: List[Action](), children: children)
185
+
186
+ fun sprite(id: Int, tex: Int, x: Float, y: Float, w: Float, h: Float) -> Sprite =
187
+ Sprite(
188
+ id: id, x: x, y: y, width: w, height: h, originX: 0.0, originY: 0.0,
189
+ scaleX: 1.0, scaleY: 1.0, rotation: 0.0, color: Color.White, visible: true,
190
+ touchable: Enabled, actions: List[Action](),
191
+ tex: tex, srcX: 0.0, srcY: 0.0, srcW: 0.0, srcH: 0.0)
192
+
193
+ fun label(id: Int, text: Str, x: Float, y: Float, size: Float) -> Label =
194
+ Label(
195
+ id: id, x: x, y: y, width: size * 0.6 * Float(text.length()), height: size, originX: 0.0, originY: 0.0,
196
+ scaleX: 1.0, scaleY: 1.0, rotation: 0.0, color: Color.White, visible: true,
197
+ touchable: Disabled, actions: List[Action](), text: text, size: size)
198
+
199
+ fun button(id: Int, text: Str, upTex: Int, downTex: Int, x: Float, y: Float, w: Float, h: Float) -> Button =
200
+ Button(
201
+ id: id, x: x, y: y, width: w, height: h, originX: 0.0, originY: 0.0,
202
+ scaleX: 1.0, scaleY: 1.0, rotation: 0.0, color: Color.White, visible: true,
203
+ touchable: Enabled, actions: List[Action](),
204
+ text: text, upTex: upTex, downTex: downTex, pressed: false, over: false)
205
+
206
+ fun psKinds(h: Handled) -> List[UiKind] =
207
+ out := List[UiKind]()
208
+ for i := range h.events.length()
209
+ out.push(h.events.at(i).kind)
210
+ out
211
+
212
+ fun psIsPressed(a: Actor) -> Bool =
213
+ match a
214
+ Button(_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, pressed, _) => pressed
215
+ _ => false
216
+
217
+ fun psClicks(h: Handled) -> Int =
218
+ n := 0
219
+ for i := range h.events.length()
220
+ if h.events.at(i).kind == Click
221
+ n = n + 1
222
+ n
223
+
224
+ fun psOverPtr(kind: PointerKind, over: Bool) -> Pointer =
225
+ Pointer(kind: kind, x: 1.0, y: 1.0, over: over)
226
+
227
+ test "clone_is_independent"
228
+ s := PureScene.sprite(1, 0, 1.0, 2.0, 4.0, 4.0)
229
+ c := s.clone()
230
+ c.x = 9.0
231
+ assert s.x == 1.0
232
+ assert c.x == 9.0
233
+
234
+ test "local_matrix_translates"
235
+ s := PureScene.sprite(1, 0, 10.0, 0.0, 2.0, 2.0)
236
+ p := PureScene.localMatrix(s).apply(Vec2(x: 0.0, y: 0.0))
237
+ assert {p.x - 10.0}.abs() < 1e-6
238
+ assert {p.y}.abs() < 1e-6
239
+
240
+ test "button_click_needs_press_and_over"
241
+ b := PureScene.button(7, "go", 1, 2, 0.0, 0.0, 10.0, 10.0)
242
+ h1 := b.onPointer(psOverPtr(Down, true))
243
+ h2 := h1.actor.onPointer(psOverPtr(Up, true))
244
+ ks := psKinds(h1)
245
+ ks.push(h2.events.at(0).kind)
246
+ ks.push(h2.events.at(1).kind)
247
+ assert h1.events.length() == 1
248
+ assert h2.events.length() == 2
249
+ assert ks.at(0) == Press
250
+ assert ks.at(1) == Release
251
+ assert ks.at(2) == Click
252
+ assert h1.events.at(0).id == 7
253
+ assert h2.events.at(1).id == 7
254
+
255
+ test "button_release_elsewhere_no_click"
256
+ b := PureScene.button(7, "go", 1, 2, 0.0, 0.0, 10.0, 10.0)
257
+ h1 := b.onPointer(psOverPtr(Down, true))
258
+ h2 := h1.actor.onPointer(psOverPtr(Up, false))
259
+ assert h2.events.length() == 1
260
+ assert h2.events.at(0).kind == Release
261
+ assert !psIsPressed(h2.actor)
262
+
263
+ test "button_drag_off_and_back_one_click"
264
+ b := PureScene.button(7, "go", 1, 2, 0.0, 0.0, 10.0, 10.0)
265
+ a1 := b.onPointer(psOverPtr(Down, true)).actor
266
+ h2 := a1.onPointer(psOverPtr(Move, false))
267
+ h3 := h2.actor.onPointer(psOverPtr(Move, true))
268
+ h4 := h3.actor.onPointer(psOverPtr(Up, true))
269
+ assert psClicks(h2) + psClicks(h3) + psClicks(h4) == 1
270
+
271
+ test "on_pointer_leaves_input_unchanged"
272
+ b := PureScene.button(7, "go", 1, 2, 0.0, 0.0, 10.0, 10.0)
273
+ _ := b.onPointer(psOverPtr(Down, true))
274
+ assert !b.pressed
plum-std/game/Scene.plum CHANGED
@@ -6,6 +6,7 @@ import std/List
6
6
  import std/Math
7
7
  import std/game/Vec2
8
8
  import std/game/Color
9
+ import std/game/Affine
9
10
  import std/game/Game
10
11
 
11
12
  # A Scene2D-like scene graph: a tree of actors under a `Stage`. `Actor` is a trait;
@@ -42,47 +43,6 @@ import std/game/Game
42
43
  # scale, since the host has no rotated shapes. Non-uniform scale under rotation
43
44
  # (skew) is not supported for drawing; hit testing handles it exactly.
44
45
 
45
- # A 2D affine transform: x' = a*x + c*y + e, y' = b*x + d*y + f.
46
- struct Affine(a: Float, b: Float, c: Float, d: Float, e: Float, f: Float) =
47
-
48
- Identity = Affine(a: 1.0, b: 0.0, c: 0.0, d: 1.0, e: 0.0, f: 0.0)
49
-
50
- # `p * q` applies q first, then p.
51
- fun mul(self, o: Affine) -> Affine =
52
- Affine(
53
- a: self.a * o.a + self.c * o.b,
54
- b: self.b * o.a + self.d * o.b,
55
- c: self.a * o.c + self.c * o.d,
56
- d: self.b * o.c + self.d * o.d,
57
- e: self.a * o.e + self.c * o.f + self.e,
58
- f: self.b * o.e + self.d * o.f + self.f)
59
-
60
- fun apply(self, p: Vec2) -> Vec2 =
61
- Vec2(x: self.a * p.x + self.c * p.y + self.e, y: self.b * p.x + self.d * p.y + self.f)
62
-
63
- # The transform that undoes this one (the identity if it is singular).
64
- fun inverse(self) -> Affine =
65
- det := self.a * self.d - self.b * self.c
66
- if det == 0.0
67
- return Affine.Identity
68
- ia := self.d / det
69
- ib := 0.0 - self.b / det
70
- ic := 0.0 - self.c / det
71
- id := self.a / det
72
- Affine(
73
- a: ia, b: ib, c: ic, d: id,
74
- e: 0.0 - {ia * self.e + ic * self.f},
75
- f: 0.0 - {ib * self.e + id * self.f})
76
-
77
- fun scaleX(self) -> Float =
78
- Math.hypot(self.a, self.b)
79
-
80
- fun scaleY(self) -> Float =
81
- Math.hypot(self.c, self.d)
82
-
83
- fun rotation(self) -> Float =
84
- Math.atan2(self.b, self.a)
85
-
86
46
  trait Actor(
87
47
  x: Float, y: Float, width: Float, height: Float,
88
48
  originX: Float, originY: Float,
plum-std/game/SceneInput.plum ADDED
@@ -0,0 +1,243 @@
1
+ module std
2
+ import std/Int
3
+ import std/Float
4
+ import std/Str
5
+ import std/List
6
+ import std/game/Vec2
7
+ import std/game/Color
8
+ import std/game/Affine
9
+ import std/game/Game
10
+ import std/game/Actions
11
+ import std/game/PureScene
12
+ import std/game/SceneTree
13
+
14
+ # The root of a pure scene tree, its camera and pointer capture. Every method
15
+ # returns a new Stage; the one you hold is never changed. A press captures the
16
+ # actor it landed on: moves and the release go to that actor until the release,
17
+ # even when the pointer leaves it (scene2d's touch focus). The id stored with
18
+ # the path guards against the tree changing under it.
19
+
20
+ enum InputKind =
21
+ | PointerDown
22
+ | PointerUp
23
+ | PointerMove
24
+
25
+ # A pointer input in screen coordinates.
26
+ record Input(kind: InputKind, x: Float, y: Float)
27
+
28
+ record Capture(path: List[Int], id: Int)
29
+
30
+ record StageHandled(stage: Stage, events: List[UiEvent])
31
+
32
+ record Stage(root: Actor, camera: Affine, captured: Capture?) =
33
+
34
+ # Handles one input: routes it, returns the new stage and the events raised.
35
+ fun input(self, e: Input) -> StageHandled =
36
+ none := List[UiEvent]()
37
+ inv := self.camera.invert()
38
+ if inv == null
39
+ return StageHandled(stage: self, events: none)
40
+ world := Vec2(x: e.x, y: e.y)
41
+ if q := inv
42
+ world = q.apply(world)
43
+ hit := SceneTree.findHit(self.root, Affine.Identity, world)
44
+ # A capture is valid only while its path still leads to its actor.
45
+ cap: Capture? := null
46
+ if c := self.captured
47
+ if SceneTree.idAt(self.root, c.path, 0) == c.id
48
+ cap = c
49
+ if e.kind == PointerDown
50
+ if p := hit
51
+ id := SceneTree.idAt(self.root, p, 0)
52
+ return self.deliver(p, Pointer(kind: Down, x: world.x, y: world.y, over: true), Capture(path: p, id: id))
53
+ return StageHandled(stage: self.withCapture(null), events: none)
54
+ if c := cap
55
+ over := false
56
+ if p := hit
57
+ over = inputIsPrefix(c.path, p)
58
+ if e.kind == PointerUp
59
+ return self.deliver(c.path, Pointer(kind: Up, x: world.x, y: world.y, over: over), null)
60
+ return self.deliver(c.path, Pointer(kind: Move, x: world.x, y: world.y, over: over), c)
61
+ # Hover without a press: route to whatever is under the pointer.
62
+ if e.kind == PointerMove
63
+ if p := hit
64
+ return self.deliver(p, Pointer(kind: Move, x: world.x, y: world.y, over: true), null)
65
+ StageHandled(stage: self.withCapture(null), events: none)
66
+
67
+ fun withCapture(self, c: Capture?) -> Stage =
68
+ Stage(root: self.root, camera: self.camera, captured: c)
69
+
70
+ fun deliver(self, path: List[Int], p: Pointer, c: Capture?) -> StageHandled =
71
+ h := SceneTree.route(self.root, path, 0, p)
72
+ StageHandled(stage: Stage(root: h.actor, camera: self.camera, captured: c), events: h.events)
73
+
74
+ # Handles the inputs in order, then advances the tree by `dt`.
75
+ fun frame(self, dt: Float, inputs: List[Input]) -> StageHandled =
76
+ cur := self
77
+ out := List[UiEvent]()
78
+ for i := range inputs.length()
79
+ h := cur.input(inputs.at(i))
80
+ cur = h.stage
81
+ for j := range h.events.length()
82
+ out.push(h.events.at(j))
83
+ StageHandled(stage: Stage(root: SceneTree.update(cur.root, dt), camera: cur.camera, captured: cur.captured), events: out)
84
+
85
+ # Paints the tree through the camera.
86
+ fun draw(self) =
87
+ PureScene.drawTree(self.root, self.camera, Color.White)
88
+
89
+ fun inputIsPrefix(a: List[Int], b: List[Int]) -> Bool =
90
+ if a.length() > b.length()
91
+ return false
92
+ for i := range a.length()
93
+ if a.at(i) != b.at(i)
94
+ return false
95
+ true
96
+
97
+ primitive SceneInput =
98
+ fun stage(root: Actor) -> Stage =
99
+ Stage(root: root, camera: Affine.Identity, captured: null)
100
+
101
+ # This frame's pointer inputs from the host mouse: a press, a release, or a
102
+ # move. `wasDown` is whether the button was down last frame.
103
+ fun pollPointer(wasDown: Bool) -> List[Input] =
104
+ m := mousePos()
105
+ down := isMouseDown(0)
106
+ out := List[Input]()
107
+ if down && !wasDown
108
+ out.push(Input(kind: PointerDown, x: m.x, y: m.y))
109
+ else if !down && wasDown
110
+ out.push(Input(kind: PointerUp, x: m.x, y: m.y))
111
+ else
112
+ out.push(Input(kind: PointerMove, x: m.x, y: m.y))
113
+ out
114
+
115
+ fun inBtnTree() -> Actor =
116
+ b: Actor := PureScene.button(5, "Go", 0, 1, 10.0, 10.0, 100.0, 40.0)
117
+ kids := List[Actor]()
118
+ kids.push(b)
119
+ g: Actor := PureScene.group(1, 0.0, 0.0, 800.0, 600.0, kids)
120
+ g
121
+
122
+ fun inBtnPressed(s: Stage) -> Bool =
123
+ if k := s.root.kids()
124
+ return psIsPressed(k.at(0))
125
+ false
126
+
127
+ fun inBtnOver(s: Stage) -> Bool =
128
+ if k := s.root.kids()
129
+ return inOverOf(k.at(0))
130
+ false
131
+
132
+ fun inOverOf(a: Actor) -> Bool =
133
+ match a
134
+ Button(_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, over) => over
135
+ _ => false
136
+
137
+ fun inDown(x: Float, y: Float) -> Input =
138
+ Input(kind: PointerDown, x: x, y: y)
139
+
140
+ fun inUp(x: Float, y: Float) -> Input =
141
+ Input(kind: PointerUp, x: x, y: y)
142
+
143
+ fun inMove(x: Float, y: Float) -> Input =
144
+ Input(kind: PointerMove, x: x, y: y)
145
+
146
+ fun inXformTree() -> Actor =
147
+ b: Actor := PureScene.button(5, "Go", 0, 1, 10.0, 10.0, 100.0, 40.0)
148
+ kids := List[Actor]()
149
+ kids.push(b)
150
+ g := PureScene.group(1, 100.0, 0.0, 800.0, 600.0, kids)
151
+ g.rotation = 1.5707963267948966
152
+ g.scaleX = 2.0
153
+ g.scaleY = 2.0
154
+ me: Actor := g
155
+ me
156
+
157
+ test "press_sets_capture"
158
+ h := SceneInput.stage(inBtnTree()).input(inDown(20.0, 20.0))
159
+ assert h.events.length() == 1
160
+ assert h.events.at(0).kind == Press
161
+ assert h.events.at(0).id == 5
162
+ if c := h.stage.captured
163
+ assert c.id == 5
164
+ else
165
+ assert false
166
+
167
+ test "release_elsewhere_no_click"
168
+ h1 := SceneInput.stage(inBtnTree()).input(inDown(20.0, 20.0))
169
+ h2 := h1.stage.input(inUp(500.0, 500.0))
170
+ assert h2.events.length() == 1
171
+ assert h2.events.at(0).kind == Release
172
+ assert h2.events.at(0).id == 5
173
+ assert h2.stage.captured == null
174
+ assert !inBtnPressed(h2.stage)
175
+
176
+ test "drag_off_and_back_one_click"
177
+ h1 := SceneInput.stage(inBtnTree()).input(inDown(20.0, 20.0))
178
+ h2 := h1.stage.input(inMove(500.0, 500.0))
179
+ h3 := h2.stage.input(inMove(20.0, 20.0))
180
+ h4 := h3.stage.input(inUp(20.0, 20.0))
181
+ assert psClicks(Handled(actor: h4.stage.root, events: h1.events)) + psClicks(Handled(actor: h4.stage.root, events: h2.events)) + psClicks(Handled(actor: h4.stage.root, events: h3.events)) + psClicks(Handled(actor: h4.stage.root, events: h4.events)) == 1
182
+
183
+ test "stale_capture_dropped"
184
+ h1 := SceneInput.stage(inBtnTree()).input(inDown(20.0, 20.0))
185
+ empty: Actor := PureScene.group(1, 0.0, 0.0, 800.0, 600.0, List[Actor]())
186
+ swapped := Stage(root: empty, camera: h1.stage.camera, captured: h1.stage.captured)
187
+ h2 := swapped.input(inUp(20.0, 20.0))
188
+ assert h2.events.length() == 0
189
+ assert h2.stage.captured == null
190
+
191
+ test "hover_without_press_routes_move"
192
+ h := SceneInput.stage(inBtnTree()).input(inMove(20.0, 20.0))
193
+ assert inBtnOver(h.stage)
194
+ assert h.stage.captured == null
195
+
196
+ test "camera_maps_screen_to_world"
197
+ cam := Affine(a: 2.0, b: 0.0, c: 0.0, d: 2.0, e: 0.0, f: 0.0)
198
+ s := Stage(root: inBtnTree(), camera: cam, captured: null)
199
+ h := s.input(inDown(40.0, 40.0))
200
+ assert h.events.length() == 1
201
+ assert h.events.at(0).kind == Press
202
+ assert h.events.at(0).id == 5
203
+
204
+ test "frame_handles_inputs_then_updates"
205
+ spr := PureScene.sprite(6, 0, 300.0, 300.0, 10.0, 10.0)
206
+ acts := List[Action]()
207
+ acts.push(Actions.moveBy(10.0, 0.0, 1.0))
208
+ spr.actions = acts
209
+ b: Actor := PureScene.button(5, "Go", 0, 1, 10.0, 10.0, 100.0, 40.0)
210
+ sp: Actor := spr
211
+ kids := List[Actor]()
212
+ kids.push(b)
213
+ kids.push(sp)
214
+ root: Actor := PureScene.group(1, 0.0, 0.0, 800.0, 600.0, kids)
215
+ ins := List[Input]()
216
+ ins.push(inDown(20.0, 20.0))
217
+ ins.push(inUp(20.0, 20.0))
218
+ h := SceneInput.stage(root).frame(0.5, ins)
219
+ assert h.events.length() == 3
220
+ assert h.events.at(0).kind == Press
221
+ assert h.events.at(1).kind == Release
222
+ assert h.events.at(2).kind == Click
223
+ if k := h.stage.root.kids()
224
+ assert {k.at(1).x - 305.0}.abs() < 0.000001
225
+ else
226
+ assert false
227
+
228
+ test "hit_and_route_agree_for_rotated_scaled_group"
229
+ # local (20,20) in the button's parent maps to world (100 - 2*20, 2*20) = (60,40)
230
+ t := inXformTree()
231
+ hit := SceneTree.findHit(t, Affine.Identity, Vec2(x: 60.0, y: 40.0))
232
+ assert hit != null
233
+ h := Stage(root: t, camera: Affine.Identity, captured: null).input(Input(kind: PointerDown, x: 60.0, y: 40.0))
234
+ assert h.events.length() == 1
235
+ assert h.events.at(0).id == 5
236
+ miss := Stage(root: t, camera: Affine.Identity, captured: null).input(Input(kind: PointerDown, x: 20.0, y: 20.0))
237
+ assert miss.events.length() == 0
238
+
239
+ test "input_leaves_stage_unchanged"
240
+ s := SceneInput.stage(inBtnTree())
241
+ _ := s.input(Input(kind: PointerDown, x: 20.0, y: 20.0))
242
+ assert s.captured == null
243
+ assert !inBtnPressed(s)
plum-std/game/SceneTree.plum ADDED
@@ -0,0 +1,316 @@
1
+ module std
2
+ import std/Int
3
+ import std/Float
4
+ import std/List
5
+ import std/Str
6
+ import std/game/Vec2
7
+ import std/game/Affine
8
+ import std/game/Actions
9
+ import std/game/PureScene
10
+
11
+ # Rebuilds a group with new children; any other actor comes back as a copy.
12
+ fun treeWithKids(a: Actor, ks: List[Actor]) -> Actor =
13
+ c := a.clone()
14
+ match c
15
+ Group(i, x, y, w, h, ox, oy, sx, sy, rot, col, vis, tch, acts, _) =>
16
+ g := Group(id: i, x: x, y: y, width: w, height: h, originX: ox, originY: oy, scaleX: sx, scaleY: sy, rotation: rot, color: col, visible: vis, touchable: tch, actions: acts, children: ks)
17
+ me: Actor := g
18
+ me
19
+ _ => c
20
+
21
+ fun treePrepend(i: Int, rest: List[Int]) -> List[Int] =
22
+ out := List[Int]()
23
+ out.push(i)
24
+ for j := range rest.length()
25
+ out.push(rest.at(j))
26
+ out
27
+
28
+ primitive SceneTree =
29
+
30
+ # The actor after `dt` seconds: its own `act`, then its actions on its pose,
31
+ # then every child. The input is never changed.
32
+ fun update(a: Actor, dt: Float) -> Actor =
33
+ b := a.act(dt)
34
+ c := b.clone()
35
+ r := Actions.stepAll(Pose(x: c.x, y: c.y, rotation: c.rotation, scaleX: c.scaleX, scaleY: c.scaleY), c.actions, dt)
36
+ c.x = r.pose.x
37
+ c.y = r.pose.y
38
+ c.rotation = r.pose.rotation
39
+ c.scaleX = r.pose.scaleX
40
+ c.scaleY = r.pose.scaleY
41
+ c.actions = r.kept
42
+ if ks := c.kids()
43
+ nks := List[Actor]()
44
+ for i := range ks.length()
45
+ nks.push(SceneTree.update(ks.at(i), dt))
46
+ return treeWithKids(c, nks)
47
+ c
48
+
49
+ # The path (child indexes from `a`) to the topmost actor under `p`, which is
50
+ # in the coordinates of `a`'s parent; `parent` is that parent's stage matrix.
51
+ fun findHit(a: Actor, parent: Affine, p: Vec2) -> List[Int]? =
52
+ if !a.visible || a.touchable == Disabled
53
+ return null
54
+ m := parent * PureScene.localMatrix(a)
55
+ inv := m.invert()
56
+ if inv == null
57
+ return null
58
+ lp := Vec2(x: 0.0, y: 0.0)
59
+ if q := inv
60
+ lp = q.apply(p)
61
+ if ks := a.kids()
62
+ i := ks.length() - 1
63
+ while i >= 0
64
+ sub := SceneTree.findHit(ks.at(i), m, p)
65
+ if s := sub
66
+ return treePrepend(i, s)
67
+ i = i - 1
68
+ if a.hit(lp)
69
+ return List[Int]()
70
+ null
71
+
72
+ # The id of the actor at `path` below `a`, or -1 if the path leads nowhere.
73
+ fun idAt(a: Actor, path: List[Int], depth: Int) -> Int =
74
+ if depth >= path.length()
75
+ return a.id
76
+ if ks := a.kids()
77
+ i := path.at(depth)
78
+ if i >= 0 && i < ks.length()
79
+ return SceneTree.idAt(ks.at(i), path, depth + 1)
80
+ -1
81
+
82
+ # The path to the first actor with `id`, depth first; null if none.
83
+ fun findPath(a: Actor, id: Int) -> List[Int]? =
84
+ if a.id == id
85
+ return List[Int]()
86
+ if ks := a.kids()
87
+ for i := range ks.length()
88
+ sub := SceneTree.findPath(ks.at(i), id)
89
+ if s := sub
90
+ return treePrepend(i, s)
91
+ null
92
+
93
+ # A copy of the tree where the first actor with `id` is replaced by `f` of it.
94
+ fun modify(a: Actor, id: Int, f: fn(Actor) -> Actor) -> Actor =
95
+ path := SceneTree.findPath(a, id)
96
+ if p := path
97
+ return SceneTree.modifyAt(a, p, 0, f)
98
+ a
99
+
100
+ fun modifyAt(a: Actor, path: List[Int], depth: Int, f: fn(Actor) -> Actor) -> Actor =
101
+ if depth >= path.length()
102
+ return f(a)
103
+ if ks := a.kids()
104
+ i := path.at(depth)
105
+ if i >= 0 && i < ks.length()
106
+ nks := List[Actor]()
107
+ for j := range ks.length()
108
+ if j == i
109
+ nks.push(SceneTree.modifyAt(ks.at(j), path, depth + 1, f))
110
+ else
111
+ nks.push(ks.at(j))
112
+ return treeWithKids(a, nks)
113
+ a
114
+
115
+ # Delivers `e` (in the coordinates of `a`'s parent) along `path`: the child on
116
+ # the path first, then `a` itself. A path that runs out reaches the deepest
117
+ # actor it can.
118
+ fun route(a: Actor, path: List[Int], depth: Int, e: Pointer) -> Handled =
119
+ lp := Vec2(x: e.x, y: e.y)
120
+ inv := PureScene.localMatrix(a).invert()
121
+ if q := inv
122
+ lp = q.apply(lp)
123
+ local := Pointer(kind: e.kind, x: lp.x, y: lp.y, over: e.over)
124
+ events := List[UiEvent]()
125
+ cur := a
126
+ if depth < path.length()
127
+ if ks := a.kids()
128
+ i := path.at(depth)
129
+ if i >= 0 && i < ks.length()
130
+ sub := SceneTree.route(ks.at(i), path, depth + 1, local)
131
+ nks := List[Actor]()
132
+ for j := range ks.length()
133
+ if j == i
134
+ nks.push(sub.actor)
135
+ else
136
+ nks.push(ks.at(j))
137
+ cur = treeWithKids(a, nks)
138
+ for j := range sub.events.length()
139
+ events.push(sub.events.at(j))
140
+ own := cur.onPointer(local)
141
+ for j := range own.events.length()
142
+ events.push(own.events.at(j))
143
+ mine := own.actor
144
+ if ks2 := cur.kids()
145
+ mine = treeWithKids(own.actor, ks2)
146
+ Handled(actor: mine, events: events)
147
+
148
+ fun ttFixture() -> Actor =
149
+ inner := List[Actor]()
150
+ inner.push(PureScene.sprite(4, 0, 0.0, 0.0, 5.0, 5.0))
151
+ kids := List[Actor]()
152
+ kids.push(PureScene.sprite(2, 0, 0.0, 0.0, 10.0, 10.0))
153
+ kids.push(PureScene.group(3, 20.0, 0.0, 10.0, 10.0, inner))
154
+ root := PureScene.group(1, 0.0, 0.0, 100.0, 100.0, kids)
155
+ me: Actor := root
156
+ me
157
+
158
+ fun ttNoPath(r: List[Int]?) -> Bool =
159
+ r == null
160
+
161
+ fun ttPathEq(r: List[Int]?, want: List[Int]) -> Bool =
162
+ if p := r
163
+ if p.length() != want.length()
164
+ return false
165
+ for i := range p.length()
166
+ if p.at(i) != want.at(i)
167
+ return false
168
+ return true
169
+ false
170
+
171
+ fun ttInts(a: Int, b: Int) -> List[Int] =
172
+ out := List[Int]()
173
+ out.push(a)
174
+ out.push(b)
175
+ out
176
+
177
+ fun ttAt(x: Float, y: Float) -> Vec2 =
178
+ Vec2(x: x, y: y)
179
+
180
+ fun ttMovingTree() -> Actor =
181
+ s := PureScene.sprite(2, 0, 0.0, 0.0, 10.0, 10.0)
182
+ s.actions = List(Actions.moveBy(10.0, 0.0, 1.0))
183
+ kids := List[Actor]()
184
+ kids.push(s)
185
+ root := PureScene.group(1, 0.0, 0.0, 100.0, 100.0, kids)
186
+ me: Actor := root
187
+ me
188
+
189
+ fun ttFirstKid(a: Actor) -> Actor =
190
+ ks := a.kids()
191
+ if k := ks
192
+ return k.at(0)
193
+ a
194
+
195
+ fun ttSetTouch(t: Actor, id: Int, tc: Touchable) -> Actor =
196
+ SceneTree.modify(t, id, |a| ttSetTouchOf(a, tc))
197
+
198
+ fun ttSetTouchOf(a: Actor, tc: Touchable) -> Actor =
199
+ c := a.clone()
200
+ c.touchable = tc
201
+ c
202
+
203
+ fun ttSetScaleX(a: Actor) -> Actor =
204
+ c := a.clone()
205
+ c.scaleX = 0.0
206
+ c
207
+
208
+ fun ttSetX9(a: Actor) -> Actor =
209
+ c := a.clone()
210
+ c.x = 9.0
211
+ c
212
+
213
+ fun ttKidAt(a: Actor, i: Int) -> Actor =
214
+ ks := a.kids()
215
+ if k := ks
216
+ return k.at(i)
217
+ a
218
+
219
+ fun ttButtonRoot() -> Actor =
220
+ kids := List[Actor]()
221
+ kids.push(PureScene.button(5, "Go", 0, 1, 10.0, 10.0, 100.0, 40.0))
222
+ root := PureScene.group(1, 0.0, 0.0, 800.0, 600.0, kids)
223
+ me: Actor := root
224
+ me
225
+
226
+ fun ttZero() -> List[Int] =
227
+ out := List[Int]()
228
+ out.push(0)
229
+ out
230
+
231
+ test "find_hit_nested"
232
+ r := SceneTree.findHit(ttFixture(), Affine.Identity, ttAt(21.0, 1.0))
233
+ assert ttPathEq(r, ttInts(1, 0))
234
+
235
+ test "find_hit_miss"
236
+ assert ttNoPath(SceneTree.findHit(ttFixture(), Affine.Identity, ttAt(200.0, 200.0)))
237
+
238
+ test "id_at_paths"
239
+ t := ttFixture()
240
+ assert SceneTree.idAt(t, ttInts(1, 0), 0) == 4
241
+ five := List[Int]()
242
+ five.push(5)
243
+ assert SceneTree.idAt(t, five, 0) == -1
244
+ assert SceneTree.idAt(t, List[Int](), 0) == 1
245
+
246
+ test "find_path_ids"
247
+ t := ttFixture()
248
+ assert ttPathEq(SceneTree.findPath(t, 4), ttInts(1, 0))
249
+ assert ttNoPath(SceneTree.findPath(t, 99))
250
+
251
+ test "update_runs_actions_and_children"
252
+ n := SceneTree.update(ttMovingTree(), 0.5)
253
+ k := ttFirstKid(n)
254
+ assert {k.x - 5.0}.abs() < 1e-6
255
+ assert k.actions.length() == 1
256
+
257
+ test "update_leaves_input_unchanged"
258
+ t := ttMovingTree()
259
+ _ := SceneTree.update(t, 0.5)
260
+ k := ttFirstKid(t)
261
+ assert k.x == 0.0
262
+ assert k.actions.length() == 1
263
+
264
+ test "disabled_group_hides_subtree"
265
+ t := ttSetTouch(ttFixture(), 3, Disabled)
266
+ r := SceneTree.findHit(t, Affine.Identity, ttAt(21.0, 1.0))
267
+ assert ttPathEq(r, List[Int]())
268
+
269
+ test "children_only_group_not_hit_itself"
270
+ t := ttSetTouch(ttFixture(), 3, ChildrenOnly)
271
+ r := SceneTree.findHit(t, Affine.Identity, ttAt(28.0, 8.0))
272
+ assert ttPathEq(r, List[Int]())
273
+
274
+ test "scale_zero_not_hit"
275
+ t := SceneTree.modify(ttFixture(), 2, |a| ttSetScaleX(a))
276
+ r := SceneTree.findHit(t, Affine.Identity, ttAt(1.0, 1.0))
277
+ assert ttPathEq(r, List[Int]())
278
+
279
+ test "topmost_child_wins"
280
+ kids := List[Actor]()
281
+ kids.push(PureScene.sprite(2, 0, 0.0, 0.0, 10.0, 10.0))
282
+ kids.push(PureScene.sprite(3, 0, 0.0, 0.0, 10.0, 10.0))
283
+ root := PureScene.group(1, 0.0, 0.0, 100.0, 100.0, kids)
284
+ me: Actor := root
285
+ r := SceneTree.findHit(me, Affine.Identity, ttAt(1.0, 1.0))
286
+ want := List[Int]()
287
+ want.push(1)
288
+ assert ttPathEq(r, want)
289
+
290
+ test "modify_changes_first_match_only"
291
+ kids := List[Actor]()
292
+ kids.push(PureScene.sprite(7, 0, 0.0, 0.0, 10.0, 10.0))
293
+ kids.push(PureScene.sprite(7, 0, 0.0, 0.0, 10.0, 10.0))
294
+ root := PureScene.group(1, 0.0, 0.0, 100.0, 100.0, kids)
295
+ me: Actor := root
296
+ n := SceneTree.modify(me, 7, |a| ttSetX9(a))
297
+ assert ttKidAt(n, 0).x == 9.0
298
+ assert ttKidAt(n, 1).x == 0.0
299
+ assert ttKidAt(me, 0).x == 0.0
300
+
301
+ test "route_child_first_then_group"
302
+ g := ttButtonRoot()
303
+ h1 := SceneTree.route(g, ttZero(), 0, Pointer(kind: Down, x: 20.0, y: 20.0, over: true))
304
+ h2 := SceneTree.route(h1.actor, ttZero(), 0, Pointer(kind: Up, x: 20.0, y: 20.0, over: true))
305
+ assert h1.events.length() == 1
306
+ assert h1.events.at(0).kind == Press
307
+ assert h2.events.length() == 2
308
+ assert h2.events.at(0).kind == Release
309
+ assert h2.events.at(1).kind == Click
310
+ assert h2.events.at(1).id == 5
311
+
312
+ test "stale_path_reaches_group_only"
313
+ nine := List[Int]()
314
+ nine.push(9)
315
+ h := SceneTree.route(ttButtonRoot(), nine, 0, Pointer(kind: Down, x: 20.0, y: 20.0, over: true))
316
+ assert h.events.length() == 0