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