plum
git clone https://git.pyrossh.dev/plum
A statically typed, imperative programming language inspired by rust, python
217e404
— Peter John
2026-11-04T15:26:24+05:30
Input step 3: typed messages for the functional scene; TRMC typing fix
- README.md +7 -5
- docs/backlog.md +1 -1
- docs/game-scene-styles.md +11 -9
- plum-core/src/trmc.rs +42 -1
- plum-examples/games/functional/game_state_view.plum +49 -43
- plum-examples/recursion.plum +19 -0
- plum-game/web-test/fixtures/stress_state_view.plum +12 -12
- plum-game/web-test/fixtures/stress_state_view_deep.plum +9 -9
- plum-std/game/functional/FunctionalScene.plum +78 -52
- plum-std/game/functional/Ui.plum +30 -8
README.md
CHANGED
|
@@ -1404,17 +1404,19 @@ Three scene styles ship, each in its own folder: `std/game/imperative/Scene` (ac
|
|
|
1404
1404
|
The functional game is written in a component style: each kind of thing is a record with an `update(self, ..)` that returns the next value and a `render(self, ..)` that returns its `SceneNode` (`Ship.update`/`Ship.render`, `Orb`, `Mine`, `Star`), and `State.render` composes them. `update` never changes a value it was given.
|
|
1405
1405
|
|
|
1406
1406
|
- **`Anim`** describes motion as a function of time: `MoveBy`, `RotateBy`, `ScaleTo`, `Delay`, `Seq`, `Par` and `Forever`. `Anim.at(anim, t)` returns an `AnimPose` (dx, dy, rotation, scaleX, scaleY) for any `t`, so a view just applies the pose for `state.clock`.
|
|
1407
|
-
- **`Ui`** is pure pointer state. `view.hitId(x, y)` finds the `
|
|
1407
|
+
- **`Ui`** is pure pointer state. `view.hitId(x, y)` finds the `Clickable(id, msg, child)` node under the pointer; `Ui.step(ui, input, hit)` returns a `UiStep` with the new `UiState` and the `UiEvent`s raised (`Press`, `Release`, `Click`, `Enter`, `Exit`, with pointer capture), and `Ui.messages(view, events)` turns the clicks into the game's own messages. `Ui.button(id, msg, label, x, y, w, h, ui)` builds a button that shows hover and pressed looks and sends `msg` when clicked. The picture is a `SceneNode[Msg]`, generic over the game's message type.
|
|
1408
1408
|
|
|
1409
1409
|
From [`game_state_view.plum`](plum-examples/games/functional/game_state_view.plum) (a method of `State`): `feed` runs `Ui.step` against the picture the player sees, then hands each event to `update`.
|
|
1410
1410
|
|
|
1411
1411
|
```
|
|
1412
|
-
fun feed(self, input:
|
|
1412
|
+
fun feed(self, input: Input) -> State =
|
|
1413
|
+
pos := input.position() ?: return self
|
|
1414
|
+
view := self.render()
|
|
1413
|
-
r := Ui.step(self.ui, input,
|
|
1415
|
+
r := Ui.step(self.ui, input, view.hitId(pos.x, pos.y))
|
|
1414
|
-
r.events.fold(State(..self, ui: r.ui), |s,
|
|
1416
|
+
Ui.messages(view, r.events).fold(State(..self, ui: r.ui), |s, m| s.update(m))
|
|
1415
1417
|
```
|
|
1416
1418
|
|
|
1417
|
-
|
|
1419
|
+
The button's id only identifies it for hover and press state (record `==` is identity today, structural `==` is a deferred item); what a click means is its message.
|
|
1418
1420
|
|
|
1419
1421
|
**Browser check:** `cargo run --manifest-path plum-game/web-test/Cargo.toml -- plum-examples/games/imperative/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.
|
|
1420
1422
|
|
docs/backlog.md
CHANGED
|
@@ -39,4 +39,4 @@ Open work, in the order the user queued it, plus smaller items found during buil
|
|
|
39
39
|
- **Retire PureScene?** — after the component-style functional game, decide whether `std/game/pure` + `games/pure` stay. (Decision record §4b)
|
|
40
40
|
- **`adder(1)(2)`** (calling a returned closure directly) is not supported. (functional-gaps plan)
|
|
41
41
|
- **Fusion past a closure-spec copy on `Nil` miscompiles** — in `drive.rs` `quiet`, resolving `Nil.fold$c0(..)` to `List.fold$c0` (read-only) lets a value move past it, and codegen then fails with `unknown method 'List.fold$c0'` (5 `closure_spec_test` cases). `typeNameMethodKey` skips `$` copies until this is fixed.
|
|
42
|
-
- **Input model** — steps 1
|
|
42
|
+
- **Input model** — steps 1-3 built 2026-11-04 (typed `Msg` events for the functional style only); see [game-scene-styles.md](game-scene-styles.md) section 6.
|
docs/game-scene-styles.md
CHANGED
|
@@ -21,7 +21,7 @@ Decisions behind the styles are in [decisions/2026-11-language-and-scene-decisio
|
|
|
21
21
|
### Main API (after the 2026-11-04 method refactor)
|
|
22
22
|
|
|
23
23
|
- **Pure:** `Node.group/sprite/label/button/custom(..)` build nodes. `node.update(dt)`, `node.drawTree(io, m, tint)`, `node.findHit(m, p)`, `node.findPath(id)`, `node.modify(id, f)`, `node.route(path, depth, e)` and `node.onPointer(e)` work on them. `Stage.create(root)`, `stage.frame(dt, inputs)`, `stage.draw(io)` and `Input.poll(io, wasDown)` run a frame. `Action.moveBy/rotateBy/delay/sequence/parallel/forever(..)` and `action.step(pose, dt)` drive animations.
|
|
24
|
-
- **Functional:** `SceneNode` is an enum (`Group`, `Filter`, `Sprite`, `Text`, `RectShape`, `CircleShape`, `LineShape`, `
|
|
24
|
+
- **Functional:** `SceneNode[M]` is an enum over the game's message type `M` (`Group`, `Filter`, `Sprite`, `Text`, `RectShape`, `CircleShape`, `LineShape`, `Clickable`). `node.draw(io)`, `node.bounds()`, `node.hitPath(x, y)` and `node.hitId(x, y)` use it. `Transform.matrix()`, `Effect.tint(alpha)` and `Rect` methods are the helpers. `Ui.step(ui, input, hitId)` and `Ui.button(..)` handle UI.
|
|
25
25
|
|
|
26
26
|
## 2. Engine analogies
|
|
27
27
|
|
|
@@ -36,7 +36,7 @@ Decisions behind the styles are in [decisions/2026-11-language-and-scene-decisio
|
|
|
36
36
|
Both avoid mutation in game logic. The difference is **where the state lives**:
|
|
37
37
|
|
|
38
38
|
- In **pure**, the tree *is* the game state. Changing the game means finding a node (`findPath`, `modify`) and returning a new tree. Nodes carry their own behaviour (`Actor`) and animations (`actions`), and pointer input changes nodes (a button's `pressed`/`over`).
|
|
39
|
-
- In **functional**, the tree is a *picture of* the game state. Changing the game means changing `State`; the next `render()` reflects it. Hover and press state lives in `UiState` inside `State`, and
|
|
39
|
+
- In **functional**, the tree is a *picture of* the game state. Changing the game means changing `State`; the next `render()` reflects it. Hover and press state lives in `UiState` inside `State`, and a click on a `Clickable(id, msg, ..)` becomes the game message `msg`.
|
|
40
40
|
|
|
41
41
|
Cost: pure updates run in place when the compiler proves uniqueness. Functional describes the whole frame each time, but fusion removes the tree (see `fusion_alloc_test.rs`, `state_view_draw_has_no_scenenode_alloc`).
|
|
42
42
|
|
|
@@ -83,7 +83,7 @@ Steps 1 and 2 of the proposal below are built (2026-11-04).
|
|
|
83
83
|
|
|
84
84
|
The example games and fixtures poll once per frame and feed every input in. Nothing else reads input from the host.
|
|
85
85
|
|
|
86
|
-
|
|
86
|
+
In the functional style, clicks arrive as the game's own messages (step 3). The pure and imperative styles still identify UI elements by integer id.
|
|
87
87
|
|
|
88
88
|
## 6. Proposal: one input model with `onInput`
|
|
89
89
|
|
|
@@ -114,16 +114,18 @@ Its ideas:
|
|
|
114
114
|
- **Imperative:** `trait Actor` has `fun onInput(self, e: Input) -> Bool` (e in the actor's coordinates; true = consumed). `stage.input(e) -> Bool` bubbles from the hit actor through `parent`. A press sets `stage.focus`; keys bubble from the focused actor.
|
|
115
115
|
- **Functional:** no methods on nodes (they are data). Inputs reach `State.update` as `Event(input)` messages.
|
|
116
116
|
|
|
117
|
-
### Step 3
|
|
117
|
+
### Step 3: typed messages (built for the functional style)
|
|
118
118
|
|
|
119
|
-
-
|
|
120
|
-
-
|
|
121
|
-
-
|
|
119
|
+
- `SceneNode[M]` is generic over the game's message type. `Clickable(id, msg: M, child)` replaces `Tagged(id, child)`. `node.msgOf(id)` reads a clickable's message.
|
|
120
|
+
- `Ui.button(id, msg, label, ..)` builds one. `Ui.messages(view, events)` turns the Clicks that `Ui.step` raised into the game's own messages, read from the view the pointer was on.
|
|
121
|
+
- The game declares `RestartClicked` in its `Msg` and handles it in `update` like any other message. The `UiEv` wrapper and the `e.id == Id.Restart && e.kind == Click` check are gone. The id stays only to identify the button for hover and press state (`Ui` needs identity, and `==` on records is identity).
|
|
122
|
+
- Cost: every scene signature says `SceneNode[Msg]`, and a local built from a bare constructor needs an annotation (`x: SceneNode[Msg] := Group(..)`); passing it straight to its use (`Filter(child: Group(..))`) avoids that, and also keeps fusion. Helpers that do not mention `M` (`hitLeaf`, `hitPrepend`, `nearLine`) are top-level functions, because a static method of a generic type must mention its parameter. Fusion of `render().draw(io)` still holds (`fusion_alloc_test`); allocations in the exempt panel/animation functions went from 15 to 12.
|
|
123
|
+
- Found on the way: TRMC hoisted a list cell's head into an untyped temporary, so `Cons(head: Full(..), ..)` in a function returning `List[Box[Int]]` gave the temporary a `Box` with its own parameter left open, and produced invalid wasm. Fixed in `trmc.rs` (the temporary takes the field's concrete type); regression test `trmc_list_of_generic_enum` in `plum-examples/recursion.plum`.
|
|
124
|
+
- Pure and imperative styles keep integer ids (generic traits, `Actor[Msg]`, are not supported).
|
|
122
125
|
|
|
123
126
|
### Next
|
|
124
127
|
|
|
125
|
-
1. Step 3 on the functional style only, kept if fusion still holds.
|
|
126
|
-
|
|
128
|
+
1. Decide whether PureScene stays (backlog: "Retire PureScene?").
|
|
127
129
|
|
|
128
130
|
### Open questions
|
|
129
131
|
|
plum-core/src/trmc.rs
CHANGED
|
@@ -60,6 +60,43 @@ struct Cx<'a> {
|
|
|
60
60
|
found: bool,
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
impl Cx<'_> {
|
|
64
|
+
/// The declared type of field `field` of the cons variant `cons`, with the return
|
|
65
|
+
/// enum's own generic parameters replaced by the return type's arguments
|
|
66
|
+
/// (`head: T` of `List[T]` is `Box[Int]` when returning `List[Box[Int]]`). None
|
|
67
|
+
/// when it can't be told.
|
|
68
|
+
fn fieldType(&self, cons: &str, field: &str) -> Option<Type> {
|
|
69
|
+
let owner = self.enums.iter().find(|e| e.name == self.ret.name)?;
|
|
70
|
+
let v = owner.variants.iter().find(|v| v.name == cons).or_else(|| {
|
|
71
|
+
self.enums.iter().find(|e| e.name == cons).and_then(|e| e.variants.first())
|
|
72
|
+
})?;
|
|
73
|
+
let i = v.field_names.iter().position(|n| n == field)?;
|
|
74
|
+
let declared = v.fields.get(i)?;
|
|
75
|
+
if owner.generics.len() != self.ret.generics.len() {
|
|
76
|
+
return None;
|
|
77
|
+
}
|
|
78
|
+
let map: Vec<(&str, &Type)> =
|
|
79
|
+
owner.generics.iter().map(|g| g.name.as_str()).zip(self.ret.generics.iter()).collect();
|
|
80
|
+
fn subst(t: &Type, map: &[(&str, &Type)]) -> Type {
|
|
81
|
+
if t.generics.is_empty() {
|
|
82
|
+
if let Some((_, r)) = map.iter().find(|(n, _)| *n == t.name) {
|
|
83
|
+
return (*r).clone();
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
Type { name: t.name.clone(), generics: t.generics.iter().map(|g| subst(g, map)).collect() }
|
|
87
|
+
}
|
|
88
|
+
fn open(t: &Type) -> bool {
|
|
89
|
+
(t.name.len() == 1 && t.name.chars().all(|c| c.is_ascii_uppercase())) || t.generics.iter().any(open)
|
|
90
|
+
}
|
|
91
|
+
// Only a concrete type helps (and a generic fn's own `B` would not re-check).
|
|
92
|
+
let t = subst(declared, &map);
|
|
93
|
+
if open(&t) {
|
|
94
|
+
return None;
|
|
95
|
+
}
|
|
96
|
+
Some(t)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
63
100
|
fn rewriteFn(f: &Fn, enums: &[Enum]) -> Option<Fn> {
|
|
64
101
|
if f.is_export
|
|
65
102
|
|| f.is_extern
|
|
@@ -442,7 +479,11 @@ impl<'a> Cx<'a> {
|
|
|
442
479
|
fields.push(FieldArg { name: f.name.clone(), value: Expr::TypeName(self.nullary.clone()) });
|
|
443
480
|
} else if i < k && !pure(&f.value) {
|
|
444
481
|
let t = self.fresh("f");
|
|
482
|
+
// Typed from the field's declaration: a bare `Full(v: 1)` for a field
|
|
483
|
+
// `head: T` of `List[Box[Int]]` would otherwise leave `Box`'s own
|
|
484
|
+
// parameter open and get a different instantiation than the cell.
|
|
485
|
+
let ty = self.fieldType(&c.type_name, &f.name);
|
|
445
|
-
stmts.push(declare(&t,
|
|
486
|
+
stmts.push(declare(&t, ty, f.value.clone()));
|
|
446
487
|
fields.push(FieldArg { name: f.name.clone(), value: var(&t) });
|
|
447
488
|
} else {
|
|
448
489
|
fields.push(f.clone());
|
plum-examples/games/functional/game_state_view.plum
CHANGED
|
@@ -16,7 +16,9 @@
|
|
|
16
16
|
# player's input once a frame (`Input.poll`, its only input read) and sends each
|
|
17
17
|
# `Input` to the state as an `Event`: keys update the held keys that steer the ship,
|
|
18
18
|
# and a pointer input finds what is under it with `SceneNode.hitId` on the picture the
|
|
19
|
-
# player is looking at
|
|
19
|
+
# player is looking at and runs `Ui.step`. A click on a button sends the button's own
|
|
20
|
+
# message (`RestartClicked`): `Ui.messages` reads it from the picture, and it goes to
|
|
21
|
+
# `State.update` like any other `Msg`. The picture is a `SceneNode[Msg]`.
|
|
20
22
|
# The game-over title and the ship's hurt flash are `Anim`s of time since they began.
|
|
21
23
|
# The `World` record is the host shell: it holds the current `State`, polls the
|
|
22
24
|
# keyboard and mouse into messages, and plays sounds.
|
|
@@ -71,10 +73,10 @@ record Spawned(at: Motion, rng: Random)
|
|
|
71
73
|
fun frameOf(clock: Float, count: Int, fps: Float) -> Int =
|
|
72
74
|
Int(clock * fps) % count
|
|
73
75
|
|
|
74
|
-
fun nothing() -> SceneNode =
|
|
76
|
+
fun nothing() -> SceneNode[Msg] =
|
|
75
77
|
Group(transform: Identity, children: Nil)
|
|
76
78
|
|
|
77
|
-
fun cell(sheet: Int, x: Float, y: Float, size: Float, col: Int, row: Int) -> SceneNode =
|
|
79
|
+
fun cell(sheet: Int, x: Float, y: Float, size: Float, col: Int, row: Int) -> SceneNode[Msg] =
|
|
78
80
|
Sprite(
|
|
79
81
|
tex: sheet,
|
|
80
82
|
x: x,
|
|
@@ -177,7 +179,7 @@ record Ship(x: Float, y: Float, vx: Float, vy: Float, rotation: Float, hurt: Flo
|
|
|
177
179
|
overlaps(self.x, self.y, Rules.ShipRadius, at.x, at.y, radius)
|
|
178
180
|
|
|
179
181
|
# The ship blinks while hurt (`hurtAge` seconds since the hit).
|
|
180
|
-
fun render(self, sheet: Int, clock: Float, hurtAge: Float) -> SceneNode =
|
|
182
|
+
fun render(self, sheet: Int, clock: Float, hurtAge: Float) -> SceneNode[Msg] =
|
|
181
183
|
blink := self.hurt > 0.0 && flashPose(hurtAge).scaleX < 0.7
|
|
182
184
|
if blink
|
|
183
185
|
nothing()
|
|
@@ -204,18 +206,21 @@ record Orb(id: Int, at: Motion, phase: Float, clock: Float) =
|
|
|
204
206
|
Orb(..self, at: Motion(..p, vx: 0.0, vy: 0.0))
|
|
205
207
|
|
|
206
208
|
# The glow is drawn additively, then the sprite on top.
|
|
207
|
-
fun render(self, sheet: Int) -> SceneNode =
|
|
209
|
+
fun render(self, sheet: Int) -> SceneNode[Msg] =
|
|
208
|
-
glow := Group(
|
|
209
|
-
transform: Identity,
|
|
210
|
-
children: List(
|
|
211
|
-
CircleShape(cx: 0.0, cy: 0.0, r: 26.0, color: Color(r: 0.35, g: 0.22, b: 0.0, a: 0.5)),
|
|
212
|
-
CircleShape(cx: 0.0, cy: 0.0, r: 17.0, color: Color(r: 0.6, g: 0.4, b: 0.05, a: 0.5))
|
|
213
|
-
)
|
|
214
|
-
)
|
|
215
210
|
Group(
|
|
216
211
|
transform: Translate(dx: self.at.x, dy: self.at.y),
|
|
217
212
|
children: List(
|
|
213
|
+
Filter(
|
|
218
|
-
|
|
214
|
+
effect: BlendFx(mode: Blend.Add),
|
|
215
|
+
child: Group(
|
|
216
|
+
transform: Identity,
|
|
217
|
+
children: List(
|
|
218
|
+
CircleShape(cx: 0.0, cy: 0.0, r: 26.0, color: Color(r: 0.35, g: 0.22, b: 0.0, a: 0.5)),
|
|
219
|
+
CircleShape(cx: 0.0, cy: 0.0, r: 17.0, color: Color(r: 0.6, g: 0.4, b: 0.05, a: 0.5))
|
|
220
|
+
)
|
|
221
|
+
),
|
|
222
|
+
target: 0 - 1
|
|
223
|
+
),
|
|
219
224
|
cell(sheet, 0.0 - Rules.Half, 0.0 - Rules.Half, Rules.Size, frameOf(self.clock + self.phase, 4, 8.0), 1)
|
|
220
225
|
)
|
|
221
226
|
)
|
|
@@ -233,7 +238,7 @@ record Mine(id: Int, at: Motion, rotation: Float, spin: Float, clock: Float) =
|
|
|
233
238
|
p := self.at
|
|
234
239
|
Mine(..self, at: Motion(..p, vx: 0.0, vy: 0.0), spin: 0.0)
|
|
235
240
|
|
|
236
|
-
fun render(self, sheet: Int) -> SceneNode =
|
|
241
|
+
fun render(self, sheet: Int) -> SceneNode[Msg] =
|
|
237
242
|
Group(
|
|
238
243
|
transform: Compose(first: Translate(dx: self.at.x, dy: self.at.y), then: Rotate(radians: self.rotation)),
|
|
239
244
|
children: List(cell(sheet, 0.0 - Rules.Half, 0.0 - Rules.Half, Rules.Size, frameOf(self.clock, 4, 4.0), 2))
|
|
@@ -246,7 +251,7 @@ record Star(i: Int) =
|
|
|
246
251
|
fun field(i: Int) -> List[Star] =
|
|
247
252
|
if i >= 40 then Nil else Cons(head: Star(i: i), tail: Star.field(i + 1))
|
|
248
253
|
|
|
249
|
-
fun render(self, clock: Float) -> SceneNode =
|
|
254
|
+
fun render(self, clock: Float) -> SceneNode[Msg] =
|
|
250
255
|
r := 1.0 + Float(self.i % 3) * 0.5
|
|
251
256
|
twinkle := 0.25 + 0.2 * Math.sin(clock * 2.0 + Float(self.i))
|
|
252
257
|
CircleShape(cx: Float({self.i * 7919} % 800), cy: Float({self.i * 104729} % 600), r: r, color: Color(r: 0.7, g: 0.8, b: 1.0, a: twinkle))
|
|
@@ -257,7 +262,7 @@ enum Msg =
|
|
|
257
262
|
| Resize(w: Float, h: Float)
|
|
258
263
|
| Restart(seed: Int)
|
|
259
264
|
| Event(input: Input)
|
|
260
|
-
|
|
|
265
|
+
| RestartClicked
|
|
261
266
|
|
|
262
267
|
# Everything that changes during a run. `sheet` and `target` are resource handles
|
|
263
268
|
# the picture needs: the sprite sheet and the render target of the game-over filter.
|
|
@@ -314,7 +319,7 @@ record State(
|
|
|
314
319
|
Resize(w, h) => State(..self, sw: w, sh: h)
|
|
315
320
|
Restart(seed) => self.restarted(seed)
|
|
316
321
|
Event(input) => self.onInput(input)
|
|
317
|
-
|
|
322
|
+
RestartClicked => if self.over then self.restarted(self.rng.nextInt().value) else self
|
|
318
323
|
|
|
319
324
|
# One frame of time. Once the game is over the actors only animate.
|
|
320
325
|
fun tick(self, dt: Float, ax: Float, ay: Float) -> State =
|
|
@@ -417,16 +422,14 @@ record State(
|
|
|
417
422
|
fun axisY(self) -> Float =
|
|
418
423
|
axis(self.held.isDown(Key.Up) || self.held.isDown(Key.KeyW), self.held.isDown(Key.Down) || self.held.isDown(Key.KeyS))
|
|
419
424
|
|
|
420
|
-
# A click on the restart button restarts a finished game; other events change nothing.
|
|
421
|
-
fun onUi(self, e: UiEvent) -> State =
|
|
422
|
-
if self.over && e.id == Id.Restart && e.kind == Click then self.restarted(self.rng.nextInt().value) else self
|
|
423
425
|
|
|
424
|
-
# One pointer input: finds what is under it in the current picture, runs the Ui,
|
|
426
|
+
# One pointer input: finds what is under it in the current picture, runs the Ui,
|
|
425
|
-
#
|
|
427
|
+
# and sends the messages of the buttons it clicked to `update`.
|
|
426
428
|
fun feed(self, input: Input) -> State =
|
|
427
429
|
pos := input.position() ?: return self
|
|
430
|
+
view := self.render()
|
|
428
|
-
r := Ui.step(self.ui, input,
|
|
431
|
+
r := Ui.step(self.ui, input, view.hitId(pos.x, pos.y))
|
|
429
|
-
r.events.fold(State(..self, ui: r.ui), |s,
|
|
432
|
+
Ui.messages(view, r.events).fold(State(..self, ui: r.ui), |s, m| s.update(m))
|
|
430
433
|
|
|
431
434
|
# --- render ---
|
|
432
435
|
|
|
@@ -441,7 +444,7 @@ record State(
|
|
|
441
444
|
{self.sh - self.zoom() * Rules.ArenaH} / 2.0
|
|
442
445
|
|
|
443
446
|
# The whole picture, in drawing order, under the camera that fits the arena on screen.
|
|
444
|
-
fun render(self) -> SceneNode =
|
|
447
|
+
fun render(self) -> SceneNode[Msg] =
|
|
445
448
|
z := self.zoom()
|
|
446
449
|
Group(
|
|
447
450
|
transform: Compose(first: Translate(dx: self.offsetX(), dy: self.offsetY()), then: Scale(sx: z, sy: z)),
|
|
@@ -457,18 +460,18 @@ record State(
|
|
|
457
460
|
)
|
|
458
461
|
|
|
459
462
|
# The ship is gone once the game is over (an empty group keeps the slots).
|
|
460
|
-
fun renderShip(self) -> SceneNode =
|
|
463
|
+
fun renderShip(self) -> SceneNode[Msg] =
|
|
461
464
|
if self.over then nothing() else self.ship.render(self.sheet, self.clock, self.clock - self.hurtAt)
|
|
462
465
|
|
|
463
466
|
# One life icon per remaining life, from the right edge going left.
|
|
464
|
-
fun lifeIcons(self, i: Int) -> List[SceneNode] =
|
|
467
|
+
fun lifeIcons(self, i: Int) -> List[SceneNode[Msg]] =
|
|
465
468
|
if i >= self.lives
|
|
466
469
|
Nil
|
|
467
470
|
else
|
|
468
471
|
Cons(head: cell(self.sheet, Rules.ArenaW - 40.0 - Float(i) * 36.0, 12.0, 32.0, 0, 0), tail: self.lifeIcons(i + 1))
|
|
469
472
|
|
|
470
473
|
# `Cons` (not `List(..).append(..)`) so the life icons fuse into the draw walk too.
|
|
471
|
-
fun renderHud(self) -> SceneNode =
|
|
474
|
+
fun renderHud(self) -> SceneNode[Msg] =
|
|
472
475
|
Group(
|
|
473
476
|
transform: Identity,
|
|
474
477
|
children: Cons(
|
|
@@ -477,7 +480,7 @@ record State(
|
|
|
477
480
|
)
|
|
478
481
|
)
|
|
479
482
|
|
|
480
|
-
fun renderTitle(self) -> SceneNode =
|
|
483
|
+
fun renderTitle(self) -> SceneNode[Msg] =
|
|
481
484
|
p := titlePose(self.clock - self.overAt)
|
|
482
485
|
Group(
|
|
483
486
|
transform: Compose(first: Translate(dx: 400.0 + p.dx, dy: 268.0 + p.dy), then: Scale(sx: p.scaleX, sy: p.scaleY)),
|
|
@@ -485,22 +488,25 @@ record State(
|
|
|
485
488
|
)
|
|
486
489
|
|
|
487
490
|
# The game-over panel is empty while playing, so nothing in it can be hit.
|
|
488
|
-
fun renderOverlay(self) -> SceneNode =
|
|
491
|
+
fun renderOverlay(self) -> SceneNode[Msg] =
|
|
489
492
|
if self.over then self.renderPanel() else nothing()
|
|
490
493
|
|
|
491
494
|
# The panel with the restart button, faded in through a render-target filter.
|
|
492
|
-
fun renderPanel(self) -> SceneNode =
|
|
495
|
+
fun renderPanel(self) -> SceneNode[Msg] =
|
|
493
|
-
button := Ui.button(Id.Restart, "Again", 340.0, 342.0, 120.0, 44.0, self.ui)
|
|
496
|
+
button := Ui.button(Id.Restart, RestartClicked, "Again", 340.0, 342.0, 120.0, 44.0, self.ui)
|
|
497
|
+
Filter(
|
|
498
|
+
effect: Alpha(alpha: {self.overClock * 2.0}.min(1.0)),
|
|
494
|
-
|
|
499
|
+
child: Group(
|
|
495
|
-
|
|
500
|
+
transform: Identity,
|
|
496
|
-
|
|
501
|
+
children: List(
|
|
497
|
-
|
|
502
|
+
RectShape(x: 0.0, y: 230.0, w: Rules.ArenaW, h: 170.0, color: Color(r: 0.0, g: 0.0, b: 0.0, a: 0.6)),
|
|
498
|
-
|
|
503
|
+
self.renderTitle(),
|
|
499
|
-
|
|
504
|
+
Text(text: "Press Enter to play again", x: 270.0, y: 300.0, size: 24.0, color: Color.White),
|
|
500
|
-
|
|
505
|
+
button
|
|
501
|
-
|
|
506
|
+
)
|
|
507
|
+
),
|
|
508
|
+
target: self.target
|
|
502
509
|
)
|
|
503
|
-
Filter(effect: Alpha(alpha: {self.overClock * 2.0}.min(1.0)), child: panel, target: self.target)
|
|
504
510
|
|
|
505
511
|
# True when (x, y) in screen pixels is on the restart button of the picture `render` builds.
|
|
506
512
|
fun onRestart(self, x: Float, y: Float) -> Bool =
|
|
@@ -604,12 +610,12 @@ fun onShip(s: State) -> Motion =
|
|
|
604
610
|
fun forceOver(s: State) -> State =
|
|
605
611
|
State(..s, lives: 0, over: true, overAt: s.clock, best: s.best.max(s.score))
|
|
606
612
|
|
|
607
|
-
fun countNodes(n: SceneNode) -> Int = match n
|
|
613
|
+
fun countNodes(n: SceneNode[Msg]) -> Int = match n
|
|
608
614
|
Group(_, kids) => 1 + countKids(kids)
|
|
609
615
|
Filter(_, child, _) => 1 + countNodes(child)
|
|
610
616
|
_ => 1
|
|
611
617
|
|
|
612
|
-
fun countKids(ks: List[SceneNode]) -> Int = match ks
|
|
618
|
+
fun countKids(ks: List[SceneNode[Msg]]) -> Int = match ks
|
|
613
619
|
Nil => 0
|
|
614
620
|
Cons(h, t) => countNodes(h) + countKids(t)
|
|
615
621
|
|
plum-examples/recursion.plum
CHANGED
|
@@ -76,6 +76,22 @@ record Cell(x: Float)
|
|
|
76
76
|
|
|
77
77
|
# ==== tail calls tests ====
|
|
78
78
|
|
|
79
|
+
# A generic enum kept in a List: TRMC must give the hoisted head the list's element
|
|
80
|
+
# type (`Box[Int]`), not a `Box` with its own parameter left open (invalid wasm before).
|
|
81
|
+
enum TrBox[M] =
|
|
82
|
+
| TrEmpty
|
|
83
|
+
| TrFull(v: Int)
|
|
84
|
+
| TrTag(id: Int, msg: M)
|
|
85
|
+
|
|
86
|
+
fun trBoxes(xs: List[Int], rest: List[TrBox[Int]]) -> List[TrBox[Int]] = match xs
|
|
87
|
+
Nil => rest
|
|
88
|
+
Cons(h, t) => Cons(head: TrFull(v: h * 2), tail: trBoxes(t, rest))
|
|
89
|
+
|
|
90
|
+
fun trSum(bs: List[TrBox[Int]]) -> Int = match bs
|
|
91
|
+
Nil => 0
|
|
92
|
+
Cons(TrFull(v), t) => v + trSum(t)
|
|
93
|
+
Cons(_, t) => trSum(t)
|
|
94
|
+
|
|
79
95
|
test "tail_sum_million"
|
|
80
96
|
assert sumTo(1000000, 0) == 500000500000
|
|
81
97
|
|
|
@@ -219,3 +235,6 @@ test "put_after_take_keeps_length"
|
|
|
219
235
|
c := xs.takeAt(0)
|
|
220
236
|
xs.putAt(0, c)
|
|
221
237
|
assert xs.length() == 3
|
|
238
|
+
|
|
239
|
+
test "trmc_list_of_generic_enum"
|
|
240
|
+
assert trSum(trBoxes(List(1, 2, 3), Nil)) == 12
|
plum-game/web-test/fixtures/stress_state_view.plum
CHANGED
|
@@ -57,11 +57,11 @@ fun nthOr(xs: List[T], i: Int, dflt: T) -> T = match xs
|
|
|
57
57
|
Cons(h, t) => if i <= 0 then h else nthOr(t, i - 1, dflt)
|
|
58
58
|
|
|
59
59
|
# The nodes f(x) for every x of `xs`, followed by `rest`.
|
|
60
|
-
fun toNodes(xs: List[T], f: fn(T) -> SceneNode, rest: List[SceneNode]) -> List[SceneNode] = match xs
|
|
60
|
+
fun toNodes(xs: List[T], f: fn(T) -> SceneNode[Int], rest: List[SceneNode[Int]]) -> List[SceneNode[Int]] = match xs
|
|
61
61
|
Nil => rest
|
|
62
62
|
Cons(h, t) => Cons(head: f(h), tail: toNodes(t, f, rest))
|
|
63
63
|
|
|
64
|
-
fun countNodes(ns: List[SceneNode]) -> Int = match ns
|
|
64
|
+
fun countNodes(ns: List[SceneNode[Int]]) -> Int = match ns
|
|
65
65
|
Nil => 0
|
|
66
66
|
Cons(_, t) => 1 + countNodes(t)
|
|
67
67
|
|
|
@@ -153,27 +153,27 @@ fun stepUi(s: State, input: Input) -> State =
|
|
|
153
153
|
|
|
154
154
|
# --- view: pure, the whole tree every frame ---
|
|
155
155
|
|
|
156
|
-
fun dancerView(sheet: Int, d: Dancer) -> SceneNode =
|
|
156
|
+
fun dancerView(sheet: Int, d: Dancer) -> SceneNode[Int] =
|
|
157
157
|
Group(
|
|
158
158
|
transform: Compose(first: Translate(dx: d.x + 8.0, dy: d.y + 8.0), then: Compose(first: Rotate(radians: d.rotation), then: Translate(dx: 0.0 - 8.0, dy: 0.0 - 8.0))),
|
|
159
159
|
children: Cons(head: Sprite(
|
|
160
160
|
tex: sheet, x: 0.0, y: 0.0, w: 16.0, h: 16.0, srcX: d.srcX, srcY: 0.0, srcW: 16.0, srcH: 16.0,
|
|
161
161
|
tint: Color.White), tail: Nil))
|
|
162
162
|
|
|
163
|
-
fun spinnerView(sheet: Int, p: Spinner) -> SceneNode =
|
|
163
|
+
fun spinnerView(sheet: Int, p: Spinner) -> SceneNode[Int] =
|
|
164
164
|
Group(
|
|
165
165
|
transform: Compose(first: Translate(dx: p.x + 75.0, dy: p.y + 85.0), then: Compose(first: Rotate(radians: p.rotation), then: Translate(dx: 0.0 - 75.0, dy: 0.0 - 85.0))),
|
|
166
166
|
children: toNodes(p.dancers, |d| dancerView(sheet, d), Nil))
|
|
167
167
|
|
|
168
|
-
fun drifterView(white: Int, d: Drifter) -> SceneNode =
|
|
168
|
+
fun drifterView(white: Int, d: Drifter) -> SceneNode[Int] =
|
|
169
169
|
Sprite(
|
|
170
170
|
tex: white, x: d.x, y: d.y, w: d.w, h: d.h, srcX: 0.0, srcY: 0.0, srcW: 0.0, srcH: 0.0,
|
|
171
171
|
tint: d.color)
|
|
172
172
|
|
|
173
|
-
fun counterView(c: Counter) -> SceneNode =
|
|
173
|
+
fun counterView(c: Counter) -> SceneNode[Int] =
|
|
174
174
|
Text(text: "n {c.n}", x: c.x, y: c.y, size: 14.0, color: Color.White)
|
|
175
175
|
|
|
176
|
-
fun pulserView(sheet: Int, anim: Anim, clock: Float, p: Pulser) -> SceneNode =
|
|
176
|
+
fun pulserView(sheet: Int, anim: Anim, clock: Float, p: Pulser) -> SceneNode[Int] =
|
|
177
177
|
pose := Anim.at(anim, clock - p.start)
|
|
178
178
|
Group(
|
|
179
179
|
transform: Compose(first: Translate(dx: p.x + pose.dx + 8.0, dy: p.y + pose.dy + 8.0), then: Compose(first: Scale(sx: pose.scaleX, sy: pose.scaleY), then: Compose(first: Rotate(radians: pose.rotation), then: Translate(dx: 0.0 - 8.0, dy: 0.0 - 8.0)))),
|
|
@@ -181,17 +181,17 @@ fun pulserView(sheet: Int, anim: Anim, clock: Float, p: Pulser) -> SceneNode =
|
|
|
181
181
|
tex: sheet, x: 0.0, y: 0.0, w: 16.0, h: 16.0, srcX: p.srcX, srcY: 0.0, srcW: 16.0, srcH: 16.0,
|
|
182
182
|
tint: Color.White), tail: Nil))
|
|
183
183
|
|
|
184
|
-
fun buttonNodes(i: Int, ui: UiState) -> List[SceneNode] =
|
|
184
|
+
fun buttonNodes(i: Int, ui: UiState) -> List[SceneNode[Int]] =
|
|
185
|
-
if i >= Counts.Buttons then Nil else Cons(head: Ui.button(40000 + i, "b{i}", Float(i % 10 * 70 + 10), Float(i / 10 * 30 + 10), 64.0, 24.0, ui), tail: buttonNodes(i + 1, ui))
|
|
185
|
+
if i >= Counts.Buttons then Nil else Cons(head: Ui.button(40000 + i, 40000 + i, "b{i}", Float(i % 10 * 70 + 10), Float(i / 10 * 30 + 10), 64.0, 24.0, ui), tail: buttonNodes(i + 1, ui))
|
|
186
186
|
|
|
187
|
-
fun buttonsView(ui: UiState) -> SceneNode =
|
|
187
|
+
fun buttonsView(ui: UiState) -> SceneNode[Int] =
|
|
188
188
|
Group(transform: Identity, children: buttonNodes(0, ui))
|
|
189
189
|
|
|
190
|
-
fun view(s: State) -> SceneNode =
|
|
190
|
+
fun view(s: State) -> SceneNode[Int] =
|
|
191
191
|
texts := toNodes(s.counters, |c| counterView(c), Nil)
|
|
192
192
|
drifts := toNodes(s.drifters, |d| drifterView(s.white, d), texts)
|
|
193
193
|
pulses := toNodes(s.pulsers, |p| pulserView(s.sheet, s.anim, s.clock, p), drifts)
|
|
194
|
-
world := Group(transform: Scale(sx: 0.8, sy: 0.8), children: toNodes(s.spinners, |p| spinnerView(s.sheet, p), pulses))
|
|
194
|
+
world: SceneNode[Int] := Group(transform: Scale(sx: 0.8, sy: 0.8), children: toNodes(s.spinners, |p| spinnerView(s.sheet, p), pulses))
|
|
195
195
|
Group(transform: Identity, children: List(world, buttonsView(s.ui)))
|
|
196
196
|
|
|
197
197
|
fun whiteTexture(io: Io) -> Int =
|
plum-game/web-test/fixtures/stress_state_view_deep.plum
CHANGED
|
@@ -48,11 +48,11 @@ fun nthOr(xs: List[T], i: Int, dflt: T) -> T = match xs
|
|
|
48
48
|
Cons(h, t) => if i <= 0 then h else nthOr(t, i - 1, dflt)
|
|
49
49
|
|
|
50
50
|
# The nodes f(x) for every x of `xs`, followed by `rest`.
|
|
51
|
-
fun toNodes(xs: List[T], f: fn(T) -> SceneNode, rest: List[SceneNode]) -> List[SceneNode] = match xs
|
|
51
|
+
fun toNodes(xs: List[T], f: fn(T) -> SceneNode[Int], rest: List[SceneNode[Int]]) -> List[SceneNode[Int]] = match xs
|
|
52
52
|
Nil => rest
|
|
53
53
|
Cons(h, t) => Cons(head: f(h), tail: toNodes(t, f, rest))
|
|
54
54
|
|
|
55
|
-
fun countNodes(ns: List[SceneNode]) -> Int = match ns
|
|
55
|
+
fun countNodes(ns: List[SceneNode[Int]]) -> Int = match ns
|
|
56
56
|
Nil => 0
|
|
57
57
|
Cons(_, t) => 1 + countNodes(t)
|
|
58
58
|
|
|
@@ -122,7 +122,7 @@ fun step(s: State, dt: Float) -> State =
|
|
|
122
122
|
|
|
123
123
|
# --- view: pure, the whole tree every frame ---
|
|
124
124
|
|
|
125
|
-
fun dancerView(sheet: Int, d: Dancer) -> SceneNode =
|
|
125
|
+
fun dancerView(sheet: Int, d: Dancer) -> SceneNode[Int] =
|
|
126
126
|
Group(
|
|
127
127
|
transform: Compose(first: Translate(dx: d.x + 8.0, dy: d.y + 8.0), then: Compose(first: Rotate(radians: d.rotation), then: Translate(dx: 0.0 - 8.0, dy: 0.0 - 8.0))),
|
|
128
128
|
children: Cons(head: Sprite(
|
|
@@ -130,7 +130,7 @@ fun dancerView(sheet: Int, d: Dancer) -> SceneNode =
|
|
|
130
130
|
tint: Color.White), tail: Nil))
|
|
131
131
|
|
|
132
132
|
# The leading dancers of `ds` that belong to group `gi`, as nodes.
|
|
133
|
-
fun groupNodes(sheet: Int, ds: List[Dancer], gi: Int) -> List[SceneNode] = match ds
|
|
133
|
+
fun groupNodes(sheet: Int, ds: List[Dancer], gi: Int) -> List[SceneNode[Int]] = match ds
|
|
134
134
|
Nil => Nil
|
|
135
135
|
Cons(h, t) => if h.gi == gi then Cons(head: dancerView(sheet, h), tail: groupNodes(sheet, t, gi)) else Nil
|
|
136
136
|
|
|
@@ -139,25 +139,25 @@ fun dropGroup(ds: List[Dancer], gi: Int) -> List[Dancer] = match ds
|
|
|
139
139
|
Nil => Nil
|
|
140
140
|
Cons(h, t) => if h.gi == gi then dropGroup(t, gi) else ds
|
|
141
141
|
|
|
142
|
-
fun spinnerView(sheet: Int, p: Spinner, ds: List[Dancer]) -> SceneNode =
|
|
142
|
+
fun spinnerView(sheet: Int, p: Spinner, ds: List[Dancer]) -> SceneNode[Int] =
|
|
143
143
|
Group(
|
|
144
144
|
transform: Compose(first: Translate(dx: p.x + 75.0, dy: p.y + 85.0), then: Compose(first: Rotate(radians: p.rotation), then: Translate(dx: 0.0 - 75.0, dy: 0.0 - 85.0))),
|
|
145
145
|
children: groupNodes(sheet, ds, p.id - 100))
|
|
146
146
|
|
|
147
147
|
# Spinner groups in order, each taking its dancers from the front of the one list.
|
|
148
|
-
fun spinnerNodes(sheet: Int, ps: List[Spinner], ds: List[Dancer], rest: List[SceneNode]) -> List[SceneNode] = match ps
|
|
148
|
+
fun spinnerNodes(sheet: Int, ps: List[Spinner], ds: List[Dancer], rest: List[SceneNode[Int]]) -> List[SceneNode[Int]] = match ps
|
|
149
149
|
Nil => rest
|
|
150
150
|
Cons(p, t) => Cons(head: spinnerView(sheet, p, ds), tail: spinnerNodes(sheet, t, dropGroup(ds, p.id - 100), rest))
|
|
151
151
|
|
|
152
|
-
fun drifterView(white: Int, d: Drifter) -> SceneNode =
|
|
152
|
+
fun drifterView(white: Int, d: Drifter) -> SceneNode[Int] =
|
|
153
153
|
Sprite(
|
|
154
154
|
tex: white, x: d.x, y: d.y, w: d.w, h: d.h, srcX: 0.0, srcY: 0.0, srcW: 0.0, srcH: 0.0,
|
|
155
155
|
tint: d.color)
|
|
156
156
|
|
|
157
|
-
fun counterView(c: Counter) -> SceneNode =
|
|
157
|
+
fun counterView(c: Counter) -> SceneNode[Int] =
|
|
158
158
|
Text(text: "n {c.n}", x: c.x, y: c.y, size: 14.0, color: Color.White)
|
|
159
159
|
|
|
160
|
-
fun view(s: State) -> SceneNode =
|
|
160
|
+
fun view(s: State) -> SceneNode[Int] =
|
|
161
161
|
texts := toNodes(s.counters, |c| counterView(c), Nil)
|
|
162
162
|
drifts := toNodes(s.drifters, |d| drifterView(s.white, d), texts)
|
|
163
163
|
Group(transform: Scale(sx: 0.8, sy: 0.8), children: spinnerNodes(s.sheet, s.spinners, s.dancers, drifts))
|
plum-std/game/functional/FunctionalScene.plum
CHANGED
|
@@ -11,10 +11,13 @@ import std/game/Affine
|
|
|
11
11
|
import std/game/Game
|
|
12
12
|
import std/Io
|
|
13
13
|
|
|
14
|
-
# The functional scene: a `SceneNode` is plain data (shapes, sprites, text, groups
|
|
14
|
+
# The functional scene: a `SceneNode[M]` is plain data (shapes, sprites, text, groups
|
|
15
|
-
# with a transform, filters), rebuilt every frame and never mutated.
|
|
15
|
+
# with a transform, filters, clickable areas), rebuilt every frame and never mutated.
|
|
16
|
+
# `M` is the game's own message type: a `Clickable(id, msg, child)` carries the
|
|
17
|
+
# message a click on it sends (see `Ui.messages`), so games match on their messages
|
|
18
|
+
# instead of comparing ids; the id identifies the element for hover and press state. Its methods draw
|
|
16
19
|
# it, measure it and hit-test it by structural recursion, passing the parent matrix
|
|
17
|
-
# and alpha down as arguments. Children are a `List[SceneNode]`; the static
|
|
20
|
+
# and alpha down as arguments. Children are a `List[SceneNode[M]]`; the static
|
|
18
21
|
# `SceneNode` helpers over such a list (`drawKids`, `boundsKids`, ..) do the same walk
|
|
19
22
|
# for each child. `Compose(first, then)` applies `then` first, then `first` (`first`
|
|
20
23
|
# is the outermost).
|
|
@@ -83,15 +86,15 @@ struct Rect(x: Float, y: Float, w: Float, h: Float) =
|
|
|
83
86
|
# creates nothing, so a view does not leak a texture per frame. The size of
|
|
84
87
|
# `target` bounds what the filter can show: child pixels beyond it are cut off.
|
|
85
88
|
# A negative target draws nothing.
|
|
86
|
-
enum SceneNode =
|
|
89
|
+
enum SceneNode[M] =
|
|
87
|
-
| Group(transform: Transform, children: List[SceneNode])
|
|
90
|
+
| Group(transform: Transform, children: List[SceneNode[M]])
|
|
88
|
-
| Filter(effect: Effect, child: SceneNode, target: Int)
|
|
91
|
+
| Filter(effect: Effect, child: SceneNode[M], target: Int)
|
|
89
92
|
| Sprite(tex: Int, x: Float, y: Float, w: Float, h: Float, srcX: Float, srcY: Float, srcW: Float, srcH: Float, tint: Color)
|
|
90
93
|
| Text(text: Str, x: Float, y: Float, size: Float, color: Color)
|
|
91
94
|
| RectShape(x: Float, y: Float, w: Float, h: Float, color: Color)
|
|
92
95
|
| CircleShape(cx: Float, cy: Float, r: Float, color: Color)
|
|
93
96
|
| LineShape(x1: Float, y1: Float, x2: Float, y2: Float, thickness: Float, color: Color)
|
|
94
|
-
|
|
|
97
|
+
| Clickable(id: Int, msg: M, child: SceneNode[M])
|
|
95
98
|
|
|
96
99
|
# Draws the scene to the current target.
|
|
97
100
|
fun draw(self, io: Io) =
|
|
@@ -109,10 +112,25 @@ enum SceneNode =
|
|
|
109
112
|
return p
|
|
110
113
|
Slice[Int]()
|
|
111
114
|
|
|
112
|
-
# The id of the innermost
|
|
115
|
+
# The id of the innermost Clickable node under (x, y), or null. Later siblings win.
|
|
113
116
|
fun hitId(self, x: Float, y: Float) -> Int? =
|
|
114
117
|
self.hitIdWith(null, x, y)
|
|
115
118
|
|
|
119
|
+
# The message of the first Clickable with `id`, depth first, or null.
|
|
120
|
+
fun msgOf(self, id: Int) -> M? = match self
|
|
121
|
+
Clickable(cid, msg, child) => if cid == id then msg else child.msgOf(id)
|
|
122
|
+
Group(_, kids) => SceneNode.msgOfKids(kids, id)
|
|
123
|
+
Filter(_, child, _) => child.msgOf(id)
|
|
124
|
+
_ => null
|
|
125
|
+
|
|
126
|
+
fun msgOfKids(kids: List[SceneNode[M]], id: Int) -> M? = match kids
|
|
127
|
+
Nil => null
|
|
128
|
+
Cons(head, tail) =>
|
|
129
|
+
if m := head.msgOf(id)
|
|
130
|
+
m
|
|
131
|
+
else
|
|
132
|
+
SceneNode.msgOfKids(tail, id)
|
|
133
|
+
|
|
116
134
|
# --- drawing ---
|
|
117
135
|
|
|
118
136
|
# Draws under the parent matrix `m` and alpha into `target` (negative: the screen).
|
|
@@ -139,15 +157,15 @@ enum SceneNode =
|
|
|
139
157
|
io.drawText(txt, tp.x, tp.y, size * m.scaleY(), tcolor.withAlpha(tcolor.a * alpha))
|
|
140
158
|
Group(tr, kids) => SceneNode.drawKids(io, kids, m.mul(tr.matrix()), alpha, target)
|
|
141
159
|
Filter(effect, child, tex) => SceneNode.drawFiltered(io, effect, child, tex, m, alpha, target)
|
|
142
|
-
|
|
160
|
+
Clickable(_, _, child) => child.drawWith(io, m, alpha, target)
|
|
143
161
|
|
|
144
|
-
fun drawKids(io: Io, kids: List[SceneNode], m: Affine, alpha: Float, target: Int) = match kids
|
|
162
|
+
fun drawKids(io: Io, kids: List[SceneNode[M]], m: Affine, alpha: Float, target: Int) = match kids
|
|
145
163
|
Nil => 0
|
|
146
164
|
Cons(head, tail) =>
|
|
147
165
|
head.drawWith(io, m, alpha, target)
|
|
148
166
|
SceneNode.drawKids(io, tail, m, alpha, target)
|
|
149
167
|
|
|
150
|
-
fun drawFiltered(io: Io, effect: Effect, child: SceneNode, tex: Int, m: Affine, alpha: Float, target: Int) = match effect
|
|
168
|
+
fun drawFiltered(io: Io, effect: Effect, child: SceneNode[M], tex: Int, m: Affine, alpha: Float, target: Int) = match effect
|
|
151
169
|
BlendFx(mode) =>
|
|
152
170
|
io.blendMode(mode)
|
|
153
171
|
child.drawWith(io, m, alpha, target)
|
|
@@ -175,9 +193,9 @@ enum SceneNode =
|
|
|
175
193
|
Text(txt, tx, ty, size, _) => Rect.through(tx, ty, size * 0.6 * Float(txt.length()), size, m)
|
|
176
194
|
Group(tr, kids) => SceneNode.boundsKids(kids, m.mul(tr.matrix()), null)
|
|
177
195
|
Filter(_, child, _) => child.boundsWith(m)
|
|
178
|
-
|
|
196
|
+
Clickable(_, _, child) => child.boundsWith(m)
|
|
179
197
|
|
|
180
|
-
fun boundsKids(kids: List[SceneNode], m: Affine, acc: Rect?) -> Rect? = match kids
|
|
198
|
+
fun boundsKids(kids: List[SceneNode[M]], m: Affine, acc: Rect?) -> Rect? = match kids
|
|
181
199
|
Nil => acc
|
|
182
200
|
Cons(head, tail) =>
|
|
183
201
|
if cr := head.boundsWith(m)
|
|
@@ -190,11 +208,11 @@ enum SceneNode =
|
|
|
190
208
|
# The child indices from the node down to the hit leaf, or null on a miss. A leaf
|
|
191
209
|
# that is hit is an empty path. (px, py) is in the node's own space.
|
|
192
210
|
fun hitWith(self, px: Float, py: Float) -> Slice[Int]? = match self
|
|
193
|
-
RectShape(x, y, w, h, _) =>
|
|
211
|
+
RectShape(x, y, w, h, _) => hitLeaf(Rect(x: x, y: y, w: w, h: h).contains(px, py))
|
|
194
|
-
CircleShape(cx, cy, r, _) =>
|
|
212
|
+
CircleShape(cx, cy, r, _) => hitLeaf(Math.hypot(px - cx, py - cy) <= r)
|
|
195
|
-
LineShape(x1, y1, x2, y2, th, _) =>
|
|
213
|
+
LineShape(x1, y1, x2, y2, th, _) => hitLeaf(nearLine(x1, y1, x2, y2, th, px, py))
|
|
196
|
-
Sprite(_, sx, sy, sw, sh, _, _, _, _, _) =>
|
|
214
|
+
Sprite(_, sx, sy, sw, sh, _, _, _, _, _) => hitLeaf(Rect(x: sx, y: sy, w: sw, h: sh).contains(px, py))
|
|
197
|
-
Text(txt, tx, ty, size, _) =>
|
|
215
|
+
Text(txt, tx, ty, size, _) => hitLeaf(Rect(x: tx, y: ty, w: size * 0.6 * Float(txt.length()), h: size).contains(px, py))
|
|
198
216
|
Group(tr, kids) =>
|
|
199
217
|
if gi := tr.matrix().invert()
|
|
200
218
|
lp := gi.apply(Vec2(x: px, y: py))
|
|
@@ -203,29 +221,29 @@ enum SceneNode =
|
|
|
203
221
|
null
|
|
204
222
|
Filter(_, child, _) =>
|
|
205
223
|
if fp := child.hitWith(px, py)
|
|
206
|
-
|
|
224
|
+
hitPrepend(0, fp)
|
|
207
225
|
else
|
|
208
226
|
null
|
|
209
|
-
|
|
227
|
+
Clickable(_, _, child) =>
|
|
210
228
|
if tp := child.hitWith(px, py)
|
|
211
|
-
|
|
229
|
+
hitPrepend(0, tp)
|
|
212
230
|
else
|
|
213
231
|
null
|
|
214
232
|
|
|
215
233
|
# Tries the later children first (they are drawn on top); `i` is the index of
|
|
216
234
|
# `kids`' head. The first hit found from the back wins.
|
|
217
|
-
fun hitKids(kids: List[SceneNode], i: Int, px: Float, py: Float) -> Slice[Int]? = match kids
|
|
235
|
+
fun hitKids(kids: List[SceneNode[M]], i: Int, px: Float, py: Float) -> Slice[Int]? = match kids
|
|
218
236
|
Nil => null
|
|
219
237
|
Cons(head, tail) =>
|
|
220
238
|
if later := SceneNode.hitKids(tail, i + 1, px, py)
|
|
221
239
|
later
|
|
222
240
|
else
|
|
223
241
|
if sub := head.hitWith(px, py)
|
|
224
|
-
|
|
242
|
+
hitPrepend(i, sub)
|
|
225
243
|
else
|
|
226
244
|
null
|
|
227
245
|
|
|
228
|
-
# The id of the innermost
|
|
246
|
+
# The id of the innermost Clickable node whose subtree is hit at (px, py), or null.
|
|
229
247
|
# `outer` is the id of the nearest enclosing tag (null at the root).
|
|
230
248
|
fun hitIdWith(self, outer: Int?, px: Float, py: Float) -> Int? = match self
|
|
231
249
|
Group(tr, kids) =>
|
|
@@ -235,7 +253,7 @@ enum SceneNode =
|
|
|
235
253
|
else
|
|
236
254
|
null
|
|
237
255
|
Filter(_, child, _) => child.hitIdWith(outer, px, py)
|
|
238
|
-
|
|
256
|
+
Clickable(id, _, child) => child.hitIdWith(id, px, py)
|
|
239
257
|
_ =>
|
|
240
258
|
if self.hitWith(px, py) != null
|
|
241
259
|
outer
|
|
@@ -243,7 +261,7 @@ enum SceneNode =
|
|
|
243
261
|
null
|
|
244
262
|
|
|
245
263
|
# Later siblings are drawn on top, so they are tried first.
|
|
246
|
-
fun hitIdKids(kids: List[SceneNode], outer: Int?, px: Float, py: Float) -> Int? = match kids
|
|
264
|
+
fun hitIdKids(kids: List[SceneNode[M]], outer: Int?, px: Float, py: Float) -> Int? = match kids
|
|
247
265
|
Nil => null
|
|
248
266
|
Cons(head, tail) =>
|
|
249
267
|
if later := SceneNode.hitIdKids(tail, outer, px, py)
|
|
@@ -251,32 +269,35 @@ enum SceneNode =
|
|
|
251
269
|
else
|
|
252
270
|
head.hitIdWith(outer, px, py)
|
|
253
271
|
|
|
272
|
+
# Hit-test helpers that do not mention `M`. They are not static methods of
|
|
273
|
+
# `SceneNode[M]` because a call like `SceneNode.leaf(..)` could not tell which `M`.
|
|
274
|
+
|
|
254
|
-
|
|
275
|
+
fun hitLeaf(hit: Bool) -> Slice[Int]? =
|
|
255
|
-
|
|
276
|
+
if hit
|
|
256
|
-
|
|
277
|
+
return Slice[Int]()
|
|
257
|
-
|
|
278
|
+
null
|
|
258
|
-
|
|
279
|
+
|
|
259
|
-
|
|
280
|
+
# `i` followed by the path `sub`.
|
|
260
|
-
|
|
281
|
+
fun hitPrepend(i: Int, sub: Slice[Int]) -> Slice[Int] =
|
|
261
|
-
|
|
282
|
+
path := Slice(i)
|
|
262
|
-
|
|
283
|
+
for j := range sub.length()
|
|
263
|
-
|
|
284
|
+
path.push(sub.at(j))
|
|
264
|
-
|
|
285
|
+
path
|
|
265
|
-
|
|
286
|
+
|
|
266
|
-
|
|
287
|
+
fun nearLine(x1: Float, y1: Float, x2: Float, y2: Float, thickness: Float, px: Float, py: Float) -> Bool =
|
|
267
|
-
|
|
288
|
+
dx := x2 - x1
|
|
268
|
-
|
|
289
|
+
dy := y2 - y1
|
|
269
|
-
|
|
290
|
+
len2 := dx * dx + dy * dy
|
|
270
|
-
|
|
291
|
+
t := if len2 == 0.0 then 0.0 else {{px - x1} * dx + {py - y1} * dy} / len2
|
|
271
|
-
|
|
292
|
+
tc := t.max(0.0).min(1.0)
|
|
272
|
-
|
|
293
|
+
qx := x1 + dx * tc
|
|
273
|
-
|
|
294
|
+
qy := y1 + dy * tc
|
|
274
|
-
|
|
295
|
+
Math.hypot(px - qx, py - qy) <= thickness.max(1.0) / 2.0
|
|
275
296
|
|
|
276
297
|
fun picNear(a: Float, b: Float) -> Bool =
|
|
277
298
|
{a - b}.abs() < 1e-6
|
|
278
299
|
|
|
279
|
-
fun picRect(x: Float, y: Float, w: Float, h: Float, c: Color) -> SceneNode =
|
|
300
|
+
fun picRect(x: Float, y: Float, w: Float, h: Float, c: Color) -> SceneNode[Int] =
|
|
280
301
|
RectShape(x: x, y: y, w: w, h: h, color: c)
|
|
281
302
|
|
|
282
303
|
test "bounds_translate_scale"
|
|
@@ -325,7 +346,7 @@ test "filter_bounds_is_child_bounds"
|
|
|
325
346
|
assert picNear(r.h, 4.0)
|
|
326
347
|
|
|
327
348
|
test "tagged_bounds_and_path"
|
|
328
|
-
g := Group(transform: Identity, children: Cons(head:
|
|
349
|
+
g := Group(transform: Identity, children: Cons(head: Clickable(id: 7, msg: 7 * 10, child: picRect(1.0, 2.0, 10.0, 4.0, Color.Red)), tail: Nil))
|
|
329
350
|
r := g.bounds()
|
|
330
351
|
assert picNear(r.x, 1.0)
|
|
331
352
|
assert picNear(r.w, 10.0)
|
|
@@ -335,9 +356,9 @@ test "tagged_bounds_and_path"
|
|
|
335
356
|
assert p.at(1) == 0
|
|
336
357
|
|
|
337
358
|
test "hit_id_innermost_and_topmost"
|
|
338
|
-
inner :=
|
|
359
|
+
inner := Clickable(id: 2, msg: 2 * 10, child: picRect(0.0, 0.0, 10.0, 10.0, Color.Blue))
|
|
339
|
-
outer :=
|
|
360
|
+
outer := Clickable(id: 1, msg: 1 * 10, child: Group(transform: Identity, children: Cons(head: picRect(0.0, 0.0, 20.0, 20.0, Color.Red), tail: Cons(head: inner, tail: Nil))))
|
|
340
|
-
top :=
|
|
361
|
+
top := Clickable(id: 3, msg: 3 * 10, child: picRect(15.0, 15.0, 10.0, 10.0, Color.Red))
|
|
341
362
|
g := Group(transform: Translate(dx: 1.0, dy: 0.0), children: Cons(head: outer, tail: Cons(head: top, tail: Nil)))
|
|
342
363
|
a := g.hitId(6.0, 5.0)
|
|
343
364
|
if va := a
|
|
@@ -355,3 +376,8 @@ test "hit_id_innermost_and_topmost"
|
|
|
355
376
|
else
|
|
356
377
|
assert false
|
|
357
378
|
assert g.hitId(100.0, 100.0) == null
|
|
379
|
+
|
|
380
|
+
test "msg_of_finds_the_clickable_message"
|
|
381
|
+
g := Group(transform: Identity, children: List(picRect(0.0, 0.0, 1.0, 1.0, Color.Red), Clickable(id: 4, msg: 40, child: picRect(0.0, 0.0, 1.0, 1.0, Color.Red))))
|
|
382
|
+
assert {g.msgOf(4) ?: -1} == 40
|
|
383
|
+
assert g.msgOf(5) == null
|
plum-std/game/functional/Ui.plum
CHANGED
|
@@ -10,7 +10,9 @@ import std/game/Input
|
|
|
10
10
|
|
|
11
11
|
# A pure UI layer for the functional scene style. `Ui.step` takes the previous
|
|
12
12
|
# UiState, one pointer input and the id under the pointer (from SceneNode.hitId on
|
|
13
|
-
# the current view) and returns the new state plus the UiEvents it raised.
|
|
13
|
+
# the current view) and returns the new state plus the UiEvents it raised.
|
|
14
|
+
# `Ui.messages` turns the Clicks among them into the game's own messages, read from
|
|
15
|
+
# the `Clickable` nodes of that view. Same
|
|
14
16
|
# button rules as PureScene: Down captures the pressed id; while captured only
|
|
15
17
|
# that id gets Enter/Exit; Up releases (Click only when still over it).
|
|
16
18
|
|
|
@@ -93,13 +95,25 @@ primitive Ui =
|
|
|
93
95
|
false
|
|
94
96
|
|
|
95
97
|
# A tagged, translated rectangle with a label whose colour follows the state.
|
|
98
|
+
# The messages of the Clickables that `events` clicked, in order, read from `view`.
|
|
99
|
+
fun messages(view: SceneNode[M], events: List[UiEvent]) -> List[M] = match events
|
|
100
|
+
Nil => Nil
|
|
101
|
+
Cons(e, rest) =>
|
|
102
|
+
later := Ui.messages(view, rest)
|
|
103
|
+
if e.kind != Click
|
|
104
|
+
return later
|
|
105
|
+
if m := view.msgOf(e.id)
|
|
106
|
+
return Cons(head: m, tail: later)
|
|
107
|
+
later
|
|
108
|
+
|
|
109
|
+
# A button that sends `msg` when clicked; `id` identifies it for hover and press.
|
|
96
|
-
fun button(id: Int, label: Str, x: Float, y: Float, w: Float, h: Float, ui: UiState) -> SceneNode =
|
|
110
|
+
fun button(id: Int, msg: M, label: Str, x: Float, y: Float, w: Float, h: Float, ui: UiState) -> SceneNode[M] =
|
|
97
111
|
color := uiIdleColor()
|
|
98
112
|
if Ui.pressed(ui, id)
|
|
99
113
|
color = uiPressedColor()
|
|
100
114
|
else if Ui.hovered(ui, id)
|
|
101
115
|
color = uiHoverColor()
|
|
102
|
-
|
|
116
|
+
Clickable(id: id, msg: msg, child: Group(transform: Translate(dx: x, dy: y), children: List(
|
|
103
117
|
RectShape(x: 0.0, y: 0.0, w: w, h: h, color: color),
|
|
104
118
|
Text(text: label, x: 4.0, y: 4.0, size: h * 0.5, color: Color.White))))
|
|
105
119
|
|
|
@@ -126,8 +140,8 @@ fun uiCount(s: UiStep, k: UiKind) -> Int =
|
|
|
126
140
|
|
|
127
141
|
fun uiIs(e: UiEvent, id: Int, kind: UiKind) -> Bool = e.id == id && e.kind == kind
|
|
128
142
|
|
|
129
|
-
fun uiButtonColor(n: SceneNode) -> Color = match n
|
|
143
|
+
fun uiButtonColor(n: SceneNode[Int]) -> Color = match n
|
|
130
|
-
|
|
144
|
+
Clickable(_, _, child) => uiButtonColor(child)
|
|
131
145
|
Group(_, children) => uiButtonColor(children.at(0))
|
|
132
146
|
RectShape(_, _, _, _, c) => c
|
|
133
147
|
_ => Color.Black
|
|
@@ -186,9 +200,9 @@ test "hover_without_press_routes_move"
|
|
|
186
200
|
assert !Ui.hovered(s3.ui, 5)
|
|
187
201
|
|
|
188
202
|
test "button_visual_reflects_state"
|
|
189
|
-
idle := Ui.button(3, "Go", 10.0, 20.0, 100.0, 40.0, Ui.init())
|
|
203
|
+
idle := Ui.button(3, 30, "Go", 10.0, 20.0, 100.0, 40.0, Ui.init())
|
|
190
|
-
hov := Ui.button(3, "Go", 10.0, 20.0, 100.0, 40.0, Ui.step(Ui.init(), uiMove(1.0), 3).ui)
|
|
204
|
+
hov := Ui.button(3, 30, "Go", 10.0, 20.0, 100.0, 40.0, Ui.step(Ui.init(), uiMove(1.0), 3).ui)
|
|
191
|
-
prs := Ui.button(3, "Go", 10.0, 20.0, 100.0, 40.0, Ui.step(Ui.init(), uiDown(1.0), 3).ui)
|
|
205
|
+
prs := Ui.button(3, 30, "Go", 10.0, 20.0, 100.0, 40.0, Ui.step(Ui.init(), uiDown(1.0), 3).ui)
|
|
192
206
|
assert uiButtonColor(idle).eq(uiIdleColor())
|
|
193
207
|
assert uiButtonColor(hov).eq(uiHoverColor())
|
|
194
208
|
assert uiButtonColor(prs).eq(uiPressedColor())
|
|
@@ -196,3 +210,11 @@ test "button_visual_reflects_state"
|
|
|
196
210
|
assert hid == 3
|
|
197
211
|
else
|
|
198
212
|
assert false
|
|
213
|
+
|
|
214
|
+
test "messages_are_the_clicked_buttons_messages"
|
|
215
|
+
view := Group(transform: Identity, children: List(Ui.button(3, 30, "Go", 0.0, 0.0, 10.0, 10.0, Ui.init()), Ui.button(4, 40, "Stop", 20.0, 0.0, 10.0, 10.0, Ui.init())))
|
|
216
|
+
evs := List(UiEvent(id: 4, kind: Press), UiEvent(id: 4, kind: Click), UiEvent(id: 9, kind: Click), UiEvent(id: 3, kind: Click))
|
|
217
|
+
ms := Ui.messages(view, evs)
|
|
218
|
+
assert ms.length() == 2
|
|
219
|
+
assert {ms.get(0) ?: -1} == 40
|
|
220
|
+
assert {ms.get(1) ?: -1} == 30
|