plum
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 +24 -0
- docs/plans/2026-11-03-state-view-parity.benchmark.md +13 -0
- docs/plans/2026-11-03-state-view-parity.incidents.jsonl +17 -0
- docs/plans/2026-11-03-state-view-parity.md +189 -0
- docs/plans/2026-11-03-state-view-parity.progress.md +22 -0
- docs/plans/2026-11-03-state-view-parity.report.json +323 -0
- docs/plans/2026-11-03-state-view-parity.report.md +50 -0
- plum-examples/games/functional/game_state_view.plum +117 -17
- plum-game/web-test/fixtures/stress_state_view.plum +88 -9
- plum-std/game/functional/Anim.plum +165 -0
- plum-std/game/functional/Picture.plum +70 -0
- plum-std/game/functional/Ui.plum +210 -0
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
|
|
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
|
|
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
|
-
|
|
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 &&
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
483
|
+
if h := Picture.hitId(view(s), x, y)
|
|
484
|
+
return h == Id.Restart
|
|
485
|
+
false
|
|
486
|
+
|
|
443
|
-
|
|
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
|
-
|
|
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),
|
|
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
|
-
|
|
237
|
+
Group(_, kids) =>
|
|
176
|
-
|
|
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
|