plum

#treesitter#compiler#wasm

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

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


aff71b3 — Peter John 2026-11-03T22:51:52+05:30
Functional scenes at feature parity: Anim, Ui, tagged hit-testing
README.md CHANGED
@@ -1414,6 +1414,30 @@ export fun draw(w: World) =
1414
1414
  - **Pure scene** ([`game_pure_scene.plum`](plum-examples/games/pure/game_pure_scene.plum)): every update takes an owned value and hands it back, 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.
1415
1415
  - **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.
1416
1416
 
1417
+ ### Functional scenes (state/view)
1418
+
1419
+ Three scene styles ship, each in its own folder: `std/game/Scene` (mutable actors), `std/game/pure/*` (owned, pure-update actors) and `std/game/functional/*` (plain state plus a `view`). 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.
1420
+
1421
+ - **`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`.
1422
+ - **`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.
1423
+
1424
+ From [`game_state_view.plum`](plum-examples/games/functional/game_state_view.plum): `update` runs `Ui.step` against the picture the player sees, then hands each event to `step`.
1425
+
1426
+ ```
1427
+ fun feed(s: State, input: UiInput) -> State =
1428
+ r := Ui.step(s.ui, input, Picture.hitId(view(s), input.x, input.y))
1429
+ n := State(..s)
1430
+ n.ui = r.ui
1431
+ feedEvents(n, r.events)
1432
+
1433
+ fun uiEvent(s: State, e: UiEvent) -> State =
1434
+ if s.over && e.id == Id.Restart && e.kind == Click
1435
+ return restarted(s, nextSeed(s.seed))
1436
+ s
1437
+ ```
1438
+
1439
+ Record `==` is identity today (structural `==` is a deferred item), so compare events by their fields (`e.id`, `e.kind`) as above.
1440
+
1417
1441
  **Browser check:** `cargo run --manifest-path plum-game/web-test/Cargo.toml -- plum-examples/game.plum --screenshot /tmp/orb-run.png` builds the static web version, serves it and drives headless Chromium (software WebGPU; `--browser webkit` drives Playwright's WebKit, the Safari engine, instead) through [playwright-rs](https://crates.io/crates/playwright-rs): it fails if the canvas stays blank or stops changing, or the page logs an error, and reports whether audio started. It also takes `--assets DIR`, `--require-audio`, `--measure SECS` (with `--json`, `draws` is the GPU draw calls per frame), `--headed`, `--dpr N` (a device pixel ratio of N), `--lose-device` (destroys the GPU device after a few frames and requires rendering to resume) and `--expect-pixel x,y,r,g,b[,tol]` (repeatable; the screenshot pixel at CSS (x, y)), `--expect-unsupported-webgpu` (hides `navigator.gpu` and requires the page's "WebGPU is not supported" message while plum.wasm still loads) and `--no-module-introspection` (makes `WebAssembly.Module.imports/exports/customSections` throw, as Safari does). `plum-game/web-test/fixtures/` has `interleave.plum`, `text_sizes.plum` and `rendertarget.plum` for these. One-time setup: `cargo run --manifest-path plum-game/web-test/Cargo.toml --example install-browsers`. See [`plum-game/web-test/README.md`](plum-game/web-test/README.md); `docs/game.png` above was made with `--screenshot`. [`plum-game/web-test/BENCHMARK.md`](plum-game/web-test/BENCHMARK.md) records the renderer benchmark that led to this design.
1418
1442
 
1419
1443
  **Asset root:** textures and files are read relative to the asset root. A path that escapes the root (`..` or absolute) fails like a missing file: `-1`, `""` or `false`. The headless runner's root is `--assets` or `assets` beside the wasm; it reads image headers for `textureWidth`/`textureHeight` and records sounds without decoding them.
docs/plans/2026-11-03-state-view-parity.benchmark.md ADDED
@@ -0,0 +1,13 @@
1
+ # State/view parity benchmark
2
+
3
+ Headed Chromium (real GPU, Apple M3 Pro), 1280x720, uncapped, 6 s warm-up then 10 s measured, run back to back in one session:
4
+ `plum-game/web-test/target/debug/plum-web-test <fixture> --headed --uncapped --measure 10 --frames-wait 6 --json`.
5
+ Reported, not a bar, except `stress_state_view` p95 < 4 ms (met: 2.50 ms).
6
+
7
+ | fixture | avg ms | p50 | p95 | update ms | draw ms |
8
+ |---|---|---|---|---|---|
9
+ | stress_scene (mutable Scene) | 1.93 | 1.80 | 2.90 | 0.051 | 1.351 |
10
+ | stress_pure_scene | 1.65 | 1.60 | 1.80 | 0.111 | 1.213 |
11
+ | stress_state_view | 2.07 | 2.00 | 2.50 | 0.048 | 1.540 |
12
+
13
+ Render (submit) was 0.30, 0.20 and 0.24 ms; all three issue 1 GPU draw call per frame. Differences under about 5% are noise.
docs/plans/2026-11-03-state-view-parity.incidents.jsonl ADDED
@@ -0,0 +1,17 @@
1
+ {"kind":"rule-block","agent":"subagent","tool":"Bash","target":"full suite: cd /Users/pyrossh/Code/plum/plum-core && sed -i 's/h + (if BadNum := e then 1 else 2) +/h + { if Bad"}
2
+ {"kind":"rule-block","agent":"subagent","tool":"Bash","target":"full suite: cd /Users/pyrossh/Code/plum && cargo test -q -p plum-wasm-codegen --test fusion_alloc_test -- --noca"}
3
+ {"file": "docs/plans/2026-11-03-state-view-parity.md", "line": 82, "context": "seam T2↔T3: T2 Produces: Picture.hitId: no module found (file missing)", "agent": "pt_review", "kind": "exception", "confidence": "high", "resolution": "open", "time": "22:38:20"}
4
+ {"file": "docs/plans/2026-11-03-state-view-parity.md", "line": 66, "context": "seam T1↔T5: T1 Produces: Anim.at: no module found (file missing)", "agent": "pt_review", "kind": "exception", "confidence": "high", "resolution": "open", "time": "22:40:37"}
5
+ {"file": "docs/plans/2026-11-03-state-view-parity.md", "line": 66, "context": "seam T1↔T5: T1 Produces: Anim.length: no module found (file missing)", "agent": "pt_review", "kind": "exception", "confidence": "high", "resolution": "open", "time": "22:40:37"}
6
+ {"file": "docs/plans/2026-11-03-state-view-parity.md", "line": 97, "context": "seam T3↔T5: T3 Produces: Ui.init: no module found (file missing)", "agent": "pt_review", "kind": "exception", "confidence": "high", "resolution": "open", "time": "22:40:37"}
7
+ {"file": "docs/plans/2026-11-03-state-view-parity.md", "line": 97, "context": "seam T3↔T5: T3 Produces: Ui.pressed: no module found (file missing)", "agent": "pt_review", "kind": "exception", "confidence": "high", "resolution": "open", "time": "22:40:37"}
8
+ {"file": "docs/plans/2026-11-03-state-view-parity.md", "line": 97, "context": "seam T3↔T5: T3 Produces: Ui.hovered: no module found (file missing)", "agent": "pt_review", "kind": "exception", "confidence": "high", "resolution": "open", "time": "22:40:37"}
9
+ {"file": "docs/plans/2026-11-03-state-view-parity.md", "line": 97, "context": "seam T3↔T5: T3 Produces: Ui.button: no module found (file missing)", "agent": "pt_review", "kind": "exception", "confidence": "high", "resolution": "open", "time": "22:40:37"}
10
+ {"file": "docs/plans/2026-11-03-state-view-parity.md", "line": 97, "context": "seam T3↔T4: T3 Produces: Ui.init: no module found (file missing)", "agent": "pt_review", "kind": "exception", "confidence": "high", "resolution": "open", "time": "22:41:26"}
11
+ {"file": "docs/plans/2026-11-03-state-view-parity.md", "line": 66, "context": "seam T1↔T4: T1 Produces: Anim.at: no module found (file missing)", "agent": "pt_review", "kind": "exception", "confidence": "high", "resolution": "open", "time": "22:41:26"}
12
+ {"file": "docs/plans/2026-11-03-state-view-parity.md", "line": 97, "context": "seam T3↔T4: T3 Produces: Ui.pressed: no module found (file missing)", "agent": "pt_review", "kind": "exception", "confidence": "high", "resolution": "open", "time": "22:41:26"}
13
+ {"file": "docs/plans/2026-11-03-state-view-parity.md", "line": 66, "context": "seam T1↔T4: T1 Produces: Anim.length: no module found (file missing)", "agent": "pt_review", "kind": "exception", "confidence": "high", "resolution": "open", "time": "22:41:26"}
14
+ {"file": "docs/plans/2026-11-03-state-view-parity.md", "line": 97, "context": "seam T3↔T4: T3 Produces: Ui.hovered: no module found (file missing)", "agent": "pt_review", "kind": "exception", "confidence": "high", "resolution": "open", "time": "22:41:26"}
15
+ {"file": "docs/plans/2026-11-03-state-view-parity.md", "line": 97, "context": "seam T3↔T4: T3 Produces: Ui.button: no module found (file missing)", "agent": "pt_review", "kind": "exception", "confidence": "high", "resolution": "open", "time": "22:41:26"}
16
+ {"file": "", "line": 0, "context": "verified: seam T4↔T6", "agent": "pt_review", "kind": "verified", "time": "22:48:37"}
17
+ {"file": "", "line": 0, "context": "verified: seam T5↔T6", "agent": "pt_review", "kind": "verified", "time": "22:48:37"}
docs/plans/2026-11-03-state-view-parity.md ADDED
@@ -0,0 +1,189 @@
1
+ # Functional state/view at feature parity
2
+
3
+ **Review plan:** docs/plans/2026-11-03-state-view-parity.review.md
4
+ **Goal:** give the functional scene style (`std/game/functional/Picture` + `games/functional/game_state_view.plum`) PureScene's features as pure functions: time-based animations (Anim), tagged hit-testing, and a pure UI layer (Ui: press/enter/exit/release/click, capture, buttons); port the game; measure and report performance.
5
+ **Architecture:** new Plum std modules `std/game/functional/Anim` and `std/game/functional/Ui`; `Picture`'s `SceneNode` gains `Tagged(id, child)` plus `Picture.hitId`; the game's `update` runs `step` -> `Ui.step` against `view(state)` -> `step` per UiEvent. No compiler changes.
6
+ **Tech stack:** Plum std/examples, plum-game host tests, playwright-rs web-test.
7
+ **Spec:** docs/plans/2026-11-03-state-view-parity.review.md
8
+ **Detail:** contracts
9
+ **Target-min:** 25
10
+
11
+ Global constraints:
12
+ - Worktree: /Users/pyrossh/Code/plum/.superlite/worktrees/sessions/state-view-parity (branch superlite/sessions/state-view-parity). Build there: `cargo build --quiet` then `./target/debug/plum test <file>`.
13
+ - Run tests ONLY via `bash "$SUPERLITE/skills/swarm/scripts/superlite-cli" run_tests --fast <files>` from the worktree (it really runs `plum test` for .plum paths and `cargo test --test <stem>` for Rust test files; output .superlite/test-output.log). Raw cargo test is hook-blocked.
14
+ - Baseline: `plum test plum-std plum-examples` all green (640 + 74 files); host_test 17.
15
+ - The functional folder never imports `std/game/pure/*`: Ui defines its own `UiEvent`/`UiKind`, Anim its own pose.
16
+ - Pure functions over values only: no `own`, no mutation of shared values, no globals.
17
+ - Plum syntax: `test "name"` + `assert`; an assignment in a match arm and a nested `match` each go on their own line; single-letter type names (`P`) parse as generics, use longer names; there is no `[a, b]` literal: use `List(a, b)` / `Cons(head:, tail:)` / `Slice(a, b)`; inline variants follow Rust rules (fields via patterns).
18
+ - Existing tests keep names and values. Never `plum format` a real file.
19
+
20
+ ## Main diagram
21
+ ```mermaid
22
+ flowchart LR
23
+ A["T1 Anim.at(anim, t) -> AnimPose"] --> G["T4 game_state_view"]
24
+ P["T2 Picture: Tagged + hitId"] --> U["T3 Ui.step / button"]
25
+ U --> G
26
+ U --> F["T5 stress_state_view"]
27
+ A --> F
28
+ G --> B["T6 benchmark + README"]
29
+ F --> B
30
+ ```
31
+
32
+ ## File structure
33
+ Each task's Owns line is its file list.
34
+
35
+ Spec map: Summary, What you will get, The design -> T1-T5; Folders (user, 2026-11-03) -> done before this plan; What will be built -> T1-T6; How it fits together -> main diagram; Decisions you can change -> defaults (time-based Anim, Tagged wrapper, PureScene kept); Risks -> R1/R2 measured in T6, R3 incidents; Out of scope -> not built; How we will know it works -> task tests + T6; Rough time and cost -> schedule.
36
+
37
+ ## Task graph
38
+ ```mermaid
39
+ flowchart LR
40
+ T1[anim]
41
+ T2[picture tags]
42
+ T3[ui]
43
+ T4[game]
44
+ T5[fixture]
45
+ T6[bench docs]
46
+ T7[record eq]
47
+ %% taskgraph
48
+ T2 --> T3
49
+ T1 --> T4
50
+ T3 --> T4
51
+ T1 --> T5
52
+ T3 --> T5
53
+ T4 --> T6
54
+ T5 --> T6
55
+ ```
56
+
57
+ Ruling: sequential - Plum modules can't be stubbed; a module's tests compile the real modules it imports - cost if wrong: ~5 min.
58
+ Ruling: sequential [chain-58d0] - T3 is already checkpointed; T7 owns Ui.plum after it and T6 documents T7 - cost if wrong: none
59
+
60
+ ## Predicted schedule
61
+ Generated by `superlite-cli plan_tools lint`.
62
+
63
+ ## Std modules
64
+
65
+ **Probe entry:** run=./target/debug/plum test plum-std/game/functional
66
+ Package: library/evaluator; categories: numeric, state, deep.
67
+
68
+ ### T1 Anim: animations as functions of time · Complexity 4 (O-1) · Est. context 35k
69
+ **Owns:** plum-std/game/functional/Anim.plum
70
+ **Components:** 1
71
+ **LOC owned:** 150
72
+ **Tests:** fast
73
+ **Model:** sonnet
74
+ **Est. time:** 3 min
75
+ **Consumes:** none
76
+ **Produces:** `struct AnimPose(dx: Float, dy: Float, rotation: Float, scaleX: Float, scaleY: Float)`; `enum Anim = | MoveBy(dx: Float, dy: Float, secs: Float) | RotateBy(radians: Float, secs: Float) | ScaleTo(sx: Float, sy: Float, secs: Float) | Delay(secs: Float) | Seq(steps: List[Anim]) | Par(steps: List[Anim]) | Forever(anim: Anim)`
77
+ **Produces:** Anim.at(a: Anim, t: Float) -> AnimPose · Anim.length(a: Anim) -> Float
78
+ e.g. Anim.at(MoveBy(dx: 10.0, dy: 0.0, secs: 2.0), 1.0) -> AnimPose(dx: 5.0, dy: 0.0, rotation: 0.0, scaleX: 1.0, scaleY: 1.0)
79
+ e.g. Anim.at(Seq(steps: List(MoveBy(dx: 10.0, dy: 0.0, secs: 1.0), RotateBy(radians: 1.0, secs: 1.0))), 1.5) -> dx 10.0, rotation 0.5
80
+ e.g. Anim.at(Forever(anim: RotateBy(radians: 1.0, secs: 2.0)), 5.0) -> rotation 0.5 (t mod 2 = 1); Anim.length(Forever(..)) -> Float.Infinity (or a documented large constant if Infinity doesn't exist)
81
+ **Review Focus:** numeric: zero-length steps apply fully (no 0/0); t < 0 -> identity pose; t past the end clamps; Forever with zero-length lap -> identity, no division by zero; Par composes offsets additively and scales multiplicatively.
82
+ **Test code:** the e.g. cases; `zero_length_move_applies_at_once`; `negative_time_is_identity`; `par_combines`; `forever_no_drift_after_many_laps` (t = 1000.25 lap 0.5).
83
+
84
+ ### T2 Picture: Tagged nodes and hitId · Complexity 3 (O-1) · Est. context 35k
85
+ **Owns:** plum-std/game/functional/Picture.plum
86
+ **Components:** 1
87
+ **LOC owned:** 60
88
+ **Tests:** fast
89
+ **Model:** sonnet
90
+ **Est. time:** 2.5 min
91
+ **Consumes:** none
92
+ **Produces:** `SceneNode` variant `Tagged(id: Int, child: SceneNode)` (draw, bounds, hit pass through; hit paths treat it like a one-child group)
93
+ **Produces:** Picture.hitId(node: SceneNode, x: Float, y: Float) -> Int?
94
+ e.g. Picture.hitId(Group(transform: Identity, children: List(Tagged(id: 7, child: RectShape(x: 0.0, y: 0.0, w: 10.0, h: 10.0, color: Color.Red)))), 5.0, 5.0) -> 7; at (50.0, 5.0) -> null
95
+ e.g. nested Tagged(1, Group(.., List(Tagged(2, rect)))) hit on the rect -> 2 (innermost wins); later siblings win over earlier ones
96
+ **Review Focus:** deep: transforms between tag and point; state: existing Picture tests unchanged.
97
+ **Test code:** the e.g. cases; `tagged_draw_and_bounds_pass_through`; `hit_path_through_tag`.
98
+
99
+ ### T3 Ui: pure pointer state, events, buttons · Complexity 5 (O-1) · Est. context 45k
100
+ **Owns:** plum-std/game/functional/Ui.plum
101
+ **Components:** 1
102
+ **LOC owned:** 180
103
+ **Tests:** fast
104
+ **Model:** sonnet
105
+ **Est. time:** 4 min
106
+ **Consumes:** Picture.hitId(node: SceneNode, x: Float, y: Float) -> Int? (T2)
107
+ **Produces:** `enum UiKind = | Press | Release | Click | Enter | Exit`; `struct UiEvent(id: Int, kind: UiKind)`; `enum UiInputKind = | PointerDown | PointerUp | PointerMove`; `struct UiInput(kind: UiInputKind, x: Float, y: Float)`; `record UiState(x: Float, y: Float, down: Bool, hot: Int?, captured: Int?)`; `record UiStep(ui: UiState, events: List[UiEvent])`
108
+ **Produces:** Ui.init() -> UiState · Ui.step(ui: UiState, input: UiInput, hit: Int?) -> UiStep · Ui.pressed(ui: UiState, id: Int) -> Bool · Ui.hovered(ui: UiState, id: Int) -> Bool · Ui.button(id: Int, label: Str, x: Float, y: Float, w: Float, h: Float, ui: UiState) -> SceneNode
109
+ e.g. step(init, Down at hit 5) -> events List(UiEvent(5, Enter), UiEvent(5, Press)), captured 5; then step(.., Up at hit 5) -> List(UiEvent(5, Release), UiEvent(5, Click)), captured null
110
+ e.g. press on 5, move to hit null -> Exit(5); up with hit null -> Release(5) and NO Click; press 5, move off, move back, up -> exactly one Click
111
+ e.g. Ui.button(3, "Go", 10.0, 20.0, 100.0, 40.0, Ui.init()) -> Tagged(id: 3, child: Group(transform: Translate(dx: 10.0, dy: 20.0), children: List(RectShape(x: 0.0, y: 0.0, w: 100.0, h: 40.0, color: <idle colour>), Text(text: "Go", ...))))
112
+ e.g. same with ui where pressed(ui, 3) -> the RectShape colour is the pressed colour; hovered -> the hover colour
113
+ **Review Focus:** state: event order; capture dropped when the captured id is not under the pointer and the button was released; hover with no press gives Enter/Exit only.
114
+ **Test code:** the e.g. cases; `hover_without_press`; `stale_capture_dropped` (captured id no longer present -> cleared on next input); `button_visual_reflects_state` (pressed/hovered change the button's colour node).
115
+
116
+ ## Game and fixture
117
+
118
+ **Probe entry:** run=./target/debug/plum test plum-examples/games/functional/game_state_view.plum
119
+ Package: data pipeline (game rules); categories: state, numeric.
120
+
121
+ ### T4 Orb Run state/view on Anim and Ui · Complexity 5 (O-1) · Est. context 50k
122
+ **Owns:** plum-examples/games/functional/game_state_view.plum
123
+ **Components:** 1
124
+ **LOC owned:** 160
125
+ **Tests:** fast
126
+ **Model:** sonnet
127
+ **Est. time:** 4 min
128
+ **Consumes:** Anim.at(a: Anim, t: Float) -> AnimPose (T1) · Ui.step(ui: UiState, input: UiInput, hit: Int?) -> UiStep (T3) · Ui.button(id: Int, label: Str, x: Float, y: Float, w: Float, h: Float, ui: UiState) -> SceneNode (T3)
129
+ **Produces:** none
130
+ **Review Focus:** state: restart via a Click UiEvent; numeric: rules unchanged.
131
+ **Test code:** every existing test keeps name and values; `restart_click_through_ui` (Down+Up over the restart button -> state restarted); `hit_flash_pose_at_fixed_times`; `title_anim_pose_at_fixed_times`.
132
+ **Steps:** State gains `ui: UiState` and animation start times; `onRestart`'s hand hit test is replaced by `Ui.button` + Click; title/game-over panel animates with an Anim (e.g. Seq of ScaleTo + Forever pulse); the ship's hurt flash is an Anim instead of hand-rolled timing; host shell feeds mouse input as UiInput.
133
+
134
+ ### T5 stress_state_view with tags, buttons, animations · Complexity 3 (O-1) · Est. context 35k
135
+ **Owns:** plum-game/web-test/fixtures/stress_state_view.plum
136
+ **Components:** 1
137
+ **LOC owned:** 80
138
+ **Tests:** fast
139
+ **Model:** sonnet
140
+ **Est. time:** 2.5 min
141
+ **Consumes:** Anim.at(a: Anim, t: Float) -> AnimPose (T1) · Ui.button(id: Int, label: Str, x: Float, y: Float, w: Float, h: Float, ui: UiState) -> SceneNode (T3)
142
+ **Produces:** none
143
+ **Review Focus:** numeric: same entity counts as before plus a fixed number of buttons/animated nodes.
144
+ **Test code:** its `test` blocks pass; existing counts unchanged; add 20 buttons and 200 animated sprites (documented in the file header).
145
+
146
+ ## Language: record equality (user request, 2026-11-03)
147
+
148
+ **Probe entry:** run=./target/debug/plum test plum-std plum-examples
149
+ Package: library/evaluator (compiler); categories: deep, state.
150
+
151
+ ### T7 structural deep `==` for records and enums · Complexity 6 (O-1) · Est. context 55k
152
+ **Status:** draft
153
+ **Owns:** plum-checker/src/lib.rs, plum-wasm-codegen/src/lib.rs, plum-wasm-codegen/tests/record_eq_e2e_test.rs (deferred; Ui.plum tests and README follow-up when it runs)
154
+ **Components:** 2
155
+ **LOC owned:** 190
156
+ **Tests:** fast
157
+ **Model:** sonnet
158
+ **Est. time:** 5 min
159
+ **Consumes:** none
160
+ **Produces:** `a == b` / `a != b` on records and enums (inline variants, union enums, generic ones) is structural and deep: same type/variant and every field `==`, recursing into records, structs, enums, Str (content) and List/Slice/Array (element-wise); a type with a user-defined `eq` keeps using it; a field of fn type makes `==` on that type a compile error naming the field
161
+ e.g. `Pt(x: 1, y: 2) == Pt(x: 1, y: 2)` -> true; `UiEvent(id: 5, kind: Click) == UiEvent(id: 5, kind: Press)` -> false
162
+ e.g. `Box(items: List(Pt(x: 1, y: 2))) == Box(items: List(Pt(x: 1, y: 2)))` -> true (deep); a record with a `f: fn(Int) -> Int` field: `a == b` -> compile error "can't compare Holder with ==: field f is a function"
163
+ **Review Focus:** deep: nested generics, recursive types (List of records), union-enum members; state: no behaviour change for types with a user `eq`; documented: not cycle-safe.
164
+ **Test code:** the e.g. cases in record_eq_e2e_test.rs plus `own_record_eq`, `enum_inline_variant_eq`, `generic_record_eq`, `user_eq_still_used`; Ui.plum tests switch from field-by-field helpers to `==`; README "Equality" paragraph.
165
+ **Steps:** generate one equality fn per compared type in codegen (memoised by type), called from `==`; keep identity nowhere for records; run the whole suite to catch code that relied on identity and report any.
166
+
167
+ ## Benchmark and docs
168
+
169
+ **Probe entry:** run=cargo test --manifest-path plum-game/Cargo.toml --test host_test
170
+ Package: docs + host checks; categories: protocol.
171
+
172
+ ### T6 benchmark and README · Complexity 3 (O-1) · Est. context 35k
173
+ **Owns:** README.md, docs/plans/2026-11-03-state-view-parity.benchmark.md
174
+ **Components:** 1
175
+ **LOC owned:** 70
176
+ **Tests:** slow
177
+ **Model:** sonnet
178
+ **Est. time:** 4 min
179
+ **Consumes:** none
180
+ **Produces:** none
181
+ **Review Focus:** protocol: host_test and web_check state_view pass.
182
+ **Test code:** `run_tests --fast plum-game/tests/host_test.rs plum-game/web-test/tests/web_check.rs` green; headed benchmark (plum-game/web-test/BENCHMARK.md command, `--headed --uncapped --measure 10 --frames-wait 6 --json`) for stress_scene, stress_pure_scene, stress_state_view in one session -> table (avg, p95, update, draw) and the only bar: state/view p95 < 4 ms.
183
+ **Steps:** README "Functional scenes (state/view)" section: State -> view -> draw loop, Anim, Ui with a short button example; mention the three styles and their folders.
184
+
185
+ ## Review Focus
186
+ Per package lines above.
187
+
188
+ ## Self-review
189
+ Spec map covers every section; Consumes match Produces; parallel tasks own distinct files.
docs/plans/2026-11-03-state-view-parity.progress.md ADDED
@@ -0,0 +1,22 @@
1
+ - start 2060bf0
2
+ - unblocked: dispatched T1 T2 at 22:29:39; T3 held (draft) until T2 checkpoints - Plum no stubs
3
+ - Ruling: T3 held as draft until T2 checkpoints despite lint dropping the edge - Ui.button builds a Tagged node, Plum modules can't be stubbed - cost if wrong: ~3 min
4
+ - checkpoint T1 33a93a8de9 Anim: animations as functions of time
5
+ - checkpoint T2 0bb57d2212 Picture: Tagged nodes and hitId
6
+ - unblocked: dispatched T3 at 22:35:56
7
+ - checkpoint T3 6565c29ba4 Ui: pure pointer state, events, buttons
8
+ - Ruling: Ui's UiEvent/UiInput are records (struct fields can't be enums); records have no ==, compare fields - cost if wrong: none
9
+ - unblocked: dispatched T4 T5 at 22:38:20
10
+ - tests fast passed=17 failed=0 skipped=0 seconds=60.6
11
+ - checkpoint T5 98f4aaa234 stress_state_view with tags, buttons, animations
12
+ - tests fast passed=17 failed=0 skipped=0 seconds=29.9
13
+ - checkpoint T4 de628f0075 Orb Run state/view on Anim and Ui
14
+ - Ruling: [scope] T7 record == (structural, deep; user choice 2026-11-03) added; T6 waits for it (README + bench after) - cost if wrong: none
15
+ - Ruling: FROZEN note on T3 ignored - T3 is checkpointed; its block only gained e.g. lines before dispatch - cost if wrong: none
16
+ - unblocked: dispatched T7 at 22:42:12; T6 held until T7 (both own README.md)
17
+ - Ruling: [scope] T7 (record ==) deferred by the user before it changed any file; kept in the plan as draft; T6 no longer waits for it - cost if wrong: none
18
+ - unblocked: dispatched T6 at 22:44:12
19
+ - tests fast passed=33 failed=0 skipped=0 seconds=148.7
20
+ - checkpoint T6 d2d4e28811 benchmark and README
21
+ - tests full passed=2045 failed=0 skipped=0 seconds=177.0
22
+ - Ruling: final review CHANGES_REQUESTED findings are tool artefacts - files 'untracked' because checkpoints are hidden until finish, and the contract checker can't parse Plum ('no module found'); verified directly: functional tests 23/23 (Anim 9, Picture 8, Ui 6), hitId signature as planned, full suite 2045/0 - cost if wrong: none
docs/plans/2026-11-03-state-view-parity.report.json ADDED
@@ -0,0 +1,323 @@
1
+ {
2
+ "projection_note": "projection: fixed forecast saved at lint 2026-11-03T22:28:59 (plan edited since)",
3
+ "headline": {
4
+ "wall_min": {
5
+ "projected": 19.405,
6
+ "actual": 18.997
7
+ },
8
+ "cost_usd_agents": {
9
+ "projected": 0.67166,
10
+ "actual": 1.608168
11
+ },
12
+ "task_min": {
13
+ "projected": 30.828,
14
+ "actual": 24.485
15
+ },
16
+ "agents": {
17
+ "projected": 6,
18
+ "actual": 21
19
+ }
20
+ },
21
+ "feature": null,
22
+ "cost_split": {
23
+ "agents_usd": 1.405984,
24
+ "rework_usd": 0.202184,
25
+ "coordinator_usd": 32.876063,
26
+ "total_usd": 34.484231
27
+ },
28
+ "concurrency": {
29
+ "peak": 3,
30
+ "projected_peak": 3
31
+ },
32
+ "critical_path": {
33
+ "actual": [
34
+ "T2",
35
+ "T3",
36
+ "T4",
37
+ "T6"
38
+ ],
39
+ "projected": [
40
+ "T3",
41
+ "T4",
42
+ "T6"
43
+ ],
44
+ "projected_min": 19.405
45
+ },
46
+ "model_mix": {
47
+ "sonnet": {
48
+ "tasks": 8,
49
+ "cost_usd": 1.608168
50
+ }
51
+ },
52
+ "coordinator_cost_by_model": {
53
+ "opus": 32.876063
54
+ },
55
+ "quality": {
56
+ "tests": {
57
+ "passed": 2045,
58
+ "failed": 0,
59
+ "skipped": 0,
60
+ "seconds": 177.0,
61
+ "mode": "full",
62
+ "failures": [],
63
+ "skipped_tests": []
64
+ },
65
+ "first_time_pass": 0,
66
+ "first_time_n": 0,
67
+ "rework_dispatches": 2,
68
+ "open_incidents": 13
69
+ },
70
+ "tasks": [
71
+ {
72
+ "task": "T3",
73
+ "model": "sonnet",
74
+ "pred_min": 6.927,
75
+ "actual_min": 2.028,
76
+ "delta": {
77
+ "abs": -4.8987,
78
+ "pct": -70.7
79
+ },
80
+ "cost_usd": 0.196543,
81
+ "first_time_pass": null
82
+ },
83
+ {
84
+ "task": "T4",
85
+ "model": "sonnet",
86
+ "pred_min": 6.178,
87
+ "actual_min": 2.543,
88
+ "delta": {
89
+ "abs": -3.6347,
90
+ "pct": -58.8
91
+ },
92
+ "cost_usd": 0.26867,
93
+ "first_time_pass": null
94
+ },
95
+ {
96
+ "task": "T2",
97
+ "model": "sonnet",
98
+ "pred_min": 2.435,
99
+ "actual_min": 5.058,
100
+ "delta": {
101
+ "abs": 2.6233,
102
+ "pct": 107.7
103
+ },
104
+ "cost_usd": 0.136374,
105
+ "first_time_pass": null
106
+ },
107
+ {
108
+ "task": "T6",
109
+ "model": "sonnet",
110
+ "pred_min": 6.3,
111
+ "actual_min": 4.365,
112
+ "delta": {
113
+ "abs": -1.935,
114
+ "pct": -30.7
115
+ },
116
+ "cost_usd": 0.123211,
117
+ "first_time_pass": null
118
+ },
119
+ {
120
+ "task": "T5",
121
+ "model": "sonnet",
122
+ "pred_min": 3.184,
123
+ "actual_min": 1.93,
124
+ "delta": {
125
+ "abs": -1.254,
126
+ "pct": -39.4
127
+ },
128
+ "cost_usd": 0.363576,
129
+ "first_time_pass": null
130
+ },
131
+ {
132
+ "task": "T1",
133
+ "model": "sonnet",
134
+ "pred_min": 5.804,
135
+ "actual_min": 4.702,
136
+ "delta": {
137
+ "abs": -1.1023,
138
+ "pct": -19.0
139
+ },
140
+ "cost_usd": 0.149532,
141
+ "first_time_pass": null
142
+ },
143
+ {
144
+ "task": "T10",
145
+ "model": "sonnet",
146
+ "pred_min": null,
147
+ "actual_min": 3.858,
148
+ "delta": {
149
+ "abs": null,
150
+ "pct": null
151
+ },
152
+ "cost_usd": 0.370262,
153
+ "first_time_pass": null
154
+ },
155
+ {
156
+ "task": "T7",
157
+ "model": "sonnet",
158
+ "pred_min": null,
159
+ "actual_min": 0.0,
160
+ "delta": {
161
+ "abs": null,
162
+ "pct": null
163
+ },
164
+ "cost_usd": 0.0,
165
+ "first_time_pass": null
166
+ }
167
+ ],
168
+ "cost_table": {
169
+ "source": "est",
170
+ "usd": 1.756,
171
+ "tokens": 3021450,
172
+ "wall_min": 298954294.6254,
173
+ "rows": [
174
+ {
175
+ "agent": "a097eab2aa6e4b189",
176
+ "model": "sonnet",
177
+ "wall_min": 29895438.8507,
178
+ "tokens": 0,
179
+ "usd": 0.0
180
+ },
181
+ {
182
+ "agent": "a2a6f5a35f6745e5c",
183
+ "model": "sonnet",
184
+ "wall_min": 2.5521,
185
+ "tokens": 349023,
186
+ "usd": 0.2687
187
+ },
188
+ {
189
+ "agent": "a2a7a87010512ff3a",
190
+ "model": "sonnet",
191
+ "wall_min": 29895424.6719,
192
+ "tokens": 0,
193
+ "usd": 0.0
194
+ },
195
+ {
196
+ "agent": "a3beeb2c61cd4ef65",
197
+ "model": "opus",
198
+ "wall_min": 0.0,
199
+ "tokens": 0,
200
+ "usd": 0.0
201
+ },
202
+ {
203
+ "agent": "a44607c5458dd7718",
204
+ "model": "sonnet",
205
+ "wall_min": 29895434.3685,
206
+ "tokens": 0,
207
+ "usd": 0.0
208
+ },
209
+ {
210
+ "agent": "a4670cccf9abb01e3",
211
+ "model": "sonnet",
212
+ "wall_min": 29895423.1749,
213
+ "tokens": 0,
214
+ "usd": 0.0
215
+ },
216
+ {
217
+ "agent": "a4cd52b6b3e6b63c2",
218
+ "model": "sonnet",
219
+ "wall_min": 29895417.7031,
220
+ "tokens": 0,
221
+ "usd": 0.0
222
+ },
223
+ {
224
+ "agent": "a56e86d1c1598fc48",
225
+ "model": "sonnet",
226
+ "wall_min": 2.8463,
227
+ "tokens": 564933,
228
+ "usd": 0.2022
229
+ },
230
+ {
231
+ "agent": "a61dbd2e694a740b6",
232
+ "model": "sonnet",
233
+ "wall_min": 0.0,
234
+ "tokens": 0,
235
+ "usd": 0.0
236
+ },
237
+ {
238
+ "agent": "a62baea907b7f1e90",
239
+ "model": "sonnet",
240
+ "wall_min": 5.0663,
241
+ "tokens": 267688,
242
+ "usd": 0.1364
243
+ },
244
+ {
245
+ "agent": "a698f99e995432cd0",
246
+ "model": "sonnet",
247
+ "wall_min": 29895432.3728,
248
+ "tokens": 0,
249
+ "usd": 0.0
250
+ },
251
+ {
252
+ "agent": "a70c930990128cf14",
253
+ "model": "sonnet",
254
+ "wall_min": 29895426.1614,
255
+ "tokens": 0,
256
+ "usd": 0.0
257
+ },
258
+ {
259
+ "agent": "a7a8b990f632507e9",
260
+ "model": "sonnet",
261
+ "wall_min": 29895419.8222,
262
+ "tokens": 0,
263
+ "usd": 0.0
264
+ },
265
+ {
266
+ "agent": "a8f785ab797f4c0cf",
267
+ "model": "opus",
268
+ "wall_min": 0.3016,
269
+ "tokens": 36604,
270
+ "usd": 0.1478
271
+ },
272
+ {
273
+ "agent": "a96803fc5635cf124",
274
+ "model": "sonnet",
275
+ "wall_min": 29895421.2612,
276
+ "tokens": 0,
277
+ "usd": 0.0
278
+ },
279
+ {
280
+ "agent": "a9e9cbf0361e9e1f4",
281
+ "model": "sonnet",
282
+ "wall_min": 29895428.5211,
283
+ "tokens": 0,
284
+ "usd": 0.0
285
+ },
286
+ {
287
+ "agent": "aa24bc5adcb4edc27",
288
+ "model": "sonnet",
289
+ "wall_min": 3.8819,
290
+ "tokens": 787729,
291
+ "usd": 0.3703
292
+ },
293
+ {
294
+ "agent": "aabe4e92223e8dda5",
295
+ "model": "sonnet",
296
+ "wall_min": 4.3713,
297
+ "tokens": 205810,
298
+ "usd": 0.1232
299
+ },
300
+ {
301
+ "agent": "ac853a061c61cdf95",
302
+ "model": "sonnet",
303
+ "wall_min": 1.939,
304
+ "tokens": 224649,
305
+ "usd": 0.1614
306
+ },
307
+ {
308
+ "agent": "ad99e540a9a911c69",
309
+ "model": "sonnet",
310
+ "wall_min": 4.7158,
311
+ "tokens": 199508,
312
+ "usd": 0.1495
313
+ },
314
+ {
315
+ "agent": "afadb2c1171f376b0",
316
+ "model": "sonnet",
317
+ "wall_min": 2.0433,
318
+ "tokens": 385506,
319
+ "usd": 0.1965
320
+ }
321
+ ]
322
+ }
323
+ }
docs/plans/2026-11-03-state-view-parity.report.md ADDED
@@ -0,0 +1,50 @@
1
+ # superlite build report
2
+
3
+ projection: fixed forecast saved at lint 2026-11-03T22:28:59 (plan edited since)
4
+
5
+ Projected Actual Delta
6
+ wall-clock (min) 19.4 19.0 -0.4 (-2%)
7
+ agent cost (USD, +rework) 0.67 1.61 +0.94 (+139%)
8
+ task-minutes 30.8 24.5 -6.3 (-21%)
9
+ agents 6 21 +15 (+250%)
10
+ cost split: agents $1.41, rework $0.20, coordinator $32.88, total $34.48
11
+ concurrency: peak 3 at once (projected 3); critical path actual T2 T3 T4 T6; projected T3 > T4 > T6 (19.4 min)
12
+ model mix: sonnet 8 tasks $1.61, coordinator opus $32.88
13
+ quality: tests 2045 passed, 0 failed; first-time pass n/a; rework dispatches 2; open incidents 13
14
+
15
+ task model pred actual delta cost
16
+ T3 sonnet 6.9 2.0 -4.9 (-71%) $0.197
17
+ T4 sonnet 6.2 2.5 -3.6 (-59%) $0.269
18
+ T2 sonnet 2.4 5.1 +2.6 (+108%) $0.136
19
+ T6 sonnet 6.3 4.4 -1.9 (-31%) $0.123
20
+ T5 sonnet 3.2 1.9 -1.3 (-39%) $0.364
21
+ (+3 smaller misses; `superlite-cli report --json` lists all)
22
+
23
+ agent cost table:
24
+ | agent | model | wall_min | tokens | usd |
25
+ | --- | --- | --- | --- | --- |
26
+ | a097eab2aa6e4b189 | sonnet | 29895438.8507 | 0 | 0.0 |
27
+ | a2a6f5a35f6745e5c | sonnet | 2.5521 | 349023 | 0.2687 |
28
+ | a2a7a87010512ff3a | sonnet | 29895424.6719 | 0 | 0.0 |
29
+ | a3beeb2c61cd4ef65 | opus | 0.0 | 0 | 0.0 |
30
+ | a44607c5458dd7718 | sonnet | 29895434.3685 | 0 | 0.0 |
31
+ | a4670cccf9abb01e3 | sonnet | 29895423.1749 | 0 | 0.0 |
32
+ | a4cd52b6b3e6b63c2 | sonnet | 29895417.7031 | 0 | 0.0 |
33
+ | a56e86d1c1598fc48 | sonnet | 2.8463 | 564933 | 0.2022 |
34
+ | a61dbd2e694a740b6 | sonnet | 0.0 | 0 | 0.0 |
35
+ | a62baea907b7f1e90 | sonnet | 5.0663 | 267688 | 0.1364 |
36
+ | a698f99e995432cd0 | sonnet | 29895432.3728 | 0 | 0.0 |
37
+ | a70c930990128cf14 | sonnet | 29895426.1614 | 0 | 0.0 |
38
+ | a7a8b990f632507e9 | sonnet | 29895419.8222 | 0 | 0.0 |
39
+ | a8f785ab797f4c0cf | opus | 0.3016 | 36604 | 0.1478 |
40
+ | a96803fc5635cf124 | sonnet | 29895421.2612 | 0 | 0.0 |
41
+ | a9e9cbf0361e9e1f4 | sonnet | 29895428.5211 | 0 | 0.0 |
42
+ | aa24bc5adcb4edc27 | sonnet | 3.8819 | 787729 | 0.3703 |
43
+ | aabe4e92223e8dda5 | sonnet | 4.3713 | 205810 | 0.1232 |
44
+ | ac853a061c61cdf95 | sonnet | 1.939 | 224649 | 0.1614 |
45
+ | ad99e540a9a911c69 | sonnet | 4.7158 | 199508 | 0.1495 |
46
+ | afadb2c1171f376b0 | sonnet | 2.0433 | 385506 | 0.1965 |
47
+ | totals | | 298954294.6254 | 3021450 | 1.756 |
48
+ cost source: est
49
+
50
+ takeaway: ran 2% faster than projected; cost 139% over; biggest miss T3
plum-examples/games/functional/game_state_view.plum CHANGED
@@ -11,8 +11,10 @@
11
11
  # `view(state)` is pure too and builds a fresh `SceneNode` picture every frame, which
12
12
  # `Picture.draw` interprets. Both are structural recursion over `Link` and `List[SceneNode]`:
13
13
  # no loop runs in `step` or `view`. There is no
14
- # retained scene: the restart button is found by `Picture.hitPath` on the picture
14
+ # retained scene: the restart button is a `Ui.button` in the picture; the host turns
15
+ # the mouse into a `UiInput`, finds what is under it with `Picture.hitId` on the
15
- # the player is looking at. The `World` record is the host shell: it holds the
16
+ # picture the player is looking at, runs `Ui.step`, and feeds each `UiEvent` to `step`.
17
+ # The game-over title and the ship's hurt flash are `Anim`s of time since they began. The `World` record is the host shell: it holds the
16
18
  # current `State`, reads the keyboard and mouse into messages, and plays sounds.
17
19
  module game_state_view
18
20
  import std/Str
@@ -25,6 +27,8 @@ import std/game/Vec2
25
27
  import std/game/Color
26
28
  import std/game/Game
27
29
  import std/game/functional/Picture
30
+ import std/game/functional/Anim
31
+ import std/game/functional/Ui
28
32
 
29
33
  primitive Rules =
30
34
  ArenaW = 800.0
@@ -56,6 +60,7 @@ primitive Slot =
56
60
  primitive Id =
57
61
  Orb = 100
58
62
  Mine = 200
63
+ Restart = 1
59
64
 
60
65
  # A position and velocity: what a spawn returns and what drifters are bounced with.
61
66
  struct Motion(x: Float, y: Float, vx: Float, vy: Float)
@@ -82,6 +87,9 @@ record State(
82
87
  over: Bool,
83
88
  clock: Float,
84
89
  overClock: Float,
90
+ hurtAt: Float,
91
+ overAt: Float,
92
+ ui: UiState,
85
93
  sw: Float,
86
94
  sh: Float,
87
95
  sheet: Int,
@@ -92,6 +100,7 @@ enum Msg =
92
100
  | Tick(dt: Float, ax: Float, ay: Float)
93
101
  | Resize(w: Float, h: Float)
94
102
  | Restart(seed: Int)
103
+ | UiEv(event: UiEvent)
95
104
 
96
105
 
97
106
  fun mapLink(xs: List[A], f: fn(A) -> B) -> List[B] = match xs
@@ -248,7 +257,7 @@ fun newState(seed: Int, best: Int, sheet: Int, target: Int, sw: Float, sh: Float
248
257
  mm := makeMines(0, mineCount(0), made.seed, ship)
249
258
  State(
250
259
  ship: ship, orbs: made.orbs, mines: mm.mines, seed: mm.seed, score: 0, best: best, lives: Rules.StartLives,
251
- over: false, clock: 0.0, overClock: 0.0, sw: sw, sh: sh, sheet: sheet, target: target)
260
+ over: false, clock: 0.0, overClock: 0.0, hurtAt: -1.0, overAt: -1.0, ui: Ui.init(), sw: sw, sh: sh, sheet: sheet, target: target)
252
261
 
253
262
  fun orbStill(o: Orb) -> Orb =
254
263
  orbAt(o.id, o.phase, o.clock, Motion(x: o.x, y: o.y, vx: 0.0, vy: 0.0))
@@ -311,8 +320,10 @@ fun tick(s: State, dt: Float, ax: Float, ay: Float) -> State =
311
320
  if n.ship.hurt <= 0.0 && touchesMine(n.mines, ship)
312
321
  n.lives = n.lives - 1
313
322
  n.ship = ShipState(x: ship.x, y: ship.y, vx: ship.vx, vy: ship.vy, rotation: ship.rotation, hurt: Rules.HurtTime)
323
+ n.hurtAt = n.clock
314
324
  if n.lives <= 0
315
325
  n.over = true
326
+ n.overAt = n.clock
316
327
  n.best = n.best.max(n.score)
317
328
  if n.over
318
329
  return freeze(n)
@@ -322,7 +333,19 @@ fun tick(s: State, dt: Float, ax: Float, ay: Float) -> State =
322
333
  fun step(s: State, msg: Msg) -> State = match msg
323
334
  Tick(dt, ax, ay) => tick(s, dt, ax, ay)
324
335
  Resize(w, h) => resized(s, w, h)
336
+ Restart(seed) => restarted(s, seed)
337
+ UiEv(event) => uiEvent(s, event)
338
+
339
+ fun restarted(s: State, seed: Int) -> State =
325
- Restart(seed) => newState(seed, s.best.max(s.score), s.sheet, s.target, s.sw, s.sh)
340
+ n := newState(seed, s.best.max(s.score), s.sheet, s.target, s.sw, s.sh)
341
+ n.ui = s.ui
342
+ n
343
+
344
+ # A click on the restart button restarts a finished game; other events change nothing.
345
+ fun uiEvent(s: State, e: UiEvent) -> State =
346
+ if s.over && e.id == Id.Restart && e.kind == Click
347
+ return restarted(s, nextSeed(s.seed))
348
+ s
326
349
 
327
350
  fun resized(s: State, w: Float, h: Float) -> State =
328
351
  n := State(..s)
@@ -377,9 +400,31 @@ fun orbView(sheet: Int, o: Orb) -> SceneNode =
377
400
  head: Filter(effect: BlendFx(mode: Blend.Add), child: glow, target: 0 - 1),
378
401
  tail: Cons(head: cell(sheet, 0.0 - Rules.Half, 0.0 - Rules.Half, Rules.Size, frameOf(o.clock + o.phase, 4, 8.0), 1), tail: Nil)))
379
402
 
403
+ # The hurt flash: the ship shrinks and recovers every 0.1 s (the scale returns to 1 each lap).
404
+ fun hitFlash() -> Anim =
405
+ Forever(anim: Seq(steps: List(ScaleTo(sx: 0.4, sy: 0.4, secs: 0.05), ScaleTo(sx: 2.5, sy: 2.5, secs: 0.05))))
406
+
407
+ fun flashPose(t: Float) -> AnimPose =
408
+ Anim.at(hitFlash(), t)
409
+
410
+ # The game-over title: grows to 1.3x, then pulses between 1.17x and 1.3x.
411
+ fun titleAnim() -> Anim =
412
+ Seq(steps: List(
413
+ ScaleTo(sx: 1.3, sy: 1.3, secs: 0.3),
414
+ Forever(anim: Seq(steps: List(ScaleTo(sx: 0.9, sy: 0.9, secs: 0.5), ScaleTo(sx: 1.0 / 0.9, sy: 1.0 / 0.9, secs: 0.5))))))
415
+
416
+ fun titlePose(t: Float) -> AnimPose =
417
+ Anim.at(titleAnim(), t)
418
+
419
+ fun titleView(s: State) -> SceneNode =
420
+ p := titlePose(s.clock - s.overAt)
421
+ Group(
422
+ transform: Compose(first: Translate(dx: 400.0 + p.dx, dy: 268.0 + p.dy), then: Scale(sx: p.scaleX, sy: p.scaleY)),
423
+ children: Cons(head: Text(text: "Game over", x: -100.0, y: -24.0, size: 48.0, color: Color.Red), tail: Nil))
424
+
380
425
  # The ship blinks while hurt and is gone once the game is over (an empty group keeps the slots).
381
426
  fun shipView(s: State) -> SceneNode =
382
- blink := s.ship.hurt > 0.0 && frameOf(s.clock, 2, 10.0) == 0
427
+ blink := s.ship.hurt > 0.0 && flashPose(s.clock - s.hurtAt).scaleX < 0.7
383
428
  if s.over || blink
384
429
  return Group(transform: Identity, children: Nil)
385
430
  Group(
@@ -407,17 +452,13 @@ fun hudView(s: State) -> SceneNode =
407
452
  fun overlayView(s: State) -> SceneNode =
408
453
  if !s.over
409
454
  return Group(transform: Identity, children: Nil)
410
- button := Group(
411
- transform: Identity,
412
- children: Cons(
413
- head: RectShape(x: 340.0, y: 342.0, w: 120.0, h: 44.0, color: Color(r: 0.5, g: 0.8, b: 1.0, a: 0.9)),
414
- tail: Cons(head: Text(text: "Again", x: 372.0, y: 352.0, size: 24.0, color: Color.White), tail: Nil)))
455
+ button := Ui.button(Id.Restart, "Again", 340.0, 342.0, 120.0, 44.0, s.ui)
415
456
  panel := Group(
416
457
  transform: Identity,
417
458
  children: Cons(
418
459
  head: RectShape(x: 0.0, y: 230.0, w: Rules.ArenaW, h: 170.0, color: Color(r: 0.0, g: 0.0, b: 0.0, a: 0.6)),
419
460
  tail: Cons(
420
- head: Text(text: "Game over", x: 300.0, y: 244.0, size: 48.0, color: Color.Red),
461
+ head: titleView(s),
421
462
  tail: Cons(
422
463
  head: Text(text: "Press Enter to play again", x: 270.0, y: 300.0, size: 24.0, color: Color.White),
423
464
  tail: Cons(head: button, tail: Nil)))))
@@ -439,8 +480,29 @@ fun view(s: State) -> SceneNode =
439
480
 
440
481
  # True when (x, y) in screen pixels is on the restart button of the picture `view` builds.
441
482
  fun onRestart(s: State, x: Float, y: Float) -> Bool =
442
- p := Picture.hitPath(view(s), x, y)
483
+ if h := Picture.hitId(view(s), x, y)
484
+ return h == Id.Restart
485
+ false
486
+
443
- p.length() >= 3 && p.at(0) == Slot.Overlay && p.at(1) == 0 && p.at(2) == 3
487
+ # One pointer input: finds what is under it in the current view, runs the Ui, and
488
+ # feeds each raised event to `step`.
489
+ fun feed(s: State, input: UiInput) -> State =
490
+ r := Ui.step(s.ui, input, Picture.hitId(view(s), input.x, input.y))
491
+ n := State(..s)
492
+ n.ui = r.ui
493
+ feedEvents(n, r.events)
494
+
495
+ fun feedEvents(s: State, evs: List[UiEvent]) -> State = match evs
496
+ Nil => s
497
+ Cons(e, rest) => feedEvents(step(s, UiEv(event: e)), rest)
498
+
499
+ fun pointerKind(down: Bool, wasDown: Bool) -> UiInputKind =
500
+ if down && !wasDown
501
+ PointerDown
502
+ else if !down && wasDown
503
+ PointerUp
504
+ else
505
+ PointerMove
444
506
 
445
507
  # --- the host shell ---
446
508
 
@@ -473,11 +535,15 @@ fun axis(negative: Bool, positive: Bool) -> Float =
473
535
  export fun update(w: World, dt: Float) =
474
536
  s := step(w.state, Resize(w: screenWidth(), h: screenHeight()))
475
537
  down := isMouseDown(0)
476
- released := w.wasDown && !down
477
- w.wasDown = down
478
538
  m := mousePos()
539
+ clicked := feed(s, UiInput(kind: pointerKind(down, w.wasDown), x: m.x, y: m.y))
540
+ w.wasDown = down
541
+ if s.over && !clicked.over
542
+ w.state = clicked
543
+ startMusic(w)
544
+ return
545
+ s = clicked
479
- again := s.over && {isKeyPressed(Key.Enter) || isKeyPressed(Key.KeyR) || {released && onRestart(s, m.x, m.y)}}
546
+ if s.over && {isKeyPressed(Key.Enter) || isKeyPressed(Key.KeyR)}
480
- if again
481
547
  w.state = step(s, Restart(seed: nextSeed(s.seed)))
482
548
  startMusic(w)
483
549
  return
@@ -543,6 +609,7 @@ fun forceOver(s: State) -> State =
543
609
  n := State(..s)
544
610
  n.lives = 0
545
611
  n.over = true
612
+ n.overAt = s.clock
546
613
  n.best = s.best.max(s.score)
547
614
  n
548
615
 
@@ -768,3 +835,36 @@ test "view_node_count"
768
835
  over := forceOver(s)
769
836
  assert countNodes(overlayView(over)) > 5
770
837
  assert countNodes(view(over)) > countNodes(view(s)) - 3
838
+
839
+ test "restart_click_through_ui"
840
+ e := emptyState()
841
+ s := State(..e)
842
+ s.score = 90
843
+ s = forceOver(s)
844
+ down := feed(s, UiInput(kind: PointerDown, x: 400.0, y: 364.0))
845
+ assert down.over
846
+ assert Ui.pressed(down.ui, Id.Restart)
847
+ up := feed(down, UiInput(kind: PointerUp, x: 400.0, y: 364.0))
848
+ assert !up.over
849
+ assert up.score == 0
850
+ assert up.best == 90
851
+ assert up.lives == Rules.StartLives
852
+ off := feed(feed(s, UiInput(kind: PointerDown, x: 400.0, y: 364.0)), UiInput(kind: PointerUp, x: 100.0, y: 100.0))
853
+ assert off.over
854
+ miss := feed(feed(s, UiInput(kind: PointerDown, x: 100.0, y: 100.0)), UiInput(kind: PointerUp, x: 100.0, y: 100.0))
855
+ assert miss.over
856
+
857
+ test "hit_flash_pose_at_fixed_times"
858
+ assert near(flashPose(0.0).scaleX, 1.0)
859
+ assert near(flashPose(0.05).scaleX, 0.4)
860
+ assert near(flashPose(0.075).scaleX, 0.7)
861
+ assert near(flashPose(0.1).scaleX, 1.0)
862
+ assert near(flashPose(1.05).scaleX, 0.4)
863
+
864
+ test "title_anim_pose_at_fixed_times"
865
+ assert near(titlePose(0.0).scaleX, 1.0)
866
+ assert near(titlePose(0.15).scaleX, 1.15)
867
+ assert near(titlePose(0.3).scaleX, 1.3)
868
+ assert near(titlePose(0.8).scaleX, 1.17)
869
+ assert near(titlePose(1.3).scaleX, 1.3)
870
+ assert near(titlePose(1.8).scaleY, 1.17)
plum-game/web-test/fixtures/stress_state_view.plum CHANGED
@@ -6,6 +6,12 @@
6
6
  # and rebuilds the whole SceneNode tree every frame with recursive functions, which
7
7
  # `Picture.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
+ #
10
+ # Entity counts: 5000 sprites (50 groups x 100), 500 rects, 200 circles, 50 texts,
11
+ # plus 200 animated sprites (Pulsers, pose = Anim.at(anim, clock - start)) and
12
+ # 20 Ui.button buttons (tagged ids 40000..40019, screen space, outside the camera).
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.
9
15
  module stress_state_view
10
16
  import std/Str
11
17
  import std/Int
@@ -16,12 +22,16 @@ import std/game/Vec2
16
22
  import std/game/Color
17
23
  import std/game/Game
18
24
  import std/game/functional/Picture
25
+ import std/game/functional/Anim
26
+ import std/game/functional/Ui
19
27
 
20
28
  primitive Counts =
21
29
  Sprites = 5000
22
30
  Rects = 500
23
31
  Circles = 200
24
32
  Texts = 50
33
+ Pulsers = 200
34
+ Buttons = 20
25
35
  Groups = 50
26
36
  WorldW = 1600
27
37
  WorldH = 900
@@ -65,7 +75,18 @@ struct Drifter(id: Int, x: Float, y: Float, w: Float, h: Float, dx: Float, dy: F
65
75
  # Shows "n <count>" and counts up each frame.
66
76
  struct Counter(id: Int, x: Float, y: Float, n: Int)
67
77
 
78
+ # An animated sprite that started its animation at `start` seconds of the clock.
79
+ struct Pulser(id: Int, x: Float, y: Float, start: Float, srcX: Float)
80
+
68
- record State(sheet: Int, white: Int, spinners: List[Spinner], drifters: List[Drifter], counters: List[Counter])
81
+ record State(sheet: Int, white: Int, spinners: List[Spinner], drifters: List[Drifter], counters: List[Counter], pulsers: List[Pulser], anim: Anim, clock: Float, ui: UiState, clicks: Int)
82
+
83
+ fun pulseAnim() -> Anim =
84
+ Forever(anim: Seq(steps: List(
85
+ Par(steps: List(MoveBy(dx: 30.0, dy: 0.0, secs: 1.0), RotateBy(radians: 3.14159, secs: 1.0), ScaleTo(sx: 1.5, sy: 1.5, secs: 1.0))),
86
+ Par(steps: List(MoveBy(dx: 0.0 - 30.0, dy: 0.0, secs: 1.0), ScaleTo(sx: 0.6666, sy: 0.6666, secs: 1.0))))))
87
+
88
+ fun newPulser(i: Int) -> Pulser =
89
+ Pulser(id: 50000 + i, x: Float(i * 97 % 1500), y: Float(i * 53 % 850), start: Float(i % 10) * 0.1, srcX: Float(i % 4 * 16))
69
90
 
70
91
  fun hue(i: Int) -> Color =
71
92
  Color(r: Float(i % 7) / 7.0, g: Float(i % 5) / 5.0, b: 0.4 + Float(i % 3) / 5.0, a: 0.8)
@@ -86,7 +107,8 @@ fun newState(sheet: Int, white: Int) -> State =
86
107
  State(
87
108
  sheet: sheet, white: white, spinners: rangeLink(0, Counts.Groups, |gi| newSpinner(gi)),
88
109
  drifters: appendLink(rects, circles),
89
- counters: rangeLink(0, Counts.Texts, |i| Counter(id: 30000 + i, x: Float(i * 30 % Counts.WorldW), y: Float(i * 17 % Counts.WorldH), n: i)))
110
+ counters: rangeLink(0, Counts.Texts, |i| Counter(id: 30000 + i, x: Float(i * 30 % Counts.WorldW), y: Float(i * 17 % Counts.WorldH), n: i)),
111
+ pulsers: rangeLink(0, Counts.Pulsers, |i| newPulser(i)), anim: pulseAnim(), clock: 0.0, ui: Ui.init(), clicks: 0)
90
112
 
91
113
  # --- step: pure ---
92
114
 
@@ -111,7 +133,20 @@ fun stepCounter(c: Counter) -> Counter =
111
133
  fun step(s: State, dt: Float) -> State =
112
134
  State(
113
135
  sheet: s.sheet, white: s.white, spinners: mapLink(s.spinners, |p| stepSpinner(p, dt)),
114
- drifters: mapLink(s.drifters, |d| stepDrifter(d)), counters: mapLink(s.counters, |c| stepCounter(c)))
136
+ drifters: mapLink(s.drifters, |d| stepDrifter(d)), counters: mapLink(s.counters, |c| stepCounter(c)),
137
+ pulsers: s.pulsers, anim: s.anim, clock: s.clock + dt, ui: s.ui, clicks: s.clicks)
138
+
139
+ fun countClicks(es: List[UiEvent]) -> Int = match es
140
+ Nil => 0
141
+ Cons(e, t) => {if e.kind == Click then 1 else 0} + countClicks(t)
142
+
143
+ # Feeds one pointer input through Ui.step against the button layer.
144
+ fun stepUi(s: State, input: UiInput) -> State =
145
+ hit := Picture.hitId(buttonsView(s.ui), input.x, input.y)
146
+ r := Ui.step(s.ui, input, hit)
147
+ State(
148
+ sheet: s.sheet, white: s.white, spinners: s.spinners, drifters: s.drifters, counters: s.counters,
149
+ pulsers: s.pulsers, anim: s.anim, clock: s.clock, ui: r.ui, clicks: s.clicks + countClicks(r.events))
115
150
 
116
151
  # --- view: pure, the whole tree every frame ---
117
152
 
@@ -135,10 +170,26 @@ fun drifterView(white: Int, d: Drifter) -> SceneNode =
135
170
  fun counterView(c: Counter) -> SceneNode =
136
171
  Text(text: "n {c.n}", x: c.x, y: c.y, size: 14.0, color: Color.White)
137
172
 
173
+ fun pulserView(sheet: Int, anim: Anim, clock: Float, p: Pulser) -> SceneNode =
174
+ pose := Anim.at(anim, clock - p.start)
175
+ Group(
176
+ transform: Compose(first: Translate(dx: p.x + pose.dx + 8.0, dy: p.y + pose.dy + 8.0), then: Compose(first: Scale(sx: pose.scaleX, sy: pose.scaleY), then: Compose(first: Rotate(radians: pose.rotation), then: Translate(dx: 0.0 - 8.0, dy: 0.0 - 8.0)))),
177
+ children: Cons(head: Sprite(
178
+ tex: sheet, x: 0.0, y: 0.0, w: 16.0, h: 16.0, srcX: p.srcX, srcY: 0.0, srcW: 16.0, srcH: 16.0,
179
+ tint: Color.White), tail: Nil))
180
+
181
+ fun buttonNodes(i: Int, ui: UiState) -> List[SceneNode] =
182
+ if i >= Counts.Buttons then Nil else Cons(head: Ui.button(40000 + i, "b{i}", Float(i % 10 * 70 + 10), Float(i / 10 * 30 + 10), 64.0, 24.0, ui), tail: buttonNodes(i + 1, ui))
183
+
184
+ fun buttonsView(ui: UiState) -> SceneNode =
185
+ Group(transform: Identity, children: buttonNodes(0, ui))
186
+
138
187
  fun view(s: State) -> SceneNode =
139
188
  texts := toNodes(s.counters, |c| counterView(c), Nil)
140
189
  drifts := toNodes(s.drifters, |d| drifterView(s.white, d), texts)
190
+ pulses := toNodes(s.pulsers, |p| pulserView(s.sheet, s.anim, s.clock, p), drifts)
141
- Group(transform: Scale(sx: 0.8, sy: 0.8), children: toNodes(s.spinners, |p| spinnerView(s.sheet, p), drifts))
191
+ world := Group(transform: Scale(sx: 0.8, sy: 0.8), children: toNodes(s.spinners, |p| spinnerView(s.sheet, p), pulses))
192
+ Group(transform: Identity, children: List(world, buttonsView(s.ui)))
142
193
 
143
194
  fun whiteTexture() -> Int =
144
195
  t := textureCreate(1, 1)
@@ -147,13 +198,21 @@ fun whiteTexture() -> Int =
147
198
  renderTarget(-1)
148
199
  t
149
200
 
150
- record World(state: State)
201
+ record World(state: State, wasDown: Bool)
151
202
 
152
203
  export fun init() -> World =
153
- World(state: newState(textureLoad("sprites.png"), whiteTexture()))
204
+ World(state: newState(textureLoad("sprites.png"), whiteTexture()), wasDown: false)
154
205
 
155
206
  export fun update(w: World, dt: Float) =
207
+ m := mousePos()
208
+ down := isMouseDown(0)
209
+ kind := PointerMove
210
+ if down && !w.wasDown
211
+ kind = PointerDown
212
+ else if !down && w.wasDown
213
+ kind = PointerUp
214
+ w.wasDown = down
156
- w.state = step(w.state, dt)
215
+ w.state = stepUi(step(w.state, dt), UiInput(kind: kind, x: m.x, y: m.y))
157
216
 
158
217
  export fun draw(w: World) =
159
218
  clear(Color.rgb(0.05, 0.06, 0.1))
@@ -172,7 +231,27 @@ test "view has one node per actor"
172
231
  s := newState(0, 0)
173
232
  v := view(s)
174
233
  match v
234
+ Group(_, top) =>
235
+ assert countNodes(top) == 2
236
+ match top.at(0)
175
- Group(_, kids) =>
237
+ Group(_, kids) =>
176
- assert countNodes(kids) == 50 + 500 + 200 + 50
238
+ assert countNodes(kids) == 50 + 500 + 200 + 50 + 200
239
+ _ =>
240
+ assert false
241
+ match top.at(1)
242
+ Group(_, buttons) =>
243
+ assert countNodes(buttons) == 20
244
+ _ =>
245
+ assert false
177
246
  _ =>
178
247
  assert false
248
+
249
+ test "button click counts and pulsers animate"
250
+ s := newState(0, 0)
251
+ down := stepUi(s, UiInput(kind: PointerDown, x: 20.0, y: 20.0))
252
+ up := stepUi(down, UiInput(kind: PointerUp, x: 20.0, y: 20.0))
253
+ assert up.clicks == 1
254
+ assert Ui.hovered(up.ui, 40000)
255
+ p := newPulser(0)
256
+ a := Anim.at(s.anim, 0.5 - p.start)
257
+ assert a.dx > 14.9 && a.dx < 15.1
plum-std/game/functional/Anim.plum ADDED
@@ -0,0 +1,165 @@
1
+ module std
2
+ import std/Int
3
+ import std/Float
4
+ import std/List
5
+
6
+ # Animations as pure functions of time. An `Anim` is plain data; `Anim.at(a, t)`
7
+ # is the pose `t` seconds after it started, so a view can animate with nothing
8
+ # but a clock in its state. Before the start (t < 0) the pose is the identity;
9
+ # past the end it clamps to the final pose. A zero-length step applies fully at
10
+ # any t >= 0. `Seq` runs steps one after another, `Par` runs them together
11
+ # (offsets add, scales multiply). `Forever` repeats its lap using t - floor(t/lap)*lap,
12
+ # so no per-frame time accumulates and there is no drift. A zero-length lap
13
+ # gives the identity pose.
14
+
15
+ # The animated part of a transform: an offset, a rotation and a scale.
16
+ struct AnimPose(dx: Float, dy: Float, rotation: Float, scaleX: Float, scaleY: Float)
17
+
18
+ enum Anim =
19
+ | MoveBy(dx: Float, dy: Float, secs: Float)
20
+ | RotateBy(radians: Float, secs: Float)
21
+ | ScaleTo(sx: Float, sy: Float, secs: Float)
22
+ | Delay(secs: Float)
23
+ | Seq(steps: List[Anim])
24
+ | Par(steps: List[Anim])
25
+ | Forever(anim: Anim)
26
+
27
+ # The pose `t` seconds into the animation.
28
+ fun at(a: Anim, t: Float) -> AnimPose = animAt(a, t)
29
+
30
+ # Duration in seconds. `Forever` has no end: it reports animForever, a large
31
+ # constant (Float has no Infinity constant).
32
+ fun length(a: Anim) -> Float = animLength(a)
33
+
34
+ # Stands in for an infinite length.
35
+ fun animForever() -> Float = 1e30
36
+
37
+ fun animIdentity() -> AnimPose =
38
+ AnimPose(dx: 0.0, dy: 0.0, rotation: 0.0, scaleX: 1.0, scaleY: 1.0)
39
+
40
+ fun animCombine(p: AnimPose, q: AnimPose) -> AnimPose =
41
+ AnimPose(dx: p.dx + q.dx, dy: p.dy + q.dy, rotation: p.rotation + q.rotation, scaleX: p.scaleX * q.scaleX, scaleY: p.scaleY * q.scaleY)
42
+
43
+ # Fraction of a `secs` long step done at t, clamped to [0, 1]; a zero-length step is done at once.
44
+ fun animProgress(secs: Float, t: Float) -> Float =
45
+ if secs <= 0.0
46
+ 1.0
47
+ else if t >= secs
48
+ 1.0
49
+ else
50
+ t / secs
51
+
52
+ fun animLength(a: Anim) -> Float = match a
53
+ MoveBy(_, _, secs) => animMax0(secs)
54
+ RotateBy(_, secs) => animMax0(secs)
55
+ ScaleTo(_, _, secs) => animMax0(secs)
56
+ Delay(secs) => animMax0(secs)
57
+ Seq(steps) => animSeqLength(steps, 0.0)
58
+ Par(steps) => animParLength(steps, 0.0)
59
+ Forever(_) => animForever()
60
+
61
+ fun animMax0(x: Float) -> Float =
62
+ if x > 0.0
63
+ x
64
+ else
65
+ 0.0
66
+
67
+ fun animSeqLength(steps: List[Anim], acc: Float) -> Float = match steps
68
+ Nil => acc
69
+ Cons(head, tail) => animSeqLength(tail, acc + animLength(head))
70
+
71
+ fun animParLength(steps: List[Anim], acc: Float) -> Float = match steps
72
+ Nil => acc
73
+ Cons(head, tail) => animParLength(tail, if animLength(head) > acc then animLength(head) else acc)
74
+
75
+ fun animAt(a: Anim, t: Float) -> AnimPose =
76
+ if t < 0.0
77
+ return animIdentity()
78
+ match a
79
+ MoveBy(dx, dy, secs) =>
80
+ p := animProgress(secs, t)
81
+ AnimPose(dx: dx * p, dy: dy * p, rotation: 0.0, scaleX: 1.0, scaleY: 1.0)
82
+ RotateBy(radians, secs) =>
83
+ AnimPose(dx: 0.0, dy: 0.0, rotation: radians * animProgress(secs, t), scaleX: 1.0, scaleY: 1.0)
84
+ ScaleTo(sx, sy, secs) =>
85
+ p := animProgress(secs, t)
86
+ AnimPose(dx: 0.0, dy: 0.0, rotation: 0.0, scaleX: 1.0 + {sx - 1.0} * p, scaleY: 1.0 + {sy - 1.0} * p)
87
+ Delay(_) => animIdentity()
88
+ Seq(steps) => animSeqAt(steps, t, animIdentity())
89
+ Par(steps) => animParAt(steps, t, animIdentity())
90
+ Forever(inner) => animForeverAt(inner, t)
91
+
92
+ # Finished steps contribute their final pose; the first unfinished one its partial pose.
93
+ fun animSeqAt(steps: List[Anim], t: Float, acc: AnimPose) -> AnimPose = match steps
94
+ Nil => acc
95
+ Cons(head, tail) =>
96
+ len := animLength(head)
97
+ if t >= len
98
+ animSeqAt(tail, t - len, animCombine(acc, animAt(head, len)))
99
+ else
100
+ animCombine(acc, animAt(head, t))
101
+
102
+ fun animParAt(steps: List[Anim], t: Float, acc: AnimPose) -> AnimPose = match steps
103
+ Nil => acc
104
+ Cons(head, tail) => animParAt(tail, t, animCombine(acc, animAt(head, t)))
105
+
106
+ fun animForeverAt(inner: Anim, t: Float) -> AnimPose =
107
+ lap := animLength(inner)
108
+ if lap <= 0.0 || lap >= animForever()
109
+ return animAt(inner, t)
110
+ animAt(inner, t - {t / lap}.floor() * lap)
111
+
112
+ fun animNear(a: Float, b: Float) -> Bool =
113
+ {a - b}.abs() < 1e-6
114
+
115
+ test "move_halfway"
116
+ p := Anim.at(MoveBy(dx: 10.0, dy: 0.0, secs: 2.0), 1.0)
117
+ assert animNear(p.dx, 5.0)
118
+ assert animNear(p.dy, 0.0)
119
+ assert animNear(p.rotation, 0.0)
120
+ assert animNear(p.scaleX, 1.0)
121
+ assert animNear(p.scaleY, 1.0)
122
+
123
+ test "seq_second_step"
124
+ a := Seq(steps: List(MoveBy(dx: 10.0, dy: 0.0, secs: 1.0), RotateBy(radians: 1.0, secs: 1.0)))
125
+ p := Anim.at(a, 1.5)
126
+ assert animNear(p.dx, 10.0)
127
+ assert animNear(p.rotation, 0.5)
128
+ assert animNear(Anim.length(a), 2.0)
129
+
130
+ test "past_the_end_clamps"
131
+ p := Anim.at(MoveBy(dx: 10.0, dy: 4.0, secs: 1.0), 99.0)
132
+ assert animNear(p.dx, 10.0)
133
+ assert animNear(p.dy, 4.0)
134
+
135
+ test "forever_wraps"
136
+ p := Anim.at(Forever(anim: RotateBy(radians: 1.0, secs: 2.0)), 5.0)
137
+ assert animNear(p.rotation, 0.5)
138
+
139
+ test "forever_no_drift_after_many_laps"
140
+ p := Anim.at(Forever(anim: RotateBy(radians: 1.0, secs: 0.5)), 1000.25)
141
+ assert animNear(p.rotation, 0.5)
142
+
143
+ test "forever_zero_lap_is_identity"
144
+ p := Anim.at(Forever(anim: Delay(secs: 0.0)), 3.0)
145
+ assert animNear(p.rotation, 0.0)
146
+ q := Anim.at(Forever(anim: MoveBy(dx: 5.0, dy: 0.0, secs: 0.0)), 3.0)
147
+ assert animNear(q.dx, 5.0)
148
+
149
+ test "zero_length_move_applies_at_once"
150
+ p := Anim.at(MoveBy(dx: 10.0, dy: 0.0, secs: 0.0), 0.0)
151
+ assert animNear(p.dx, 10.0)
152
+
153
+ test "negative_time_is_identity"
154
+ p := Anim.at(MoveBy(dx: 10.0, dy: 3.0, secs: 1.0), 0.0 - 1.0)
155
+ assert animNear(p.dx, 0.0)
156
+ assert animNear(p.dy, 0.0)
157
+ assert animNear(p.scaleX, 1.0)
158
+
159
+ test "par_combines"
160
+ a := Par(steps: List(MoveBy(dx: 2.0, dy: 0.0, secs: 1.0), MoveBy(dx: 3.0, dy: 0.0, secs: 2.0), ScaleTo(sx: 2.0, sy: 3.0, secs: 1.0), ScaleTo(sx: 2.0, sy: 1.0, secs: 1.0)))
161
+ p := Anim.at(a, 1.0)
162
+ assert animNear(p.dx, 3.5)
163
+ assert animNear(p.scaleX, 4.0)
164
+ assert animNear(p.scaleY, 3.0)
165
+ assert animNear(Anim.length(a), 2.0)
plum-std/game/functional/Picture.plum CHANGED
@@ -44,6 +44,7 @@ enum SceneNode =
44
44
  | RectShape(x: Float, y: Float, w: Float, h: Float, color: Color)
45
45
  | CircleShape(cx: Float, cy: Float, r: Float, color: Color)
46
46
  | LineShape(x1: Float, y1: Float, x2: Float, y2: Float, thickness: Float, color: Color)
47
+ | Tagged(id: Int, child: SceneNode)
47
48
 
48
49
 
49
50
  struct Rect(x: Float, y: Float, w: Float, h: Float)
@@ -90,6 +91,7 @@ fun picBounds(node: SceneNode, m: Affine) -> Rect? = match node
90
91
  Text(txt, tx, ty, size, _) => picBox(tx, ty, size * 0.6 * Float(txt.length()), size, m)
91
92
  Group(tr, kids) => picBoundsKids(kids, m.mul(picMatrix(tr)), null)
92
93
  Filter(_, child, _) => picBounds(child, m)
94
+ Tagged(_, child) => picBounds(child, m)
93
95
 
94
96
  fun picBoundsKids(kids: List[SceneNode], m: Affine, acc: Rect?) -> Rect? = match kids
95
97
  Nil => acc
@@ -143,6 +145,37 @@ fun picHit(node: SceneNode, px: Float, py: Float) -> Slice[Int]? = match node
143
145
  picPrepend(0, fp)
144
146
  else
145
147
  null
148
+ Tagged(_, child) =>
149
+ if tp := picHit(child, px, py)
150
+ picPrepend(0, tp)
151
+ else
152
+ null
153
+
154
+ # The id of the innermost Tagged node whose subtree is hit at (px, py), or null.
155
+ # `outer` is the id of the nearest enclosing tag (null at the root).
156
+ fun picHitId(node: SceneNode, outer: Int?, px: Float, py: Float) -> Int? = match node
157
+ Group(tr, kids) =>
158
+ if gi := picMatrix(tr).invert()
159
+ lp := gi.apply(Vec2(x: px, y: py))
160
+ picHitIdKids(kids, outer, lp.x, lp.y)
161
+ else
162
+ null
163
+ Filter(_, child, _) => picHitId(child, outer, px, py)
164
+ Tagged(id, child) => picHitId(child, id, px, py)
165
+ _ =>
166
+ if picHit(node, px, py) != null
167
+ outer
168
+ else
169
+ null
170
+
171
+ # Later siblings are drawn on top, so they are tried first.
172
+ fun picHitIdKids(kids: List[SceneNode], outer: Int?, px: Float, py: Float) -> Int? = match kids
173
+ Nil => null
174
+ Cons(head, tail) =>
175
+ if later := picHitIdKids(tail, outer, px, py)
176
+ later
177
+ else
178
+ picHitId(head, outer, px, py)
146
179
 
147
180
  # Tries the later children first (they are drawn on top); `i` is the index of
148
181
  # `kids`' head. The first hit found from the back wins.
@@ -180,6 +213,7 @@ fun picDraw(node: SceneNode, m: Affine, alpha: Float, target: Int) = match node
180
213
  drawText(txt, tp.x, tp.y, size * m.scaleY(), tcolor.withAlpha(tcolor.a * alpha))
181
214
  Group(tr, kids) => picDrawKids(kids, m.mul(picMatrix(tr)), alpha, target)
182
215
  Filter(effect, child, tex) => picFilter(effect, child, tex, m, alpha, target)
216
+ Tagged(_, child) => picDraw(child, m, alpha, target)
183
217
 
184
218
  fun picDrawKids(kids: List[SceneNode], m: Affine, alpha: Float, target: Int) = match kids
185
219
  Nil => 0
@@ -229,6 +263,10 @@ primitive Picture =
229
263
  return p
230
264
  Slice[Int]()
231
265
 
266
+ # The id of the innermost Tagged node under (x, y), or null. Later siblings win.
267
+ fun hitId(node: SceneNode, x: Float, y: Float) -> Int? =
268
+ picHitId(node, null, x, y)
269
+
232
270
  fun picNear(a: Float, b: Float) -> Bool =
233
271
  {a - b}.abs() < 1e-6
234
272
 
@@ -279,3 +317,35 @@ test "filter_bounds_is_child_bounds"
279
317
  assert picNear(r.y, 2.0)
280
318
  assert picNear(r.w, 10.0)
281
319
  assert picNear(r.h, 4.0)
320
+
321
+ test "tagged_bounds_and_path"
322
+ g := Group(transform: Identity, children: Cons(head: Tagged(id: 7, child: picRect(1.0, 2.0, 10.0, 4.0, Color.Red)), tail: Nil))
323
+ r := Picture.bounds(g)
324
+ assert picNear(r.x, 1.0)
325
+ assert picNear(r.w, 10.0)
326
+ p := Picture.hitPath(g, 5.0, 3.0)
327
+ assert p.length() == 2
328
+ assert p.at(0) == 0
329
+ assert p.at(1) == 0
330
+
331
+ test "hit_id_innermost_and_topmost"
332
+ inner := Tagged(id: 2, child: picRect(0.0, 0.0, 10.0, 10.0, Color.Blue))
333
+ 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))))
334
+ top := Tagged(id: 3, child: picRect(15.0, 15.0, 10.0, 10.0, Color.Red))
335
+ g := Group(transform: Translate(dx: 1.0, dy: 0.0), children: Cons(head: outer, tail: Cons(head: top, tail: Nil)))
336
+ a := Picture.hitId(g, 6.0, 5.0)
337
+ if va := a
338
+ assert va == 2
339
+ else
340
+ assert false
341
+ b := Picture.hitId(g, 19.0, 5.0)
342
+ if vb := b
343
+ assert vb == 1
344
+ else
345
+ assert false
346
+ c := Picture.hitId(g, 17.0, 17.0)
347
+ if vc := c
348
+ assert vc == 3
349
+ else
350
+ assert false
351
+ assert Picture.hitId(g, 100.0, 100.0) == null
plum-std/game/functional/Ui.plum ADDED
@@ -0,0 +1,210 @@
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/game/Color
8
+ import std/game/functional/Picture
9
+
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
12
+ # the current view) and returns the new state plus the UiEvents it raised. Same
13
+ # button rules as PureScene: Down captures the pressed id; while captured only
14
+ # that id gets Enter/Exit; Up releases (Click only when still over it).
15
+
16
+ enum UiKind =
17
+ | Press
18
+ | Release
19
+ | Click
20
+ | Enter
21
+ | Exit
22
+
23
+ record UiEvent(id: Int, kind: UiKind)
24
+
25
+ enum UiInputKind =
26
+ | PointerDown
27
+ | PointerUp
28
+ | PointerMove
29
+
30
+ record UiInput(kind: UiInputKind, x: Float, y: Float)
31
+
32
+ record UiState(x: Float, y: Float, down: Bool, hot: Int?, captured: Int?)
33
+
34
+ record UiStep(ui: UiState, events: List[UiEvent])
35
+
36
+ fun uiSame(a: Int?, b: Int?) -> Bool =
37
+ if va := a
38
+ if vb := b
39
+ return va == vb
40
+ return false
41
+ if vb := b
42
+ return false
43
+ true
44
+
45
+ # Exit for the old hot id and Enter for the new one when they differ.
46
+ fun uiHover(out: Slice[UiEvent], old: Int?, now: Int?) =
47
+ if !uiSame(old, now)
48
+ if o := old
49
+ out.push(UiEvent(id: o, kind: Exit))
50
+ if n := now
51
+ out.push(UiEvent(id: n, kind: Enter))
52
+
53
+ primitive Ui =
54
+ fun init() -> UiState =
55
+ UiState(x: 0.0, y: 0.0, down: false, hot: null, captured: null)
56
+
57
+ fun step(ui: UiState, input: UiInput, hit: Int?) -> UiStep =
58
+ out := Slice[UiEvent]()
59
+ # a capture left over without a held button is stale
60
+ cap: Int? := ui.captured
61
+ if !ui.down
62
+ cap = null
63
+ hot: Int? := ui.hot
64
+ down := ui.down
65
+ match input.kind
66
+ PointerDown =>
67
+ cap = null
68
+ uiHover(out, hot, hit)
69
+ hot = hit
70
+ down = true
71
+ if h := hit
72
+ out.push(UiEvent(id: h, kind: Press))
73
+ cap = h
74
+ PointerMove =>
75
+ if c := cap
76
+ now: Int? := null
77
+ if uiSame(hit, cap)
78
+ now = cap
79
+ uiHover(out, hot, now)
80
+ hot = now
81
+ else
82
+ uiHover(out, hot, hit)
83
+ hot = hit
84
+ PointerUp =>
85
+ if c := cap
86
+ out.push(UiEvent(id: c, kind: Release))
87
+ if uiSame(hit, cap)
88
+ out.push(UiEvent(id: c, kind: Click))
89
+ cap = null
90
+ hot = hit
91
+ else
92
+ uiHover(out, hot, hit)
93
+ hot = hit
94
+ down = false
95
+ UiStep(ui: UiState(x: input.x, y: input.y, down: down, hot: hot, captured: cap), events: List.fromSlice(out))
96
+
97
+ fun pressed(ui: UiState, id: Int) -> Bool =
98
+ if c := ui.captured
99
+ return c == id && uiSame(ui.hot, ui.captured)
100
+ false
101
+
102
+ fun hovered(ui: UiState, id: Int) -> Bool =
103
+ if h := ui.hot
104
+ return h == id
105
+ false
106
+
107
+ # A tagged, translated rectangle with a label whose colour follows the state.
108
+ fun button(id: Int, label: Str, x: Float, y: Float, w: Float, h: Float, ui: UiState) -> SceneNode =
109
+ color := uiIdleColor()
110
+ if Ui.pressed(ui, id)
111
+ color = uiPressedColor()
112
+ else if Ui.hovered(ui, id)
113
+ color = uiHoverColor()
114
+ Tagged(id: id, child: Group(transform: Translate(dx: x, dy: y), children: List(
115
+ RectShape(x: 0.0, y: 0.0, w: w, h: h, color: color),
116
+ Text(text: label, x: 4.0, y: 4.0, size: h * 0.5, color: Color.White))))
117
+
118
+ fun uiIdleColor() -> Color = Color.rgb(0.2, 0.2, 0.3)
119
+
120
+ fun uiHoverColor() -> Color = Color.rgb(0.35, 0.35, 0.5)
121
+
122
+ fun uiPressedColor() -> Color = Color.rgb(0.6, 0.2, 0.2)
123
+
124
+ fun uiKinds(s: UiStep) -> List[UiKind] = s.events.map(|e| e.kind)
125
+
126
+ fun uiDown(x: Float) -> UiInput = UiInput(kind: PointerDown, x: x, y: 0.0)
127
+
128
+ fun uiUp(x: Float) -> UiInput = UiInput(kind: PointerUp, x: x, y: 0.0)
129
+
130
+ fun uiMove(x: Float) -> UiInput = UiInput(kind: PointerMove, x: x, y: 0.0)
131
+
132
+ fun uiCount(s: UiStep, k: UiKind) -> Int =
133
+ n := 0
134
+ for e := range s.events.items()
135
+ if e.kind == k
136
+ n = n + 1
137
+ n
138
+
139
+ fun uiIs(e: UiEvent, id: Int, kind: UiKind) -> Bool = e.id == id && e.kind == kind
140
+
141
+ fun uiButtonColor(n: SceneNode) -> Color = match n
142
+ Tagged(_, child) => uiButtonColor(child)
143
+ Group(_, children) => uiButtonColor(children.at(0))
144
+ RectShape(_, _, _, _, c) => c
145
+ _ => Color.Black
146
+
147
+ test "press_sets_capture"
148
+ s1 := Ui.step(Ui.init(), uiDown(1.0), 5)
149
+ assert s1.events.length() == 2
150
+ assert uiIs(s1.events.at(0), 5, Enter)
151
+ assert uiIs(s1.events.at(1), 5, Press)
152
+ assert Ui.pressed(s1.ui, 5)
153
+ s2 := Ui.step(s1.ui, uiUp(1.0), 5)
154
+ assert s2.events.length() == 2
155
+ assert uiIs(s2.events.at(0), 5, Release)
156
+ assert uiIs(s2.events.at(1), 5, Click)
157
+ assert !Ui.pressed(s2.ui, 5)
158
+ assert Ui.hovered(s2.ui, 5)
159
+
160
+ test "release_elsewhere_no_click"
161
+ s1 := Ui.step(Ui.init(), uiDown(1.0), 5)
162
+ s2 := Ui.step(s1.ui, uiMove(99.0), null)
163
+ assert s2.events.length() == 1
164
+ assert uiIs(s2.events.at(0), 5, Exit)
165
+ s3 := Ui.step(s2.ui, uiUp(99.0), null)
166
+ assert s3.events.length() == 1
167
+ assert uiIs(s3.events.at(0), 5, Release)
168
+ assert uiCount(s3, Click) == 0
169
+
170
+ test "drag_off_and_back_one_click"
171
+ s1 := Ui.step(Ui.init(), uiDown(1.0), 5)
172
+ s2 := Ui.step(s1.ui, uiMove(99.0), null)
173
+ s3 := Ui.step(s2.ui, uiMove(1.0), 5)
174
+ assert s3.events.length() == 1
175
+ assert uiIs(s3.events.at(0), 5, Enter)
176
+ s4 := Ui.step(s3.ui, uiUp(1.0), 5)
177
+ assert uiCount(s4, Click) == 1
178
+ assert uiCount(s4, Release) == 1
179
+
180
+ test "stale_capture_dropped"
181
+ stale := UiState(x: 0.0, y: 0.0, down: false, hot: null, captured: 5)
182
+ s := Ui.step(stale, uiMove(50.0), null)
183
+ assert s.events.length() == 0
184
+ if c := s.ui.captured
185
+ assert false
186
+ assert !Ui.pressed(s.ui, 5)
187
+
188
+ test "hover_without_press_routes_move"
189
+ s1 := Ui.step(Ui.init(), uiMove(1.0), 5)
190
+ assert s1.events.length() == 1
191
+ assert uiIs(s1.events.at(0), 5, Enter)
192
+ assert Ui.hovered(s1.ui, 5)
193
+ s2 := Ui.step(s1.ui, uiMove(2.0), 5)
194
+ assert s2.events.length() == 0
195
+ s3 := Ui.step(s2.ui, uiMove(99.0), null)
196
+ assert s3.events.length() == 1
197
+ assert uiIs(s3.events.at(0), 5, Exit)
198
+ assert !Ui.hovered(s3.ui, 5)
199
+
200
+ test "button_visual_reflects_state"
201
+ idle := Ui.button(3, "Go", 10.0, 20.0, 100.0, 40.0, Ui.init())
202
+ hov := Ui.button(3, "Go", 10.0, 20.0, 100.0, 40.0, Ui.step(Ui.init(), uiMove(1.0), 3).ui)
203
+ prs := Ui.button(3, "Go", 10.0, 20.0, 100.0, 40.0, Ui.step(Ui.init(), uiDown(1.0), 3).ui)
204
+ assert uiButtonColor(idle).eq(uiIdleColor())
205
+ assert uiButtonColor(hov).eq(uiHoverColor())
206
+ assert uiButtonColor(prs).eq(uiPressedColor())
207
+ if hid := Picture.hitId(idle, 20.0, 30.0)
208
+ assert hid == 3
209
+ else
210
+ assert false