plum

#treesitter#compiler#wasm

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

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


a86fb1c — Peter John 2026-11-04T14:39:29+05:30
Game scenes: methods on types instead of free functions; scene styles doc
README.md CHANGED
@@ -851,7 +851,7 @@ What it does:
851
851
 
852
852
  - **Fuses a pure producer into its consumer.** A function is a candidate only when it is expression-bodied: bindings plus one tail expression (`= match`, `= if`, `= Ctor(..)`, `= call`); block bodies with early returns are skipped (`--explain-fusion` says `not expression-bodied`). When a candidate is pure (no `Io` reachable, no writes outside its own fresh values) and its result is used once, by a function that is one `match` on that parameter, the call is specialised on the producer's constructors and the intermediate value is never built. `sumL(mapCells(xs))` becomes one loop.
853
853
  - **List chains.** `xs.map(f).filter(g).reduce(...)`-style chains on `List` fuse the same way, so the intermediate lists disappear (the reason `List.filter` is written in direct recursive style).
854
- - **View to draw.** `Picture.draw(view(state))` fuses the scene built by a pure `view` into the draw walk, so the frame's picture tree is not allocated.
854
+ - **View to draw.** `view(state).draw(io)` fuses the scene built by a pure `view` into the draw walk, so the frame's picture tree is not allocated.
855
855
  - **Uniqueness to in-place spreads.** `markUnique` proves that a spread source (`T(..x, f: v)`) is not used afterwards and not shared, and marks the construction `unique`: codegen then writes the fields into `x` instead of copying it.
856
856
  - **Reuse.** A same-shape rebuild of a value that is dead afterwards (a `match` arm that rebuilds the constructor it matched) reuses the old cell.
857
857
 
@@ -1364,12 +1364,11 @@ The page calls `init` once after the assets are loaded, then `update` and `draw`
1364
1364
 
1365
1365
  ### Pure scene graph
1366
1366
 
1367
- The pure scene graph is the same kind of tree as `std/game/imperative/Scene`, but a function never changes a value it was only given: updates take an actor, change it in place when it is proved unique (see [In-place updates](#in-place-updates)) and hand it back, so no `clone()` is needed. `Actor` is a `trait` and `Group`, `Sprite`, `Label`, `Button` and `Stage` are `record`s. The graph is split into four modules: `std/game/pure/Actions` (timed actions such as `forever(rotateBy(...))`), `std/game/pure/PureScene` (the `Actor` trait and its leaves, `group`, `sprite`, `button`, and the draw walk), `std/game/pure/SceneTree` (update, hit testing, paths and event routing) and `std/game/pure/SceneInput` (`Stage`, pointer capture and `UiEvent`). A game imports them by name:
1367
+ The pure scene graph is the same kind of tree as `std/game/imperative/Scene`, but a function never changes a value it was only given: updates take a node, change it in place when it is proved unique (see [In-place updates](#in-place-updates)) and hand it back, so no `clone()` is needed. Every node is a `Node` record (a `Transform`, an id, colour, visibility, its running `actions` and a `Kind`: `Group`, `Sprite`, `Label`, `Button` or `Custom(actor)`); game behaviour is an `Actor` trait in a `Custom` node. The graph is three modules: `std/game/pure/Actions` (timed `Action`s such as `Action.forever(Action.rotateBy(..))`), `std/game/pure/PureScene` (`Node` and its methods: `Node.group`/`sprite`/`label`/`button`/`custom`, `update`, `drawTree`, `findHit`, `findPath`, `modify` and `route`) and `std/game/pure/SceneInput` (`Stage`, pointer capture, `Input.poll`). A game imports them by name:
1368
1368
 
1369
1369
  ```
1370
1370
  import std/game/pure/Actions
1371
1371
  import std/game/pure/PureScene
1372
- import std/game/pure/SceneTree
1373
1372
  import std/game/pure/SceneInput
1374
1373
  ```
1375
1374
 
@@ -1379,16 +1378,13 @@ A minimal game holds a `Stage` and replaces it each frame:
1379
1378
  record World(mut stage: Stage, mut wasDown: Bool)
1380
1379
 
1381
1380
  export fun init(io: Io) -> World =
1382
- spr := PureScene.sprite(2, 0, 100.0, 100.0, 48.0, 48.0)
1381
+ spr := Node.sprite(2, 0, 100.0, 100.0, 48.0, 48.0)
1383
- spr.actions = Slice(Actions.forever(Actions.rotateBy(1.0, 1.0)))
1382
+ spinning := Node(..spr, actions: Slice(Action.forever(Action.rotateBy(1.0, 1.0))))
1384
- sp: Actor := spr
1385
- kids := Slice[Actor]()
1386
- kids.push(sp)
1387
- root: Actor := PureScene.group(1, 0.0, 0.0, 800.0, 600.0, kids)
1383
+ root := Node.group(1, 0.0, 0.0, 800.0, 600.0, Slice(spinning))
1388
- World(stage: SceneInput.stage(root), wasDown: false)
1384
+ World(stage: Stage.create(root), wasDown: false)
1389
1385
 
1390
1386
  export fun update(io: Io, w: World, dt: Float) =
1391
- inputs := SceneInput.pollPointer(io, w.wasDown)
1387
+ inputs := Input.poll(io, w.wasDown)
1392
1388
  w.wasDown = io.isMouseDown(0)
1393
1389
  w.stage = w.stage.frame(dt, inputs).intoStage()
1394
1390
 
@@ -1399,22 +1395,22 @@ export fun draw(io: Io, w: World) =
1399
1395
  - **Immediate mode** ([`game.plum`](plum-examples/games/imperative/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.
1400
1396
  - **Scene** ([`game_scene.plum`](plum-examples/games/imperative/game_scene.plum)): a retained tree of actors with `mut` fields. Use it when you want objects that change in place and a familiar `act`/`draw` object model.
1401
1397
  - **Pure scene** ([`game_pure_scene.plum`](plum-examples/games/pure/game_pure_scene.plum)): every update takes an actor and hands back an updated one (written in place when uniqueness proves it), 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.
1402
- - **State/view** ([`game_state_view.plum`](plum-examples/games/functional/game_state_view.plum)): the game state is plain data and a `view` turns it into `std/game/functional/Picture`, a recursive picture type drawn by recursive functions. Use it when you want to draw from state instead of keeping a tree of actors.
1398
+ - **State/view** ([`game_state_view.plum`](plum-examples/games/functional/game_state_view.plum)): the game state is plain data and a `view` turns it into a `SceneNode` (`std/game/functional/FunctionalScene`), a recursive data type whose methods draw, measure and hit-test it. Use it when you want to draw from state instead of keeping a tree of actors.
1403
1399
 
1404
1400
  ### Functional scenes (state/view)
1405
1401
 
1406
- Three scene styles ship, each in its own folder: `std/game/imperative/Scene` (actors with `mut` fields, in `plum-examples/games/imperative`), `std/game/pure/*` (pure-update actors, in `plum-examples/games/pure`) and `std/game/functional/*` (plain state plus a `view`, in `plum-examples/games/functional`); the shared assets and generators are in `plum-examples/games/assets` and `plum-examples/games/tools`. The functional style is a loop of `State -> view -> draw`: the game state is plain data, `view(state)` builds a `SceneNode` (`std/game/functional/Picture`) and `draw` walks it. Nothing is retained between frames.
1402
+ Three scene styles ship, each in its own folder: `std/game/imperative/Scene` (actors with `mut` fields, in `plum-examples/games/imperative`), `std/game/pure/*` (pure-update actors, in `plum-examples/games/pure`) and `std/game/functional/*` (plain state plus a `view`, in `plum-examples/games/functional`); the shared assets and generators are in `plum-examples/games/assets` and `plum-examples/games/tools`. The functional style is a loop of `State -> view -> draw`: the game state is plain data, `view(state)` builds a `SceneNode` (`std/game/functional/FunctionalScene`) and `draw` walks it. Nothing is retained between frames.
1407
1403
 
1408
1404
  The functional game is written in a component style: each kind of thing is a record with an `update(self, ..)` that returns the next value and a `render(self, ..)` that returns its `SceneNode` (`Ship.update`/`Ship.render`, `Orb`, `Mine`, `Star`), and `State.render` composes them. `update` never changes a value it was given.
1409
1405
 
1410
1406
  - **`Anim`** describes motion as a function of time: `MoveBy`, `RotateBy`, `ScaleTo`, `Delay`, `Seq`, `Par` and `Forever`. `Anim.at(anim, t)` returns an `AnimPose` (dx, dy, rotation, scaleX, scaleY) for any `t`, so a view just applies the pose for `state.clock`.
1411
- - **`Ui`** is pure pointer state. `Picture.hitId(view, x, y)` finds the `Tagged(id, child)` node under the pointer; `Ui.step(ui, input, hit)` returns a `UiStep` with the new `UiState` and the `UiEvent`s raised (`Press`, `Release`, `Click`, `Enter`, `Exit`, with pointer capture). `Ui.button(id, label, x, y, w, h, ui)` builds a tagged button that shows hover and pressed looks.
1407
+ - **`Ui`** is pure pointer state. `view.hitId(x, y)` finds the `Tagged(id, child)` node under the pointer; `Ui.step(ui, input, hit)` returns a `UiStep` with the new `UiState` and the `UiEvent`s raised (`Press`, `Release`, `Click`, `Enter`, `Exit`, with pointer capture). `Ui.button(id, label, x, y, w, h, ui)` builds a tagged button that shows hover and pressed looks.
1412
1408
 
1413
1409
  From [`game_state_view.plum`](plum-examples/games/functional/game_state_view.plum) (a method of `State`): `feed` runs `Ui.step` against the picture the player sees, then hands each event to `update`.
1414
1410
 
1415
1411
  ```
1416
1412
  fun feed(self, input: UiInput) -> State =
1417
- r := Ui.step(self.ui, input, Picture.hitId(self.render(), input.x, input.y))
1413
+ r := Ui.step(self.ui, input, self.render().hitId(input.x, input.y))
1418
1414
  r.events.fold(State(..self, ui: r.ui), |s, e| s.update(UiEv(event: e)))
1419
1415
  ```
1420
1416
 
docs/backlog.md CHANGED
@@ -38,3 +38,5 @@ Open work, in the order the user queued it, plus smaller items found during buil
38
38
  - **Structural deep `==` for records** — user chose deep structural equality (like Rust `#[derive(PartialEq)]`), deferred before implementation; until then `==` on records is identity. (state-view parity T7)
39
39
  - **Retire PureScene?** — after the component-style functional game, decide whether `std/game/pure` + `games/pure` stay. (Decision record §4b)
40
40
  - **`adder(1)(2)`** (calling a returned closure directly) is not supported. (functional-gaps plan)
41
+ - **Fusion past a closure-spec copy on `Nil` miscompiles** — in `drive.rs` `quiet`, resolving `Nil.fold$c0(..)` to `List.fold$c0` (read-only) lets a value move past it, and codegen then fails with `unknown method 'List.fold$c0'` (5 `closure_spec_test` cases). `typeNameMethodKey` skips `$` copies until this is fixed.
42
+ - **Input model** — one shared `Input` (keys included), `onInput` on actors, optional typed `Msg` events; proposal in [game-scene-styles.md](game-scene-styles.md) section 6. Not started.
docs/decisions/2026-11-language-and-scene-decisions.md CHANGED
@@ -15,6 +15,7 @@ A record of the design decisions taken in the 2026-11-03/04 sessions, with the r
15
15
  - `plum-std/game/imperative/`: the mutable `Scene` (moved 2026-11-04, user).
16
16
  - `plum-std/game/pure/`: PureScene, SceneTree, SceneInput, Actions (pure updates, in place via `own` today).
17
17
  - `plum-std/game/functional/`: Picture, Anim, Ui (state/view).
18
+ - **Update 2026-11-04 (user):** `Picture` was renamed `FunctionalScene` and `SceneTree` was merged into `PureScene`; their free functions became methods on `SceneNode`/`Node`/`Action` etc. See [game-scene-styles.md](../game-scene-styles.md).
18
19
  - `plum-examples/games/imperative/`, `games/pure/` and `games/functional/` for their example games (the mutable games moved too, 2026-11-04). PureScene is imperative under an ownership discipline (`act(own n)` edits a retained tree in place); only the functional component style is functional. `plum game` finds assets in the nearest parent `assets/` folder.
19
20
 
20
21
  - **Examples (user, 2026-11-04):** examples consolidated from 34 files to about 20 topical ones (every test kept), the mutable `game_loop` moved to `games/imperative/`, and the shared game assets moved to `plum-examples/games/assets/`.
docs/game-scene-styles.md ADDED
@@ -0,0 +1,139 @@
1
+ # Game scene styles
2
+
3
+ Plum ships three ways to structure a game's scene. This page compares them, maps them to engines people know, says how far each is from deterministic simulation, and proposes a better input model (section 6, not built yet).
4
+
5
+ Decisions behind the styles are in [decisions/2026-11-language-and-scene-decisions.md](decisions/2026-11-language-and-scene-decisions.md).
6
+
7
+ ## 1. The three styles
8
+
9
+ | | Imperative | Pure | Functional |
10
+ |---|---|---|---|
11
+ | Std modules | `std/game/imperative/Scene` | `std/game/pure/PureScene`, `SceneInput`, `Actions` | `std/game/functional/FunctionalScene`, `Ui`, `Anim` |
12
+ | Example | `games/imperative/game_scene.plum` | `games/pure/game_pure_scene.plum` | `games/functional/game_state_view.plum` |
13
+ | Scene tree | Kept across frames, changed in place | Kept across frames, each update returns the next tree (in place when the compiler proves it unique) | Rebuilt from `State` every frame, then thrown away |
14
+ | Where game data lives | In actors (`record Walker(Actor, mut speed: Float)`) | In nodes (`Node` fields, `actions`, `Custom(actor)`) | In your own `State` and per-kind records (`Ship`, `Orb`); the tree holds none |
15
+ | Per-frame entry | `stage.act(dt)`, `stage.draw(io)` | `stage.frame(dt, inputs)`, `stage.draw(io)` | `state.update(msg)`, `state.render().draw(io)` |
16
+ | Behaviour | Override `act`/`draw`/`hit` on the `Actor` trait | `Actor` trait (`act`, `draw`) in a `Custom` node, plus timed `Action`s | Methods on your records; nodes have none |
17
+ | Finding a node | Object references (`same(a, b)`) | By id or path: `root.findPath(id)`, `root.modify(id, f)` | Not needed: change `State`, the next `render()` shows it |
18
+ | Animation | Code in `act` | `Action.forever(Action.rotateBy(..))`, stepped each frame | `Anim.at(anim, t)`, a function of time; nothing is stepped |
19
+ | Children | `Slice` of actors | `Slice[Node]` | `List[SceneNode]` |
20
+
21
+ ### Main API (after the 2026-11-04 method refactor)
22
+
23
+ - **Pure:** `Node.group/sprite/label/button/custom(..)` build nodes. `node.update(dt)`, `node.drawTree(io, m, tint)`, `node.findHit(m, p)`, `node.findPath(id)`, `node.modify(id, f)`, `node.route(path, depth, e)` and `node.onPointer(e)` work on them. `Stage.create(root)`, `stage.frame(dt, inputs)`, `stage.draw(io)` and `Input.poll(io, wasDown)` run a frame. `Action.moveBy/rotateBy/delay/sequence/parallel/forever(..)` and `action.step(pose, dt)` drive animations.
24
+ - **Functional:** `SceneNode` is an enum (`Group`, `Filter`, `Sprite`, `Text`, `RectShape`, `CircleShape`, `LineShape`, `Tagged`). `node.draw(io)`, `node.bounds()`, `node.hitPath(x, y)` and `node.hitId(x, y)` use it. `Transform.matrix()`, `Effect.tint(alpha)` and `Rect` methods are the helpers. `Ui.step(ui, input, hitId)` and `Ui.button(..)` handle UI.
25
+
26
+ ## 2. Engine analogies
27
+
28
+ | Plum style | Closest engine model | Notes |
29
+ |---|---|---|
30
+ | Imperative | **Flame** (Flutter's game engine), libGDX **Scene2D** | `Stage` ~ `FlameGame`, `Actor` ~ `PositionComponent`, `act(dt)` ~ `update(dt)`, `draw` ~ `render(canvas)`, `add`/`remove`/`zIndex` ~ `add`/`remove`/`priority`, `hit` ~ `containsLocalPoint`. The names come from Scene2D; the model is the same as Flame's. |
31
+ | Pure | None directly | An immutable Flame/Scene2D: the same component tree, updated by returning copies. Closest to Redux applied to a scene graph. Flutter never has you update its retained tree; the framework owns it. |
32
+ | Functional | **Elm**, and **Flutter's widget layer** | `render()` ~ `build()`, `SceneNode` ~ immutable widgets. Flutter also keeps an Element tree (diffed, holds local `State`) and a RenderObject tree (layout, paint); Plum has neither. Nothing is retained, so nothing is diffed: every frame is redrawn, and the compiler fuses `render` into `draw` so the tree is never built. There is no local widget state (everything is in `State`), no keys and no constraint layout (positions are absolute). |
33
+
34
+ ## 3. Pure vs functional
35
+
36
+ Both avoid mutation in game logic. The difference is **where the state lives**:
37
+
38
+ - In **pure**, the tree *is* the game state. Changing the game means finding a node (`findPath`, `modify`) and returning a new tree. Nodes carry their own behaviour (`Actor`) and animations (`actions`), and pointer input changes nodes (a button's `pressed`/`over`).
39
+ - In **functional**, the tree is a *picture of* the game state. Changing the game means changing `State`; the next `render()` reflects it. Hover and press state lives in `UiState` inside `State`, and buttons are found with `Tagged(id, ..)` + `hitId`.
40
+
41
+ Cost: pure updates run in place when the compiler proves uniqueness. Functional describes the whole frame each time, but fusion removes the tree (see `fusion_alloc_test.rs`, `state_view_draw_has_no_scenenode_alloc`).
42
+
43
+ ## 4. Determinism
44
+
45
+ **Goal:** the same seed and the same inputs produce the same game, bit for bit, on every machine. This enables replays, lockstep multiplayer, rollback and desync checks (planned: [backlog](backlog.md), "deterministic sim").
46
+
47
+ ### Already in place (all three styles)
48
+
49
+ - **Seeded randomness.** Every game uses a `Random` value threaded through its state (`Random.seeded(seed)`), never a host call per draw.
50
+ - **Math in Plum.** `Math.sin/cos/sqrt/hypot` are written in Plum (`std/Math.plum`), not imported from the browser, so they don't vary between JS engines.
51
+ - **wasm floats.** wasm `f64` arithmetic is IEEE 754 and gives the same results everywhere (only NaN bit patterns can differ).
52
+ - **Deterministic maps.** `Map` uses a fixed FNV-1a hash, so iteration order depends only on the insert sequence.
53
+
54
+ ### Still missing (all three styles)
55
+
56
+ 1. **Fixed timestep.** `plum_bridge.js` passes the real frame time (`dt = (now - last) / 1000`) to `update`. The simulation must instead advance in fixed steps (for example 1/60 s), with the shell accumulating real time and running 0..n steps per frame.
57
+ 2. **Recorded seed.** `init` calls `io.randomSeed()`; a replay must record and supply that seed.
58
+ 3. **Recorded input.** Keyboard and mouse are read straight from `io` in the game shell (`io.isKeyDown`, `io.mousePos`). Everything the simulation reads from the host must go through one recorded input list per step (see section 6).
59
+ 4. **Host values in state.** The functional game stores the screen size (`sw`, `sh`) and gets `Resize` messages; these must be in the input log too, or kept out of the simulation.
60
+ 5. **Checksums.** A per-step hash of the simulation state, to detect desyncs.
61
+
62
+ ### How hard each style is
63
+
64
+ 1. **Functional: easiest.** All game state is one immutable `State`, and everything that happens arrives as a `Msg` (`Tick`, `Resize`, `Restart`, `UiEv`). A replay is the seed plus the `Msg` list, applied with `State.update` in order. Rollback is keeping an old `State` (nothing changes it). A checksum hashes `State`. Only the `World` shell, which turns keyboard and mouse into messages, touches `io`.
65
+ 2. **Pure: moderate.** Updates are pure too, but state is split between the `Stage` tree and the `World` fields, and the tree is updated in place, so a snapshot needs an explicit deep copy. Inputs already arrive as a list per frame (`Input.poll`), which is easy to record.
66
+ 3. **Imperative: hardest.** State is a mutable object graph: snapshots and checksums need a deep walk, and any code may read `io`. Replay still works if every step's inputs are recorded and nothing else reads the host.
67
+
68
+ **Recommendation:** build deterministic simulation on the functional style first. Its `Msg` stream is already the replay log.
69
+
70
+ ## 5. Input today
71
+
72
+ Each style handles input differently:
73
+
74
+ | | Imperative | Pure | Functional |
75
+ |---|---|---|---|
76
+ | Pointer | Game asks `stage.hit(x, y)` and handles the result itself | `Input.poll` -> `stage.frame(dt, inputs)` -> hit path -> `node.route` -> `onPointer` (only `Button` reacts) -> `UiEvent`s | Shell builds a `UiInput` -> `render().hitId(x, y)` -> `Ui.step` -> `UiEvent`s -> `Msg.UiEv` |
77
+ | Keyboard | Read from `io` | Read from `io` in the shell | Read from `io` in the shell, folded into `Tick(dt, ax, ay)` |
78
+ | Event type | none | `UiEvent(id: Int, kind: UiKind)` | its own `UiEvent(id: Int, kind: UiKind)` |
79
+ | Capture (press, drag off, release) | none | yes | yes |
80
+
81
+ Problems:
82
+
83
+ - **Two event types.** `PureScene` and `Ui` each define `UiKind`/`UiEvent`/pointer kinds with the same meaning.
84
+ - **Untyped events.** Games compare integer ids: `if e.id == Id.Restart && e.kind == Click`. Nothing checks that ids are unique or that the game handles every button.
85
+ - **No input hook for game actors.** In pure, a `Custom` actor has only `act` and `draw`; only the built-in `Button` reacts to the pointer. In imperative, actors have `hit` but no input method.
86
+ - **Keyboard bypasses everything.** It is read directly from `io`, so it is not part of any event stream: there is no focus, and it is not recorded (determinism item 3).
87
+
88
+ ## 6. Proposal: one input model with `onInput` (not built)
89
+
90
+ The sketch `scene_graph.pfl` (repo root, not tracked) designs input for a scene2d-style pure scene:
91
+
92
+ ```
93
+ trait Actor<Msg> {
94
+ fn on_input(self: own Self, input: Input) -> (own Self, List<Msg>) = (self, [])
95
+ fn route(self: own Self, path: Path, input: Input) -> (own Self, List<Msg>)
96
+ }
97
+ class Button<Msg> { .., pressed: Bool, on_click: Msg }
98
+ enum Input { PointerDown(Vec2), PointerUp(Vec2), PointerMove(Vec2), Key(Int) }
99
+ ```
100
+
101
+ Its ideas:
102
+ - one `Input` type that includes keys;
103
+ - an overridable `on_input` on every actor;
104
+ - bubbling along the hit path, child first and then each parent, with messages flowing back up;
105
+ - **typed messages**: a button carries the game's own `Msg` value, so the game matches on `StartPressed` instead of comparing ids.
106
+
107
+ Plum's pure scene already has the path routing (`route` delivers child first, then each ancestor) and adds pointer capture, which the sketch lacks. The rest maps onto Plum in steps, ordered so each one stands alone.
108
+
109
+ ### Step 1: one shared `Input` type, keys included (no generics)
110
+
111
+ - Move pointer and key input into `std/game/Input.plum`, shared by all styles: `enum Input = PointerDown(x, y) | PointerUp(x, y) | PointerMove(x, y) | KeyDown(key: Int) | KeyUp(key: Int)`. `Input.poll(io, prev) -> Slice[Input]` is the only code that reads input from `io`.
112
+ - Define one shared `UiKind`/`UiEvent`, and delete the duplicates in `PureScene` and `Ui`.
113
+ - Games keep held keys in their state (`Keys`, updated from `KeyDown`/`KeyUp`) instead of calling `io.isKeyDown`.
114
+ - **Gain:** one recorded input list per step (determinism item 3), and one vocabulary across styles.
115
+
116
+ ### Step 2: `onInput` for game actors (no generics)
117
+
118
+ - **Pure:** `trait Actor` gains `fun onInput(self, n: Node, e: Input) -> Handled` (default: unchanged node, no events). `route` calls it on `Custom` nodes along the path, like `onPointer` for `Button`. `Handled` gains `consumed: Bool`, so a child can stop the event reaching its parents (like the DOM's `stopPropagation`).
119
+ - **Imperative:** `trait Actor` gains `fun onInput(self, e: Input) -> Bool` (true = consumed), called child first along the hit path. This is Scene2D's `InputListener` and Flame's `TapCallbacks`.
120
+ - **Functional:** no methods on nodes. Instead, interactive nodes are data, as in Elm: `Tagged(id, child)` stays, and `Ui.step` reports events for it.
121
+ - **Keyboard focus:** `UiState`/`Stage` keep a focused id. `KeyDown` goes to the focused node's `onInput` first; unconsumed keys go to the game.
122
+
123
+ ### Step 3 (optional): typed messages instead of ids (needs generics)
124
+
125
+ - **Functional:** `SceneNode[Msg]` gains `OnClick(msg: Msg, child: SceneNode[Msg])` and similar, and `Ui.step` returns `List[Msg]`. The game writes `Ui.button(Restart, "Again", ..)` and `match msg Restart => ..`; no ids, and the compiler checks every message is handled.
126
+ - **Pure:** `Node[Msg]`, `Button(.., onClick: Msg)`, `Actor[Msg]`.
127
+ - **This reopens a decision:** generic `Node[K]`/`Actor[Msg]` was rejected on 2026-11-03 ("Generics can be tried later"), and generic traits are not supported yet. A generic *enum* (`SceneNode[Msg]`) is supported, so the functional style could try it first, but fusion of `render().draw(io)` must be checked on a generic `SceneNode`.
128
+
129
+ ### Recommendation
130
+
131
+ 1. Do steps 1 and 2: no language work, shared vocabulary, and they unblock deterministic replay.
132
+ 2. Try step 3 on the functional style only, once steps 1 and 2 are in, and keep it if fusion still holds.
133
+ 3. Decide whether PureScene stays (backlog: "Retire PureScene?"). If it is retired, step 2's pure half is not needed.
134
+
135
+ ### Open questions
136
+
137
+ - Should pointer events reach a node's parents after a child consumes them (Flame: no, the DOM: yes unless stopped)?
138
+ - Should `onInput` see the event in local coordinates (as `onPointer` does today) or in world coordinates?
139
+ - Do held-key sets belong in `UiState` (shared) or in each game's state?
plum-core/src/drive.rs CHANGED
@@ -736,7 +736,7 @@ impl Driver {
736
736
  if t.contains('.') {
737
737
  return vec![]; // a method on a constant (`Color.White.withAlpha(..)`)
738
738
  }
739
- // a static method, `Picture.draw(node)`
739
+ // a static method, `SceneNode.drawKids(io, kids, ..)`
740
740
  let key = format!("{}.{}", t, c.name);
741
741
  return match self.fns.get(&key) {
742
742
  Some(f) if !f.has_self && rest.len() == f.params.len() => {
@@ -1158,7 +1158,7 @@ impl Driver {
1158
1158
  self.fresh += 1;
1159
1159
  let (names, body) = self.renamed(&c.f, &format!("$k{}", self.fresh))?;
1160
1160
  let Some(Stmt::Match(m)) = body.stmts.first().cloned() else { return None };
1161
- // An arm may read the matched value again once (`_ => picComposite(effect, ..)`):
1161
+ // An arm may read the matched value again once (`_ => .. effect.tint(alpha)`):
1162
1162
  // the value is then built in that arm instead, so it is still built at most once.
1163
1163
  let again = m.cases.iter().any(|cs| {
1164
1164
  let n = &names[k];
@@ -1718,7 +1718,7 @@ impl Driver {
1718
1718
  }
1719
1719
  } else {
1720
1720
  // A just-built value read only as the matched argument of non-mutating
1721
- // consumers (`picBounds(panel, m)` then `picDraw(panel, ..)`): each consumer
1721
+ // consumers (`panel.boundsWith(m)` then `panel.drawWith(io, ..)`): each consumer
1722
1722
  // picks its arm on its own copy of the (pure) construction, so none is built.
1723
1723
  let uses = cntStmts(&rest, &p, true);
1724
1724
  let last = rest.iter().rposition(|s| cntStmts(std::slice::from_ref(s), &p, true) > 0).unwrap_or(0);
@@ -1852,7 +1852,7 @@ impl Driver {
1852
1852
  if let AttrKind::Method(c) = &a.attr {
1853
1853
  let ro = |k: &String| self.pure.contains(k) || self.readOnly.contains(k);
1854
1854
  let known = match &a.object {
1855
- Expr::TypeName(t) if !t.contains('.') => ro(&format!("{}.{}", t, c.name)),
1855
+ Expr::TypeName(t) if !t.contains('.') => ro(&self.typeNameMethodKey(t, &c.name)),
1856
1856
  o => {
1857
1857
  let ks = self.methodKeys(o, &c.name);
1858
1858
  !ks.is_empty() && ks.iter().all(ro)
@@ -1869,6 +1869,21 @@ impl Driver {
1869
1869
  ok
1870
1870
  }
1871
1871
 
1872
+ /// The fn key of `T.method(..)`: a static method of type `T`, or, when `T` is a nullary
1873
+ /// variant value (`Identity.matrix()` once a just-built `Identity` is substituted), its
1874
+ /// enum's method with a receiver (`Transform.matrix`). Compiler-made copies (`fold$c0`)
1875
+ /// are left out: moving a value past one on `Nil` miscompiles (open, see docs/backlog.md).
1876
+ fn typeNameMethodKey(&self, t: &str, method: &str) -> String {
1877
+ let own = format!("{t}.{method}");
1878
+ if self.fns.contains_key(&own) {
1879
+ return own;
1880
+ }
1881
+ match self.ctorOwner.get(t) {
1882
+ Some(o) if o != t && !method.contains('$') && self.fns.get(&format!("{o}.{method}")).is_some_and(|f| f.has_self) => format!("{o}.{method}"),
1883
+ _ => own,
1884
+ }
1885
+ }
1886
+
1872
1887
  fn quietStmt(&self, s: &Stmt) -> bool {
1873
1888
  match s {
1874
1889
  Stmt::Assign(a) if a.targets.iter().any(|t| matches!(t, AssignTarget::Field(..))) => false,
plum-core/tests/drive_test.rs CHANGED
@@ -424,7 +424,7 @@ fun total(xs: Lst) -> Int = sumL(mapB(xs))
424
424
  assert!(notes.iter().any(|n| n.contains("not expression-bodied mapB")), "{notes:?}");
425
425
  }
426
426
 
427
- // F-T23-1: the component game's draw path (`Picture.draw(io, s.render())`) after the `io`
427
+ // F-T23-1: the component game's draw path (`s.render().draw(io)`) after the `io`
428
428
  // migration and the component rewrite. Shapes: a consumer with an extra `Io` argument whose
429
429
  // method shares its name with a mutating method of another type, and children built by
430
430
  // a generic `map`-like producer whose elements call same-named methods of several types.
plum-examples/games/functional/game_state_view.plum CHANGED
@@ -10,10 +10,10 @@
10
10
  # `State` is the same for the whole game: the components, the score, a `Random` and the
11
11
  # screen size. `Msg` is what can happen to it (a tick of time, a resize, a restart, a
12
12
  # Ui event); `State.update(msg)` is a short sequence of named steps and
13
- # `State.render()` builds a fresh picture every frame, which `Picture.draw` interprets
13
+ # `State.render()` builds a fresh picture every frame, which `SceneNode.draw` interprets
14
14
  # (the compiler fuses the two, so the tree is never built). There is no retained
15
15
  # scene: the restart button is a `Ui.button` in the picture; the host turns the mouse
16
- # into a `UiInput`, finds what is under it with `Picture.hitId` on the picture the
16
+ # into a `UiInput`, finds what is under it with `SceneNode.hitId` on the picture the
17
17
  # player is looking at, runs `Ui.step`, and feeds each `UiEvent` to `State.update`.
18
18
  # The game-over title and the ship's hurt flash are `Anim`s of time since they began.
19
19
  # The `World` record is the host shell: it holds the current `State`, reads the keyboard
@@ -29,7 +29,7 @@ import std/Random
29
29
  import std/game/Vec2
30
30
  import std/game/Color
31
31
  import std/game/Game
32
- import std/game/functional/Picture
32
+ import std/game/functional/FunctionalScene
33
33
  import std/game/functional/Anim
34
34
  import std/game/functional/Ui
35
35
  import std/Io
@@ -400,7 +400,7 @@ record State(
400
400
  # One pointer input: finds what is under it in the current picture, runs the Ui, and
401
401
  # feeds each raised event to `update`.
402
402
  fun feed(self, input: UiInput) -> State =
403
- r := Ui.step(self.ui, input, Picture.hitId(self.render(), input.x, input.y))
403
+ r := Ui.step(self.ui, input, self.render().hitId(input.x, input.y))
404
404
  r.events.fold(State(..self, ui: r.ui), |s, e| s.update(UiEv(event: e)))
405
405
 
406
406
  # --- render ---
@@ -442,6 +442,7 @@ record State(
442
442
  else
443
443
  Cons(head: cell(self.sheet, Rules.ArenaW - 40.0 - Float(i) * 36.0, 12.0, 32.0, 0, 0), tail: self.lifeIcons(i + 1))
444
444
 
445
+ # `Cons` (not `List(..).append(..)`) so the life icons fuse into the draw walk too.
445
446
  fun renderHud(self) -> SceneNode =
446
447
  Group(
447
448
  transform: Identity,
@@ -478,7 +479,7 @@ record State(
478
479
 
479
480
  # True when (x, y) in screen pixels is on the restart button of the picture `render` builds.
480
481
  fun onRestart(self, x: Float, y: Float) -> Bool =
481
- {Picture.hitId(self.render(), x, y) ?: -1} == Id.Restart
482
+ {self.render().hitId(x, y) ?: -1} == Id.Restart
482
483
 
483
484
  fun pointerKind(down: Bool, wasDown: Bool) -> UiInputKind =
484
485
  if down && !wasDown
@@ -553,7 +554,7 @@ export fun update(io: Io, w: World, dt: Float) =
553
554
  export fun draw(io: Io, w: World) =
554
555
  s := w.state
555
556
  io.clear(Color.rgb(0.04, 0.05, 0.1))
556
- Picture.draw(io, s.render())
557
+ s.render().draw(io)
557
558
 
558
559
  # --- tests ---
559
560
 
@@ -790,8 +791,8 @@ test "view_is_pure"
790
791
  a := countNodes(s.render())
791
792
  b := countNodes(s.render())
792
793
  assert a == b
793
- ba := Picture.bounds(s.render())
794
+ ba := s.render().bounds()
794
- bb := Picture.bounds(s.render())
795
+ bb := s.render().bounds()
795
796
  assert ba.x == bb.x
796
797
  assert ba.w == bb.w
797
798
  assert s.clock == 0.0
plum-examples/games/pure/game_pure_scene.plum CHANGED
@@ -9,7 +9,7 @@
9
9
  # (std `Group`, `Sprite`, `Label`, `Button` kinds, and `Custom` nodes whose behaviour is a
10
10
  # record embedding `Actor`: `Ship`, `Orb`, `Mine`, `Star`, `Panel`):
11
11
  # `act` returns the next node, `Stage.frame` returns a new `Stage`, and a change from the
12
- # rules ("this orb is collected", "show the game-over panel") is a `SceneTree.modify`
12
+ # rules ("this orb is collected", "show the game-over panel") is a `Node.modify`
13
13
  # on an actor id that returns a new tree. The `World` record below is the mutable shell the
14
14
  # host talks to: it keeps the current `Stage` and the run's state (score, lives, the ship's
15
15
  # velocity) and replaces its `stage` every frame. Nothing that a function was given
@@ -27,7 +27,6 @@ import std/game/Affine
27
27
  import std/game/Game
28
28
  import std/game/pure/Actions
29
29
  import std/game/pure/PureScene
30
- import std/game/pure/SceneTree
31
30
  import std/game/pure/SceneInput
32
31
  import std/Io
33
32
 
@@ -47,7 +46,7 @@ primitive Rules =
47
46
  Size = 48.0
48
47
  Half = 24.0
49
48
 
50
- # Actor ids: unique in the tree, so the rules can find an actor with `SceneTree.modify`.
49
+ # Actor ids: unique in the tree, so the rules can find an actor with `Node.modify`.
51
50
  primitive Id =
52
51
  Root = 1
53
52
  Backdrop = 2
@@ -164,7 +163,7 @@ fun cy(n: Node) -> Float =
164
163
  record Panel(Actor) =
165
164
 
166
165
  fun create(id: Int, x: Float, y: Float, w: Float, h: Float, c: Color) -> Node =
167
- n := PureScene.custom(id, x, y, w, h, Panel())
166
+ n := Node.custom(id, x, y, w, h, Panel())
168
167
  Node(..n, color: c)
169
168
 
170
169
  override fun draw(self, io: Io, n: Node, m: Affine, tint: Color) =
@@ -175,7 +174,7 @@ record Panel(Actor) =
175
174
  record Ship(Actor, mut clock: Float, tex: Int) =
176
175
 
177
176
  fun create(sheet: Int, s: ShipState) -> Node =
178
- n := PureScene.custom(Id.Ship, s.x - Rules.Half, s.y - Rules.Half, Rules.Size, Rules.Size, Ship(clock: 0.0, tex: sheet))
177
+ n := Node.custom(Id.Ship, s.x - Rules.Half, s.y - Rules.Half, Rules.Size, Rules.Size, Ship(clock: 0.0, tex: sheet))
179
178
  Node(..n, originX: Rules.Half, originY: Rules.Half)
180
179
 
181
180
  override fun act(self, n: Node, dt: Float) -> Node =
@@ -186,7 +185,7 @@ record Ship(Actor, mut clock: Float, tex: Int) =
186
185
  c := m.apply(Vec2(x: n.width / 2.0, y: n.height / 2.0))
187
186
  io.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
187
  frame := Float(frameOf(self.clock, 4, 14.0))
189
- pureDrawSprite(io, m, tint, n.width, n.height, self.tex, frame * Rules.Cell, 0.0, Rules.Cell, Rules.Cell)
188
+ Node.drawSprite(io, m, tint, n.width, n.height, self.tex, frame * Rules.Cell, 0.0, Rules.Cell, Rules.Cell)
190
189
 
191
190
  # A glowing orb drifting about. `phase` offsets its animation from the other orbs.
192
191
  record Orb(Actor, mut vx: Float, mut vy: Float, phase: Float, mut clock: Float, tex: Int) =
@@ -196,7 +195,7 @@ record Orb(Actor, mut vx: Float, mut vy: Float, phase: Float, mut clock: Float,
196
195
 
197
196
  # An orb that carries on an animation `clock`, so a respawn or freeze never jumps a frame.
198
197
  fun createAt(sheet: Int, phase: Float, id: Int, at: Motion, clock: Float) -> Node =
199
- n := PureScene.custom(id, at.x - Rules.Half, at.y - Rules.Half, Rules.Size, Rules.Size,
198
+ n := Node.custom(id, at.x - Rules.Half, at.y - Rules.Half, Rules.Size, Rules.Size,
200
199
  Orb(vx: at.vx, vy: at.vy, phase: phase, clock: clock, tex: sheet))
201
200
  Node(..n, originX: Rules.Half, originY: Rules.Half)
202
201
 
@@ -215,7 +214,7 @@ record Orb(Actor, mut vx: Float, mut vy: Float, phase: Float, mut clock: Float,
215
214
  io.drawCircle(c.x, c.y, 17.0 * k, Color(r: 0.6, g: 0.4, b: 0.05, a: 0.5 * tint.a))
216
215
  io.blendMode(Blend.Normal)
217
216
  frame := Float(frameOf(self.clock + self.phase, 4, 8.0))
218
- pureDrawSprite(io, m, tint, n.width, n.height, self.tex, frame * Rules.Cell, Rules.Cell, Rules.Cell, Rules.Cell)
217
+ Node.drawSprite(io, m, tint, n.width, n.height, self.tex, frame * Rules.Cell, Rules.Cell, Rules.Cell, Rules.Cell)
219
218
 
220
219
  # A mine drifting about, turning `spin` radians per second (0 once the game is over).
221
220
  record Mine(Actor, mut vx: Float, mut vy: Float, spin: Float, mut clock: Float, tex: Int) =
@@ -224,7 +223,7 @@ record Mine(Actor, mut vx: Float, mut vy: Float, spin: Float, mut clock: Float,
224
223
  Mine.createAt(sheet, id, at, spin, 0.0)
225
224
 
226
225
  fun createAt(sheet: Int, id: Int, at: Motion, spin: Float, clock: Float) -> Node =
227
- n := PureScene.custom(id, at.x - Rules.Half, at.y - Rules.Half, Rules.Size, Rules.Size,
226
+ n := Node.custom(id, at.x - Rules.Half, at.y - Rules.Half, Rules.Size, Rules.Size,
228
227
  Mine(vx: at.vx, vy: at.vy, spin: spin, clock: clock, tex: sheet))
229
228
  Node(..n, originX: Rules.Half, originY: Rules.Half)
230
229
 
@@ -237,14 +236,14 @@ record Mine(Actor, mut vx: Float, mut vy: Float, spin: Float, mut clock: Float,
237
236
 
238
237
  override fun draw(self, io: Io, n: Node, m: Affine, tint: Color) =
239
238
  frame := Float(frameOf(self.clock, 4, 4.0))
240
- pureDrawSprite(io, m, tint, n.width, n.height, self.tex, frame * Rules.Cell, 2.0 * Rules.Cell, Rules.Cell, Rules.Cell)
239
+ Node.drawSprite(io, m, tint, n.width, n.height, self.tex, frame * Rules.Cell, 2.0 * Rules.Cell, Rules.Cell, Rules.Cell)
241
240
 
242
241
  # A background star that twinkles.
243
242
  record Star(Actor, seed: Float, mut clock: Float) =
244
243
 
245
244
  fun create(i: Int) -> Node =
246
245
  r := 1.0 + Float(i % 3) * 0.5
247
- PureScene.custom(Id.Star + i, Float({i * 7919} % 800) - r, Float({i * 104729} % 600) - r, r * 2.0, r * 2.0,
246
+ Node.custom(Id.Star + i, Float({i * 7919} % 800) - r, Float({i * 104729} % 600) - r, r * 2.0, r * 2.0,
248
247
  Star(seed: Float(i), clock: 0.0))
249
248
 
250
249
  override fun act(self, n: Node, dt: Float) -> Node =
@@ -269,11 +268,11 @@ fun nodeAt(a: Node, path: Slice[Int], depth: Int) -> Node =
269
268
 
270
269
  # The node with `id` below `root`, searched depth first, borrowed from the tree.
271
270
  fun find(root: Node, id: Int) -> Node =
272
- path := SceneTree.findPath(root, id) ?: crash "no such actor"
271
+ path := root.findPath(id) ?: crash "no such actor"
273
272
  nodeAt(root, path, 0)
274
273
 
275
274
  fun kidsOf(n: Node) -> Slice[Node] =
276
- PureScene.childrenOf(n) ?: crash "not a group"
275
+ n.children() ?: crash "not a group"
277
276
 
278
277
  fun setVisible(n: Node, v: Bool) -> Node =
279
278
  Node(..n, visible: v)
@@ -302,11 +301,11 @@ fun textOf(n: Node) -> Str =
302
301
  _ => ""
303
302
 
304
303
  fun hudLabel(id: Int, text: Str, x: Float, y: Float, size: Float, c: Color) -> Node =
305
- l := PureScene.label(id, text, x, y, size)
304
+ l := Node.label(id, text, x, y, size)
306
305
  Node(..l, color: c)
307
306
 
308
307
  fun icon(sheet: Int, i: Int) -> Node =
309
- s := PureScene.sprite(Id.Icon + i, sheet, Rules.ArenaW - 40.0 - Float(i) * 36.0, 12.0, 32.0, 32.0)
308
+ s := Node.sprite(Id.Icon + i, sheet, Rules.ArenaW - 40.0 - Float(i) * 36.0, 12.0, 32.0, 32.0)
310
309
  Node(..s, kind: Sprite(tex: sheet, srcX: 0.0, srcY: 0.0, srcW: Rules.Cell, srcH: Rules.Cell))
311
310
 
312
311
  # The whole scene: backdrop, stars, mines, orbs, ship and HUD layers, in drawing order.
@@ -340,14 +339,14 @@ fun buildRoot(spots: Spots, sheet: Int, best: Int) -> Node =
340
339
  orbs.push(Orb.create(sheet, Float(i) * 0.1, Id.Orb + i, spots.orbs.at(i)))
341
340
  for i := range mineCount(0)
342
341
  mines.push(Mine.create(sheet, Id.Mine + i, spots.mines.at(i), 2.0))
343
- b0 := PureScene.button(Id.Restart, "Again", sheet, sheet, 340.0, 342.0, 120.0, 44.0)
342
+ b0 := Node.button(Id.Restart, "Again", sheet, sheet, 340.0, 342.0, 120.0, 44.0)
344
343
  btn := Node(..b0, color: Color(r: 0.5, g: 0.8, b: 1.0, a: 0.9), visible: false)
345
344
  over := Slice(
346
345
  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)),
347
346
  hudLabel(Id.OverTitle, "Game over", 300.0, 244.0, 48.0, Color.Red),
348
347
  hudLabel(Id.OverHint, "Press Enter to play again", 270.0, 300.0, 24.0, Color.White),
349
348
  btn)
350
- o0 := PureScene.group(Id.Overlay, 0.0, 0.0, 0.0, 0.0, over)
349
+ o0 := Node.group(Id.Overlay, 0.0, 0.0, 0.0, 0.0, over)
351
350
  overlay := Node(..o0, visible: false)
352
351
  hud := Slice(
353
352
  hudLabel(Id.Score, "Score 0", 16.0, 12.0, 28.0, Color.White),
@@ -357,12 +356,12 @@ fun buildRoot(spots: Spots, sheet: Int, best: Int) -> Node =
357
356
  hud.push(overlay)
358
357
  layers := Slice(
359
358
  Panel.create(Id.Backdrop, 0.0, 0.0, Rules.ArenaW, Rules.ArenaH, Color.rgb(0.07, 0.08, 0.16)),
360
- PureScene.group(Id.StarLayer, 0.0, 0.0, 0.0, 0.0, stars),
359
+ Node.group(Id.StarLayer, 0.0, 0.0, 0.0, 0.0, stars),
361
- PureScene.group(Id.MineLayer, 0.0, 0.0, 0.0, 0.0, mines),
360
+ Node.group(Id.MineLayer, 0.0, 0.0, 0.0, 0.0, mines),
362
- PureScene.group(Id.OrbLayer, 0.0, 0.0, 0.0, 0.0, orbs),
361
+ Node.group(Id.OrbLayer, 0.0, 0.0, 0.0, 0.0, orbs),
363
362
  Ship.create(sheet, ship),
364
- PureScene.group(Id.Hud, 0.0, 0.0, 0.0, 0.0, hud))
363
+ Node.group(Id.Hud, 0.0, 0.0, 0.0, 0.0, hud))
365
- PureScene.group(Id.Root, 0.0, 0.0, Rules.ArenaW, Rules.ArenaH, layers)
364
+ Node.group(Id.Root, 0.0, 0.0, Rules.ArenaW, Rules.ArenaH, layers)
366
365
 
367
366
  # --- the world ---
368
367
 
@@ -390,13 +389,13 @@ fun newWorld(seed: Int, sheet: Int) -> World =
390
389
  spots := startSpots(Random.seeded(seed), ship)
391
390
  next := spots.rng
392
391
  World(
393
- stage: SceneInput.stage(buildRoot(spots, sheet, 0)), ship: ship, rng: next, mines: mineCount(0), score: 0, best: 0,
392
+ stage: Stage.create(buildRoot(spots, sheet, 0)), ship: ship, rng: next, mines: mineCount(0), score: 0, best: 0,
394
393
  lives: Rules.StartLives, over: false, clock: 0.0, wasDown: false, sheet: sheet,
395
394
  pickupSound: -1, hitSound: -1, overSound: -1, musicSound: -1)
396
395
 
397
396
  fun modifyRoot(st: Stage, id: Int, f: fn(Node) -> Node) -> Stage =
398
397
  match st
399
- Stage(root, camera, captured) => Stage(root: SceneTree.modify(root, id, f), camera: camera, captured: captured)
398
+ Stage(root, camera, captured) => Stage(root: root.modify(id, f), camera: camera, captured: captured)
400
399
 
401
400
  fun withCamera(st: Stage, camera: Affine) -> Stage =
402
401
  match st
@@ -574,7 +573,7 @@ export fun update(io: Io, w: World, dt: Float) =
574
573
  if w.over && {io.isKeyPressed(Key.Enter) || io.isKeyPressed(Key.KeyR)}
575
574
  restart(w)
576
575
  startMusic(io, w)
577
- inputs := SceneInput.pollPointer(io, w.wasDown)
576
+ inputs := Input.poll(io, w.wasDown)
578
577
  w.wasDown = io.isMouseDown(0)
579
578
  left := io.isKeyDown(Key.Left) || io.isKeyDown(Key.KeyA)
580
579
  right := io.isKeyDown(Key.Right) || io.isKeyDown(Key.KeyD)
@@ -664,12 +663,12 @@ test "drifters bounce off the walls"
664
663
  assert b.vx == -100.0
665
664
  assert b.x == Rules.ArenaW - 10.0
666
665
  orb := Orb.create(-1, 0.0, Id.Orb, Motion(x: 795.0, y: 300.0, vx: 100.0, vy: 0.0))
667
- moved := SceneTree.update(orb, 0.1)
666
+ moved := orb.update(0.1)
668
667
  assert near(cx(moved), Rules.ArenaW - 10.0)
669
668
 
670
669
  test "a mine spins as it drifts"
671
670
  mine := Mine.create(-1, Id.Mine, Motion(x: 400.0, y: 300.0, vx: 10.0, vy: 0.0), 2.0)
672
- next := SceneTree.update(mine, 0.5)
671
+ next := mine.update(0.5)
673
672
  assert near(next.rotation, 1.0)
674
673
  assert near(cx(next), 405.0)
675
674
 
plum-game/web-test/fixtures/stress_pure_scene.plum CHANGED
@@ -1,7 +1,7 @@
1
1
  # The stress scene of stress_scene.plum on the PURE scene graph (std/game/pure/PureScene):
2
2
  # 5000 sprites in 50 slowly rotating groups, 500 rects and 200 circles (sprites of a
3
3
  # 1x1 white texture), 50 texts with a changing number, drawn through a camera. Every
4
- # node is a unique value: each frame changes the tree in place with SceneTree.update. Group and
4
+ # node is a unique value: each frame changes the tree in place with Node.update. Group and
5
5
  # sprite spin comes from `forever(rotateBy(...))` actions; drifting and counting
6
6
  # actors do theirs in `act`.
7
7
  module stress_pure_scene
@@ -15,7 +15,6 @@ import std/game/Affine
15
15
  import std/game/Game
16
16
  import std/game/pure/Actions
17
17
  import std/game/pure/PureScene
18
- import std/game/pure/SceneTree
19
18
  import std/game/pure/SceneInput
20
19
  import std/Io
21
20
 
@@ -69,21 +68,21 @@ record CountLabel(Actor, n: Int, text: Str) =
69
68
  fun drifter(id: Int, tex: Int, c: Color, x: Float, y: Float, w: Float, h: Float, dx: Float, dy: Float) -> Node =
70
69
  d := Drifter(tex: tex, dx: dx, dy: dy)
71
70
  me: Actor := d
72
- n := PureScene.custom(id, x, y, w, h, me)
71
+ n := Node.custom(id, x, y, w, h, me)
73
72
  Node(..n, color: c)
74
73
 
75
74
  fun countLabel(id: Int, n: Int, x: Float, y: Float) -> Node =
76
75
  l := CountLabel(n: n, text: "n {n}")
77
76
  me: Actor := l
78
- PureScene.custom(id, x, y, 60.0, 14.0, me)
77
+ Node.custom(id, x, y, 60.0, 14.0, me)
79
78
 
80
79
  fun spinning(rate: Float) -> Slice[Action] =
81
80
  acts := Slice[Action]()
82
- acts.push(Actions.forever(Actions.rotateBy(rate, 1.0)))
81
+ acts.push(Action.forever(Action.rotateBy(rate, 1.0)))
83
82
  acts
84
83
 
85
84
  fun dancer(id: Int, sheet: Int, j: Int, i: Int) -> Node =
86
- s := PureScene.sprite(id, sheet, Float({j * j * 13 + j * 37} % 150), Float({j * j * 29 + j * 53} % 170), 16.0, 16.0)
85
+ s := Node.sprite(id, sheet, Float({j * j * 13 + j * 37} % 150), Float({j * j * 29 + j * 53} % 170), 16.0, 16.0)
87
86
  Node(..s, originX: 8.0, originY: 8.0, rotation: Float(i),
88
87
  kind: Sprite(tex: sheet, srcX: Float(i % 4 * 16), srcY: 0.0, srcW: 16.0, srcH: 16.0), actions: spinning(1.2))
89
88
 
@@ -95,7 +94,7 @@ fun buildWorld(sheet: Int, white: Int) -> World =
95
94
  for j := range per
96
95
  i := gi * per + j
97
96
  kids.push(dancer(1000 + i, sheet, j, i))
98
- g := PureScene.group(100 + gi, Float(gi % 10 * 160), Float(gi / 10 * 180), 150.0, 170.0, kids)
97
+ g := Node.group(100 + gi, Float(gi % 10 * 160), Float(gi / 10 * 180), 150.0, 170.0, kids)
99
98
  root.push(Node(..g, originX: 75.0, originY: 85.0, actions: spinning(0.24)))
100
99
  for i := range Counts.Rects
101
100
  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))
@@ -103,7 +102,7 @@ fun buildWorld(sheet: Int, white: Int) -> World =
103
102
  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))
104
103
  for i := range Counts.Texts
105
104
  root.push(countLabel(30000 + i, i, Float(i * 30 % Counts.WorldW), Float(i * 17 % Counts.WorldH)))
106
- top := PureScene.group(1, 0.0, 0.0, Float(Counts.WorldW), Float(Counts.WorldH), root)
105
+ top := Node.group(1, 0.0, 0.0, Float(Counts.WorldW), Float(Counts.WorldH), root)
107
106
  cam := Affine(a: 0.8, b: 0.0, c: 0.0, d: 0.8, e: 0.0, f: 0.0)
108
107
  World(sheet: sheet, stage: Stage(root: top, camera: cam, captured: null))
109
108
 
@@ -131,7 +130,7 @@ test "ten frames of update run"
131
130
  w := buildWorld(0, 0)
132
131
  for i := range 10
133
132
  update(w, 0.016)
134
- if k := PureScene.childrenOf(w.stage.root)
133
+ if k := w.stage.root.children()
135
134
  assert k.length() == 50 + 500 + 200 + 50
136
135
  assert {k.at(50).x - 20.0}.abs() < 0.000001
137
136
  else
plum-game/web-test/fixtures/stress_state_view.plum CHANGED
@@ -4,14 +4,14 @@
4
4
  # `State` is cons `List` lists (each spinner owns the `List` of its 100 dancers);
5
5
  # `step` is pure, a structural recursion that returns the next state; `view` is pure
6
6
  # and rebuilds the whole SceneNode tree every frame with recursive functions, which
7
- # `Picture.draw` interprets. Nothing is retained between frames but `State`. The
7
+ # `SceneNode.draw` interprets. Nothing is retained between frames but `State`. The
8
8
  # deepest recursion is the 700 drifters; no loops run in step or view.
9
9
  #
10
10
  # Entity counts: 5000 sprites (50 groups x 100), 500 rects, 200 circles, 50 texts,
11
11
  # plus 200 animated sprites (Pulsers, pose = Anim.at(anim, clock - start)) and
12
12
  # 20 Ui.button buttons (tagged ids 40000..40019, screen space, outside the camera).
13
13
  # `State.ui` is a Ui.UiState fed by mouse input: update turns the mouse into a
14
- # UiInput, hit-tests the button layer with Picture.hitId, and calls Ui.step.
14
+ # UiInput, hit-tests the button layer with SceneNode.hitId, and calls Ui.step.
15
15
  module stress_state_view
16
16
  import std/Str
17
17
  import std/Int
@@ -21,7 +21,7 @@ import std/List
21
21
  import std/game/Vec2
22
22
  import std/game/Color
23
23
  import std/game/Game
24
- import std/game/functional/Picture
24
+ import std/game/functional/FunctionalScene
25
25
  import std/game/functional/Anim
26
26
  import std/game/functional/Ui
27
27
  import std/Io
@@ -143,7 +143,7 @@ fun countClicks(es: List[UiEvent]) -> Int = match es
143
143
 
144
144
  # Feeds one pointer input through Ui.step against the button layer.
145
145
  fun stepUi(s: State, input: UiInput) -> State =
146
- hit := Picture.hitId(buttonsView(s.ui), input.x, input.y)
146
+ hit := buttonsView(s.ui).hitId(input.x, input.y)
147
147
  r := Ui.step(s.ui, input, hit)
148
148
  State(
149
149
  sheet: s.sheet, white: s.white, spinners: s.spinners, drifters: s.drifters, counters: s.counters,
@@ -217,7 +217,7 @@ export fun update(io: Io, w: World, dt: Float) =
217
217
 
218
218
  export fun draw(io: Io, w: World) =
219
219
  io.clear(Color.rgb(0.05, 0.06, 0.1))
220
- Picture.draw(io, view(w.state))
220
+ view(w.state).draw(io)
221
221
 
222
222
  test "ten frames of step run"
223
223
  s := newState(0, 0)
plum-game/web-test/fixtures/stress_state_view_deep.plum CHANGED
@@ -6,7 +6,7 @@
6
6
  # `State` is cons `List` lists (each spinner owns the `List` of its 100 dancers);
7
7
  # `step` is pure, a structural recursion that returns the next state; `view` is pure
8
8
  # and rebuilds the whole SceneNode tree every frame with recursive functions, which
9
- # `Picture.draw` interprets. Nothing is retained between frames but `State`. The
9
+ # `SceneNode.draw` interprets. Nothing is retained between frames but `State`. The
10
10
  # deepest recursion is the 700 drifters; no loops run in step or view.
11
11
  module stress_state_view
12
12
  import std/Str
@@ -17,7 +17,7 @@ import std/List
17
17
  import std/game/Vec2
18
18
  import std/game/Color
19
19
  import std/game/Game
20
- import std/game/functional/Picture
20
+ import std/game/functional/FunctionalScene
21
21
  import std/Io
22
22
 
23
23
  primitive Counts =
@@ -179,7 +179,7 @@ export fun update(w: World, dt: Float) =
179
179
 
180
180
  export fun draw(io: Io, w: World) =
181
181
  io.clear(Color.rgb(0.05, 0.06, 0.1))
182
- Picture.draw(io, view(w.state))
182
+ view(w.state).draw(io)
183
183
 
184
184
  test "ten frames of step run"
185
185
  s := newState(0, 0)
plum-std/game/Color.plum CHANGED
@@ -19,6 +19,10 @@ struct Color(r: Float, g: Float, b: Float, a: Float) =
19
19
  fun withAlpha(self, a: Float) -> Color =
20
20
  Color(r: self.r, g: self.g, b: self.b, a: a)
21
21
 
22
+ # Each channel multiplied by `o`'s (a tint applied to this colour).
23
+ fun times(self, o: Color) -> Color =
24
+ Color(r: self.r * o.r, g: self.g * o.g, b: self.b * o.b, a: self.a * o.a)
25
+
22
26
  fun eq(self, o: Color) -> Bool =
23
27
  self.r == o.r && self.g == o.g && self.b == o.b && self.a == o.a
24
28
 
@@ -26,6 +30,9 @@ test "Color.rgb is opaque and withAlpha replaces only alpha"
26
30
  assert Color.rgb(1.0, 0.5, 0.0) == Color(r: 1.0, g: 0.5, b: 0.0, a: 1.0)
27
31
  assert Color.Red.withAlpha(0.25) == Color(r: 1.0, g: 0.0, b: 0.0, a: 0.25)
28
32
 
33
+ test "Color.times multiplies each channel"
34
+ assert Color(r: 0.5, g: 1.0, b: 0.0, a: 0.5).times(Color(r: 0.5, g: 0.5, b: 1.0, a: 1.0)) == Color(r: 0.25, g: 0.5, b: 0.0, a: 0.5)
35
+
29
36
  test "Color constants"
30
37
  assert Color.White.r == 1.0
31
38
  assert Color.Black.a == 1.0
plum-std/game/functional/FunctionalScene.plum ADDED
@@ -0,0 +1,357 @@
1
+ module std
2
+ import std/Int
3
+ import std/Float
4
+ import std/Str
5
+ import std/Slice
6
+ import std/List
7
+ import std/Math
8
+ import std/game/Vec2
9
+ import std/game/Color
10
+ import std/game/Affine
11
+ import std/game/Game
12
+ import std/Io
13
+
14
+ # The functional scene: a `SceneNode` is plain data (shapes, sprites, text, groups
15
+ # with a transform, filters), rebuilt every frame and never mutated. Its methods draw
16
+ # it, measure it and hit-test it by structural recursion, passing the parent matrix
17
+ # and alpha down as arguments. Children are a `List[SceneNode]`; the static
18
+ # `SceneNode` helpers over such a list (`drawKids`, `boundsKids`, ..) do the same walk
19
+ # for each child. `Compose(first, then)` applies `then` first, then `first` (`first`
20
+ # is the outermost).
21
+
22
+ enum Transform =
23
+ | Identity
24
+ | Translate(dx: Float, dy: Float)
25
+ | Rotate(radians: Float)
26
+ | Scale(sx: Float, sy: Float)
27
+ | Compose(first: Transform, then: Transform)
28
+
29
+ fun matrix(self) -> Affine = match self
30
+ Identity => Affine.Identity
31
+ Translate(dx, dy) => Affine(a: 1.0, b: 0.0, c: 0.0, d: 1.0, e: dx, f: dy)
32
+ Rotate(rad) => Affine(a: Math.cos(rad), b: Math.sin(rad), c: 0.0 - Math.sin(rad), d: Math.cos(rad), e: 0.0, f: 0.0)
33
+ Scale(sx, sy) => Affine(a: sx, b: 0.0, c: 0.0, d: sy, e: 0.0, f: 0.0)
34
+ Compose(first, then) => first.matrix().mul(then.matrix())
35
+
36
+ # `mode` is a `Blend` constant from std/game/Game.
37
+ enum Effect =
38
+ | Tint(color: Color)
39
+ | Alpha(alpha: Float)
40
+ | BlendFx(mode: Int)
41
+
42
+ # The color a filtered texture is composited with.
43
+ fun tint(self, alpha: Float) -> Color = match self
44
+ Tint(color) => color.withAlpha(color.a * alpha)
45
+ Alpha(a) => Color.White.withAlpha(a * alpha)
46
+ _ => Color.White.withAlpha(alpha)
47
+
48
+ struct Rect(x: Float, y: Float, w: Float, h: Float) =
49
+ # The bounding box, in the outer space, of the box (x, y, w, h) seen through `m`.
50
+ fun through(x: Float, y: Float, w: Float, h: Float, m: Affine) -> Rect =
51
+ p1 := m.apply(Vec2(x: x, y: y))
52
+ p2 := m.apply(Vec2(x: x + w, y: y))
53
+ p3 := m.apply(Vec2(x: x, y: y + h))
54
+ p4 := m.apply(Vec2(x: x + w, y: y + h))
55
+ x0 := p1.x.min(p2.x).min(p3.x.min(p4.x))
56
+ x1 := p1.x.max(p2.x).max(p3.x.max(p4.x))
57
+ y0 := p1.y.min(p2.y).min(p3.y.min(p4.y))
58
+ y1 := p1.y.max(p2.y).max(p3.y.max(p4.y))
59
+ Rect(x: x0, y: y0, w: x1 - x0, h: y1 - y0)
60
+
61
+ # The bounding box of a line `thickness` wide seen through `m`.
62
+ fun throughLine(x1: Float, y1: Float, x2: Float, y2: Float, thickness: Float, m: Affine) -> Rect =
63
+ lx := x1.min(x2) - thickness / 2.0
64
+ ly := y1.min(y2) - thickness / 2.0
65
+ Rect.through(lx, ly, {x1 - x2}.abs() + thickness, {y1 - y2}.abs() + thickness, m)
66
+
67
+ # The smallest box holding `a` (when there is one) and `b`.
68
+ fun union(a: Rect?, b: Rect) -> Rect =
69
+ if ra := a
70
+ x0 := ra.x.min(b.x)
71
+ y0 := ra.y.min(b.y)
72
+ x1 := {ra.x + ra.w}.max(b.x + b.w)
73
+ y1 := {ra.y + ra.h}.max(b.y + b.h)
74
+ return Rect(x: x0, y: y0, w: x1 - x0, h: y1 - y0)
75
+ b
76
+
77
+ fun contains(self, px: Float, py: Float) -> Bool =
78
+ px >= self.x && px <= self.x + self.w && py >= self.y && py <= self.y + self.h
79
+
80
+ # A Sprite is a texture region `w` by `h` with its top-left at (x, y).
81
+ # A Filter draws its child into `target`, then composites it with the effect.
82
+ # `target` is a texture the game creates ONCE at load with `textureCreate`; draw
83
+ # creates nothing, so a view does not leak a texture per frame. The size of
84
+ # `target` bounds what the filter can show: child pixels beyond it are cut off.
85
+ # A negative target draws nothing.
86
+ enum SceneNode =
87
+ | Group(transform: Transform, children: List[SceneNode])
88
+ | Filter(effect: Effect, child: SceneNode, target: Int)
89
+ | Sprite(tex: Int, x: Float, y: Float, w: Float, h: Float, srcX: Float, srcY: Float, srcW: Float, srcH: Float, tint: Color)
90
+ | Text(text: Str, x: Float, y: Float, size: Float, color: Color)
91
+ | RectShape(x: Float, y: Float, w: Float, h: Float, color: Color)
92
+ | CircleShape(cx: Float, cy: Float, r: Float, color: Color)
93
+ | LineShape(x1: Float, y1: Float, x2: Float, y2: Float, thickness: Float, color: Color)
94
+ | Tagged(id: Int, child: SceneNode)
95
+
96
+ # Draws the scene to the current target.
97
+ fun draw(self, io: Io) =
98
+ self.drawWith(io, Affine.Identity, 1.0, 0 - 1)
99
+
100
+ # The bounding box of the scene in its own space (all zero when empty).
101
+ fun bounds(self) -> Rect =
102
+ if r := self.boundsWith(Affine.Identity)
103
+ return r
104
+ Rect(x: 0.0, y: 0.0, w: 0.0, h: 0.0)
105
+
106
+ # Child indices from the root down to the topmost hit leaf; empty on a miss.
107
+ fun hitPath(self, x: Float, y: Float) -> Slice[Int] =
108
+ if p := self.hitWith(x, y)
109
+ return p
110
+ Slice[Int]()
111
+
112
+ # The id of the innermost Tagged node under (x, y), or null. Later siblings win.
113
+ fun hitId(self, x: Float, y: Float) -> Int? =
114
+ self.hitIdWith(null, x, y)
115
+
116
+ # --- drawing ---
117
+
118
+ # Draws under the parent matrix `m` and alpha into `target` (negative: the screen).
119
+ fun drawWith(self, io: Io, m: Affine, alpha: Float, target: Int) = match self
120
+ RectShape(x, y, w, h, color) =>
121
+ sx := w * m.scaleX()
122
+ sy := h * m.scaleY()
123
+ c := m.apply(Vec2(x: x + w / 2.0, y: y + h / 2.0))
124
+ io.drawRect(c.x - sx / 2.0, c.y - sy / 2.0, sx, sy, color.withAlpha(color.a * alpha))
125
+ CircleShape(cx, cy, r, ccolor) =>
126
+ cc := m.apply(Vec2(x: cx, y: cy))
127
+ io.drawCircle(cc.x, cc.y, r * m.scaleX(), ccolor.withAlpha(ccolor.a * alpha))
128
+ LineShape(x1, y1, x2, y2, th, lcolor) =>
129
+ p := m.apply(Vec2(x: x1, y: y1))
130
+ q := m.apply(Vec2(x: x2, y: y2))
131
+ io.drawLine(p.x, p.y, q.x, q.y, th * m.scaleX(), lcolor.withAlpha(lcolor.a * alpha))
132
+ Sprite(tex, sx0, sy0, sw0, sh0, srcX, srcY, srcW, srcH, tint) =>
133
+ sw := sw0 * m.scaleX()
134
+ sh := sh0 * m.scaleY()
135
+ sc := m.apply(Vec2(x: sx0 + sw0 / 2.0, y: sy0 + sh0 / 2.0))
136
+ io.drawTextureEx(tex, srcX, srcY, srcW, srcH, sc.x - sw / 2.0, sc.y - sh / 2.0, sw, sh, m.rotation(), false, tint.withAlpha(tint.a * alpha))
137
+ Text(txt, tx, ty, size, tcolor) =>
138
+ tp := m.apply(Vec2(x: tx, y: ty))
139
+ io.drawText(txt, tp.x, tp.y, size * m.scaleY(), tcolor.withAlpha(tcolor.a * alpha))
140
+ Group(tr, kids) => SceneNode.drawKids(io, kids, m.mul(tr.matrix()), alpha, target)
141
+ Filter(effect, child, tex) => SceneNode.drawFiltered(io, effect, child, tex, m, alpha, target)
142
+ Tagged(_, child) => child.drawWith(io, m, alpha, target)
143
+
144
+ fun drawKids(io: Io, kids: List[SceneNode], m: Affine, alpha: Float, target: Int) = match kids
145
+ Nil => 0
146
+ Cons(head, tail) =>
147
+ head.drawWith(io, m, alpha, target)
148
+ SceneNode.drawKids(io, tail, m, alpha, target)
149
+
150
+ fun drawFiltered(io: Io, effect: Effect, child: SceneNode, tex: Int, m: Affine, alpha: Float, target: Int) = match effect
151
+ BlendFx(mode) =>
152
+ io.blendMode(mode)
153
+ child.drawWith(io, m, alpha, target)
154
+ io.blendMode(Blend.Normal)
155
+ _ =>
156
+ if b := child.boundsWith(m)
157
+ if tex >= 0
158
+ w := {Int(b.w) + 1}.min(io.textureWidth(tex))
159
+ h := {Int(b.h) + 1}.min(io.textureHeight(tex))
160
+ io.renderTarget(tex)
161
+ io.clear(Color.Transparent)
162
+ shift := Affine(a: 1.0, b: 0.0, c: 0.0, d: 1.0, e: 0.0 - b.x, f: 0.0 - b.y)
163
+ child.drawWith(io, shift.mul(m), 1.0, tex)
164
+ io.renderTarget(target)
165
+ io.drawTextureEx(tex, 0.0, 0.0, Float(w), Float(h), b.x, b.y, Float(w), Float(h), 0.0, false, effect.tint(alpha))
166
+
167
+ # --- bounds ---
168
+
169
+ # The bounds of the node drawn under `m`, or null when it has no extent.
170
+ fun boundsWith(self, m: Affine) -> Rect? = match self
171
+ RectShape(x, y, w, h, _) => Rect.through(x, y, w, h, m)
172
+ CircleShape(cx, cy, r, _) => Rect.through(cx - r, cy - r, r * 2.0, r * 2.0, m)
173
+ LineShape(x1, y1, x2, y2, th, _) => Rect.throughLine(x1, y1, x2, y2, th, m)
174
+ Sprite(_, sx, sy, sw, sh, _, _, _, _, _) => Rect.through(sx, sy, sw, sh, m)
175
+ Text(txt, tx, ty, size, _) => Rect.through(tx, ty, size * 0.6 * Float(txt.length()), size, m)
176
+ Group(tr, kids) => SceneNode.boundsKids(kids, m.mul(tr.matrix()), null)
177
+ Filter(_, child, _) => child.boundsWith(m)
178
+ Tagged(_, child) => child.boundsWith(m)
179
+
180
+ fun boundsKids(kids: List[SceneNode], m: Affine, acc: Rect?) -> Rect? = match kids
181
+ Nil => acc
182
+ Cons(head, tail) =>
183
+ if cr := head.boundsWith(m)
184
+ SceneNode.boundsKids(tail, m, Rect.union(acc, cr))
185
+ else
186
+ SceneNode.boundsKids(tail, m, acc)
187
+
188
+ # --- hit testing ---
189
+
190
+ # The child indices from the node down to the hit leaf, or null on a miss. A leaf
191
+ # that is hit is an empty path. (px, py) is in the node's own space.
192
+ fun hitWith(self, px: Float, py: Float) -> Slice[Int]? = match self
193
+ RectShape(x, y, w, h, _) => SceneNode.leaf(Rect(x: x, y: y, w: w, h: h).contains(px, py))
194
+ CircleShape(cx, cy, r, _) => SceneNode.leaf(Math.hypot(px - cx, py - cy) <= r)
195
+ LineShape(x1, y1, x2, y2, th, _) => SceneNode.leaf(SceneNode.nearLine(x1, y1, x2, y2, th, px, py))
196
+ Sprite(_, sx, sy, sw, sh, _, _, _, _, _) => SceneNode.leaf(Rect(x: sx, y: sy, w: sw, h: sh).contains(px, py))
197
+ Text(txt, tx, ty, size, _) => SceneNode.leaf(Rect(x: tx, y: ty, w: size * 0.6 * Float(txt.length()), h: size).contains(px, py))
198
+ Group(tr, kids) =>
199
+ if gi := tr.matrix().invert()
200
+ lp := gi.apply(Vec2(x: px, y: py))
201
+ SceneNode.hitKids(kids, 0, lp.x, lp.y)
202
+ else
203
+ null
204
+ Filter(_, child, _) =>
205
+ if fp := child.hitWith(px, py)
206
+ SceneNode.prepend(0, fp)
207
+ else
208
+ null
209
+ Tagged(_, child) =>
210
+ if tp := child.hitWith(px, py)
211
+ SceneNode.prepend(0, tp)
212
+ else
213
+ null
214
+
215
+ # Tries the later children first (they are drawn on top); `i` is the index of
216
+ # `kids`' head. The first hit found from the back wins.
217
+ fun hitKids(kids: List[SceneNode], i: Int, px: Float, py: Float) -> Slice[Int]? = match kids
218
+ Nil => null
219
+ Cons(head, tail) =>
220
+ if later := SceneNode.hitKids(tail, i + 1, px, py)
221
+ later
222
+ else
223
+ if sub := head.hitWith(px, py)
224
+ SceneNode.prepend(i, sub)
225
+ else
226
+ null
227
+
228
+ # The id of the innermost Tagged node whose subtree is hit at (px, py), or null.
229
+ # `outer` is the id of the nearest enclosing tag (null at the root).
230
+ fun hitIdWith(self, outer: Int?, px: Float, py: Float) -> Int? = match self
231
+ Group(tr, kids) =>
232
+ if gi := tr.matrix().invert()
233
+ lp := gi.apply(Vec2(x: px, y: py))
234
+ SceneNode.hitIdKids(kids, outer, lp.x, lp.y)
235
+ else
236
+ null
237
+ Filter(_, child, _) => child.hitIdWith(outer, px, py)
238
+ Tagged(id, child) => child.hitIdWith(id, px, py)
239
+ _ =>
240
+ if self.hitWith(px, py) != null
241
+ outer
242
+ else
243
+ null
244
+
245
+ # Later siblings are drawn on top, so they are tried first.
246
+ fun hitIdKids(kids: List[SceneNode], outer: Int?, px: Float, py: Float) -> Int? = match kids
247
+ Nil => null
248
+ Cons(head, tail) =>
249
+ if later := SceneNode.hitIdKids(tail, outer, px, py)
250
+ later
251
+ else
252
+ head.hitIdWith(outer, px, py)
253
+
254
+ fun leaf(hit: Bool) -> Slice[Int]? =
255
+ if hit
256
+ return Slice[Int]()
257
+ null
258
+
259
+ # `i` followed by the path `sub`.
260
+ fun prepend(i: Int, sub: Slice[Int]) -> Slice[Int] =
261
+ path := Slice(i)
262
+ for j := range sub.length()
263
+ path.push(sub.at(j))
264
+ path
265
+
266
+ fun nearLine(x1: Float, y1: Float, x2: Float, y2: Float, thickness: Float, px: Float, py: Float) -> Bool =
267
+ dx := x2 - x1
268
+ dy := y2 - y1
269
+ len2 := dx * dx + dy * dy
270
+ t := if len2 == 0.0 then 0.0 else {{px - x1} * dx + {py - y1} * dy} / len2
271
+ tc := t.max(0.0).min(1.0)
272
+ qx := x1 + dx * tc
273
+ qy := y1 + dy * tc
274
+ Math.hypot(px - qx, py - qy) <= thickness.max(1.0) / 2.0
275
+
276
+ fun picNear(a: Float, b: Float) -> Bool =
277
+ {a - b}.abs() < 1e-6
278
+
279
+ fun picRect(x: Float, y: Float, w: Float, h: Float, c: Color) -> SceneNode =
280
+ RectShape(x: x, y: y, w: w, h: h, color: c)
281
+
282
+ test "bounds_translate_scale"
283
+ g := Group(transform: Compose(first: Translate(dx: 5.0, dy: 5.0), then: Scale(sx: 2.0, sy: 2.0)), children: Cons(head: picRect(0.0, 0.0, 10.0, 10.0, Color.Red), tail: Nil))
284
+ r := g.bounds()
285
+ assert picNear(r.x, 5.0)
286
+ assert picNear(r.y, 5.0)
287
+ assert picNear(r.w, 20.0)
288
+ assert picNear(r.h, 20.0)
289
+
290
+ test "bounds_rotate_quarter"
291
+ g := Group(transform: Rotate(radians: 1.5707963267948966), children: Cons(head: picRect(0.0, 0.0, 10.0, 4.0, Color.Red), tail: Nil))
292
+ r := g.bounds()
293
+ assert picNear(r.x, 0.0 - 4.0)
294
+ assert picNear(r.y, 0.0)
295
+ assert picNear(r.w, 4.0)
296
+ assert picNear(r.h, 10.0)
297
+
298
+ test "hit_path_nested"
299
+ inner := Group(transform: Translate(dx: 10.0, dy: 0.0), children: Cons(head: picRect(0.0, 0.0, 10.0, 10.0, Color.Blue), tail: Nil))
300
+ g := Group(transform: Identity, children: Cons(head: picRect(0.0, 0.0, 10.0, 10.0, Color.Red), tail: Cons(head: inner, tail: Nil)))
301
+ p := g.hitPath(15.0, 5.0)
302
+ assert p.length() == 2
303
+ assert p.at(0) == 1
304
+ assert p.at(1) == 0
305
+ q := g.hitPath(5.0, 5.0)
306
+ assert q.length() == 1
307
+ assert q.at(0) == 0
308
+
309
+ test "hit_path_last_child_wins"
310
+ g := Group(transform: Identity, children: Cons(head: picRect(0.0, 0.0, 10.0, 10.0, Color.Red), tail: Cons(head: picRect(0.0, 0.0, 10.0, 10.0, Color.Blue), tail: Nil)))
311
+ p := g.hitPath(5.0, 5.0)
312
+ assert p.length() == 1
313
+ assert p.at(0) == 1
314
+
315
+ test "hit_miss_empty"
316
+ g := Group(transform: Identity, children: Cons(head: picRect(0.0, 0.0, 10.0, 10.0, Color.Red), tail: Nil))
317
+ assert g.hitPath(50.0, 50.0).length() == 0
318
+
319
+ test "filter_bounds_is_child_bounds"
320
+ g := Filter(effect: Alpha(alpha: 0.5), child: picRect(1.0, 2.0, 10.0, 4.0, Color.Red), target: 0 - 1)
321
+ r := g.bounds()
322
+ assert picNear(r.x, 1.0)
323
+ assert picNear(r.y, 2.0)
324
+ assert picNear(r.w, 10.0)
325
+ assert picNear(r.h, 4.0)
326
+
327
+ test "tagged_bounds_and_path"
328
+ g := Group(transform: Identity, children: Cons(head: Tagged(id: 7, child: picRect(1.0, 2.0, 10.0, 4.0, Color.Red)), tail: Nil))
329
+ r := g.bounds()
330
+ assert picNear(r.x, 1.0)
331
+ assert picNear(r.w, 10.0)
332
+ p := g.hitPath(5.0, 3.0)
333
+ assert p.length() == 2
334
+ assert p.at(0) == 0
335
+ assert p.at(1) == 0
336
+
337
+ test "hit_id_innermost_and_topmost"
338
+ inner := Tagged(id: 2, child: picRect(0.0, 0.0, 10.0, 10.0, Color.Blue))
339
+ outer := Tagged(id: 1, child: Group(transform: Identity, children: Cons(head: picRect(0.0, 0.0, 20.0, 20.0, Color.Red), tail: Cons(head: inner, tail: Nil))))
340
+ top := Tagged(id: 3, child: picRect(15.0, 15.0, 10.0, 10.0, Color.Red))
341
+ g := Group(transform: Translate(dx: 1.0, dy: 0.0), children: Cons(head: outer, tail: Cons(head: top, tail: Nil)))
342
+ a := g.hitId(6.0, 5.0)
343
+ if va := a
344
+ assert va == 2
345
+ else
346
+ assert false
347
+ b := g.hitId(19.0, 5.0)
348
+ if vb := b
349
+ assert vb == 1
350
+ else
351
+ assert false
352
+ c := g.hitId(17.0, 17.0)
353
+ if vc := c
354
+ assert vc == 3
355
+ else
356
+ assert false
357
+ assert g.hitId(100.0, 100.0) == null
plum-std/game/functional/Picture.plum DELETED
@@ -1,352 +0,0 @@
1
- module std
2
- import std/Int
3
- import std/Float
4
- import std/Str
5
- import std/Slice
6
- import std/List
7
- import std/Math
8
- import std/game/Vec2
9
- import std/game/Color
10
- import std/game/Affine
11
- import std/game/Game
12
- import std/Io
13
-
14
- # A recursive picture: a SceneNode is plain data (shapes, sprites, text, groups
15
- # with a transform, filters) and `Picture` is a set of structurally recursive
16
- # functions that draw it, measure it and hit-test it. Children are a recursive
17
- # `List[SceneNode]` list, so every operation recurses over SceneNode and List[SceneNode], passing
18
- # the parent matrix and alpha down as arguments. Nothing here mutates the picture.
19
- # `Compose(first, then)` applies `then` first, then `first` (`first` is the outermost).
20
-
21
- enum Transform =
22
- | Identity
23
- | Translate(dx: Float, dy: Float)
24
- | Rotate(radians: Float)
25
- | Scale(sx: Float, sy: Float)
26
- | Compose(first: Transform, then: Transform)
27
-
28
- # `mode` is a `Blend` constant from std/game/Game.
29
- enum Effect =
30
- | Tint(color: Color)
31
- | Alpha(alpha: Float)
32
- | BlendFx(mode: Int)
33
-
34
- # A Sprite is a texture region `w` by `h` with its top-left at (x, y).
35
- # A Filter draws its child into `target`, then composites it with the effect.
36
- # `target` is a texture the game creates ONCE at load with `textureCreate`; draw
37
- # creates nothing, so a view does not leak a texture per frame. The size of
38
- # `target` bounds what the filter can show: child pixels beyond it are cut off.
39
- # A negative target draws nothing.
40
- enum SceneNode =
41
- | Group(transform: Transform, children: List[SceneNode])
42
- | Filter(effect: Effect, child: SceneNode, target: Int)
43
- | Sprite(tex: Int, x: Float, y: Float, w: Float, h: Float, srcX: Float, srcY: Float, srcW: Float, srcH: Float, tint: Color)
44
- | Text(text: Str, x: Float, y: Float, size: Float, color: Color)
45
- | RectShape(x: Float, y: Float, w: Float, h: Float, color: Color)
46
- | CircleShape(cx: Float, cy: Float, r: Float, color: Color)
47
- | LineShape(x1: Float, y1: Float, x2: Float, y2: Float, thickness: Float, color: Color)
48
- | Tagged(id: Int, child: SceneNode)
49
-
50
-
51
- struct Rect(x: Float, y: Float, w: Float, h: Float)
52
-
53
- fun picMatrix(t: Transform) -> Affine = match t
54
- Identity => Affine.Identity
55
- Translate(dx, dy) => Affine(a: 1.0, b: 0.0, c: 0.0, d: 1.0, e: dx, f: dy)
56
- Rotate(rad) => Affine(a: Math.cos(rad), b: Math.sin(rad), c: 0.0 - Math.sin(rad), d: Math.cos(rad), e: 0.0, f: 0.0)
57
- Scale(sx, sy) => Affine(a: sx, b: 0.0, c: 0.0, d: sy, e: 0.0, f: 0.0)
58
- Compose(first, then) => picMatrix(first).mul(picMatrix(then))
59
-
60
- # The bounding box, in the outer space, of the box (x, y, w, h) seen through `m`.
61
- fun picBox(x: Float, y: Float, w: Float, h: Float, m: Affine) -> Rect =
62
- p1 := m.apply(Vec2(x: x, y: y))
63
- p2 := m.apply(Vec2(x: x + w, y: y))
64
- p3 := m.apply(Vec2(x: x, y: y + h))
65
- p4 := m.apply(Vec2(x: x + w, y: y + h))
66
- x0 := p1.x.min(p2.x).min(p3.x.min(p4.x))
67
- x1 := p1.x.max(p2.x).max(p3.x.max(p4.x))
68
- y0 := p1.y.min(p2.y).min(p3.y.min(p4.y))
69
- y1 := p1.y.max(p2.y).max(p3.y.max(p4.y))
70
- Rect(x: x0, y: y0, w: x1 - x0, h: y1 - y0)
71
-
72
- fun picUnion(a: Rect?, b: Rect) -> Rect =
73
- if ra := a
74
- x0 := ra.x.min(b.x)
75
- y0 := ra.y.min(b.y)
76
- x1 := {ra.x + ra.w}.max(b.x + b.w)
77
- y1 := {ra.y + ra.h}.max(b.y + b.h)
78
- return Rect(x: x0, y: y0, w: x1 - x0, h: y1 - y0)
79
- b
80
-
81
- fun picBoxLine(x1: Float, y1: Float, x2: Float, y2: Float, thickness: Float, m: Affine) -> Rect =
82
- lx := x1.min(x2) - thickness / 2.0
83
- ly := y1.min(y2) - thickness / 2.0
84
- picBox(lx, ly, {x1 - x2}.abs() + thickness, {y1 - y2}.abs() + thickness, m)
85
-
86
- # The bounds of `node` drawn under `m`, or null when it has no extent.
87
- fun picBounds(node: SceneNode, m: Affine) -> Rect? = match node
88
- RectShape(x, y, w, h, _) => picBox(x, y, w, h, m)
89
- CircleShape(cx, cy, r, _) => picBox(cx - r, cy - r, r * 2.0, r * 2.0, m)
90
- LineShape(x1, y1, x2, y2, th, _) => picBoxLine(x1, y1, x2, y2, th, m)
91
- Sprite(_, sx, sy, sw, sh, _, _, _, _, _) => picBox(sx, sy, sw, sh, m)
92
- Text(txt, tx, ty, size, _) => picBox(tx, ty, size * 0.6 * Float(txt.length()), size, m)
93
- Group(tr, kids) => picBoundsKids(kids, m.mul(picMatrix(tr)), null)
94
- Filter(_, child, _) => picBounds(child, m)
95
- Tagged(_, child) => picBounds(child, m)
96
-
97
- fun picBoundsKids(kids: List[SceneNode], m: Affine, acc: Rect?) -> Rect? = match kids
98
- Nil => acc
99
- Cons(head, tail) =>
100
- if cr := picBounds(head, m)
101
- picBoundsKids(tail, m, picUnion(acc, cr))
102
- else
103
- picBoundsKids(tail, m, acc)
104
-
105
- fun picInBox(x: Float, y: Float, w: Float, h: Float, px: Float, py: Float) -> Bool =
106
- px >= x && px <= x + w && py >= y && py <= y + h
107
-
108
- fun picNearLine(x1: Float, y1: Float, x2: Float, y2: Float, thickness: Float, px: Float, py: Float) -> Bool =
109
- dx := x2 - x1
110
- dy := y2 - y1
111
- len2 := dx * dx + dy * dy
112
- t := if len2 == 0.0 then 0.0 else {{px - x1} * dx + {py - y1} * dy} / len2
113
- tc := t.max(0.0).min(1.0)
114
- qx := x1 + dx * tc
115
- qy := y1 + dy * tc
116
- Math.hypot(px - qx, py - qy) <= thickness.max(1.0) / 2.0
117
-
118
- fun picLeaf(hit: Bool) -> Slice[Int]? =
119
- if hit
120
- return Slice[Int]()
121
- null
122
-
123
- # `i` followed by the path `sub`.
124
- fun picPrepend(i: Int, sub: Slice[Int]) -> Slice[Int] =
125
- path := Slice(i)
126
- for j := range sub.length()
127
- path.push(sub.at(j))
128
- path
129
-
130
- # The child indices from `node` down to the hit leaf, or null on a miss. A leaf
131
- # that is hit is an empty path. (px, py) is in `node`'s own space.
132
- fun picHit(node: SceneNode, px: Float, py: Float) -> Slice[Int]? = match node
133
- RectShape(x, y, w, h, _) => picLeaf(picInBox(x, y, w, h, px, py))
134
- CircleShape(cx, cy, r, _) => picLeaf(Math.hypot(px - cx, py - cy) <= r)
135
- LineShape(x1, y1, x2, y2, th, _) => picLeaf(picNearLine(x1, y1, x2, y2, th, px, py))
136
- Sprite(_, sx, sy, sw, sh, _, _, _, _, _) => picLeaf(picInBox(sx, sy, sw, sh, px, py))
137
- Text(txt, tx, ty, size, _) => picLeaf(picInBox(tx, ty, size * 0.6 * Float(txt.length()), size, px, py))
138
- Group(tr, kids) =>
139
- if gi := picMatrix(tr).invert()
140
- lp := gi.apply(Vec2(x: px, y: py))
141
- picHitKids(kids, 0, lp.x, lp.y)
142
- else
143
- null
144
- Filter(_, child, _) =>
145
- if fp := picHit(child, px, py)
146
- picPrepend(0, fp)
147
- else
148
- null
149
- Tagged(_, child) =>
150
- if tp := picHit(child, px, py)
151
- picPrepend(0, tp)
152
- else
153
- null
154
-
155
- # The id of the innermost Tagged node whose subtree is hit at (px, py), or null.
156
- # `outer` is the id of the nearest enclosing tag (null at the root).
157
- fun picHitId(node: SceneNode, outer: Int?, px: Float, py: Float) -> Int? = match node
158
- Group(tr, kids) =>
159
- if gi := picMatrix(tr).invert()
160
- lp := gi.apply(Vec2(x: px, y: py))
161
- picHitIdKids(kids, outer, lp.x, lp.y)
162
- else
163
- null
164
- Filter(_, child, _) => picHitId(child, outer, px, py)
165
- Tagged(id, child) => picHitId(child, id, px, py)
166
- _ =>
167
- if picHit(node, px, py) != null
168
- outer
169
- else
170
- null
171
-
172
- # Later siblings are drawn on top, so they are tried first.
173
- fun picHitIdKids(kids: List[SceneNode], outer: Int?, px: Float, py: Float) -> Int? = match kids
174
- Nil => null
175
- Cons(head, tail) =>
176
- if later := picHitIdKids(tail, outer, px, py)
177
- later
178
- else
179
- picHitId(head, outer, px, py)
180
-
181
- # Tries the later children first (they are drawn on top); `i` is the index of
182
- # `kids`' head. The first hit found from the back wins.
183
- fun picHitKids(kids: List[SceneNode], i: Int, px: Float, py: Float) -> Slice[Int]? = match kids
184
- Nil => null
185
- Cons(head, tail) =>
186
- if later := picHitKids(tail, i + 1, px, py)
187
- later
188
- else
189
- if sub := picHit(head, px, py)
190
- picPrepend(i, sub)
191
- else
192
- null
193
-
194
- fun picDraw(io: Io, node: SceneNode, m: Affine, alpha: Float, target: Int) = match node
195
- RectShape(x, y, w, h, color) =>
196
- sx := w * m.scaleX()
197
- sy := h * m.scaleY()
198
- c := m.apply(Vec2(x: x + w / 2.0, y: y + h / 2.0))
199
- io.drawRect(c.x - sx / 2.0, c.y - sy / 2.0, sx, sy, color.withAlpha(color.a * alpha))
200
- CircleShape(cx, cy, r, ccolor) =>
201
- cc := m.apply(Vec2(x: cx, y: cy))
202
- io.drawCircle(cc.x, cc.y, r * m.scaleX(), ccolor.withAlpha(ccolor.a * alpha))
203
- LineShape(x1, y1, x2, y2, th, lcolor) =>
204
- p := m.apply(Vec2(x: x1, y: y1))
205
- q := m.apply(Vec2(x: x2, y: y2))
206
- io.drawLine(p.x, p.y, q.x, q.y, th * m.scaleX(), lcolor.withAlpha(lcolor.a * alpha))
207
- Sprite(tex, sx0, sy0, sw0, sh0, srcX, srcY, srcW, srcH, tint) =>
208
- sw := sw0 * m.scaleX()
209
- sh := sh0 * m.scaleY()
210
- sc := m.apply(Vec2(x: sx0 + sw0 / 2.0, y: sy0 + sh0 / 2.0))
211
- io.drawTextureEx(tex, srcX, srcY, srcW, srcH, sc.x - sw / 2.0, sc.y - sh / 2.0, sw, sh, m.rotation(), false, tint.withAlpha(tint.a * alpha))
212
- Text(txt, tx, ty, size, tcolor) =>
213
- tp := m.apply(Vec2(x: tx, y: ty))
214
- io.drawText(txt, tp.x, tp.y, size * m.scaleY(), tcolor.withAlpha(tcolor.a * alpha))
215
- Group(tr, kids) => picDrawKids(io, kids, m.mul(picMatrix(tr)), alpha, target)
216
- Filter(effect, child, tex) => picFilter(io, effect, child, tex, m, alpha, target)
217
- Tagged(_, child) => picDraw(io, child, m, alpha, target)
218
-
219
- fun picDrawKids(io: Io, kids: List[SceneNode], m: Affine, alpha: Float, target: Int) = match kids
220
- Nil => 0
221
- Cons(head, tail) =>
222
- picDraw(io, head, m, alpha, target)
223
- picDrawKids(io, tail, m, alpha, target)
224
-
225
- fun picFilter(io: Io, effect: Effect, child: SceneNode, tex: Int, m: Affine, alpha: Float, target: Int) = match effect
226
- BlendFx(mode) =>
227
- io.blendMode(mode)
228
- picDraw(io, child, m, alpha, target)
229
- io.blendMode(Blend.Normal)
230
- _ =>
231
- if b := picBounds(child, m)
232
- if tex >= 0
233
- w := {Int(b.w) + 1}.min(io.textureWidth(tex))
234
- h := {Int(b.h) + 1}.min(io.textureHeight(tex))
235
- io.renderTarget(tex)
236
- io.clear(Color.Transparent)
237
- shift := Affine(a: 1.0, b: 0.0, c: 0.0, d: 1.0, e: 0.0 - b.x, f: 0.0 - b.y)
238
- picDraw(io, child, shift.mul(m), 1.0, tex)
239
- io.renderTarget(target)
240
- picComposite(io, effect, tex, b, alpha, w, h)
241
-
242
- fun picTint(e: Effect, alpha: Float) -> Color = match e
243
- Tint(color) => color.withAlpha(color.a * alpha)
244
- Alpha(a) => Color.White.withAlpha(a * alpha)
245
- _ => Color.White.withAlpha(alpha)
246
-
247
- fun picComposite(io: Io, e: Effect, tex: Int, b: Rect, alpha: Float, w: Int, h: Int) =
248
- io.drawTextureEx(tex, 0.0, 0.0, Float(w), Float(h), b.x, b.y, Float(w), Float(h), 0.0, false, picTint(e, alpha))
249
-
250
- primitive Picture =
251
- # Draws the picture to the current target, recursing with the parent matrix and alpha.
252
- fun draw(io: Io, node: SceneNode) =
253
- picDraw(io, node, Affine.Identity, 1.0, 0 - 1)
254
-
255
- # The bounding box of the picture in its own space (all zero when empty).
256
- fun bounds(node: SceneNode) -> Rect =
257
- if r := picBounds(node, Affine.Identity)
258
- return r
259
- Rect(x: 0.0, y: 0.0, w: 0.0, h: 0.0)
260
-
261
- # Child indices from the root down to the topmost hit leaf; empty on a miss.
262
- fun hitPath(node: SceneNode, x: Float, y: Float) -> Slice[Int] =
263
- if p := picHit(node, x, y)
264
- return p
265
- Slice[Int]()
266
-
267
- # The id of the innermost Tagged node under (x, y), or null. Later siblings win.
268
- fun hitId(node: SceneNode, x: Float, y: Float) -> Int? =
269
- picHitId(node, null, x, y)
270
-
271
- fun picNear(a: Float, b: Float) -> Bool =
272
- {a - b}.abs() < 1e-6
273
-
274
- fun picRect(x: Float, y: Float, w: Float, h: Float, c: Color) -> SceneNode =
275
- RectShape(x: x, y: y, w: w, h: h, color: c)
276
-
277
- test "bounds_translate_scale"
278
- g := Group(transform: Compose(first: Translate(dx: 5.0, dy: 5.0), then: Scale(sx: 2.0, sy: 2.0)), children: Cons(head: picRect(0.0, 0.0, 10.0, 10.0, Color.Red), tail: Nil))
279
- r := Picture.bounds(g)
280
- assert picNear(r.x, 5.0)
281
- assert picNear(r.y, 5.0)
282
- assert picNear(r.w, 20.0)
283
- assert picNear(r.h, 20.0)
284
-
285
- test "bounds_rotate_quarter"
286
- g := Group(transform: Rotate(radians: 1.5707963267948966), children: Cons(head: picRect(0.0, 0.0, 10.0, 4.0, Color.Red), tail: Nil))
287
- r := Picture.bounds(g)
288
- assert picNear(r.x, 0.0 - 4.0)
289
- assert picNear(r.y, 0.0)
290
- assert picNear(r.w, 4.0)
291
- assert picNear(r.h, 10.0)
292
-
293
- test "hit_path_nested"
294
- inner := Group(transform: Translate(dx: 10.0, dy: 0.0), children: Cons(head: picRect(0.0, 0.0, 10.0, 10.0, Color.Blue), tail: Nil))
295
- g := Group(transform: Identity, children: Cons(head: picRect(0.0, 0.0, 10.0, 10.0, Color.Red), tail: Cons(head: inner, tail: Nil)))
296
- p := Picture.hitPath(g, 15.0, 5.0)
297
- assert p.length() == 2
298
- assert p.at(0) == 1
299
- assert p.at(1) == 0
300
- q := Picture.hitPath(g, 5.0, 5.0)
301
- assert q.length() == 1
302
- assert q.at(0) == 0
303
-
304
- test "hit_path_last_child_wins"
305
- g := Group(transform: Identity, children: Cons(head: picRect(0.0, 0.0, 10.0, 10.0, Color.Red), tail: Cons(head: picRect(0.0, 0.0, 10.0, 10.0, Color.Blue), tail: Nil)))
306
- p := Picture.hitPath(g, 5.0, 5.0)
307
- assert p.length() == 1
308
- assert p.at(0) == 1
309
-
310
- test "hit_miss_empty"
311
- g := Group(transform: Identity, children: Cons(head: picRect(0.0, 0.0, 10.0, 10.0, Color.Red), tail: Nil))
312
- assert Picture.hitPath(g, 50.0, 50.0).length() == 0
313
-
314
- test "filter_bounds_is_child_bounds"
315
- g := Filter(effect: Alpha(alpha: 0.5), child: picRect(1.0, 2.0, 10.0, 4.0, Color.Red), target: 0 - 1)
316
- r := Picture.bounds(g)
317
- assert picNear(r.x, 1.0)
318
- assert picNear(r.y, 2.0)
319
- assert picNear(r.w, 10.0)
320
- assert picNear(r.h, 4.0)
321
-
322
- test "tagged_bounds_and_path"
323
- g := Group(transform: Identity, children: Cons(head: Tagged(id: 7, child: picRect(1.0, 2.0, 10.0, 4.0, Color.Red)), tail: Nil))
324
- r := Picture.bounds(g)
325
- assert picNear(r.x, 1.0)
326
- assert picNear(r.w, 10.0)
327
- p := Picture.hitPath(g, 5.0, 3.0)
328
- assert p.length() == 2
329
- assert p.at(0) == 0
330
- assert p.at(1) == 0
331
-
332
- test "hit_id_innermost_and_topmost"
333
- inner := Tagged(id: 2, child: picRect(0.0, 0.0, 10.0, 10.0, Color.Blue))
334
- outer := Tagged(id: 1, child: Group(transform: Identity, children: Cons(head: picRect(0.0, 0.0, 20.0, 20.0, Color.Red), tail: Cons(head: inner, tail: Nil))))
335
- top := Tagged(id: 3, child: picRect(15.0, 15.0, 10.0, 10.0, Color.Red))
336
- g := Group(transform: Translate(dx: 1.0, dy: 0.0), children: Cons(head: outer, tail: Cons(head: top, tail: Nil)))
337
- a := Picture.hitId(g, 6.0, 5.0)
338
- if va := a
339
- assert va == 2
340
- else
341
- assert false
342
- b := Picture.hitId(g, 19.0, 5.0)
343
- if vb := b
344
- assert vb == 1
345
- else
346
- assert false
347
- c := Picture.hitId(g, 17.0, 17.0)
348
- if vc := c
349
- assert vc == 3
350
- else
351
- assert false
352
- assert Picture.hitId(g, 100.0, 100.0) == null
plum-std/game/functional/Ui.plum CHANGED
@@ -5,10 +5,10 @@ import std/Str
5
5
  import std/Slice
6
6
  import std/List
7
7
  import std/game/Color
8
- import std/game/functional/Picture
8
+ import std/game/functional/FunctionalScene
9
9
 
10
10
  # A pure UI layer for the functional scene style. `Ui.step` takes the previous
11
- # UiState, one pointer input and the id under the pointer (from Picture.hitId on
11
+ # UiState, one pointer input and the id under the pointer (from SceneNode.hitId on
12
12
  # the current view) and returns the new state plus the UiEvents it raised. Same
13
13
  # button rules as PureScene: Down captures the pressed id; while captured only
14
14
  # that id gets Enter/Exit; Up releases (Click only when still over it).
@@ -204,7 +204,7 @@ test "button_visual_reflects_state"
204
204
  assert uiButtonColor(idle).eq(uiIdleColor())
205
205
  assert uiButtonColor(hov).eq(uiHoverColor())
206
206
  assert uiButtonColor(prs).eq(uiPressedColor())
207
- if hid := Picture.hitId(idle, 20.0, 30.0)
207
+ if hid := idle.hitId(20.0, 30.0)
208
208
  assert hid == 3
209
209
  else
210
210
  assert false
plum-std/game/pure/Actions.plum CHANGED
@@ -3,7 +3,7 @@ import std/Slice
3
3
  import std/Int
4
4
  import std/Float
5
5
 
6
- # scene2d-style actions as plain data plus a step function. `Actions.stepAction`
6
+ # scene2d-style actions as plain data plus a step method. `Action.step`
7
7
  # steps an action IN PLACE on its own record (a running action allocates nothing:
8
8
  # the result is a struct, flattened into multi-value returns) and reports the new
9
9
  # pose, the part of `dt` it did not use and whether it finished. `stepAll` drops
@@ -17,25 +17,7 @@ struct Pose(x: Float, y: Float, rotation: Float, scaleX: Float, scaleY: Float)
17
17
  # One action. `elapsed` and `current` (the running lap of a `Forever`, null
18
18
  # before it starts) are the mutable state; `kind` is the immutable description.
19
19
  # A `Forever`'s `template` is the pristine action restarted each lap.
20
- record Action(mut elapsed: Float, mut current: Action?, kind: ActionKind)
20
+ record Action(mut elapsed: Float, mut current: Action?, kind: ActionKind) =
21
-
22
- enum ActionKind =
23
- | MoveBy(dx: Float, dy: Float, duration: Float)
24
- | RotateBy(radians: Float, duration: Float)
25
- | Delay(duration: Float)
26
- | Sequence(steps: Slice[Action])
27
- | Parallel(steps: Slice[Action])
28
- | Forever(template: Action)
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 one step: the pose, the unused part of dt (0 while running) and
35
- # whether the action(s) finished. For `stepAll`, `done` means none are left.
36
- struct Step(pose: Pose, spare: Float, done: Bool)
37
-
38
- primitive Actions =
39
21
  fun moveBy(dx: Float, dy: Float, secs: Float) -> Action =
40
22
  Action(elapsed: 0.0, current: null, kind: MoveBy(dx: dx, dy: dy, duration: secs))
41
23
 
@@ -52,76 +34,70 @@ primitive Actions =
52
34
  Action(elapsed: 0.0, current: null, kind: Parallel(steps: steps))
53
35
 
54
36
  fun forever(a: Action) -> Action =
55
- Action(elapsed: 0.0, current: a, kind: Forever(template: Actions.fresh(a)))
37
+ Action(elapsed: 0.0, current: a, kind: Forever(template: a.fresh()))
56
-
38
+
57
- # An unstarted copy of `a` (elapsed 0, all steps present).
39
+ # An unstarted copy (elapsed 0, all steps present).
58
- fun fresh(a: Action) -> Action =
40
+ fun fresh(self) -> Action =
59
- match a.kind
41
+ match self.kind
60
- MoveBy(dx, dy, duration) => Actions.moveBy(dx, dy, duration)
42
+ MoveBy(dx, dy, duration) => Action.moveBy(dx, dy, duration)
61
- RotateBy(radians, duration) => Actions.rotateBy(radians, duration)
43
+ RotateBy(radians, duration) => Action.rotateBy(radians, duration)
62
- Delay(duration) => Actions.delay(duration)
44
+ Delay(duration) => Action.delay(duration)
63
45
  Sequence(steps) =>
64
46
  out := Slice[Action]()
65
47
  for i := range steps.length()
66
- out.push(Actions.fresh(steps.at(i)))
48
+ out.push(steps.at(i).fresh())
67
- Actions.sequence(out)
49
+ Action.sequence(out)
68
50
  Parallel(steps) =>
69
51
  out := Slice[Action]()
70
52
  for i := range steps.length()
71
- out.push(Actions.fresh(steps.at(i)))
53
+ out.push(steps.at(i).fresh())
72
- Actions.parallel(out)
54
+ Action.parallel(out)
73
- Forever(template) =>
74
- Action(elapsed: 0.0, current: Actions.fresh(template), kind: Forever(template: template))
55
+ Forever(template) => Action(elapsed: 0.0, current: template.fresh(), kind: Forever(template: template))
75
-
76
- fun tick(duration: Float, elapsed: Float, dt: Float) -> Tick =
77
- if duration <= 0.0
78
- return Tick(used: 0.0, frac: 1.0, done: true)
79
- used := dt.min(duration - elapsed)
80
- Tick(used: used, frac: used / duration, done: elapsed + used >= duration)
81
56
 
57
+ # Steps this action by `dt` in place, from pose `p`.
82
- fun stepAction(p: Pose, a: Action, dt: Float) -> Step =
58
+ fun step(self, p: Pose, dt: Float) -> Step =
59
+ a := self
83
60
  match a.kind
84
61
  MoveBy(dx, dy, duration) =>
85
- k := Actions.tick(duration, a.elapsed, dt)
62
+ k := Tick.of(duration, a.elapsed, dt)
86
63
  a.elapsed = a.elapsed + k.used
87
64
  q := Pose(x: p.x + dx * k.frac, y: p.y + dy * k.frac, rotation: p.rotation, scaleX: p.scaleX, scaleY: p.scaleY)
88
65
  Step(pose: q, spare: if k.done then dt - k.used else 0.0, done: k.done)
89
66
  RotateBy(radians, duration) =>
90
- k := Actions.tick(duration, a.elapsed, dt)
67
+ k := Tick.of(duration, a.elapsed, dt)
91
68
  a.elapsed = a.elapsed + k.used
92
69
  q := Pose(x: p.x, y: p.y, rotation: p.rotation + radians * k.frac, scaleX: p.scaleX, scaleY: p.scaleY)
93
70
  Step(pose: q, spare: if k.done then dt - k.used else 0.0, done: k.done)
94
71
  Delay(duration) =>
95
- k := Actions.tick(duration, a.elapsed, dt)
72
+ k := Tick.of(duration, a.elapsed, dt)
96
73
  a.elapsed = a.elapsed + k.used
97
74
  Step(pose: p, spare: if k.done then dt - k.used else 0.0, done: k.done)
98
75
  Sequence(steps) =>
99
76
  if steps.length() == 0
100
77
  return Step(pose: p, spare: dt, done: true)
101
- s := Actions.stepAction(p, steps.at(0), dt)
78
+ s := steps.at(0).step(p, dt)
102
79
  if !s.done
103
80
  return Step(pose: s.pose, spare: 0.0, done: false)
104
81
  # The rest starts in this same frame with the time left over; the list
105
82
  # is one shorter on every call, so this terminates.
106
83
  steps.removeAt(0)
107
- Actions.stepAction(s.pose, a, s.spare)
84
+ a.step(s.pose, s.spare)
108
- Parallel(steps) =>
109
- Actions.stepAll(p, steps, dt)
85
+ Parallel(steps) => Action.stepAll(p, steps, dt)
110
86
  Forever(template) =>
111
87
  # Restarts at most once per step, with the spare time, so a zero-length
112
88
  # template cannot spin forever inside one step.
113
89
  if a.current == null
114
- a.current = Actions.fresh(template)
90
+ a.current = template.fresh()
115
91
  lap := template
116
92
  if c := a.current
117
93
  lap = c
118
- s := Actions.stepAction(p, lap, dt)
94
+ s := lap.step(p, dt)
119
95
  if !s.done
120
96
  return Step(pose: s.pose, spare: 0.0, done: false)
121
- next := Actions.fresh(template)
97
+ next := template.fresh()
122
- r := Actions.stepAction(s.pose, next, s.spare)
98
+ r := next.step(s.pose, s.spare)
123
99
  if r.done
124
- a.current = Actions.fresh(template)
100
+ a.current = template.fresh()
125
101
  else
126
102
  a.current = next
127
103
  Step(pose: r.pose, spare: 0.0, done: false)
@@ -134,7 +110,7 @@ primitive Actions =
134
110
  spare := dt
135
111
  w := 0
136
112
  for i := range acts.length()
137
- s := Actions.stepAction(pose, acts.at(i), dt)
113
+ s := acts.at(i).step(pose, dt)
138
114
  pose = s.pose
139
115
  if !s.done
140
116
  if w != i
@@ -148,6 +124,28 @@ primitive Actions =
148
124
  spare = 0.0
149
125
  Step(pose: pose, spare: spare, done: w == 0)
150
126
 
127
+ enum ActionKind =
128
+ | MoveBy(dx: Float, dy: Float, duration: Float)
129
+ | RotateBy(radians: Float, duration: Float)
130
+ | Delay(duration: Float)
131
+ | Sequence(steps: Slice[Action])
132
+ | Parallel(steps: Slice[Action])
133
+ | Forever(template: Action)
134
+
135
+ # How much of a timed action one step covers. A zero-length action finishes at
136
+ # once and applies all of itself (no 0/0).
137
+ struct Tick(used: Float, frac: Float, done: Bool) =
138
+ # One step of `dt` on an action `duration` long that has run for `elapsed`.
139
+ fun of(duration: Float, elapsed: Float, dt: Float) -> Tick =
140
+ if duration <= 0.0
141
+ return Tick(used: 0.0, frac: 1.0, done: true)
142
+ used := dt.min(duration - elapsed)
143
+ Tick(used: used, frac: used / duration, done: elapsed + used >= duration)
144
+
145
+ # The result of one step: the pose, the unused part of dt (0 while running) and
146
+ # whether the action(s) finished. For `stepAll`, `done` means none are left.
147
+ struct Step(pose: Pose, spare: Float, done: Bool)
148
+
151
149
  fun actNear(a: Float, b: Float) -> Bool =
152
150
  {a - b}.abs() < 0.000001
153
151
 
@@ -177,7 +175,7 @@ fun goldAdv(r0: GoldRun, n: Int, dt: Float) -> GoldRun =
177
175
  r := r0
178
176
  for i := range n
179
177
  if !r.done
180
- s := Actions.stepAction(r.p, r.a, dt)
178
+ s := r.a.step(r.p, dt)
181
179
  r = GoldRun(..r, p: s.pose, spare: s.spare, done: s.done)
182
180
  r
183
181
 
@@ -192,7 +190,7 @@ fun goldWant(r: GoldRun, x: Float, y: Float, rotation: Float, spare: Float, done
192
190
  assert r.done == done
193
191
 
194
192
  fun goldMove() =
195
- r := goldRun(Actions.moveBy(10.0, 5.0, 1.0))
193
+ r := goldRun(Action.moveBy(10.0, 5.0, 1.0))
196
194
  r = goldAdv(r, 1, 0.016)
197
195
  goldWant(r, 0.16, 0.08, 0.0, 0.0, false)
198
196
  r = goldAdv(r, 29, 0.016)
@@ -203,7 +201,7 @@ fun goldMove() =
203
201
  goldWant(r, 10.0, 5.0, 0.0, 0.008, true)
204
202
 
205
203
  fun goldRotate() =
206
- r := goldRun(Actions.rotateBy(2.0, 0.5))
204
+ r := goldRun(Action.rotateBy(2.0, 0.5))
207
205
  r = goldAdv(r, 10, 0.016)
208
206
  goldWant(r, 0.0, 0.0, 0.64, 0.0, false)
209
207
  r = goldAdv(r, 21, 0.016)
@@ -212,14 +210,14 @@ fun goldRotate() =
212
210
  goldWant(r, 0.0, 0.0, 2.0, 0.012, true)
213
211
 
214
212
  fun goldDelay() =
215
- r := goldRun(Actions.delay(0.1))
213
+ r := goldRun(Action.delay(0.1))
216
214
  r = goldAdv(r, 6, 0.016)
217
215
  goldWant(r, 0.0, 0.0, 0.0, 0.0, false)
218
216
  r = goldAdv(r, 1, 0.016)
219
217
  goldWant(r, 0.0, 0.0, 0.0, 0.012, true)
220
218
 
221
219
  fun goldSequence() =
222
- r := goldRun(Actions.sequence(Slice(Actions.moveBy(10.0, 0.0, 0.2), Actions.delay(0.1), Actions.rotateBy(1.0, 0.2))))
220
+ r := goldRun(Action.sequence(Slice(Action.moveBy(10.0, 0.0, 0.2), Action.delay(0.1), Action.rotateBy(1.0, 0.2))))
223
221
  r = goldAdv(r, 12, 0.016)
224
222
  goldWant(r, 9.6, 0.0, 0.0, 0.0, false)
225
223
  r = goldAdv(r, 1, 0.016)
@@ -236,7 +234,7 @@ fun goldSequence() =
236
234
  goldWant(r, 10.0, 0.0, 1.0, 0.012, true)
237
235
 
238
236
  fun goldParallel() =
239
- r := goldRun(Actions.parallel(Slice(Actions.moveBy(10.0, 0.0, 0.3), Actions.rotateBy(1.0, 0.1), Actions.delay(0.2))))
237
+ r := goldRun(Action.parallel(Slice(Action.moveBy(10.0, 0.0, 0.3), Action.rotateBy(1.0, 0.1), Action.delay(0.2))))
240
238
  r = goldAdv(r, 5, 0.016)
241
239
  goldWant(r, 2.666667, 0.0, 0.8, 0.0, false)
242
240
  r = goldAdv(r, 2, 0.016)
@@ -249,7 +247,7 @@ fun goldParallel() =
249
247
  goldWant(r, 10.0, 0.0, 1.0, 0.004, true)
250
248
 
251
249
  fun goldForever() =
252
- r := goldRun(Actions.forever(Actions.sequence(Slice(Actions.moveBy(1.0, 0.0, 0.1), Actions.delay(0.05)))))
250
+ r := goldRun(Action.forever(Action.sequence(Slice(Action.moveBy(1.0, 0.0, 0.1), Action.delay(0.05)))))
253
251
  r = goldAdv(r, 9, 0.016)
254
252
  goldWant(r, 1.0, 0.0, 0.0, 0.0, false)
255
253
  r = goldAdv(r, 91, 0.016)
@@ -258,9 +256,9 @@ fun goldForever() =
258
256
  goldWant(r, 107.0, 0.0, 0.0, 0.0, false)
259
257
 
260
258
  fun goldNested() =
261
- inner := Actions.parallel(Slice(Actions.moveBy(4.0, 0.0, 0.1), Actions.delay(0.2)))
259
+ inner := Action.parallel(Slice(Action.moveBy(4.0, 0.0, 0.1), Action.delay(0.2)))
262
- loop := Actions.forever(Actions.parallel(Slice(Actions.rotateBy(1.0, 0.1), Actions.moveBy(0.0, 1.0, 0.3))))
260
+ loop := Action.forever(Action.parallel(Slice(Action.rotateBy(1.0, 0.1), Action.moveBy(0.0, 1.0, 0.3))))
263
- r := goldRun(Actions.sequence(Slice(inner, loop)))
261
+ r := goldRun(Action.sequence(Slice(inner, loop)))
264
262
  r = goldAdv(r, 6, 0.016)
265
263
  goldWant(r, 3.84, 0.0, 0.0, 0.0, false)
266
264
  r = goldAdv(r, 7, 0.016)
@@ -271,7 +269,7 @@ fun goldNested() =
271
269
  goldWant(r, 4.0, 11.76, 12.0, 0.0, false)
272
270
 
273
271
  fun goldLargeDt() =
274
- r := goldRun(Actions.sequence(Slice(Actions.moveBy(1.0, 0.0, 0.1), Actions.moveBy(0.0, 1.0, 0.1), Actions.rotateBy(1.0, 0.1), Actions.delay(0.1))))
272
+ r := goldRun(Action.sequence(Slice(Action.moveBy(1.0, 0.0, 0.1), Action.moveBy(0.0, 1.0, 0.1), Action.rotateBy(1.0, 0.1), Action.delay(0.1))))
275
273
  r = goldAdv(r, 1, 0.25)
276
274
  goldWant(r, 1.0, 1.0, 0.5, 0.0, false)
277
275
  r = goldAdv(r, 1, 0.05)
@@ -280,7 +278,7 @@ fun goldLargeDt() =
280
278
  goldWant(r, 1.0, 1.0, 1.0, 0.4, true)
281
279
 
282
280
  fun goldLargeDtParallel() =
283
- r := goldRun(Actions.parallel(Slice(Actions.moveBy(2.0, 0.0, 0.1), Actions.sequence(Slice(Actions.delay(0.1), Actions.rotateBy(1.0, 0.1))), Actions.delay(0.3))))
281
+ r := goldRun(Action.parallel(Slice(Action.moveBy(2.0, 0.0, 0.1), Action.sequence(Slice(Action.delay(0.1), Action.rotateBy(1.0, 0.1))), Action.delay(0.3))))
284
282
  r = goldAdv(r, 1, 0.15)
285
283
  goldWant(r, 2.0, 0.0, 0.5, 0.0, false)
286
284
  r = goldAdv(r, 1, 0.1)
@@ -289,7 +287,7 @@ fun goldLargeDtParallel() =
289
287
  goldWant(r, 2.0, 0.0, 1.0, 0.35, true)
290
288
 
291
289
  fun goldLargeDtForever() =
292
- r := goldRun(Actions.forever(Actions.sequence(Slice(Actions.moveBy(1.0, 0.0, 0.1), Actions.rotateBy(1.0, 0.1)))))
290
+ r := goldRun(Action.forever(Action.sequence(Slice(Action.moveBy(1.0, 0.0, 0.1), Action.rotateBy(1.0, 0.1)))))
293
291
  r = goldAdv(r, 1, 0.25)
294
292
  goldWant(r, 1.5, 0.0, 1.0, 0.0, false)
295
293
  r = goldAdv(r, 1, 0.5)
@@ -298,15 +296,15 @@ fun goldLargeDtForever() =
298
296
  goldWant(r, 3.16, 0.0, 3.0, 0.0, false)
299
297
 
300
298
  test "stepAll moves partway and keeps the action"
301
- acts := Slice(Actions.moveBy(10.0, 0.0, 1.0))
299
+ acts := Slice(Action.moveBy(10.0, 0.0, 1.0))
302
- r := Actions.stepAll(actPose0(), acts, 0.5)
300
+ r := Action.stepAll(actPose0(), acts, 0.5)
303
301
  assert actNear(r.pose.x, 5.0)
304
302
  assert acts.length() == 1
305
303
  assert actNear(r.spare, 0.0)
306
304
 
307
305
  test "stepAll finishes every action and reports no spare"
308
- acts := Slice(Actions.moveBy(10.0, 0.0, 1.0), Actions.rotateBy(1.0, 0.5))
306
+ acts := Slice(Action.moveBy(10.0, 0.0, 1.0), Action.rotateBy(1.0, 0.5))
309
- r := Actions.stepAll(actPose0(), acts, 1.0)
307
+ r := Action.stepAll(actPose0(), acts, 1.0)
310
308
  assert actNear(r.pose.x, 10.0)
311
309
  assert actNear(r.pose.rotation, 1.0)
312
310
  assert acts.length() == 0
@@ -314,29 +312,29 @@ test "stepAll finishes every action and reports no spare"
314
312
 
315
313
  test "stepAll with no actions is a no-op"
316
314
  acts := Slice[Action]()
317
- r := Actions.stepAll(actPose0(), acts, 0.016)
315
+ r := Action.stepAll(actPose0(), acts, 0.016)
318
316
  assert acts.length() == 0
319
317
  assert actNear(r.spare, 0.016)
320
318
  assert actNear(r.pose.x, 0.0)
321
319
 
322
320
  test "zero_length_move_applies_at_once"
323
- r := Actions.stepAction(actPose0(), Actions.moveBy(10.0, 0.0, 0.0), 0.016)
321
+ r := Action.moveBy(10.0, 0.0, 0.0).step(actPose0(), 0.016)
324
322
  assert actNear(r.pose.x, 10.0)
325
323
  assert r.done
326
324
  assert actNear(r.spare, 0.016)
327
325
 
328
326
  test "forever_rotate_no_drift"
329
- a := Actions.forever(Actions.rotateBy(1.0, 1.0))
327
+ a := Action.forever(Action.rotateBy(1.0, 1.0))
330
328
  p := actPose0()
331
329
  for i := range 625
332
- r := Actions.stepAction(p, a, 0.016)
330
+ r := a.step(p, 0.016)
333
331
  p = r.pose
334
332
  assert !r.done
335
333
  assert actNear(p.rotation, 10.0)
336
334
 
337
335
  test "sequence_spills_spare_time"
338
- a := Actions.sequence(Slice(Actions.moveBy(10.0, 0.0, 0.5), Actions.moveBy(0.0, 10.0, 0.5)))
336
+ a := Action.sequence(Slice(Action.moveBy(10.0, 0.0, 0.5), Action.moveBy(0.0, 10.0, 0.5)))
339
- r := Actions.stepAction(actPose0(), a, 0.75)
337
+ r := a.step(actPose0(), 0.75)
340
338
  assert actNear(r.pose.x, 10.0)
341
339
  assert actNear(r.pose.y, 5.0)
342
340
  assert actNear(r.spare, 0.0)
@@ -344,30 +342,30 @@ test "sequence_spills_spare_time"
344
342
  assert actSeqLen(a) == 1
345
343
 
346
344
  test "empty_sequence_finishes_with_full_spare"
347
- r := Actions.stepAction(actPose0(), Actions.sequence(Slice[Action]()), 0.3)
345
+ r := Action.sequence(Slice[Action]()).step(actPose0(), 0.3)
348
346
  assert r.done
349
347
  assert actNear(r.spare, 0.3)
350
348
 
351
349
  test "parallel_spare_is_min_of_finished"
352
- r := Actions.stepAction(actPose0(), Actions.parallel(Slice(Actions.delay(0.2), Actions.delay(0.5))), 1.0)
350
+ r := Action.parallel(Slice(Action.delay(0.2), Action.delay(0.5))).step(actPose0(), 1.0)
353
351
  assert r.done
354
352
  assert actNear(r.spare, 0.5)
355
353
 
356
354
  test "forever_zero_length_template_terminates"
357
- r := Actions.stepAction(actPose0(), Actions.forever(Actions.delay(0.0)), 1.0)
355
+ r := Action.forever(Action.delay(0.0)).step(actPose0(), 1.0)
358
356
  assert !r.done
359
357
 
360
358
  test "forever of a nested sequence keeps looping"
361
- a := Actions.forever(Actions.sequence(Slice(Actions.moveBy(1.0, 0.0, 0.1), Actions.delay(0.1))))
359
+ a := Action.forever(Action.sequence(Slice(Action.moveBy(1.0, 0.0, 0.1), Action.delay(0.1))))
362
360
  p := actPose0()
363
361
  for i := range 100
364
- r := Actions.stepAction(p, a, 0.05)
362
+ r := a.step(p, 0.05)
365
363
  p = r.pose
366
364
  assert actNear(p.x, 25.0)
367
365
 
368
366
  test "step_moves_the_action_in_place"
369
- a := Actions.moveBy(10.0, 0.0, 1.0)
367
+ a := Action.moveBy(10.0, 0.0, 1.0)
370
- r := Actions.stepAction(actPose0(), a, 0.5)
368
+ r := a.step(actPose0(), 0.5)
371
369
  assert actNear(r.pose.x, 5.0)
372
370
  assert actElapsed(a) == 0.5
373
371
 
@@ -375,12 +373,12 @@ test "step_moves_the_action_in_place"
375
373
  # elapsed moves, no replacement lists or actions), and the slice keeps its length.
376
374
  # The wasm of this path has no struct.new (checked with wasm-tools print).
377
375
  test "step_all_no_alloc_steady_state"
378
- m := Actions.moveBy(100.0, 0.0, 100.0)
376
+ m := Action.moveBy(100.0, 0.0, 100.0)
379
- r := Actions.rotateBy(1.0, 100.0)
377
+ r := Action.rotateBy(1.0, 100.0)
380
378
  acts := Slice(m, r)
381
379
  p := actPose0()
382
380
  for i := range 10
383
- s := Actions.stepAll(p, acts, 0.5)
381
+ s := Action.stepAll(p, acts, 0.5)
384
382
  p = s.pose
385
383
  assert !s.done
386
384
  assert acts.length() == 2
plum-std/game/pure/PureScene.plum CHANGED
@@ -14,9 +14,9 @@ import std/game/pure/Actions
14
14
  # A pure scene graph: a node is a record (`Node`) holding a `Transform` and
15
15
  # an `enum Kind`. Group, Sprite, Label and Button are built in; games add their
16
16
  # own behaviour with `Custom(actor: Actor)`, a behaviour-only trait their records
17
- # embed (like Rust's `Box<dyn Actor>`). `onPointer` takes the node, changes it in
17
+ # embed (like Rust's `Box<dyn Actor>`). A node is a unique value: `update`,
18
+ # `onPointer`, `modify` and `route` take it, change it in place and hand it back;
18
- # place and hands it back; readers borrow. Only `drawTree` talks to the host,
19
+ # readers borrow. Only `drawTree` talks to the host, through `std/game/Game`.
19
- # through `std/game/Game`. Actions and tree-wide updates live in std/game/pure/SceneTree.
20
20
 
21
21
  enum Touchable =
22
22
  | Enabled
@@ -53,9 +53,13 @@ record Transform(x: Float, y: Float, width: Float, height: Float, originX: Float
53
53
  mc := 0.0 - sn * self.scaleY
54
54
  md := cs * self.scaleY
55
55
  Affine(
56
- a: ma, b: mb, c: mc, d: md,
56
+ a: ma,
57
+ b: mb,
58
+ c: mc,
59
+ d: md,
57
60
  e: self.x + self.originX - {ma * self.originX + mc * self.originY},
58
- f: self.y + self.originY - {mb * self.originX + md * self.originY})
61
+ f: self.y + self.originY - {mb * self.originX + md * self.originY}
62
+ )
59
63
 
60
64
  # Whether `p`, in the node's own coordinates, is inside its box.
61
65
  fun contains(self, p: Vec2) -> Bool =
@@ -63,101 +67,207 @@ record Transform(x: Float, y: Float, width: Float, height: Float, originX: Float
63
67
 
64
68
  record Node(
65
69
  Transform(x, y, width, height, originX, originY, scaleX, scaleY, rotation),
70
+ id: Int,
71
+ color: Color,
72
+ visible: Bool,
73
+ touchable: Touchable,
66
- id: Int, color: Color, visible: Bool, touchable: Touchable, actions: Slice[Action], kind: Kind)
74
+ actions: Slice[Action],
75
+ kind: Kind
76
+ ) =
67
77
 
68
- enum Kind =
78
+ # --- building ---
69
- | Group(children: Slice[Node])
70
- | Sprite(tex: Int, srcX: Float, srcY: Float, srcW: Float, srcH: Float)
71
- | Label(text: Str, size: Float)
72
- | Button(text: Str, upTex: Int, downTex: Int, pressed: Bool, over: Bool)
73
- | Custom(actor: Actor)
74
79
 
80
+ fun group(id: Int, x: Float, y: Float, w: Float, h: Float, children: Slice[Node]) -> Node =
75
- # Game behaviour for a `Custom` node. The defaults do nothing.
81
+ Node.make(id, x, y, w, h, Enabled, Group(children: children))
76
- trait Actor =
77
82
 
78
- # The node after `dt` seconds of this actor's behaviour; the default is itself.
79
- fun act(self, n: Node, dt: Float) -> Node = n
83
+ fun sprite(id: Int, tex: Int, x: Float, y: Float, w: Float, h: Float) -> Node =
84
+ Node.make(id, x, y, w, h, Enabled, Sprite(tex: tex, srcX: 0.0, srcY: 0.0, srcW: 0.0, srcH: 0.0))
80
85
 
81
- # Paints the node at its stage transform `m` with the combined `tint`.
82
- fun draw(self, io: Io, n: Node, m: Affine, tint: Color) =
86
+ fun label(id: Int, text: Str, x: Float, y: Float, size: Float) -> Node =
83
- return
87
+ Node.make(id, x, y, size * 0.6 * Float(text.length()), size, Disabled, Label(text: text, size: size))
84
88
 
85
- # The node after handling an event, and the events it raised.
89
+ fun button(id: Int, text: Str, upTex: Int, downTex: Int, x: Float, y: Float, w: Float, h: Float) -> Node =
86
- record Handled(node: Node, events: Slice[UiEvent]) =
90
+ Node.make(id, x, y, w, h, Enabled, Button(text: text, upTex: upTex, downTex: downTex, pressed: false, over: false))
87
91
 
92
+ fun custom(id: Int, x: Float, y: Float, w: Float, h: Float, actor: Actor) -> Node =
88
- # The node, moved out; read `events` first, they stay behind.
93
+ Node.make(id, x, y, w, h, Disabled, Custom(actor: actor))
94
+
89
- fun intoNode(self) -> Node =
95
+ fun make(id: Int, x: Float, y: Float, w: Float, h: Float, touchable: Touchable, kind: Kind) -> Node =
96
+ Node(
97
+ x: x,
98
+ y: y,
99
+ width: w,
90
- match self
100
+ height: h,
101
+ originX: 0.0,
102
+ originY: 0.0,
103
+ scaleX: 1.0,
104
+ scaleY: 1.0,
105
+ rotation: 0.0,
106
+ id: id,
107
+ color: Color.White,
108
+ visible: true,
91
- Handled(n, _) => n
109
+ touchable: touchable,
110
+ actions: Slice[Action](),
111
+ kind: kind
112
+ )
113
+
114
+ # --- reading ---
92
115
 
93
- fun pureDrawSprite(io: Io, m: Affine, tint: Color, w: Float, h: Float, tex: Int, srcX: Float, srcY: Float, srcW: Float, srcH: Float) =
94
- sx := w * m.scaleX()
95
- sy := h * m.scaleY()
96
- c := m.apply(Vec2(x: w / 2.0, y: h / 2.0))
97
- io.drawTextureEx(tex, srcX, srcY, srcW, srcH, c.x - sx / 2.0, c.y - sy / 2.0, sx, sy, m.rotation(), false, tint)
98
-
99
- fun pureDrawText(io: Io, m: Affine, tint: Color, s: Str, size: Float) =
100
- p := m.apply(Vec2(x: 0.0, y: 0.0))
101
- io.drawText(s, p.x, p.y, size * m.scaleY(), tint)
102
-
103
- fun pureMulColor(a: Color, b: Color) -> Color =
104
- Color(r: a.r * b.r, g: a.g * b.g, b: a.b * b.b, a: a.a * b.a)
105
-
106
- fun pureDrawButton(io: Io, n: Node, m: Affine, tint: Color, text: Str, upTex: Int, downTex: Int, pressed: Bool, over: Bool) =
107
- t := upTex
108
- if pressed && over
109
- t = downTex
110
- pureDrawSprite(io, m, tint, n.width, n.height, t, 0.0, 0.0, 0.0, 0.0)
111
- size := n.height * 0.5
112
- ox := {n.width - size * 0.6 * Float(text.length())} / 2.0
113
- oy := {n.height - size} / 2.0
114
- pureDrawText(io, m.mul(Affine(a: 1.0, b: 0.0, c: 0.0, d: 1.0, e: ox, f: oy)), tint, text, size)
115
-
116
- fun pureNode(id: Int, x: Float, y: Float, w: Float, h: Float, touchable: Touchable, kind: Kind) -> Node =
117
- Node(
118
- x: x, y: y, width: w, height: h, originX: 0.0, originY: 0.0,
119
- scaleX: 1.0, scaleY: 1.0, rotation: 0.0,
120
- id: id, color: Color.White, visible: true, touchable: touchable,
121
- actions: Slice[Action](), kind: kind)
122
-
123
- primitive PureScene =
124
-
125
- # The node's transform into its parent's coordinates.
126
- fun localMatrix(n: Node) -> Affine =
127
- n.localMatrix()
116
+ # The children of a group, or null for anything else.
117
+ fun children(self) -> Slice[Node]? =
118
+ match self.kind
119
+ Group(children) => children
120
+ _ => null
121
+
122
+ # The actor of a Custom node, or null for anything else.
123
+ fun actor(self) -> Actor? =
124
+ match self.kind
125
+ Custom(actor) => actor
126
+ _ => null
127
+
128
+ # Whether `p`, in the node's own coordinates, is on it.
129
+ fun hit(self, p: Vec2) -> Bool =
130
+ self.touchable == Enabled && self.contains(p)
131
+
132
+ # The path (child indexes from this node) to the topmost actor under `p`, which
133
+ # is in the coordinates of the parent; `parent` is that parent's stage matrix.
134
+ fun findHit(self, parent: Affine, p: Vec2) -> Slice[Int]? =
135
+ if !self.visible || self.touchable == Disabled
136
+ return null
137
+ m := parent * self.localMatrix()
138
+ inv := m.invert()
139
+ if inv == null
140
+ return null
141
+ lp := Vec2(x: 0.0, y: 0.0)
142
+ if q := inv
143
+ lp = q.apply(p)
144
+ match self.kind
145
+ Group(ks) =>
146
+ i := ks.length() - 1
147
+ while i >= 0
148
+ sub := ks.at(i).findHit(m, p)
149
+ if s := sub
150
+ return Node.prepend(i, s)
151
+ i = i - 1
152
+ _ => 0
153
+ if self.hit(lp)
154
+ return Slice[Int]()
155
+ null
156
+
157
+ # The id of the actor at `path` below this node, or -1 if the path leads nowhere.
158
+ fun idAt(self, path: Slice[Int], depth: Int) -> Int =
159
+ if depth >= path.length()
160
+ return self.id
161
+ match self.kind
162
+ Group(ks) =>
163
+ i := path.at(depth)
164
+ if i >= 0 && i < ks.length()
165
+ return ks.at(i).idAt(path, depth + 1)
166
+ _ => 0
167
+ -1
168
+
169
+ # The path to the first actor with `id`, depth first; null if none.
170
+ fun findPath(self, id: Int) -> Slice[Int]? =
171
+ if self.id == id
172
+ return Slice[Int]()
173
+ match self.kind
174
+ Group(ks) =>
175
+ for i := range ks.length()
176
+ sub := ks.at(i).findPath(id)
177
+ if s := sub
178
+ return Node.prepend(i, s)
179
+ _ => 0
180
+ null
181
+
182
+ # `i` followed by the path `rest`.
183
+ fun prepend(i: Int, rest: Slice[Int]) -> Slice[Int] =
184
+ out := Slice(i)
185
+ for j := range rest.length()
186
+ out.push(rest.at(j))
187
+ out
188
+
189
+ # --- drawing ---
128
190
 
129
191
  # Draws the node and its children given the parent's matrix and tint.
130
- fun drawTree(io: Io, n: Node, parent: Affine, tint: Color) =
192
+ fun drawTree(self, io: Io, parent: Affine, tint: Color) =
131
- if !n.visible
193
+ if !self.visible
132
194
  return
133
- m := parent * n.localMatrix()
195
+ m := parent * self.localMatrix()
134
- t := pureMulColor(tint, n.color)
196
+ t := tint.times(self.color)
135
- match n.kind
197
+ match self.kind
136
198
  Group(children) =>
137
199
  for i := range children.length()
138
- PureScene.drawTree(io, children.at(i), m, t)
200
+ children.at(i).drawTree(io, m, t)
201
+ Sprite(tex, srcX, srcY, srcW, srcH) => Node.drawSprite(io, m, t, self.width, self.height, tex, srcX, srcY, srcW, srcH)
202
+ Label(text, size) => Node.drawText(io, m, t, text, size)
203
+ Button(text, upTex, downTex, pressed, over) => Node.drawButton(io, self, m, t, text, upTex, downTex, pressed, over)
204
+ Custom(actor) => actor.draw(io, self, m, t)
205
+
206
+ fun drawSprite(io: Io, m: Affine, tint: Color, w: Float, h: Float, tex: Int, srcX: Float, srcY: Float, srcW: Float, srcH: Float) =
207
+ sx := w * m.scaleX()
208
+ sy := h * m.scaleY()
209
+ c := m.apply(Vec2(x: w / 2.0, y: h / 2.0))
210
+ io.drawTextureEx(tex, srcX, srcY, srcW, srcH, c.x - sx / 2.0, c.y - sy / 2.0, sx, sy, m.rotation(), false, tint)
211
+
212
+ fun drawText(io: Io, m: Affine, tint: Color, s: Str, size: Float) =
213
+ p := m.apply(Vec2(x: 0.0, y: 0.0))
139
- Sprite(tex, srcX, srcY, srcW, srcH) =>
214
+ io.drawText(s, p.x, p.y, size * m.scaleY(), tint)
215
+
216
+ fun drawButton(io: Io, n: Node, m: Affine, tint: Color, text: Str, upTex: Int, downTex: Int, pressed: Bool, over: Bool) =
217
+ t := upTex
218
+ if pressed && over
219
+ t = downTex
140
- pureDrawSprite(io, m, t, n.width, n.height, tex, srcX, srcY, srcW, srcH)
220
+ Node.drawSprite(io, m, tint, n.width, n.height, t, 0.0, 0.0, 0.0, 0.0)
221
+ size := n.height * 0.5
222
+ ox := {n.width - size * 0.6 * Float(text.length())} / 2.0
141
- Label(text, size) =>
223
+ oy := {n.height - size} / 2.0
142
- pureDrawText(io, m, t, text, size)
143
- Button(text, upTex, downTex, pressed, over) =>
144
- pureDrawButton(io, n, m, t, text, upTex, downTex, pressed, over)
224
+ Node.drawText(io, m.mul(Affine(a: 1.0, b: 0.0, c: 0.0, d: 1.0, e: ox, f: oy)), tint, text, size)
225
+
145
- Custom(actor) =>
226
+ # --- updating ---
146
- actor.draw(io, n, m, t)
227
+
147
-
148
- # The children of a group, or null for anything else.
228
+ # A Custom node's actor acts on it; every other node is itself.
149
- fun childrenOf(n: Node) -> Slice[Node]? =
229
+ fun acted(self, dt: Float) -> Node =
230
+ if actor := self.actor()
231
+ return actor.act(self, dt)
232
+ self
233
+
234
+ # The node after `dt` seconds: a Custom actor's own `act`, then its actions on its pose,
235
+ # then every child. The tree is changed in place and handed back.
236
+ fun update(self, dt: Float) -> Node =
237
+ b := self.acted(dt)
238
+ r := Action.stepAll(Pose(x: b.x, y: b.y, rotation: b.rotation, scaleX: b.scaleX, scaleY: b.scaleY), b.actions, dt)
239
+ b = Node(..b, x: r.pose.x, y: r.pose.y, rotation: r.pose.rotation, scaleX: r.pose.scaleX, scaleY: r.pose.scaleY)
150
- match n.kind
240
+ match b.kind
151
- Group(children) => children
241
+ Group(ks) =>
242
+ _ := ks.mapInPlace(|k| k.update(dt))
152
- _ => null
243
+ _ => 0
244
+ b
245
+
246
+ # The tree where the first actor with `id` is replaced by `f` of it.
247
+ fun modify(self, id: Int, f: fn(Node) -> Node) -> Node =
248
+ path := self.findPath(id)
249
+ if p := path
250
+ return self.modifyAt(p, 0, f)
251
+ self
252
+
253
+ fun modifyAt(self, path: Slice[Int], depth: Int, f: fn(Node) -> Node) -> Node =
254
+ if depth >= path.length()
255
+ return f(self)
256
+ match self.kind
257
+ Group(ks) =>
258
+ i := path.at(depth)
259
+ if i >= 0 && i < ks.length()
260
+ c := ks.takeAt(i)
261
+ ks.putAt(i, c.modifyAt(path, depth + 1, f))
262
+ _ => 0
263
+ self
153
264
 
154
- # Whether `p`, in the node's own coordinates, is on it.
155
- fun hit(n: Node, p: Vec2) -> Bool =
265
+ # --- input ---
156
- n.touchable == Enabled && n.contains(p)
157
266
 
158
267
  # Handles a pointer event and returns the new node with the events it raised.
159
268
  # Only a Button reacts.
160
- fun onPointer(n: Node, e: Pointer) -> Handled =
269
+ fun onPointer(self, e: Pointer) -> Handled =
270
+ n := self
161
271
  ev := Slice[UiEvent]()
162
272
  match n.kind
163
273
  Button(text, upTex, downTex, wasPressed, wasOver) =>
@@ -184,20 +294,58 @@ primitive PureScene =
184
294
  _ => 0
185
295
  Handled(node: n, events: ev)
186
296
 
297
+ # Delivers `e` (in the coordinates of the parent) along `path`: the child on
298
+ # the path first, then this node. A path that runs out reaches the deepest
299
+ # actor it can.
300
+ fun route(self, path: Slice[Int], depth: Int, e: Pointer) -> Handled =
301
+ events := Slice[UiEvent]()
302
+ r := self.routeInto(path, depth, e, events)
303
+ Handled(node: r, events: events)
304
+
305
+ # `route`, appending the raised events to `events` and returning the node.
187
- fun group(id: Int, x: Float, y: Float, w: Float, h: Float, children: Slice[Node]) -> Node =
306
+ fun routeInto(self, path: Slice[Int], depth: Int, e: Pointer, events: Slice[UiEvent]) -> Node =
307
+ lp := Vec2(x: e.x, y: e.y)
308
+ inv := self.localMatrix().invert()
309
+ if q := inv
310
+ lp = q.apply(lp)
311
+ local := Pointer(kind: e.kind, x: lp.x, y: lp.y, over: e.over)
312
+ if depth < path.length()
313
+ match self.kind
314
+ Group(ks) =>
315
+ i := path.at(depth)
316
+ if i >= 0 && i < ks.length()
317
+ c := ks.takeAt(i)
188
- pureNode(id, x, y, w, h, Enabled, Group(children: children))
318
+ ks.putAt(i, c.routeInto(path, depth + 1, local, events))
319
+ _ => 0
320
+ h := self.onPointer(local)
321
+ for j := range h.events.length()
322
+ events.push(h.events.at(j))
323
+ h.intoNode()
189
324
 
325
+ enum Kind =
326
+ | Group(children: Slice[Node])
190
- fun sprite(id: Int, tex: Int, x: Float, y: Float, w: Float, h: Float) -> Node =
327
+ | Sprite(tex: Int, srcX: Float, srcY: Float, srcW: Float, srcH: Float)
328
+ | Label(text: Str, size: Float)
191
- pureNode(id, x, y, w, h, Enabled, Sprite(tex: tex, srcX: 0.0, srcY: 0.0, srcW: 0.0, srcH: 0.0))
329
+ | Button(text: Str, upTex: Int, downTex: Int, pressed: Bool, over: Bool)
330
+ | Custom(actor: Actor)
192
331
 
193
- fun label(id: Int, text: Str, x: Float, y: Float, size: Float) -> Node =
332
+ # Game behaviour for a `Custom` node. The defaults do nothing.
194
- pureNode(id, x, y, size * 0.6 * Float(text.length()), size, Disabled, Label(text: text, size: size))
333
+ trait Actor =
195
334
 
196
- fun button(id: Int, text: Str, upTex: Int, downTex: Int, x: Float, y: Float, w: Float, h: Float) -> Node =
335
+ # The node after `dt` seconds of this actor's behaviour; the default is itself.
197
- pureNode(id, x, y, w, h, Enabled, Button(text: text, upTex: upTex, downTex: downTex, pressed: false, over: false))
336
+ fun act(self, n: Node, dt: Float) -> Node = n
198
337
 
199
- fun custom(id: Int, x: Float, y: Float, w: Float, h: Float, actor: Actor) -> Node =
338
+ # Paints the node at its stage transform `m` with the combined `tint`.
200
- pureNode(id, x, y, w, h, Disabled, Custom(actor: actor))
339
+ fun draw(self, io: Io, n: Node, m: Affine, tint: Color) =
340
+ return
341
+
342
+ # The node after handling an event, and the events it raised.
343
+ record Handled(node: Node, events: Slice[UiEvent]) =
344
+
345
+ # The node, moved out; read `events` first, they stay behind.
346
+ fun intoNode(self) -> Node =
347
+ match self
348
+ Handled(n, _) => n
201
349
 
202
350
  fun psKinds(h: Handled) -> Slice[UiKind] =
203
351
  out := Slice[UiKind]()
@@ -227,28 +375,128 @@ record Beacon(Actor, mut draws: Int) =
227
375
  override fun draw(self, io: Io, n: Node, m: Affine, tint: Color) =
228
376
  self.draws = self.draws + 1
229
377
 
378
+ fun ttFixture() -> Node =
379
+ inner := Slice(Node.sprite(4, 0, 0.0, 0.0, 5.0, 5.0))
380
+ kids := Slice(Node.sprite(2, 0, 0.0, 0.0, 10.0, 10.0), Node.group(3, 20.0, 0.0, 10.0, 10.0, inner))
381
+ root := Node.group(1, 0.0, 0.0, 100.0, 100.0, kids)
382
+ me: Node := root
383
+ me
384
+
230
- fun psActorOf(n: Node) -> Actor? = match n.kind
385
+ fun ttNoPath(r: Slice[Int]?) -> Bool =
231
- Custom(a) => a
232
- _ => null
386
+ r == null
387
+
388
+ fun ttPathEq(r: Slice[Int]?, want: Slice[Int]) -> Bool =
389
+ if p := r
390
+ if p.length() != want.length()
391
+ return false
392
+ for i := range p.length()
393
+ if p.at(i) != want.at(i)
394
+ return false
395
+ return true
396
+ false
397
+
398
+ fun ttInts(a: Int, b: Int) -> Slice[Int] =
399
+ out := Slice(a, b)
400
+ out
233
401
 
402
+ fun ttAt(x: Float, y: Float) -> Vec2 =
403
+ Vec2(x: x, y: y)
404
+
405
+ fun ttMovingTree() -> Node =
406
+ s := Node.sprite(2, 0, 0.0, 0.0, 10.0, 10.0)
407
+ s2 := Node(..s, actions: Slice(Action.moveBy(10.0, 0.0, 1.0)))
408
+ kids := Slice(s2)
409
+ root := Node.group(1, 0.0, 0.0, 100.0, 100.0, kids)
410
+ me: Node := root
411
+ me
412
+
413
+ fun ttFirstKidX(a: Node) -> Float =
414
+ match a.kind
415
+ Group(k) =>
416
+ return k.at(0).x
417
+ _ => 0
418
+ 0.0 - 1.0
419
+
420
+ fun ttFirstKidActions(a: Node) -> Int =
421
+ match a.kind
422
+ Group(k) =>
423
+ return k.at(0).actions.length()
424
+ _ => 0
425
+ 0 - 1
426
+
427
+ fun ttSetTouch(t: Node, id: Int, tc: Touchable) -> Node =
428
+ t.modify(id, |a| ttSetTouchOf(a, tc))
429
+
430
+ fun ttSetTouchOf(a: Node, tc: Touchable) -> Node =
431
+ Node(..a, touchable: tc)
432
+
433
+ fun ttSetScaleX(a: Node) -> Node =
434
+ Node(..a, scaleX: 0.0)
435
+
436
+ fun ttSetX9(a: Node) -> Node =
437
+ Node(..a, x: 9.0)
438
+
439
+ fun ttKidX(a: Node, i: Int) -> Float =
440
+ match a.kind
441
+ Group(k) =>
442
+ return k.at(i).x
443
+ _ => 0
444
+ 0.0 - 1.0
445
+
446
+ fun ttButtonRoot() -> Node =
447
+ kids := Slice(Node.button(5, "Go", 0, 1, 10.0, 10.0, 100.0, 40.0))
448
+ root := Node.group(1, 0.0, 0.0, 800.0, 600.0, kids)
449
+ me: Node := root
450
+ me
451
+
452
+ fun ttZero() -> Slice[Int] =
453
+ out := Slice(0)
454
+ out
455
+
456
+ # Five actions on one sprite: the 1st, 3rd and 5th finish in the first 0.1s step,
457
+ # the 2nd (MoveBy) and 4th (RotateBy) keep running.
458
+ fun ttCompactTree() -> Node =
459
+ s := Node.sprite(2, 0, 0.0, 0.0, 10.0, 10.0)
460
+ s2 := Node(..s, actions: Slice(Action.moveBy(1.0, 0.0, 0.1), Action.moveBy(0.0, 10.0, 10.0), Action.rotateBy(1.0, 0.1), Action.rotateBy(2.0, 10.0), Action.delay(0.1)))
461
+ kids := Slice(s2)
462
+ root := Node.group(1, 0.0, 0.0, 100.0, 100.0, kids)
463
+ me: Node := root
464
+ me
465
+
466
+ fun ttKid(a: Node) -> Node =
467
+ match a.kind
468
+ Group(k) =>
469
+ return k.at(0)
470
+ _ => 0
471
+ none: Node := Node.sprite(0, 0, 0.0, 0.0, 0.0, 0.0)
472
+ none
473
+
474
+ # 1 MoveBy, 2 RotateBy, 3 Delay, 0 anything else.
475
+ fun ttActKind(a: Action) -> Int =
476
+ match a.kind
477
+ MoveBy(_, _, _) => 1
478
+ RotateBy(_, _) => 2
479
+ Delay(_) => 3
480
+ _ => 0
481
+
482
+ # Adds 1.0 to its node's x on every act.
483
+ record TtStepper(Actor, ticks: Int) =
234
- fun psActed(n: Node, dt: Float) -> Node =
484
+ override fun act(self, n: Node, dt: Float) -> Node =
235
- if a := psActorOf(n)
236
- return a.act(n, dt)
485
+ Node(..n, x: n.x + 1.0)
237
- n
238
486
 
239
487
  test "local_matrix_translates"
240
- s := PureScene.sprite(1, 0, 10.0, 0.0, 2.0, 2.0)
488
+ s := Node.sprite(1, 0, 10.0, 0.0, 2.0, 2.0)
241
- p := PureScene.localMatrix(s).apply(Vec2(x: 0.0, y: 0.0))
489
+ p := s.localMatrix().apply(Vec2(x: 0.0, y: 0.0))
242
490
  assert {p.x - 10.0}.abs() < 1e-6
243
491
  assert {p.y}.abs() < 1e-6
244
492
 
245
493
  test "button_click_needs_press_and_over"
246
- b := PureScene.button(7, "go", 1, 2, 0.0, 0.0, 10.0, 10.0)
494
+ b := Node.button(7, "go", 1, 2, 0.0, 0.0, 10.0, 10.0)
247
- h1 := PureScene.onPointer(b, psOverPtr(Down, true))
495
+ h1 := b.onPointer(psOverPtr(Down, true))
248
496
  ks := psKinds(h1)
249
497
  n1 := h1.events.length()
250
498
  id1 := h1.events.at(0).id
251
- h2 := PureScene.onPointer(h1.intoNode(), psOverPtr(Up, true))
499
+ h2 := h1.intoNode().onPointer(psOverPtr(Up, true))
252
500
  ks.push(h2.events.at(0).kind)
253
501
  ks.push(h2.events.at(1).kind)
254
502
  assert n1 == 1
@@ -260,69 +508,173 @@ test "button_click_needs_press_and_over"
260
508
  assert h2.events.at(1).id == 7
261
509
 
262
510
  test "button_release_elsewhere_no_click"
263
- b := PureScene.button(7, "go", 1, 2, 0.0, 0.0, 10.0, 10.0)
511
+ b := Node.button(7, "go", 1, 2, 0.0, 0.0, 10.0, 10.0)
264
- h1 := PureScene.onPointer(b, psOverPtr(Down, true))
512
+ h1 := b.onPointer(psOverPtr(Down, true))
265
- h2 := PureScene.onPointer(h1.intoNode(), psOverPtr(Up, false))
513
+ h2 := h1.intoNode().onPointer(psOverPtr(Up, false))
266
514
  assert h2.events.length() == 1
267
515
  assert h2.events.at(0).kind == Release
268
516
  assert !psIsPressed(h2.intoNode())
269
517
 
270
518
  test "button_drag_off_and_back_one_click"
271
- b := PureScene.button(7, "go", 1, 2, 0.0, 0.0, 10.0, 10.0)
519
+ b := Node.button(7, "go", 1, 2, 0.0, 0.0, 10.0, 10.0)
272
- a1 := PureScene.onPointer(b, psOverPtr(Down, true)).intoNode()
520
+ a1 := b.onPointer(psOverPtr(Down, true)).intoNode()
273
- h2 := PureScene.onPointer(a1, psOverPtr(Move, false))
521
+ h2 := a1.onPointer(psOverPtr(Move, false))
274
522
  c2 := psClicks(h2)
275
- h3 := PureScene.onPointer(h2.intoNode(), psOverPtr(Move, true))
523
+ h3 := h2.intoNode().onPointer(psOverPtr(Move, true))
276
524
  c3 := psClicks(h3)
277
- h4 := PureScene.onPointer(h3.intoNode(), psOverPtr(Up, true))
525
+ h4 := h3.intoNode().onPointer(psOverPtr(Up, true))
278
526
  assert c2 + c3 + psClicks(h4) == 1
279
527
 
280
528
  test "on_pointer_hands_back_changed_actor"
281
- b := PureScene.button(7, "go", 1, 2, 0.0, 0.0, 10.0, 10.0)
529
+ b := Node.button(7, "go", 1, 2, 0.0, 0.0, 10.0, 10.0)
282
- h := PureScene.onPointer(b, psOverPtr(Down, true))
530
+ h := b.onPointer(psOverPtr(Down, true))
283
531
  assert psIsPressed(h.intoNode())
284
532
 
285
533
  test "custom_actor_act_and_draw_dispatch"
286
534
  beacon := Beacon(draws: 0)
287
- n := PureScene.custom(7, 2.0, 0.0, 8.0, 8.0, beacon)
535
+ n := Node.custom(7, 2.0, 0.0, 8.0, 8.0, beacon)
288
- PureScene.drawTree(io, n, Affine.Identity, Color.White)
536
+ n.drawTree(io, Affine.Identity, Color.White)
289
537
  assert beacon.draws == 1
290
- moved := psActed(n, 0.016)
538
+ moved := n.acted(0.016)
291
539
  assert moved.x == 3.0
292
540
 
293
541
  test "draw_tree_nested_groups_reaches_custom"
294
542
  beacon := Beacon(draws: 0)
295
- inner := PureScene.group(2, 0.0, 0.0, 10.0, 10.0, Slice(PureScene.custom(3, 1.0, 1.0, 4.0, 4.0, beacon)))
543
+ inner := Node.group(2, 0.0, 0.0, 10.0, 10.0, Slice(Node.custom(3, 1.0, 1.0, 4.0, 4.0, beacon)))
296
- root := PureScene.group(1, 0.0, 0.0, 10.0, 10.0, Slice(inner))
544
+ root := Node.group(1, 0.0, 0.0, 10.0, 10.0, Slice(inner))
297
- PureScene.drawTree(io, root, Affine.Identity, Color.White)
545
+ root.drawTree(io, Affine.Identity, Color.White)
298
546
  assert beacon.draws == 1
299
547
 
300
548
  test "hit_box_and_children_of"
301
- s := PureScene.sprite(1, 0, 0.0, 0.0, 10.0, 10.0)
549
+ s := Node.sprite(1, 0, 0.0, 0.0, 10.0, 10.0)
302
- assert PureScene.hit(s, Vec2(x: 5.0, y: 5.0))
550
+ assert s.hit(Vec2(x: 5.0, y: 5.0))
303
- assert !PureScene.hit(s, Vec2(x: 11.0, y: 5.0))
551
+ assert !s.hit(Vec2(x: 11.0, y: 5.0))
304
- assert PureScene.childrenOf(s) == null
552
+ assert s.children() == null
305
- assert PureScene.childrenOf(PureScene.group(2, 0.0, 0.0, 1.0, 1.0, Slice(s))) != null
553
+ assert Node.group(2, 0.0, 0.0, 1.0, 1.0, Slice(s)).children() != null
306
554
 
307
555
  test "button_move_raises_enter_then_exit"
308
- b := PureScene.button(7, "go", 1, 2, 0.0, 0.0, 10.0, 10.0)
556
+ b := Node.button(7, "go", 1, 2, 0.0, 0.0, 10.0, 10.0)
309
- h1 := PureScene.onPointer(b, psOverPtr(Move, true))
557
+ h1 := b.onPointer(psOverPtr(Move, true))
310
558
  assert h1.events.length() == 1
311
559
  assert h1.events.at(0).kind == Enter
312
- h2 := PureScene.onPointer(h1.intoNode(), psOverPtr(Move, false))
560
+ h2 := h1.intoNode().onPointer(psOverPtr(Move, false))
313
561
  assert h2.events.length() == 1
314
562
  assert h2.events.at(0).kind == Exit
315
563
  assert !psIsPressed(h2.intoNode())
316
564
 
317
565
  test "on_pointer_non_button_raises_nothing"
318
- s := PureScene.sprite(1, 0, 0.0, 0.0, 10.0, 10.0)
566
+ s := Node.sprite(1, 0, 0.0, 0.0, 10.0, 10.0)
319
- h := PureScene.onPointer(s, psOverPtr(Down, true))
567
+ h := s.onPointer(psOverPtr(Down, true))
320
568
  assert h.events.length() == 0
321
569
  assert h.intoNode().id == 1
322
570
 
323
571
  test "draw_tree_skips_invisible_custom"
324
572
  beacon := Beacon(draws: 0)
325
- n := PureScene.custom(7, 0.0, 0.0, 8.0, 8.0, beacon)
573
+ n := Node.custom(7, 0.0, 0.0, 8.0, 8.0, beacon)
326
574
  n2 := Node(..n, visible: false)
327
- PureScene.drawTree(io, n2, Affine.Identity, Color.White)
575
+ n2.drawTree(io, Affine.Identity, Color.White)
328
576
  assert beacon.draws == 0
577
+
578
+ test "find_hit_nested"
579
+ r := ttFixture().findHit(Affine.Identity, ttAt(21.0, 1.0))
580
+ assert ttPathEq(r, ttInts(1, 0))
581
+
582
+ test "find_hit_miss"
583
+ assert ttNoPath(ttFixture().findHit(Affine.Identity, ttAt(200.0, 200.0)))
584
+
585
+ test "id_at_paths"
586
+ t := ttFixture()
587
+ assert t.idAt(ttInts(1, 0), 0) == 4
588
+ five := Slice(5)
589
+ assert t.idAt(five, 0) == -1
590
+ assert t.idAt(Slice[Int](), 0) == 1
591
+
592
+ test "find_path_ids"
593
+ t := ttFixture()
594
+ assert ttPathEq(t.findPath(4), ttInts(1, 0))
595
+ assert ttNoPath(t.findPath(99))
596
+
597
+ test "update_runs_actions_and_children"
598
+ n := ttMovingTree().update(0.5)
599
+ assert {ttFirstKidX(n) - 5.0}.abs() < 1e-6
600
+ assert ttFirstKidActions(n) == 1
601
+
602
+ test "update_changes_tree_in_place"
603
+ t := ttMovingTree()
604
+ n := t.update(0.5)
605
+ assert ttFirstKidX(n) == 5.0
606
+ assert ttFirstKidActions(n) == 1
607
+
608
+ test "disabled_group_hides_subtree"
609
+ t := ttSetTouch(ttFixture(), 3, Disabled)
610
+ r := t.findHit(Affine.Identity, ttAt(21.0, 1.0))
611
+ assert ttPathEq(r, Slice[Int]())
612
+
613
+ test "children_only_group_not_hit_itself"
614
+ t := ttSetTouch(ttFixture(), 3, ChildrenOnly)
615
+ r := t.findHit(Affine.Identity, ttAt(28.0, 8.0))
616
+ assert ttPathEq(r, Slice[Int]())
617
+
618
+ test "scale_zero_not_hit"
619
+ t := ttFixture().modify(2, |a| ttSetScaleX(a))
620
+ r := t.findHit(Affine.Identity, ttAt(1.0, 1.0))
621
+ assert ttPathEq(r, Slice[Int]())
622
+
623
+ test "topmost_child_wins"
624
+ kids := Slice(Node.sprite(2, 0, 0.0, 0.0, 10.0, 10.0), Node.sprite(3, 0, 0.0, 0.0, 10.0, 10.0))
625
+ root := Node.group(1, 0.0, 0.0, 100.0, 100.0, kids)
626
+ me: Node := root
627
+ r := me.findHit(Affine.Identity, ttAt(1.0, 1.0))
628
+ want := Slice(1)
629
+ assert ttPathEq(r, want)
630
+
631
+ test "modify_changes_first_match_only"
632
+ kids := Slice(Node.sprite(7, 0, 0.0, 0.0, 10.0, 10.0), Node.sprite(7, 0, 0.0, 0.0, 10.0, 10.0))
633
+ root := Node.group(1, 0.0, 0.0, 100.0, 100.0, kids)
634
+ me: Node := root
635
+ n := me.modify(7, |a| ttSetX9(a))
636
+ assert ttKidX(n, 0) == 9.0
637
+ assert ttKidX(n, 1) == 0.0
638
+
639
+ test "route_child_first_then_group"
640
+ g := ttButtonRoot()
641
+ h1 := g.route(ttZero(), 0, Pointer(kind: Down, x: 20.0, y: 20.0, over: true))
642
+ n1 := h1.events.length()
643
+ k1 := h1.events.at(0).kind
644
+ h2 := h1.intoNode().route(ttZero(), 0, Pointer(kind: Up, x: 20.0, y: 20.0, over: true))
645
+ assert n1 == 1
646
+ assert k1 == Press
647
+ assert h2.events.length() == 2
648
+ assert h2.events.at(0).kind == Release
649
+ assert h2.events.at(1).kind == Click
650
+ assert h2.events.at(1).id == 5
651
+
652
+ test "stale_path_reaches_group_only"
653
+ nine := Slice(9)
654
+ h := ttButtonRoot().route(nine, 0, Pointer(kind: Down, x: 20.0, y: 20.0, over: true))
655
+ assert h.events.length() == 0
656
+
657
+ test "compaction_keeps_order"
658
+ n := ttCompactTree().update(0.1)
659
+ acts := ttKid(n).actions
660
+ assert acts.length() == 2
661
+ assert ttActKind(acts.at(0)) == 1
662
+ assert ttActKind(acts.at(1)) == 2
663
+ assert {ttKid(n).x - 1.0}.abs() < 1e-6
664
+ assert {ttKid(n).y - 0.1}.abs() < 1e-6
665
+ assert {ttKid(n).rotation - 1.02}.abs() < 1e-6
666
+ # The next step advances the right survivors: MoveBy 0.1 -> 0.6, RotateBy 0.1 -> 0.6.
667
+ m := n.update(0.5)
668
+ acts2 := ttKid(m).actions
669
+ assert acts2.length() == 2
670
+ assert ttActKind(acts2.at(0)) == 1
671
+ assert ttActKind(acts2.at(1)) == 2
672
+ assert actElapsed(acts2.at(0)) == 0.6
673
+ assert {ttKid(m).y - 0.6}.abs() < 1e-6
674
+ assert {ttKid(m).rotation - 1.12}.abs() < 1e-6
675
+ assert {ttKid(m).x - 1.0}.abs() < 1e-6
676
+
677
+ test "update_runs_custom_actor_act"
678
+ c := Node.custom(9, 1.0, 0.0, 4.0, 4.0, TtStepper(ticks: 0))
679
+ n := Node.group(1, 0.0, 0.0, 10.0, 10.0, Slice(c)).update(0.016)
680
+ assert ttFirstKidX(n) == 2.0
plum-std/game/pure/SceneInput.plum CHANGED
@@ -10,7 +10,6 @@ import std/game/Affine
10
10
  import std/game/Game
11
11
  import std/game/pure/Actions
12
12
  import std/game/pure/PureScene
13
- import std/game/pure/SceneTree
14
13
 
15
14
  # The root of a pure scene tree, its camera and pointer capture. A Stage is an
16
15
  # unique value: `input` and `frame` take it, change it in place and hand it back
@@ -25,9 +24,33 @@ enum InputKind =
25
24
  | PointerMove
26
25
 
27
26
  # A pointer input in screen coordinates.
28
- record Input(kind: InputKind, x: Float, y: Float)
27
+ record Input(kind: InputKind, x: Float, y: Float) =
29
28
 
29
+ # This frame's pointer inputs from the host mouse: a press, a release, or a
30
+ # move. `wasDown` is whether the button was down last frame.
31
+ fun poll(io: Io, wasDown: Bool) -> Slice[Input] =
32
+ m := io.mousePos()
33
+ down := io.isMouseDown(0)
34
+ out := Slice[Input]()
35
+ if down && !wasDown
36
+ out.push(Input(kind: PointerDown, x: m.x, y: m.y))
37
+ else if !down && wasDown
38
+ out.push(Input(kind: PointerUp, x: m.x, y: m.y))
39
+ else
40
+ out.push(Input(kind: PointerMove, x: m.x, y: m.y))
41
+ out
42
+
30
- record Capture(path: Slice[Int], id: Int)
43
+ record Capture(path: Slice[Int], id: Int) =
44
+
45
+ # Whether the captured path is `p` or leads into it (the pointer is over the actor).
46
+ fun within(self, p: Slice[Int]) -> Bool =
47
+ a := self.path
48
+ if a.length() > p.length()
49
+ return false
50
+ for i := range a.length()
51
+ if a.at(i) != p.at(i)
52
+ return false
53
+ true
31
54
 
32
55
  record StageHandled(stage: Stage, events: Slice[UiEvent]) =
33
56
 
@@ -38,6 +61,10 @@ record StageHandled(stage: Stage, events: Slice[UiEvent]) =
38
61
 
39
62
  record Stage(root: Node, camera: Affine, captured: Capture?) =
40
63
 
64
+ # A stage over `root` with no camera transform and nothing captured.
65
+ fun create(root: Node) -> Stage =
66
+ Stage(root: root, camera: Affine.Identity, captured: null)
67
+
41
68
  # Handles one input: routes it, returns the new stage and the events raised.
42
69
  fun input(self, e: Input) -> StageHandled =
43
70
  none := Slice[UiEvent]()
@@ -47,21 +74,21 @@ record Stage(root: Node, camera: Affine, captured: Capture?) =
47
74
  world := Vec2(x: e.x, y: e.y)
48
75
  if q := inv
49
76
  world = q.apply(world)
50
- hit := SceneTree.findHit(self.root, Affine.Identity, world)
77
+ hit := self.root.findHit(Affine.Identity, world)
51
78
  # A capture is valid only while its path still leads to its actor.
52
79
  cap: Capture? := null
53
80
  if c := self.captured
54
- if SceneTree.idAt(self.root, c.path, 0) == c.id
81
+ if self.root.idAt(c.path, 0) == c.id
55
82
  cap = c
56
83
  if e.kind == PointerDown
57
84
  if p := hit
58
- id := SceneTree.idAt(self.root, p, 0)
85
+ id := self.root.idAt(p, 0)
59
86
  return self.deliver(p, Pointer(kind: Down, x: world.x, y: world.y, over: true), Capture(path: p, id: id))
60
87
  return StageHandled(stage: self.withCapture(null), events: none)
61
88
  if c := cap
62
89
  over := false
63
90
  if p := hit
64
- over = inputIsPrefix(c.path, p)
91
+ over = c.within(p)
65
92
  if e.kind == PointerUp
66
93
  return self.deliver(c.path, Pointer(kind: Up, x: world.x, y: world.y, over: over), null)
67
94
  return self.deliver(c.path, Pointer(kind: Move, x: world.x, y: world.y, over: over), c)
@@ -78,7 +105,7 @@ record Stage(root: Node, camera: Affine, captured: Capture?) =
78
105
  events := Slice[UiEvent]()
79
106
  match self
80
107
  Stage(root, camera, _) =>
81
- r := SceneTree.routeInto(root, path, 0, p, events)
108
+ r := root.routeInto(path, 0, p, events)
82
109
  StageHandled(stage: Stage(root: r, camera: camera, captured: c), events: events)
83
110
 
84
111
  # Handles the inputs in order, then advances the tree by `dt`.
@@ -91,52 +118,25 @@ record Stage(root: Node, camera: Affine, captured: Capture?) =
91
118
  out.push(h.events.at(j))
92
119
  cur = h.intoStage()
93
120
  match cur
94
- Stage(root, camera, captured) =>
95
- StageHandled(stage: Stage(root: SceneTree.update(root, dt), camera: camera, captured: captured), events: out)
121
+ Stage(root, camera, captured) => StageHandled(stage: Stage(root: root.update(dt), camera: camera, captured: captured), events: out)
96
122
 
97
123
  # Paints the tree through the camera.
98
124
  fun draw(self, io: Io) =
99
- PureScene.drawTree(io, self.root, self.camera, Color.White)
125
+ self.root.drawTree(io, self.camera, Color.White)
100
-
101
- fun inputIsPrefix(a: Slice[Int], b: Slice[Int]) -> Bool =
102
- if a.length() > b.length()
103
- return false
104
- for i := range a.length()
105
- if a.at(i) != b.at(i)
106
- return false
107
- true
108
-
109
- primitive SceneInput =
110
- fun stage(root: Node) -> Stage =
111
- Stage(root: root, camera: Affine.Identity, captured: null)
112
-
113
- # This frame's pointer inputs from the host mouse: a press, a release, or a
114
- # move. `wasDown` is whether the button was down last frame.
115
- fun pollPointer(io: Io, wasDown: Bool) -> Slice[Input] =
116
- m := io.mousePos()
117
- down := io.isMouseDown(0)
118
- out := Slice[Input]()
119
- if down && !wasDown
120
- out.push(Input(kind: PointerDown, x: m.x, y: m.y))
121
- else if !down && wasDown
122
- out.push(Input(kind: PointerUp, x: m.x, y: m.y))
123
- else
124
- out.push(Input(kind: PointerMove, x: m.x, y: m.y))
125
- out
126
126
 
127
127
  fun inBtnTree() -> Node =
128
- b: Node := PureScene.button(5, "Go", 0, 1, 10.0, 10.0, 100.0, 40.0)
128
+ b: Node := Node.button(5, "Go", 0, 1, 10.0, 10.0, 100.0, 40.0)
129
129
  kids := Slice(b)
130
- g: Node := PureScene.group(1, 0.0, 0.0, 800.0, 600.0, kids)
130
+ g: Node := Node.group(1, 0.0, 0.0, 800.0, 600.0, kids)
131
131
  g
132
132
 
133
133
  fun inBtnPressed(s: Stage) -> Bool =
134
- if k := PureScene.childrenOf(s.root)
134
+ if k := s.root.children()
135
135
  return psIsPressed(k.at(0))
136
136
  false
137
137
 
138
138
  fun inBtnOver(s: Stage) -> Bool =
139
- if k := PureScene.childrenOf(s.root)
139
+ if k := s.root.children()
140
140
  return inOverOf(k.at(0))
141
141
  false
142
142
 
@@ -162,15 +162,15 @@ fun inClicks(events: Slice[UiEvent]) -> Int =
162
162
  n
163
163
 
164
164
  fun inXformTree() -> Node =
165
- b: Node := PureScene.button(5, "Go", 0, 1, 10.0, 10.0, 100.0, 40.0)
165
+ b: Node := Node.button(5, "Go", 0, 1, 10.0, 10.0, 100.0, 40.0)
166
166
  kids := Slice(b)
167
- g := PureScene.group(1, 100.0, 0.0, 800.0, 600.0, kids)
167
+ g := Node.group(1, 100.0, 0.0, 800.0, 600.0, kids)
168
168
  g2 := Node(..g, rotation: 1.5707963267948966, scaleX: 2.0, scaleY: 2.0)
169
169
  me: Node := g2
170
170
  me
171
171
 
172
172
  test "press_sets_capture"
173
- h := SceneInput.stage(inBtnTree()).input(inDown(20.0, 20.0))
173
+ h := Stage.create(inBtnTree()).input(inDown(20.0, 20.0))
174
174
  assert h.events.length() == 1
175
175
  assert h.events.at(0).kind == Press
176
176
  assert h.events.at(0).id == 5
@@ -180,7 +180,7 @@ test "press_sets_capture"
180
180
  assert false
181
181
 
182
182
  test "release_elsewhere_no_click"
183
- h1 := SceneInput.stage(inBtnTree()).input(inDown(20.0, 20.0))
183
+ h1 := Stage.create(inBtnTree()).input(inDown(20.0, 20.0))
184
184
  h2 := h1.intoStage().input(inUp(500.0, 500.0))
185
185
  assert h2.events.length() == 1
186
186
  assert h2.events.at(0).kind == Release
@@ -189,7 +189,7 @@ test "release_elsewhere_no_click"
189
189
  assert !inBtnPressed(h2.stage)
190
190
 
191
191
  test "drag_off_and_back_one_click"
192
- h1 := SceneInput.stage(inBtnTree()).input(inDown(20.0, 20.0))
192
+ h1 := Stage.create(inBtnTree()).input(inDown(20.0, 20.0))
193
193
  c1 := inClicks(h1.events)
194
194
  h2 := h1.intoStage().input(inMove(500.0, 500.0))
195
195
  c2 := inClicks(h2.events)
@@ -199,15 +199,15 @@ test "drag_off_and_back_one_click"
199
199
  assert c1 + c2 + c3 + inClicks(h4.events) == 1
200
200
 
201
201
  test "stale_capture_dropped"
202
- h1 := SceneInput.stage(inBtnTree()).input(inDown(20.0, 20.0))
202
+ h1 := Stage.create(inBtnTree()).input(inDown(20.0, 20.0))
203
- empty: Node := PureScene.group(1, 0.0, 0.0, 800.0, 600.0, Slice[Node]())
203
+ empty: Node := Node.group(1, 0.0, 0.0, 800.0, 600.0, Slice[Node]())
204
204
  swapped := Stage(root: empty, camera: h1.stage.camera, captured: h1.stage.captured)
205
205
  h2 := swapped.input(inUp(20.0, 20.0))
206
206
  assert h2.events.length() == 0
207
207
  assert h2.stage.captured == null
208
208
 
209
209
  test "hover_without_press_routes_move"
210
- h := SceneInput.stage(inBtnTree()).input(inMove(20.0, 20.0))
210
+ h := Stage.create(inBtnTree()).input(inMove(20.0, 20.0))
211
211
  assert inBtnOver(h.stage)
212
212
  assert h.stage.captured == null
213
213
 
@@ -220,20 +220,20 @@ test "camera_maps_screen_to_world"
220
220
  assert h.events.at(0).id == 5
221
221
 
222
222
  test "frame_handles_inputs_then_updates"
223
- spr := PureScene.sprite(6, 0, 300.0, 300.0, 10.0, 10.0)
223
+ spr := Node.sprite(6, 0, 300.0, 300.0, 10.0, 10.0)
224
- acts := Slice(Actions.moveBy(10.0, 0.0, 1.0))
224
+ acts := Slice(Action.moveBy(10.0, 0.0, 1.0))
225
225
  spr2 := Node(..spr, actions: acts)
226
- b: Node := PureScene.button(5, "Go", 0, 1, 10.0, 10.0, 100.0, 40.0)
226
+ b: Node := Node.button(5, "Go", 0, 1, 10.0, 10.0, 100.0, 40.0)
227
227
  sp: Node := spr2
228
228
  kids := Slice(b, sp)
229
- root: Node := PureScene.group(1, 0.0, 0.0, 800.0, 600.0, kids)
229
+ root: Node := Node.group(1, 0.0, 0.0, 800.0, 600.0, kids)
230
230
  ins := Slice(inDown(20.0, 20.0), inUp(20.0, 20.0))
231
- h := SceneInput.stage(root).frame(0.5, ins)
231
+ h := Stage.create(root).frame(0.5, ins)
232
232
  assert h.events.length() == 3
233
233
  assert h.events.at(0).kind == Press
234
234
  assert h.events.at(1).kind == Release
235
235
  assert h.events.at(2).kind == Click
236
- if k := PureScene.childrenOf(h.stage.root)
236
+ if k := h.stage.root.children()
237
237
  assert {k.at(1).x - 305.0}.abs() < 0.000001
238
238
  else
239
239
  assert false
@@ -241,7 +241,7 @@ test "frame_handles_inputs_then_updates"
241
241
  test "hit_and_route_agree_for_rotated_scaled_group"
242
242
  # local (20,20) in the button's parent maps to world (100 - 2*20, 2*20) = (60,40)
243
243
  t := inXformTree()
244
- hit := SceneTree.findHit(t, Affine.Identity, Vec2(x: 60.0, y: 40.0))
244
+ hit := t.findHit(Affine.Identity, Vec2(x: 60.0, y: 40.0))
245
245
  assert hit != null
246
246
  h := Stage(root: t, camera: Affine.Identity, captured: null).input(Input(kind: PointerDown, x: 60.0, y: 40.0))
247
247
  assert h.events.length() == 1
@@ -250,6 +250,6 @@ test "hit_and_route_agree_for_rotated_scaled_group"
250
250
  assert miss.events.length() == 0
251
251
 
252
252
  test "input_changes_stage_in_place"
253
- h := SceneInput.stage(inBtnTree()).input(Input(kind: PointerDown, x: 20.0, y: 20.0))
253
+ h := Stage.create(inBtnTree()).input(Input(kind: PointerDown, x: 20.0, y: 20.0))
254
254
  assert h.stage.captured != null
255
255
  assert inBtnPressed(h.stage)
plum-std/game/pure/SceneTree.plum DELETED
@@ -1,350 +0,0 @@
1
- module std
2
- import std/Int
3
- import std/Float
4
- import std/Slice
5
- import std/Str
6
- import std/game/Vec2
7
- import std/game/Affine
8
- import std/game/pure/Actions
9
- import std/game/pure/PureScene
10
-
11
- fun treeActorOf(n: Node) -> Actor? =
12
- match n.kind
13
- Custom(actor) => actor
14
- _ => null
15
-
16
- fun treePrepend(i: Int, rest: Slice[Int]) -> Slice[Int] =
17
- out := Slice(i)
18
- for j := range rest.length()
19
- out.push(rest.at(j))
20
- out
21
-
22
- primitive SceneTree =
23
-
24
- # A Custom node's actor acts on it; every other node is itself.
25
- fun act(n: Node, dt: Float) -> Node =
26
- if actor := treeActorOf(n)
27
- return actor.act(n, dt)
28
- n
29
-
30
- # The node after `dt` seconds: a Custom actor's own `act`, then its actions on its pose,
31
- # then every child. The tree is changed in place and handed back.
32
- fun update(a: Node, dt: Float) -> Node =
33
- b := SceneTree.act(a, dt)
34
- r := Actions.stepAll(Pose(x: b.x, y: b.y, rotation: b.rotation, scaleX: b.scaleX, scaleY: b.scaleY), b.actions, dt)
35
- b = Node(..b, x: r.pose.x, y: r.pose.y, rotation: r.pose.rotation, scaleX: r.pose.scaleX, scaleY: r.pose.scaleY)
36
- match b.kind
37
- Group(ks) =>
38
- _ := ks.mapInPlace(|k| SceneTree.update(k, dt))
39
- _ => 0
40
- b
41
-
42
- # The path (child indexes from `a`) to the topmost actor under `p`, which is
43
- # in the coordinates of `a`'s parent; `parent` is that parent's stage matrix.
44
- fun findHit(a: Node, parent: Affine, p: Vec2) -> Slice[Int]? =
45
- if !a.visible || a.touchable == Disabled
46
- return null
47
- m := parent * PureScene.localMatrix(a)
48
- inv := m.invert()
49
- if inv == null
50
- return null
51
- lp := Vec2(x: 0.0, y: 0.0)
52
- if q := inv
53
- lp = q.apply(p)
54
- match a.kind
55
- Group(ks) =>
56
- i := ks.length() - 1
57
- while i >= 0
58
- sub := SceneTree.findHit(ks.at(i), m, p)
59
- if s := sub
60
- return treePrepend(i, s)
61
- i = i - 1
62
- _ => 0
63
- if PureScene.hit(a, lp)
64
- return Slice[Int]()
65
- null
66
-
67
- # The id of the actor at `path` below `a`, or -1 if the path leads nowhere.
68
- fun idAt(a: Node, path: Slice[Int], depth: Int) -> Int =
69
- if depth >= path.length()
70
- return a.id
71
- match a.kind
72
- Group(ks) =>
73
- i := path.at(depth)
74
- if i >= 0 && i < ks.length()
75
- return SceneTree.idAt(ks.at(i), path, depth + 1)
76
- _ => 0
77
- -1
78
-
79
- # The path to the first actor with `id`, depth first; null if none.
80
- fun findPath(a: Node, id: Int) -> Slice[Int]? =
81
- if a.id == id
82
- return Slice[Int]()
83
- match a.kind
84
- Group(ks) =>
85
- for i := range ks.length()
86
- sub := SceneTree.findPath(ks.at(i), id)
87
- if s := sub
88
- return treePrepend(i, s)
89
- _ => 0
90
- null
91
-
92
- # The tree where the first actor with `id` is replaced by `f` of it.
93
- fun modify(a: Node, id: Int, f: fn(Node) -> Node) -> Node =
94
- path := SceneTree.findPath(a, id)
95
- if p := path
96
- return SceneTree.modifyAt(a, p, 0, f)
97
- a
98
-
99
- fun modifyAt(a: Node, path: Slice[Int], depth: Int, f: fn(Node) -> Node) -> Node =
100
- if depth >= path.length()
101
- return f(a)
102
- match a.kind
103
- Group(ks) =>
104
- i := path.at(depth)
105
- if i >= 0 && i < ks.length()
106
- c := ks.takeAt(i)
107
- ks.putAt(i, SceneTree.modifyAt(c, path, depth + 1, f))
108
- _ => 0
109
- a
110
-
111
- # Delivers `e` (in the coordinates of `a`'s parent) along `path`: the child on
112
- # the path first, then `a` itself. A path that runs out reaches the deepest
113
- # actor it can.
114
- fun route(a: Node, path: Slice[Int], depth: Int, e: Pointer) -> Handled =
115
- events := Slice[UiEvent]()
116
- r := SceneTree.routeInto(a, path, depth, e, events)
117
- Handled(node: r, events: events)
118
-
119
- # `route`, appending the raised events to `events` and returning the actor.
120
- fun routeInto(a: Node, path: Slice[Int], depth: Int, e: Pointer, events: Slice[UiEvent]) -> Node =
121
- lp := Vec2(x: e.x, y: e.y)
122
- inv := PureScene.localMatrix(a).invert()
123
- if q := inv
124
- lp = q.apply(lp)
125
- local := Pointer(kind: e.kind, x: lp.x, y: lp.y, over: e.over)
126
- if depth < path.length()
127
- match a.kind
128
- Group(ks) =>
129
- i := path.at(depth)
130
- if i >= 0 && i < ks.length()
131
- c := ks.takeAt(i)
132
- ks.putAt(i, SceneTree.routeInto(c, path, depth + 1, local, events))
133
- _ => 0
134
- h := PureScene.onPointer(a, local)
135
- for j := range h.events.length()
136
- events.push(h.events.at(j))
137
- h.intoNode()
138
-
139
- fun ttFixture() -> Node =
140
- inner := Slice(PureScene.sprite(4, 0, 0.0, 0.0, 5.0, 5.0))
141
- kids := Slice(PureScene.sprite(2, 0, 0.0, 0.0, 10.0, 10.0), PureScene.group(3, 20.0, 0.0, 10.0, 10.0, inner))
142
- root := PureScene.group(1, 0.0, 0.0, 100.0, 100.0, kids)
143
- me: Node := root
144
- me
145
-
146
- fun ttNoPath(r: Slice[Int]?) -> Bool =
147
- r == null
148
-
149
- fun ttPathEq(r: Slice[Int]?, want: Slice[Int]) -> Bool =
150
- if p := r
151
- if p.length() != want.length()
152
- return false
153
- for i := range p.length()
154
- if p.at(i) != want.at(i)
155
- return false
156
- return true
157
- false
158
-
159
- fun ttInts(a: Int, b: Int) -> Slice[Int] =
160
- out := Slice(a, b)
161
- out
162
-
163
- fun ttAt(x: Float, y: Float) -> Vec2 =
164
- Vec2(x: x, y: y)
165
-
166
- fun ttMovingTree() -> Node =
167
- s := PureScene.sprite(2, 0, 0.0, 0.0, 10.0, 10.0)
168
- s2 := Node(..s, actions: Slice(Actions.moveBy(10.0, 0.0, 1.0)))
169
- kids := Slice(s2)
170
- root := PureScene.group(1, 0.0, 0.0, 100.0, 100.0, kids)
171
- me: Node := root
172
- me
173
-
174
- fun ttFirstKidX(a: Node) -> Float =
175
- match a.kind
176
- Group(k) =>
177
- return k.at(0).x
178
- _ => 0
179
- 0.0 - 1.0
180
-
181
- fun ttFirstKidActions(a: Node) -> Int =
182
- match a.kind
183
- Group(k) =>
184
- return k.at(0).actions.length()
185
- _ => 0
186
- 0 - 1
187
-
188
- fun ttSetTouch(t: Node, id: Int, tc: Touchable) -> Node =
189
- SceneTree.modify(t, id, |a| ttSetTouchOf(a, tc))
190
-
191
- fun ttSetTouchOf(a: Node, tc: Touchable) -> Node =
192
- Node(..a, touchable: tc)
193
-
194
- fun ttSetScaleX(a: Node) -> Node =
195
- Node(..a, scaleX: 0.0)
196
-
197
- fun ttSetX9(a: Node) -> Node =
198
- Node(..a, x: 9.0)
199
-
200
- fun ttKidX(a: Node, i: Int) -> Float =
201
- match a.kind
202
- Group(k) =>
203
- return k.at(i).x
204
- _ => 0
205
- 0.0 - 1.0
206
-
207
- fun ttButtonRoot() -> Node =
208
- kids := Slice(PureScene.button(5, "Go", 0, 1, 10.0, 10.0, 100.0, 40.0))
209
- root := PureScene.group(1, 0.0, 0.0, 800.0, 600.0, kids)
210
- me: Node := root
211
- me
212
-
213
- fun ttZero() -> Slice[Int] =
214
- out := Slice(0)
215
- out
216
-
217
- # Five actions on one sprite: the 1st, 3rd and 5th finish in the first 0.1s step,
218
- # the 2nd (MoveBy) and 4th (RotateBy) keep running.
219
- fun ttCompactTree() -> Node =
220
- s := PureScene.sprite(2, 0, 0.0, 0.0, 10.0, 10.0)
221
- s2 := Node(..s, actions: Slice(Actions.moveBy(1.0, 0.0, 0.1), Actions.moveBy(0.0, 10.0, 10.0), Actions.rotateBy(1.0, 0.1), Actions.rotateBy(2.0, 10.0), Actions.delay(0.1)))
222
- kids := Slice(s2)
223
- root := PureScene.group(1, 0.0, 0.0, 100.0, 100.0, kids)
224
- me: Node := root
225
- me
226
-
227
- fun ttKid(a: Node) -> Node =
228
- match a.kind
229
- Group(k) =>
230
- return k.at(0)
231
- _ => 0
232
- none: Node := PureScene.sprite(0, 0, 0.0, 0.0, 0.0, 0.0)
233
- none
234
-
235
- # 1 MoveBy, 2 RotateBy, 3 Delay, 0 anything else.
236
- fun ttActKind(a: Action) -> Int =
237
- match a.kind
238
- MoveBy(_, _, _) => 1
239
- RotateBy(_, _) => 2
240
- Delay(_) => 3
241
- _ => 0
242
-
243
- # Adds 1.0 to its node's x on every act.
244
- record TtStepper(Actor, ticks: Int) =
245
- override fun act(self, n: Node, dt: Float) -> Node =
246
- Node(..n, x: n.x + 1.0)
247
-
248
- test "find_hit_nested"
249
- r := SceneTree.findHit(ttFixture(), Affine.Identity, ttAt(21.0, 1.0))
250
- assert ttPathEq(r, ttInts(1, 0))
251
-
252
- test "find_hit_miss"
253
- assert ttNoPath(SceneTree.findHit(ttFixture(), Affine.Identity, ttAt(200.0, 200.0)))
254
-
255
- test "id_at_paths"
256
- t := ttFixture()
257
- assert SceneTree.idAt(t, ttInts(1, 0), 0) == 4
258
- five := Slice(5)
259
- assert SceneTree.idAt(t, five, 0) == -1
260
- assert SceneTree.idAt(t, Slice[Int](), 0) == 1
261
-
262
- test "find_path_ids"
263
- t := ttFixture()
264
- assert ttPathEq(SceneTree.findPath(t, 4), ttInts(1, 0))
265
- assert ttNoPath(SceneTree.findPath(t, 99))
266
-
267
- test "update_runs_actions_and_children"
268
- n := SceneTree.update(ttMovingTree(), 0.5)
269
- assert {ttFirstKidX(n) - 5.0}.abs() < 1e-6
270
- assert ttFirstKidActions(n) == 1
271
-
272
- test "update_changes_tree_in_place"
273
- t := ttMovingTree()
274
- n := SceneTree.update(t, 0.5)
275
- assert ttFirstKidX(n) == 5.0
276
- assert ttFirstKidActions(n) == 1
277
-
278
- test "disabled_group_hides_subtree"
279
- t := ttSetTouch(ttFixture(), 3, Disabled)
280
- r := SceneTree.findHit(t, Affine.Identity, ttAt(21.0, 1.0))
281
- assert ttPathEq(r, Slice[Int]())
282
-
283
- test "children_only_group_not_hit_itself"
284
- t := ttSetTouch(ttFixture(), 3, ChildrenOnly)
285
- r := SceneTree.findHit(t, Affine.Identity, ttAt(28.0, 8.0))
286
- assert ttPathEq(r, Slice[Int]())
287
-
288
- test "scale_zero_not_hit"
289
- t := SceneTree.modify(ttFixture(), 2, |a| ttSetScaleX(a))
290
- r := SceneTree.findHit(t, Affine.Identity, ttAt(1.0, 1.0))
291
- assert ttPathEq(r, Slice[Int]())
292
-
293
- test "topmost_child_wins"
294
- kids := Slice(PureScene.sprite(2, 0, 0.0, 0.0, 10.0, 10.0), PureScene.sprite(3, 0, 0.0, 0.0, 10.0, 10.0))
295
- root := PureScene.group(1, 0.0, 0.0, 100.0, 100.0, kids)
296
- me: Node := root
297
- r := SceneTree.findHit(me, Affine.Identity, ttAt(1.0, 1.0))
298
- want := Slice(1)
299
- assert ttPathEq(r, want)
300
-
301
- test "modify_changes_first_match_only"
302
- kids := Slice(PureScene.sprite(7, 0, 0.0, 0.0, 10.0, 10.0), PureScene.sprite(7, 0, 0.0, 0.0, 10.0, 10.0))
303
- root := PureScene.group(1, 0.0, 0.0, 100.0, 100.0, kids)
304
- me: Node := root
305
- n := SceneTree.modify(me, 7, |a| ttSetX9(a))
306
- assert ttKidX(n, 0) == 9.0
307
- assert ttKidX(n, 1) == 0.0
308
-
309
- test "route_child_first_then_group"
310
- g := ttButtonRoot()
311
- h1 := SceneTree.route(g, ttZero(), 0, Pointer(kind: Down, x: 20.0, y: 20.0, over: true))
312
- n1 := h1.events.length()
313
- k1 := h1.events.at(0).kind
314
- h2 := SceneTree.route(h1.intoNode(), ttZero(), 0, Pointer(kind: Up, x: 20.0, y: 20.0, over: true))
315
- assert n1 == 1
316
- assert k1 == Press
317
- assert h2.events.length() == 2
318
- assert h2.events.at(0).kind == Release
319
- assert h2.events.at(1).kind == Click
320
- assert h2.events.at(1).id == 5
321
-
322
- test "stale_path_reaches_group_only"
323
- nine := Slice(9)
324
- h := SceneTree.route(ttButtonRoot(), nine, 0, Pointer(kind: Down, x: 20.0, y: 20.0, over: true))
325
- assert h.events.length() == 0
326
-
327
- test "compaction_keeps_order"
328
- n := SceneTree.update(ttCompactTree(), 0.1)
329
- acts := ttKid(n).actions
330
- assert acts.length() == 2
331
- assert ttActKind(acts.at(0)) == 1
332
- assert ttActKind(acts.at(1)) == 2
333
- assert {ttKid(n).x - 1.0}.abs() < 1e-6
334
- assert {ttKid(n).y - 0.1}.abs() < 1e-6
335
- assert {ttKid(n).rotation - 1.02}.abs() < 1e-6
336
- # The next step advances the right survivors: MoveBy 0.1 -> 0.6, RotateBy 0.1 -> 0.6.
337
- m := SceneTree.update(n, 0.5)
338
- acts2 := ttKid(m).actions
339
- assert acts2.length() == 2
340
- assert ttActKind(acts2.at(0)) == 1
341
- assert ttActKind(acts2.at(1)) == 2
342
- assert actElapsed(acts2.at(0)) == 0.6
343
- assert {ttKid(m).y - 0.6}.abs() < 1e-6
344
- assert {ttKid(m).rotation - 1.12}.abs() < 1e-6
345
- assert {ttKid(m).x - 1.0}.abs() < 1e-6
346
-
347
- test "update_runs_custom_actor_act"
348
- c := PureScene.custom(9, 1.0, 0.0, 4.0, 4.0, TtStepper(ticks: 0))
349
- n := SceneTree.update(PureScene.group(1, 0.0, 0.0, 10.0, 10.0, Slice(c)), 0.016)
350
- assert ttFirstKidX(n) == 2.0
plum-wasm-codegen/tests/fusion_alloc_test.rs CHANGED
@@ -219,8 +219,8 @@ fn list_pipeline_loop_has_no_cons_alloc() {
219
219
  assert_eq!(a, expected(100));
220
220
  }
221
221
 
222
- /// `Picture.draw(io, s.render())` in the functional component game fuses so no
222
+ /// `s.render().draw(io)` in the functional component game fuses so no
223
- /// SceneNode / Transform / List cell is built per frame: `picDraw` / `picDrawKids` fuse
223
+ /// SceneNode / Transform / List cell is built per frame: `SceneNode.drawWith` / `drawKids` fuse
224
224
  /// with `State.render`, its `renderShip` / `renderHud` / `renderOverlay` parts and the
225
225
  /// `List.map(|o| o.render(..))` children (each `render` resolved by its receiver type).
226
226
  #[test]
@@ -230,12 +230,12 @@ fn state_view_draw_has_no_scenenode_alloc() {
230
230
  say(format!("state_view: fusion notes {ns:?}"));
231
231
  let w = compile(&g, true);
232
232
  let fns = functions(&w);
233
- // Fused fns: every `picDraw$..` / `picDrawKids$..` (`picDraw$render$0`, `picDrawKids$map$c5$0`, ..).
233
+ // Fused fns: every `drawWith$..` / `drawKids$..` (`drawWith$render$0`, `drawKids$map$c5$0`, ..).
234
- let isFused = |n: &str| n.starts_with("picDraw$") || n.starts_with("picDrawKids$");
234
+ let isFused = |n: &str| n.starts_with("drawWith$") || n.starts_with("drawKids$");
235
235
  let fused: Vec<&FnInfo> = fns.iter().filter(|f| isFused(&f.name)).collect();
236
236
  let names: Vec<&str> = fused.iter().map(|f| f.name.as_str()).collect();
237
237
  say(format!("state_view: fused fns {names:?}"));
238
- assert!(names.contains(&"picDraw$render$0"), "draw(render(s)) did not fuse; notes: {ns:?}");
238
+ assert!(names.contains(&"drawWith$render$0"), "draw(render(s)) did not fuse; notes: {ns:?}");
239
239
  // Every part of the picture is fused in: no fused fn still calls a part's builder.
240
240
  let parts = ["State.render", "State.renderShip", "State.renderHud", "State.renderOverlay", "Orb.render", "Mine.render", "Star.render", "Ship.render", "State.lifeIcons", "List[Orb].map", "List[Mine].map", "List[Star].map"];
241
241
  for f in &fused {
@@ -249,7 +249,7 @@ fn state_view_draw_has_no_scenenode_alloc() {
249
249
  }
250
250
  // struct types are unnamed in the wasm name section, so assert no `struct.new` at all,
251
251
  // except (a) the game-over panel `renderPanel`, whose button subtree is a genuinely
252
- // shared result (`picBounds`, then `picDraw`), and (b) the constant Anim trees the ship
252
+ // shared result (`boundsWith`, then `drawWith`), and (b) the constant Anim trees the ship
253
253
  // and the title rebuild per frame (`Anim.at` on a just-built Anim is not one match).
254
254
  let exempt = |f: &FnInfo| f.name.contains("renderPanel") || f.name.contains("renderTitle") || f.calls.iter().any(|c| c == "Anim.at");
255
255
  let mut exemptAllocs = 0;