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