plum

#treesitter#compiler#wasm

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

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


ee3e4e6 — Peter John 2026-11-04T14:56:25+05:30
Input steps 1 and 2: shared Input with keys, onInput on actors
docs/backlog.md CHANGED
@@ -39,4 +39,4 @@ Open work, in the order the user queued it, plus smaller items found during buil
39
39
  - **Retire PureScene?** — after the component-style functional game, decide whether `std/game/pure` + `games/pure` stay. (Decision record §4b)
40
40
  - **`adder(1)(2)`** (calling a returned closure directly) is not supported. (functional-gaps plan)
41
41
  - **Fusion past a closure-spec copy on `Nil` miscompiles** — in `drive.rs` `quiet`, resolving `Nil.fold$c0(..)` to `List.fold$c0` (read-only) lets a value move past it, and codegen then fails with `unknown method 'List.fold$c0'` (5 `closure_spec_test` cases). `typeNameMethodKey` skips `$` copies until this is fixed.
42
- - **Input model** — 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.
42
+ - **Input model** — steps 1 (shared `Input`, keys included) and 2 (`onInput` on actors, focus) built 2026-11-04; step 3 (typed `Msg` events) in [game-scene-styles.md](game-scene-styles.md) section 6.
docs/game-scene-styles.md CHANGED
@@ -55,7 +55,7 @@ Cost: pure updates run in place when the compiler proves uniqueness. Functional
55
55
 
56
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
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).
58
+ 3. **Recorded input.** Done for input: `Input.poll` is the only input read, and the games feed every polled `Input` into their state (section 5). Recording is a matter of saving each step's `Polled.inputs`.
59
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
60
  5. **Checksums.** A per-step hash of the simulation state, to detect desyncs.
61
61
 
@@ -67,25 +67,25 @@ Cost: pure updates run in place when the compiler proves uniqueness. Functional
67
67
 
68
68
  **Recommendation:** build deterministic simulation on the functional style first. Its `Msg` stream is already the replay log.
69
69
 
70
- ## 5. Input today
70
+ ## 5. Input
71
71
 
72
- Each style handles input differently:
72
+ Steps 1 and 2 of the proposal below are built (2026-11-04).
73
+
74
+ - **One `Input` type** (`std/game/Input`): `PointerDown(x, y) | PointerUp(x, y) | PointerMove(x, y) | KeyDown(key) | KeyUp(key)`. `Input.poll(io, held) -> Polled(inputs, held)` is the only code that reads input from `io`. It compares the host's mouse button and every `Key` code with what was held after the last poll. `Held` (`isDown(key)`, `after(input)`) tracks held keys from the inputs, so games never call `io.isKeyDown`. `input.position()`, `input.through(m)` and `input.isPointer()` are helpers.
75
+ - **One `UiKind`/`UiEvent`**, also in `std/game/Input`, used by both `PureScene` and `Ui`.
73
76
 
74
77
  | | Imperative | Pure | Functional |
75
78
  |---|---|---|---|
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
+ | Pointer | `stage.input(e)`: the topmost actor under the pointer, then each parent, until one's `onInput` returns true | `stage.input(e)` / `stage.frame(dt, inputs)`: hit path, then `node.route` child first; `Button` reacts, `Custom` asks `actor.onInput` | `state.update(Event(input))` -> `render().hitId(x, y)` -> `Ui.step` -> `UiEvent`s |
80
+ | Keyboard | A press gives the actor the focus; keys go to it, then its parents | A press gives the node the focus; keys go to it (then its ancestors); keys no node consumes come back in `StageHandled.keys` | `Event(KeyDown/KeyUp)` updates `State.held`; the game reads `held.isDown(..)` |
79
81
  | Capture (press, drag off, release) | none | yes | yes |
82
+ | Events | `onInput -> Bool` (consumed) | `UiEvent(id, kind)`, `Handled.consumed` | `UiEvent(id, kind)` |
80
83
 
81
- Problems:
84
+ The example games and fixtures poll once per frame and feed every input in. Nothing else reads input from the host.
82
85
 
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.
86
+ Remaining problem: events still identify UI elements by integer id (`e.id == Id.Restart && e.kind == Click`); step 3 addresses it.
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
87
 
88
- ## 6. Proposal: one input model with `onInput` (not built)
88
+ ## 6. Proposal: one input model with `onInput`
89
89
 
90
90
  The sketch `scene_graph.pfl` (repo root, not tracked) designs input for a scene2d-style pure scene:
91
91
 
@@ -102,23 +102,17 @@ Its ideas:
102
102
  - one `Input` type that includes keys;
103
103
  - an overridable `on_input` on every actor;
104
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.
105
+ - **typed messages**: a button carries the game's own `Msg` value.
108
106
 
109
- ### Step 1: one shared `Input` type, keys included (no generics)
107
+ ### Step 1: one shared `Input` type, keys included (built)
110
108
 
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.
109
+ `std/game/Input.plum`: see section 5. Gain: one recorded input list per step (determinism item 3), one vocabulary across styles.
115
110
 
116
- ### Step 2: `onInput` for game actors (no generics)
111
+ ### Step 2: `onInput` for game actors (built)
117
112
 
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.
113
+ - **Pure:** `trait Actor` has `fun onInput(self, n: Node, e: Input, over: Bool) -> Handled` (default: unchanged, nothing raised, not consumed). `Node.onInput` handles `Button` itself and asks a `Custom` node's actor. `Handled` has `consumed: Bool`; `route` skips the ancestors of a node that consumed the input. Only groups have children, so for pointers this matters once groups react; for keys it decides what the game receives.
114
+ - **Imperative:** `trait Actor` has `fun onInput(self, e: Input) -> Bool` (e in the actor's coordinates; true = consumed). `stage.input(e) -> Bool` bubbles from the hit actor through `parent`. A press sets `stage.focus`; keys bubble from the focused actor.
115
+ - **Functional:** no methods on nodes (they are data). Inputs reach `State.update` as `Event(input)` messages.
122
116
 
123
117
  ### Step 3 (optional): typed messages instead of ids (needs generics)
124
118
 
@@ -126,11 +120,10 @@ Plum's pure scene already has the path routing (`route` delivers child first, th
126
120
  - **Pure:** `Node[Msg]`, `Button(.., onClick: Msg)`, `Actor[Msg]`.
127
121
  - **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
122
 
129
- ### Recommendation
123
+ ### Next
130
124
 
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.
125
+ 1. Step 3 on the functional style only, kept 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.
126
+ 2. Decide whether PureScene stays (backlog: "Retire PureScene?").
134
127
 
135
128
  ### Open questions
136
129
 
plum-examples/games/functional/game_state_view.plum CHANGED
@@ -9,15 +9,17 @@
9
9
  # nothing is changed in place) and `render(self, ..)` returns its `SceneNode` picture.
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
- # Ui event); `State.update(msg)` is a short sequence of named steps and
12
+ # player input, a Ui event); `State.update(msg)` is a short sequence of named steps and
13
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
- # scene: the restart button is a `Ui.button` in the picture; the host turns the mouse
15
+ # scene: the restart button is a `Ui.button` in the picture. The host polls the
16
+ # player's input once a frame (`Input.poll`, its only input read) and sends each
17
+ # `Input` to the state as an `Event`: keys update the held keys that steer the ship,
16
- # into a `UiInput`, finds what is under it with `SceneNode.hitId` on the picture the
18
+ # and a pointer input finds what is under it with `SceneNode.hitId` on the picture the
17
19
  # player is looking at, runs `Ui.step`, and feeds each `UiEvent` to `State.update`.
18
20
  # The game-over title and the ship's hurt flash are `Anim`s of time since they began.
19
- # The `World` record is the host shell: it holds the current `State`, reads the keyboard
21
+ # The `World` record is the host shell: it holds the current `State`, polls the
20
- # and mouse into messages, and plays sounds.
22
+ # keyboard and mouse into messages, and plays sounds.
21
23
  module game_state_view
22
24
  import std/Str
23
25
  import std/Int
@@ -32,6 +34,7 @@ import std/game/Game
32
34
  import std/game/functional/FunctionalScene
33
35
  import std/game/functional/Anim
34
36
  import std/game/functional/Ui
37
+ import std/game/Input
35
38
  import std/Io
36
39
 
37
40
  primitive Rules =
@@ -253,6 +256,7 @@ enum Msg =
253
256
  | Tick(dt: Float, ax: Float, ay: Float)
254
257
  | Resize(w: Float, h: Float)
255
258
  | Restart(seed: Int)
259
+ | Event(input: Input)
256
260
  | UiEv(event: UiEvent)
257
261
 
258
262
  # Everything that changes during a run. `sheet` and `target` are resource handles
@@ -272,6 +276,7 @@ record State(
272
276
  hurtAt: Float,
273
277
  overAt: Float,
274
278
  ui: UiState,
279
+ held: Held,
275
280
  sw: Float,
276
281
  sh: Float,
277
282
  sheet: Int,
@@ -295,6 +300,7 @@ record State(
295
300
  hurtAt: -1.0,
296
301
  overAt: -1.0,
297
302
  ui: Ui.init(),
303
+ held: Held.none(),
298
304
  sw: sw,
299
305
  sh: sh,
300
306
  sheet: sheet,
@@ -307,6 +313,7 @@ record State(
307
313
  Tick(dt, ax, ay) => self.tick(dt, ax, ay)
308
314
  Resize(w, h) => State(..self, sw: w, sh: h)
309
315
  Restart(seed) => self.restarted(seed)
316
+ Event(input) => self.onInput(input)
310
317
  UiEv(event) => self.onUi(event)
311
318
 
312
319
  # One frame of time. Once the game is over the actors only animate.
@@ -391,7 +398,24 @@ record State(
391
398
 
392
399
  fun restarted(self, seed: Int) -> State =
393
400
  fresh := State.start(seed, self.best.max(self.score), self.sheet, self.target, self.sw, self.sh)
394
- State(..fresh, ui: self.ui)
401
+ State(..fresh, ui: self.ui, held: self.held)
402
+
403
+ # A player input: keys update the held keys (Enter or R restarts a finished game),
404
+ # a pointer input goes through the Ui.
405
+ fun onInput(self, e: Input) -> State =
406
+ s := State(..self, held: self.held.after(e))
407
+ if e.isPointer()
408
+ return s.feed(e)
409
+ if s.over && isRestartKey(e)
410
+ return s.restarted(s.rng.nextInt().value)
411
+ s
412
+
413
+ # The steering axes from the held arrow or WASD keys.
414
+ fun axisX(self) -> Float =
415
+ axis(self.held.isDown(Key.Left) || self.held.isDown(Key.KeyA), self.held.isDown(Key.Right) || self.held.isDown(Key.KeyD))
416
+
417
+ fun axisY(self) -> Float =
418
+ axis(self.held.isDown(Key.Up) || self.held.isDown(Key.KeyW), self.held.isDown(Key.Down) || self.held.isDown(Key.KeyS))
395
419
 
396
420
  # A click on the restart button restarts a finished game; other events change nothing.
397
421
  fun onUi(self, e: UiEvent) -> State =
@@ -399,8 +423,9 @@ record State(
399
423
 
400
424
  # One pointer input: finds what is under it in the current picture, runs the Ui, and
401
425
  # feeds each raised event to `update`.
402
- fun feed(self, input: UiInput) -> State =
426
+ fun feed(self, input: Input) -> State =
427
+ pos := input.position() ?: return self
403
- r := Ui.step(self.ui, input, self.render().hitId(input.x, input.y))
428
+ r := Ui.step(self.ui, input, self.render().hitId(pos.x, pos.y))
404
429
  r.events.fold(State(..self, ui: r.ui), |s, e| s.update(UiEv(event: e)))
405
430
 
406
431
  # --- render ---
@@ -481,17 +506,14 @@ record State(
481
506
  fun onRestart(self, x: Float, y: Float) -> Bool =
482
507
  {self.render().hitId(x, y) ?: -1} == Id.Restart
483
508
 
509
+ # Enter or R going down.
484
- fun pointerKind(down: Bool, wasDown: Bool) -> UiInputKind =
510
+ fun isRestartKey(e: Input) -> Bool = match e
485
- if down && !wasDown
486
- PointerDown
487
- else if !down && wasDown
511
+ KeyDown(k) => k == Key.Enter || k == Key.KeyR
488
- PointerUp
489
- else
490
- PointerMove
512
+ _ => false
491
513
 
492
514
  # --- the host shell ---
493
515
 
494
- record World(mut state: State, mut wasDown: Bool, pickupSound: Int, hitSound: Int, overSound: Int, musicSound: Int)
516
+ record World(mut state: State, mut held: Held, pickupSound: Int, hitSound: Int, overSound: Int, musicSound: Int)
495
517
 
496
518
  fun musicVolume() -> Float =
497
519
  0.3
@@ -505,7 +527,7 @@ export fun init(io: Io) -> World =
505
527
  s := State.start(io.randomSeed(), 0, io.textureLoad("sprites.png"), io.textureCreate(1600, 1200), Rules.ArenaW, Rules.ArenaH)
506
528
  w := World(
507
529
  state: s,
508
- wasDown: false,
530
+ held: Held.none(),
509
531
  pickupSound: io.soundLoad("pickup.wav"),
510
532
  hitSound: io.soundLoad("hit.wav"),
511
533
  overSound: io.soundLoad("gameover.wav"),
@@ -522,25 +544,20 @@ fun axis(negative: Bool, positive: Bool) -> Float =
522
544
  else
523
545
  0.0
524
546
 
547
+ # Polls the host once (the only input read), sends every input to the state, then
548
+ # one tick steered by the held keys; plays sounds for what changed.
525
549
  export fun update(io: Io, w: World, dt: Float) =
550
+ polled := Input.poll(io, w.held)
551
+ w.held = polled.held
526
552
  s := w.state.update(Resize(w: io.screenWidth(), h: io.screenHeight()))
553
+ clicked := s
527
- down := io.isMouseDown(0)
554
+ for i := range polled.inputs.length()
528
- m := io.mousePos()
529
- clicked := s.feed(UiInput(kind: pointerKind(down, w.wasDown), x: m.x, y: m.y))
555
+ clicked = clicked.update(Event(input: polled.inputs.at(i)))
530
- w.wasDown = down
531
556
  if s.over && !clicked.over
532
557
  w.state = clicked
533
558
  startMusic(io, w)
534
559
  return
535
- if clicked.over && {io.isKeyPressed(Key.Enter) || io.isKeyPressed(Key.KeyR)}
536
- w.state = clicked.update(Restart(seed: clicked.rng.nextInt().value))
537
- startMusic(io, w)
538
- return
539
- left := io.isKeyDown(Key.Left) || io.isKeyDown(Key.KeyA)
540
- right := io.isKeyDown(Key.Right) || io.isKeyDown(Key.KeyD)
541
- up := io.isKeyDown(Key.Up) || io.isKeyDown(Key.KeyW)
542
- downKey := io.isKeyDown(Key.Down) || io.isKeyDown(Key.KeyS)
543
- t := clicked.update(Tick(dt: dt, ax: axis(left, right), ay: axis(up, downKey)))
560
+ t := clicked.update(Tick(dt: dt, ax: clicked.axisX(), ay: clicked.axisY()))
544
561
  if t.score > clicked.score
545
562
  io.soundPlay(w.pickupSound, 0.8)
546
563
  if t.lives < clicked.lives
@@ -807,17 +824,17 @@ test "view_node_count"
807
824
  test "restart_click_through_ui"
808
825
  e := emptyState()
809
826
  s := forceOver(State(..e, score: 90))
810
- down := s.feed(UiInput(kind: PointerDown, x: 400.0, y: 364.0))
827
+ down := s.feed(PointerDown(x: 400.0, y: 364.0))
811
828
  assert down.over
812
829
  assert Ui.pressed(down.ui, Id.Restart)
813
- up := down.feed(UiInput(kind: PointerUp, x: 400.0, y: 364.0))
830
+ up := down.feed(PointerUp(x: 400.0, y: 364.0))
814
831
  assert !up.over
815
832
  assert up.score == 0
816
833
  assert up.best == 90
817
834
  assert up.lives == Rules.StartLives
818
- off := s.feed(UiInput(kind: PointerDown, x: 400.0, y: 364.0)).feed(UiInput(kind: PointerUp, x: 100.0, y: 100.0))
835
+ off := s.feed(PointerDown(x: 400.0, y: 364.0)).feed(PointerUp(x: 100.0, y: 100.0))
819
836
  assert off.over
820
- miss := s.feed(UiInput(kind: PointerDown, x: 100.0, y: 100.0)).feed(UiInput(kind: PointerUp, x: 100.0, y: 100.0))
837
+ miss := s.feed(PointerDown(x: 100.0, y: 100.0)).feed(PointerUp(x: 100.0, y: 100.0))
821
838
  assert miss.over
822
839
 
823
840
  test "hit_flash_pose_at_fixed_times"
@@ -834,3 +851,22 @@ test "title_anim_pose_at_fixed_times"
834
851
  assert near(titlePose(0.8).scaleX, 1.17)
835
852
  assert near(titlePose(1.3).scaleX, 1.3)
836
853
  assert near(titlePose(1.8).scaleY, 1.17)
854
+
855
+ test "held keys from input events steer the ship"
856
+ s := emptyState().update(Event(input: KeyDown(key: Key.Left))).update(Event(input: KeyDown(key: Key.KeyS)))
857
+ assert s.axisX() == -1.0
858
+ assert s.axisY() == 1.0
859
+ t := s.update(Event(input: KeyUp(key: Key.Left)))
860
+ assert t.axisX() == 0.0
861
+ assert t.axisY() == 1.0
862
+
863
+ test "enter restarts a finished game and nothing else"
864
+ playing := emptyState().update(Event(input: KeyDown(key: Key.Enter)))
865
+ assert playing.score == 0
866
+ assert !playing.over
867
+ e := emptyState()
868
+ s := forceOver(State(..e, score: 40))
869
+ r := s.update(Event(input: KeyDown(key: Key.Enter)))
870
+ assert !r.over
871
+ assert r.best == 40
872
+ assert r.held.isDown(Key.Enter)
plum-examples/games/imperative/game.plum CHANGED
@@ -16,6 +16,7 @@ import std/Random
16
16
  import std/game/Vec2
17
17
  import std/game/Color
18
18
  import std/game/Game
19
+ import std/game/Input
19
20
  import std/Io
20
21
 
21
22
  primitive Rules =
@@ -47,6 +48,7 @@ record World(
47
48
  mut hurt: Float,
48
49
  mut clock: Float,
49
50
  mut over: Bool,
51
+ mut held: Held,
50
52
  sheet: Int,
51
53
  pickupSound: Int,
52
54
  hitSound: Int,
@@ -57,7 +59,7 @@ record World(
57
59
  ship := Body(x: Rules.ArenaW / 2.0, y: Rules.ArenaH / 2.0, vx: 0.0, vy: 0.0, angle: 0.0)
58
60
  w := World(
59
61
  ship: ship, orbs: Slice[Body](), mines: Slice[Body](), rng: Random.seeded(seed), score: 0, best: 0,
60
- lives: Rules.StartLives, hurt: 0.0, clock: 0.0, over: false, sheet: sheet,
62
+ lives: Rules.StartLives, hurt: 0.0, clock: 0.0, over: false, held: Held.none(), sheet: sheet,
61
63
  pickupSound: -1, hitSound: -1, overSound: -1, musicSound: -1)
62
64
  for i := range Rules.OrbCount
63
65
  sp := spawn(w.rng, ship, 120.0, 40.0)
@@ -165,14 +167,23 @@ record World(
165
167
  twinkle := 0.25 + 0.2 * Math.sin(self.clock * 2.0 + Float(i))
166
168
  io.drawCircle(sx, sy, 1.0 + Float(i % 3) * 0.5, Color(r: 0.7, g: 0.8, b: 1.0, a: twinkle))
167
169
 
170
+ # Polls the host once (the only input read): Enter or R restarts a finished game,
171
+ # the held keys steer.
168
172
  fun update(self, io: Io, dt: Float) =
173
+ polled := Input.poll(io, self.held)
174
+ self.held = polled.held
175
+ for i := range polled.inputs.length()
176
+ match polled.inputs.at(i)
177
+ KeyDown(k) =>
169
- if self.over && {io.isKeyPressed(Key.Enter) || io.isKeyPressed(Key.KeyR)}
178
+ if self.over && {k == Key.Enter || k == Key.KeyR}
170
- self.restart()
179
+ self.restart()
171
- self.startMusic(io)
180
+ self.startMusic(io)
181
+ _ => 0
182
+ h := self.held
172
- left := io.isKeyDown(Key.Left) || io.isKeyDown(Key.KeyA)
183
+ left := h.isDown(Key.Left) || h.isDown(Key.KeyA)
173
- right := io.isKeyDown(Key.Right) || io.isKeyDown(Key.KeyD)
184
+ right := h.isDown(Key.Right) || h.isDown(Key.KeyD)
174
- up := io.isKeyDown(Key.Up) || io.isKeyDown(Key.KeyW)
185
+ up := h.isDown(Key.Up) || h.isDown(Key.KeyW)
175
- down := io.isKeyDown(Key.Down) || io.isKeyDown(Key.KeyS)
186
+ down := h.isDown(Key.Down) || h.isDown(Key.KeyS)
176
187
  score := self.score
177
188
  lives := self.lives
178
189
  self.step(axis(left, right), axis(up, down), dt)
plum-examples/games/imperative/game_scene.plum CHANGED
@@ -31,6 +31,7 @@ import std/game/Vec2
31
31
  import std/game/Color
32
32
  import std/game/Game
33
33
  import std/game/imperative/Scene
34
+ import std/game/Input
34
35
  import std/Io
35
36
 
36
37
  primitive Rules =
@@ -237,8 +238,13 @@ record Star(Actor(x, y, width, height), seed: Float, mut clock: Float) =
237
238
  # The whole game: it embeds the Stage (camera and root) and has its layers, typed handles to the actors the rules
238
239
  # touch, and the run's state. `mines` and `orbs` are the layer Groups; `mineList`
239
240
  # and `orbList` are the same actors, typed.
241
+ # Enter or R going down.
242
+ fun isRestartKey(e: Input) -> Bool = match e
243
+ KeyDown(k) => k == Key.Enter || k == Key.KeyR
244
+ _ => false
245
+
240
246
  record World(
241
- Stage(camX, camY, camScale),
247
+ Stage(camX, camY, camScale, focus),
242
248
  ship: Ship,
243
249
  mines: Group,
244
250
  orbs: Group,
@@ -254,6 +260,7 @@ record World(
254
260
  mut lives: Int,
255
261
  mut over: Bool,
256
262
  mut clock: Float,
263
+ mut held: Held,
257
264
  sheet: Int,
258
265
  pickupSound: Int,
259
266
  hitSound: Int,
@@ -290,9 +297,9 @@ record World(
290
297
  overlay.add(Text.create("Press Enter to play again", 24.0, Color.White).at(270.0, 316.0))
291
298
  hud.add(overlay)
292
299
  w := World(
293
- camX: 0.0, camY: 0.0, camScale: 1.0, ship: ship, mines: mines, orbs: orbs, mineList: Slice[Mine](), orbList: Slice[Orb](),
300
+ camX: 0.0, camY: 0.0, camScale: 1.0, focus: null, ship: ship, mines: mines, orbs: orbs, mineList: Slice[Mine](), orbList: Slice[Orb](),
294
301
  overlay: overlay, scoreText: scoreText, bestText: bestText, lifeIcons: icons,
295
- rng: Random.seeded(seed), score: 0, best: 0, lives: Rules.StartLives, over: false, clock: 0.0, sheet: sheet,
302
+ rng: Random.seeded(seed), score: 0, best: 0, lives: Rules.StartLives, over: false, clock: 0.0, held: Held.none(), sheet: sheet,
296
303
  pickupSound: -1, hitSound: -1, overSound: -1, musicSound: -1)
297
304
  w.root.add(Rect.create(Rules.ArenaW, Rules.ArenaH, Color.rgb(0.07, 0.08, 0.16)))
298
305
  w.root.add(stars)
@@ -412,14 +419,21 @@ record World(
412
419
  io.soundStop(self.musicSound)
413
420
  io.soundLoop(self.musicSound, musicVolume())
414
421
 
422
+ # Polls the host once (the only input read), offers each input to the stage and
423
+ # handles what no actor consumed: Enter or R restarts a finished game.
415
424
  fun update(self, io: Io, dt: Float) =
425
+ polled := Input.poll(io, self.held)
426
+ self.held = polled.held
427
+ for i := range polled.inputs.length()
428
+ e := polled.inputs.at(i)
416
- if self.over && {io.isKeyPressed(Key.Enter) || io.isKeyPressed(Key.KeyR)}
429
+ if !self.input(e) && self.over && isRestartKey(e)
417
- self.restart()
430
+ self.restart()
418
- self.startMusic(io)
431
+ self.startMusic(io)
432
+ h := self.held
419
- left := io.isKeyDown(Key.Left) || io.isKeyDown(Key.KeyA)
433
+ left := h.isDown(Key.Left) || h.isDown(Key.KeyA)
420
- right := io.isKeyDown(Key.Right) || io.isKeyDown(Key.KeyD)
434
+ right := h.isDown(Key.Right) || h.isDown(Key.KeyD)
421
- up := io.isKeyDown(Key.Up) || io.isKeyDown(Key.KeyW)
435
+ up := h.isDown(Key.Up) || h.isDown(Key.KeyW)
422
- down := io.isKeyDown(Key.Down) || io.isKeyDown(Key.KeyS)
436
+ down := h.isDown(Key.Down) || h.isDown(Key.KeyS)
423
437
  score := self.score
424
438
  lives := self.lives
425
439
  self.step(axis(left, right), axis(up, down), dt)
plum-examples/games/imperative/scene.plum CHANGED
@@ -21,6 +21,7 @@ import std/game/Vec2
21
21
  import std/game/Color
22
22
  import std/game/Game
23
23
  import std/game/imperative/Scene
24
+ import std/game/Input
24
25
  import std/Io
25
26
 
26
27
  primitive Layout =
@@ -102,6 +103,7 @@ record Sky(
102
103
  mut selected: Int,
103
104
  mut clock: Float,
104
105
  mut paused: Bool,
106
+ mut held: Held,
105
107
  map: Int)
106
108
 
107
109
  # A body sprite and its (hidden) halo in a group `slot` centred on the group's own
@@ -133,7 +135,7 @@ fun newSky(sheet: Int) -> Sky =
133
135
  label.y = 12.0
134
136
  sky := Sky(
135
137
  stage: stage, bodies: Slice[Actor](), halos: Slice[Actor](), names: Slice[Str](),
136
- sun: sun, label: label, crowd: Group(), front: null, selected: -1, clock: 0.0, paused: false, map: -1)
138
+ sun: sun, label: label, crowd: Group(), front: null, selected: -1, clock: 0.0, paused: false, held: Held.none(), map: -1)
137
139
  # faint orbit rings, added first so every body draws (and is hit) above them
138
140
  for i := range Layout.Planets
139
141
  r := 90.0 + Float(i) * 60.0
@@ -223,15 +225,24 @@ export fun init(io: Io) -> Sky =
223
225
  sky.map = io.textureCreate(Layout.MapW, Layout.MapH)
224
226
  sky
225
227
 
228
+ # Polls the host once (the only input read): Space pauses, a press selects a body.
226
229
  export fun update(io: Io, sky: Sky, dt: Float) =
230
+ polled := Input.poll(io, sky.held)
231
+ sky.held = polled.held
232
+ for i := range polled.inputs.length()
233
+ match polled.inputs.at(i)
234
+ KeyDown(k) =>
227
- if io.isKeyPressed(Key.Space)
235
+ if k == Key.Space
228
- sky.paused = !sky.paused
236
+ sky.paused = !sky.paused
237
+ _ => 0
229
238
  tick(sky, dt)
230
239
  fit(sky, io.screenWidth(), io.screenHeight())
231
- if io.isMousePressed(Mouse.Left)
240
+ for i := range polled.inputs.length()
232
- m := io.mousePos()
241
+ match polled.inputs.at(i)
242
+ PointerDown(x, y) =>
233
- p := sky.stage.screenToStage(m.x, m.y)
243
+ p := sky.stage.screenToStage(x, y)
234
- clickAt(sky, p.x, p.y)
244
+ clickAt(sky, p.x, p.y)
245
+ _ => 0
235
246
 
236
247
  # Redraws the minimap texture (every body as a dot, the selected one highlighted)
237
248
  # and shows it in the bottom-right corner of the screen.
plum-examples/games/pure/game_pure_scene.plum CHANGED
@@ -28,6 +28,7 @@ import std/game/Game
28
28
  import std/game/pure/Actions
29
29
  import std/game/pure/PureScene
30
30
  import std/game/pure/SceneInput
31
+ import std/game/Input
31
32
  import std/Io
32
33
 
33
34
  primitive Rules =
@@ -377,7 +378,7 @@ record World(
377
378
  mut lives: Int,
378
379
  mut over: Bool,
379
380
  mut clock: Float,
380
- mut wasDown: Bool,
381
+ mut held: Held,
381
382
  sheet: Int,
382
383
  mut pickupSound: Int,
383
384
  mut hitSound: Int,
@@ -390,16 +391,16 @@ fun newWorld(seed: Int, sheet: Int) -> World =
390
391
  next := spots.rng
391
392
  World(
392
393
  stage: Stage.create(buildRoot(spots, sheet, 0)), ship: ship, rng: next, mines: mineCount(0), score: 0, best: 0,
393
- lives: Rules.StartLives, over: false, clock: 0.0, wasDown: false, sheet: sheet,
394
+ lives: Rules.StartLives, over: false, clock: 0.0, held: Held.none(), sheet: sheet,
394
395
  pickupSound: -1, hitSound: -1, overSound: -1, musicSound: -1)
395
396
 
396
397
  fun modifyRoot(st: Stage, id: Int, f: fn(Node) -> Node) -> Stage =
397
398
  match st
398
- Stage(root, camera, captured) => Stage(root: root.modify(id, f), camera: camera, captured: captured)
399
+ Stage(root, camera, captured, focus) => Stage(root: root.modify(id, f), camera: camera, captured: captured, focus: focus)
399
400
 
400
401
  fun withCamera(st: Stage, camera: Affine) -> Stage =
401
402
  match st
402
- Stage(root, _, captured) => Stage(root: root, camera: camera, captured: captured)
403
+ Stage(root, _, captured, focus) => Stage(root: root, camera: camera, captured: captured, focus: focus)
403
404
 
404
405
  fun swap(w: World, id: Int, f: fn(Node) -> Node) =
405
406
  w.stage = modifyRoot(w.stage, id, f)
@@ -415,7 +416,7 @@ fun restart(w: World) =
415
416
  spots := startSpots(w.rng, w.ship)
416
417
  next := spots.rng
417
418
  w.rng = next
418
- w.stage = Stage(root: buildRoot(spots, w.sheet, w.best), camera: w.stage.camera, captured: null)
419
+ w.stage = Stage(root: buildRoot(spots, w.sheet, w.best), camera: w.stage.camera, captured: null, focus: null)
419
420
 
420
421
  # Copies the score, best score, lives and game-over panel into the HUD actors.
421
422
  fun syncHud(w: World) =
@@ -569,16 +570,25 @@ fun axis(negative: Bool, positive: Bool) -> Float =
569
570
  else
570
571
  0.0
571
572
 
573
+ # Enter or R going down.
574
+ fun isRestartKey(e: Input) -> Bool = match e
575
+ KeyDown(k) => k == Key.Enter || k == Key.KeyR
576
+ _ => false
577
+
578
+ # Polls the host once (the only input read); the held keys steer the ship.
572
579
  export fun update(io: Io, w: World, dt: Float) =
580
+ polled := Input.poll(io, w.held)
581
+ inputs := polled.inputs
582
+ w.held = polled.held
583
+ for i := range inputs.length()
573
- if w.over && {io.isKeyPressed(Key.Enter) || io.isKeyPressed(Key.KeyR)}
584
+ if w.over && isRestartKey(inputs.at(i))
574
- restart(w)
585
+ restart(w)
575
- startMusic(io, w)
586
+ startMusic(io, w)
576
- inputs := Input.poll(io, w.wasDown)
577
- w.wasDown = io.isMouseDown(0)
587
+ h := w.held
578
- left := io.isKeyDown(Key.Left) || io.isKeyDown(Key.KeyA)
588
+ left := h.isDown(Key.Left) || h.isDown(Key.KeyA)
579
- right := io.isKeyDown(Key.Right) || io.isKeyDown(Key.KeyD)
589
+ right := h.isDown(Key.Right) || h.isDown(Key.KeyD)
580
- up := io.isKeyDown(Key.Up) || io.isKeyDown(Key.KeyW)
590
+ up := h.isDown(Key.Up) || h.isDown(Key.KeyW)
581
- down := io.isKeyDown(Key.Down) || io.isKeyDown(Key.KeyS)
591
+ down := h.isDown(Key.Down) || h.isDown(Key.KeyS)
582
592
  score := w.score
583
593
  lives := w.lives
584
594
  restarted := advance(w, axis(left, right), axis(up, down), dt, inputs)
@@ -825,8 +835,8 @@ test "restart_button_click_restarts"
825
835
  w.score = 30
826
836
  forceOver(w)
827
837
  assert find(w.stage.root, Id.Restart).visible
828
- down := Input(kind: PointerDown, x: 400.0, y: 364.0)
838
+ down := PointerDown(x: 400.0, y: 364.0)
829
- up := Input(kind: PointerUp, x: 400.0, y: 364.0)
839
+ up := PointerUp(x: 400.0, y: 364.0)
830
840
  taps := Slice[Input]()
831
841
  taps.push(down)
832
842
  taps.push(up)
@@ -841,8 +851,8 @@ test "a click on the restart button while playing does nothing"
841
851
  w := emptyWorld()
842
852
  w.score = 30
843
853
  taps := Slice[Input]()
844
- taps.push(Input(kind: PointerDown, x: 400.0, y: 364.0))
854
+ taps.push(PointerDown(x: 400.0, y: 364.0))
845
- taps.push(Input(kind: PointerUp, x: 400.0, y: 364.0))
855
+ taps.push(PointerUp(x: 400.0, y: 364.0))
846
856
  restarted := advance(w, 0.0, 0.0, 0.016, taps)
847
857
  assert !restarted
848
858
  assert w.score == 30
plum-game/web-test/fixtures/stress_pure_scene.plum CHANGED
@@ -104,7 +104,7 @@ fun buildWorld(sheet: Int, white: Int) -> World =
104
104
  root.push(countLabel(30000 + i, i, Float(i * 30 % Counts.WorldW), Float(i * 17 % Counts.WorldH)))
105
105
  top := Node.group(1, 0.0, 0.0, Float(Counts.WorldW), Float(Counts.WorldH), root)
106
106
  cam := Affine(a: 0.8, b: 0.0, c: 0.0, d: 0.8, e: 0.0, f: 0.0)
107
- World(sheet: sheet, stage: Stage(root: top, camera: cam, captured: null))
107
+ World(sheet: sheet, stage: Stage(root: top, camera: cam, captured: null, focus: null))
108
108
 
109
109
  fun whiteTexture(io: Io) -> Int =
110
110
  t := io.textureCreate(1, 1)
plum-game/web-test/fixtures/stress_state_view.plum CHANGED
@@ -11,7 +11,7 @@
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 SceneNode.hitId, and calls Ui.step.
14
+ # shared Input, 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
@@ -24,6 +24,7 @@ import std/game/Game
24
24
  import std/game/functional/FunctionalScene
25
25
  import std/game/functional/Anim
26
26
  import std/game/functional/Ui
27
+ import std/game/Input
27
28
  import std/Io
28
29
 
29
30
  primitive Counts =
@@ -142,8 +143,9 @@ fun countClicks(es: List[UiEvent]) -> Int = match es
142
143
  Cons(e, t) => {if e.kind == Click then 1 else 0} + countClicks(t)
143
144
 
144
145
  # Feeds one pointer input through Ui.step against the button layer.
145
- fun stepUi(s: State, input: UiInput) -> State =
146
+ fun stepUi(s: State, input: Input) -> State =
147
+ pos := input.position() ?: return s
146
- hit := buttonsView(s.ui).hitId(input.x, input.y)
148
+ hit := buttonsView(s.ui).hitId(pos.x, pos.y)
147
149
  r := Ui.step(s.ui, input, hit)
148
150
  State(
149
151
  sheet: s.sheet, white: s.white, spinners: s.spinners, drifters: s.drifters, counters: s.counters,
@@ -199,21 +201,18 @@ fun whiteTexture(io: Io) -> Int =
199
201
  io.renderTarget(-1)
200
202
  t
201
203
 
202
- record World(mut state: State, mut wasDown: Bool)
204
+ record World(mut state: State, mut held: Held)
203
205
 
204
206
  export fun init(io: Io) -> World =
205
- World(state: newState(io.textureLoad("sprites.png"), whiteTexture(io)), wasDown: false)
207
+ World(state: newState(io.textureLoad("sprites.png"), whiteTexture(io)), held: Held.none())
206
208
 
207
209
  export fun update(io: Io, w: World, dt: Float) =
210
+ polled := Input.poll(io, w.held)
211
+ w.held = polled.held
208
- m := io.mousePos()
212
+ s := step(w.state, dt)
209
- down := io.isMouseDown(0)
213
+ for i := range polled.inputs.length()
210
- kind := PointerMove
211
- if down && !w.wasDown
212
- kind = PointerDown
213
- else if !down && w.wasDown
214
+ s = stepUi(s, polled.inputs.at(i))
214
- kind = PointerUp
215
- w.wasDown = down
215
+ w.state = s
216
- w.state = stepUi(step(w.state, dt), UiInput(kind: kind, x: m.x, y: m.y))
217
216
 
218
217
  export fun draw(io: Io, w: World) =
219
218
  io.clear(Color.rgb(0.05, 0.06, 0.1))
@@ -249,8 +248,8 @@ test "view has one node per actor"
249
248
 
250
249
  test "button click counts and pulsers animate"
251
250
  s := newState(0, 0)
252
- down := stepUi(s, UiInput(kind: PointerDown, x: 20.0, y: 20.0))
251
+ down := stepUi(s, PointerDown(x: 20.0, y: 20.0))
253
- up := stepUi(down, UiInput(kind: PointerUp, x: 20.0, y: 20.0))
252
+ up := stepUi(down, PointerUp(x: 20.0, y: 20.0))
254
253
  assert up.clicks == 1
255
254
  assert Ui.hovered(up.ui, 40000)
256
255
  p := newPulser(0)
plum-std/game/Input.plum ADDED
@@ -0,0 +1,166 @@
1
+ module std
2
+ import std/Int
3
+ import std/Float
4
+ import std/Slice
5
+ import std/game/Vec2
6
+ import std/game/Affine
7
+ import std/game/Game
8
+ import std/Io
9
+
10
+ # Player input shared by every scene style. `Input.poll` is the only code that reads
11
+ # input from the host: a game that feeds every polled `Input` into its state reads
12
+ # nothing else, so a recorded list of inputs replays the game exactly (see
13
+ # docs/game-scene-styles.md, "Determinism").
14
+
15
+ # One input: a pointer event in screen pixels, or a key going down or up.
16
+ enum Input =
17
+ | PointerDown(x: Float, y: Float)
18
+ | PointerUp(x: Float, y: Float)
19
+ | PointerMove(x: Float, y: Float)
20
+ | KeyDown(key: Int)
21
+ | KeyUp(key: Int)
22
+
23
+ # This frame's inputs: the host's mouse button and keys compared with `prev`, what
24
+ # was held after the last poll. One pointer input (down, up or move) comes first,
25
+ # then a KeyDown or KeyUp for each key that changed, in key-code order.
26
+ fun poll(io: Io, prev: Held) -> Polled =
27
+ out := Slice[Input]()
28
+ m := io.mousePos()
29
+ down := io.isMouseDown(Mouse.Left)
30
+ if down && !prev.mouse
31
+ out.push(PointerDown(x: m.x, y: m.y))
32
+ else if !down && prev.mouse
33
+ out.push(PointerUp(x: m.x, y: m.y))
34
+ else
35
+ out.push(PointerMove(x: m.x, y: m.y))
36
+ keys := Slice[Int]()
37
+ codes := Input.keyCodes()
38
+ for i := range codes.length()
39
+ k := codes.at(i)
40
+ now := io.isKeyDown(k)
41
+ was := prev.isDown(k)
42
+ if now
43
+ keys.push(k)
44
+ if now && !was
45
+ out.push(KeyDown(key: k))
46
+ if !now && was
47
+ out.push(KeyUp(key: k))
48
+ Polled(inputs: out, held: Held(mouse: down, keys: keys))
49
+
50
+ # Every `Key` code in std/game/Game, in increasing order.
51
+ fun keyCodes() -> Slice[Int] =
52
+ out := Slice(Key.Space)
53
+ for i := range 10
54
+ out.push(Key.Num0 + i)
55
+ for i := range 26
56
+ out.push(Key.KeyA + i)
57
+ for i := range 4
58
+ out.push(Key.Escape + i)
59
+ for i := range 4
60
+ out.push(Key.Right + i)
61
+ out.push(Key.LeftShift)
62
+ out.push(Key.LeftControl)
63
+ out
64
+
65
+ # The pointer position, or null for a key.
66
+ fun position(self) -> Vec2? = match self
67
+ PointerDown(x, y) => Vec2(x: x, y: y)
68
+ PointerUp(x, y) => Vec2(x: x, y: y)
69
+ PointerMove(x, y) => Vec2(x: x, y: y)
70
+ _ => null
71
+
72
+ # The input with its position seen through `m` (a key is unchanged).
73
+ fun through(self, m: Affine) -> Input = match self
74
+ PointerDown(x, y) => Input.pointerAt(PointerDown(x: x, y: y), m.apply(Vec2(x: x, y: y)))
75
+ PointerUp(x, y) => Input.pointerAt(PointerUp(x: x, y: y), m.apply(Vec2(x: x, y: y)))
76
+ PointerMove(x, y) => Input.pointerAt(PointerMove(x: x, y: y), m.apply(Vec2(x: x, y: y)))
77
+ _ => self
78
+
79
+ # The pointer input `e` (same kind) at `p`.
80
+ fun pointerAt(e: Input, p: Vec2) -> Input = match e
81
+ PointerDown(_, _) => PointerDown(x: p.x, y: p.y)
82
+ PointerUp(_, _) => PointerUp(x: p.x, y: p.y)
83
+ _ => PointerMove(x: p.x, y: p.y)
84
+
85
+ fun isPointer(self) -> Bool =
86
+ self.position() != null
87
+
88
+ # What is held: the left mouse button and the keys that are down.
89
+ record Held(mouse: Bool, keys: Slice[Int]) =
90
+ fun none() -> Held =
91
+ Held(mouse: false, keys: Slice[Int]())
92
+
93
+ fun isDown(self, key: Int) -> Bool =
94
+ self.keys.contains(key)
95
+
96
+ # What is held after `e`: KeyDown adds its key, KeyUp removes it, a pointer down
97
+ # or up sets `mouse`.
98
+ fun after(self, e: Input) -> Held = match e
99
+ KeyDown(key) => if self.isDown(key) then self else Held(mouse: self.mouse, keys: self.keysWith(key, true))
100
+ KeyUp(key) => Held(mouse: self.mouse, keys: self.keysWith(key, false))
101
+ PointerDown(_, _) => Held(mouse: true, keys: self.keys)
102
+ PointerUp(_, _) => Held(mouse: false, keys: self.keys)
103
+ _ => self
104
+
105
+ # A fresh copy of the held keys with `key` added (`add`) or removed.
106
+ fun keysWith(self, key: Int, add: Bool) -> Slice[Int] =
107
+ out := Slice[Int]()
108
+ for i := range self.keys.length()
109
+ if self.keys.at(i) != key
110
+ out.push(self.keys.at(i))
111
+ if add
112
+ out.push(key)
113
+ out
114
+
115
+ # A poll's inputs and what is held after them (pass it to the next poll).
116
+ record Polled(inputs: Slice[Input], held: Held)
117
+
118
+ # What happened to a UI element: pressed on it, released after a press on it,
119
+ # clicked (pressed and released on it), or the pointer entered or left it.
120
+ enum UiKind =
121
+ | Press
122
+ | Release
123
+ | Click
124
+ | Enter
125
+ | Exit
126
+
127
+ record UiEvent(id: Int, kind: UiKind)
128
+
129
+ fun inHeld(keys: Slice[Int]) -> Held =
130
+ Held(mouse: false, keys: keys)
131
+
132
+ test "held_after_tracks_keys_and_mouse"
133
+ h := Held.none().after(KeyDown(key: Key.KeyA)).after(KeyDown(key: Key.Left)).after(PointerDown(x: 1.0, y: 2.0))
134
+ assert h.isDown(Key.KeyA)
135
+ assert h.isDown(Key.Left)
136
+ assert h.mouse
137
+ h2 := h.after(KeyUp(key: Key.KeyA)).after(PointerUp(x: 1.0, y: 2.0))
138
+ assert !h2.isDown(Key.KeyA)
139
+ assert h2.isDown(Key.Left)
140
+ assert !h2.mouse
141
+ assert h.isDown(Key.KeyA)
142
+
143
+ test "key_down_twice_keeps_one_entry"
144
+ h := Held.none().after(KeyDown(key: Key.KeyA)).after(KeyDown(key: Key.KeyA))
145
+ assert h.keys.length() == 1
146
+
147
+ test "key_codes_cover_every_key_constant"
148
+ codes := Input.keyCodes()
149
+ assert codes.length() == 47
150
+ assert inHeld(codes).isDown(Key.Space)
151
+ assert inHeld(codes).isDown(Key.Num9)
152
+ assert inHeld(codes).isDown(Key.KeyZ)
153
+ assert inHeld(codes).isDown(Key.Backspace)
154
+ assert inHeld(codes).isDown(Key.Up)
155
+ assert inHeld(codes).isDown(Key.LeftControl)
156
+
157
+ test "through_moves_pointers_only"
158
+ m := Affine(a: 1.0, b: 0.0, c: 0.0, d: 1.0, e: 10.0, f: 0.0)
159
+ if p := PointerDown(x: 1.0, y: 2.0).through(m).position()
160
+ assert p.x == 11.0
161
+ assert p.y == 2.0
162
+ else
163
+ assert false
164
+ assert KeyDown(key: 5).through(m).position() == null
165
+ assert !KeyDown(key: 5).isPointer()
166
+ assert PointerMove(x: 0.0, y: 0.0).isPointer()
plum-std/game/functional/Ui.plum CHANGED
@@ -6,6 +6,7 @@ import std/Slice
6
6
  import std/List
7
7
  import std/game/Color
8
8
  import std/game/functional/FunctionalScene
9
+ import std/game/Input
9
10
 
10
11
  # A pure UI layer for the functional scene style. `Ui.step` takes the previous
11
12
  # UiState, one pointer input and the id under the pointer (from SceneNode.hitId on
@@ -13,22 +14,6 @@ import std/game/functional/FunctionalScene
13
14
  # button rules as PureScene: Down captures the pressed id; while captured only
14
15
  # that id gets Enter/Exit; Up releases (Click only when still over it).
15
16
 
16
- enum UiKind =
17
- | Press
18
- | Release
19
- | Click
20
- | Enter
21
- | Exit
22
-
23
- record UiEvent(id: Int, kind: UiKind)
24
-
25
- enum UiInputKind =
26
- | PointerDown
27
- | PointerUp
28
- | PointerMove
29
-
30
- record UiInput(kind: UiInputKind, x: Float, y: Float)
31
-
32
17
  record UiState(x: Float, y: Float, down: Bool, hot: Int?, captured: Int?)
33
18
 
34
19
  record UiStep(ui: UiState, events: List[UiEvent])
@@ -54,7 +39,9 @@ primitive Ui =
54
39
  fun init() -> UiState =
55
40
  UiState(x: 0.0, y: 0.0, down: false, hot: null, captured: null)
56
41
 
42
+ # A key changes nothing here (keys go to the game).
57
- fun step(ui: UiState, input: UiInput, hit: Int?) -> UiStep =
43
+ fun step(ui: UiState, input: Input, hit: Int?) -> UiStep =
44
+ pos := input.position() ?: return UiStep(ui: ui, events: Nil)
58
45
  out := Slice[UiEvent]()
59
46
  # a capture left over without a held button is stale
60
47
  cap: Int? := ui.captured
@@ -62,8 +49,8 @@ primitive Ui =
62
49
  cap = null
63
50
  hot: Int? := ui.hot
64
51
  down := ui.down
65
- match input.kind
52
+ match input
66
- PointerDown =>
53
+ PointerDown(_, _) =>
67
54
  cap = null
68
55
  uiHover(out, hot, hit)
69
56
  hot = hit
@@ -71,7 +58,7 @@ primitive Ui =
71
58
  if h := hit
72
59
  out.push(UiEvent(id: h, kind: Press))
73
60
  cap = h
74
- PointerMove =>
61
+ PointerMove(_, _) =>
75
62
  if c := cap
76
63
  now: Int? := null
77
64
  if uiSame(hit, cap)
@@ -81,7 +68,7 @@ primitive Ui =
81
68
  else
82
69
  uiHover(out, hot, hit)
83
70
  hot = hit
84
- PointerUp =>
71
+ PointerUp(_, _) =>
85
72
  if c := cap
86
73
  out.push(UiEvent(id: c, kind: Release))
87
74
  if uiSame(hit, cap)
@@ -92,7 +79,8 @@ primitive Ui =
92
79
  uiHover(out, hot, hit)
93
80
  hot = hit
94
81
  down = false
82
+ _ => 0
95
- UiStep(ui: UiState(x: input.x, y: input.y, down: down, hot: hot, captured: cap), events: List.fromSlice(out))
83
+ UiStep(ui: UiState(x: pos.x, y: pos.y, down: down, hot: hot, captured: cap), events: List.fromSlice(out))
96
84
 
97
85
  fun pressed(ui: UiState, id: Int) -> Bool =
98
86
  if c := ui.captured
@@ -123,11 +111,11 @@ fun uiPressedColor() -> Color = Color.rgb(0.6, 0.2, 0.2)
123
111
 
124
112
  fun uiKinds(s: UiStep) -> List[UiKind] = s.events.map(|e| e.kind)
125
113
 
126
- fun uiDown(x: Float) -> UiInput = UiInput(kind: PointerDown, x: x, y: 0.0)
114
+ fun uiDown(x: Float) -> Input = PointerDown(x: x, y: 0.0)
127
115
 
128
- fun uiUp(x: Float) -> UiInput = UiInput(kind: PointerUp, x: x, y: 0.0)
116
+ fun uiUp(x: Float) -> Input = PointerUp(x: x, y: 0.0)
129
117
 
130
- fun uiMove(x: Float) -> UiInput = UiInput(kind: PointerMove, x: x, y: 0.0)
118
+ fun uiMove(x: Float) -> Input = PointerMove(x: x, y: 0.0)
131
119
 
132
120
  fun uiCount(s: UiStep, k: UiKind) -> Int =
133
121
  n := 0
plum-std/game/imperative/Scene.plum CHANGED
@@ -9,6 +9,7 @@ import std/game/Color
9
9
  import std/Io
10
10
  import std/game/Affine
11
11
  import std/game/Game
12
+ import std/game/Input
12
13
 
13
14
  # A Scene2D-like scene graph: a tree of actors under a `Stage`. `Actor` is a trait;
14
15
  # every kind of actor is a `record` that embeds it (`Group`, `Sprite`, `Rect`,
@@ -100,6 +101,11 @@ trait Actor(
100
101
  fun hit(self, p: Vec2) -> Bool =
101
102
  self.width > 0.0 && self.height > 0.0 && p.x >= 0.0 && p.x <= self.width && p.y >= 0.0 && p.y <= self.height
102
103
 
104
+ # Handles `e`, with a pointer position in this actor's own coordinates. Return true
105
+ # to consume it; otherwise it goes on to the parent. The default ignores it.
106
+ fun onInput(self, e: Input) -> Bool =
107
+ false
108
+
103
109
  fun at(self, x: Float, y: Float) -> Actor =
104
110
  self.x = x
105
111
  self.y = y
@@ -468,13 +474,13 @@ fun hitNode(n: Actor, p: Vec2) -> Actor? =
468
474
 
469
475
  # A root actor plus a camera (the world point at the screen's top-left and the
470
476
  # pixels per world unit, as in `setCamera`).
471
- record Stage(mut camX: Float, mut camY: Float, mut camScale: Float) =
477
+ record Stage(mut camX: Float, mut camY: Float, mut camScale: Float, mut focus: Actor?) =
472
478
 
473
479
  fun init(self) =
474
480
  self.root: Actor = Group()
475
481
 
476
482
  fun create() -> Stage =
477
- Stage(camX: 0.0, camY: 0.0, camScale: 1.0)
483
+ Stage(camX: 0.0, camY: 0.0, camScale: 1.0, focus: null)
478
484
 
479
485
  # Runs every actor's `act`, parents before children, in insertion order.
480
486
  fun act(self, dt: Float) =
@@ -489,12 +495,48 @@ record Stage(mut camX: Float, mut camY: Float, mut camScale: Float) =
489
495
  fun hit(self, x: Float, y: Float) -> Actor? =
490
496
  self.root.hitTree(Vec2(x: x, y: y))
491
497
 
498
+ # Delivers one input and says whether an actor consumed it. A pointer input goes
499
+ # to the topmost actor under it, then each parent in turn until one's `onInput`
500
+ # returns true; a press also gives that actor the keyboard focus. A key goes to the
501
+ # focused actor, then its parents. Unconsumed input is the game's to handle.
502
+ fun input(self, e: Input) -> Bool =
503
+ if p := e.position()
504
+ sp := self.screenToStage(p.x, p.y)
505
+ target := self.hit(sp.x, sp.y)
506
+ match e
507
+ PointerDown(_, _) =>
508
+ self.focus = target
509
+ _ => 0
510
+ if a := target
511
+ return Stage.bubble(a, Input.pointerAt(e, sp))
512
+ return false
513
+ if f := self.focus
514
+ return Stage.bubble(f, e)
515
+ false
516
+
517
+ # Offers `e` (in stage coordinates) to `a`, then each parent, until one consumes it.
518
+ fun bubble(a: Actor, e: Input) -> Bool =
519
+ cur: Actor? := a
520
+ while c := cur
521
+ if c.onInput(e.through(worldMatrix(c).inverse()))
522
+ return true
523
+ cur = c.parent
524
+ false
525
+
492
526
  # A screen pixel in stage coordinates under this camera.
493
527
  fun screenToStage(self, sx: Float, sy: Float) -> Vec2 =
494
528
  Vec2(x: sx / self.camScale + self.camX, y: sy / self.camScale + self.camY)
495
529
 
496
530
  # --- tests ---
497
531
 
532
+ # Counts the inputs it is offered and the last pointer x it saw; consumes them if `eat`.
533
+ record Catcher(Actor(width, height), mut got: Int, mut lastX: Float, eat: Bool) =
534
+ override fun onInput(self, e: Input) -> Bool =
535
+ self.got = self.got + 1
536
+ if p := e.position()
537
+ self.lastX = p.x
538
+ self.eat
539
+
498
540
  fun sceneNear(a: Float, b: Float) -> Bool =
499
541
  {a - b}.abs() < 0.0001
500
542
 
@@ -776,3 +818,30 @@ test "a child without a size is never culled"
776
818
  slot.add(Rect.create(10.0, 10.0, Color.Red).at(-890.0, -890.0))
777
819
  g.setCullingArea(0.0, 0.0, 100.0, 100.0)
778
820
  assert flatten(g).length() == 3
821
+
822
+ test "input bubbles from the hit actor to its parents until consumed"
823
+ stage := Stage.create()
824
+ outer := Catcher(width: 100.0, height: 100.0, got: 0, lastX: 0.0, eat: true)
825
+ inner := Catcher(width: 10.0, height: 10.0, got: 0, lastX: 0.0, eat: false)
826
+ inner.x = 20.0
827
+ outer.add(inner)
828
+ stage.root.add(outer)
829
+ assert stage.input(PointerDown(x: 25.0, y: 5.0))
830
+ assert inner.got == 1
831
+ assert inner.lastX == 5.0
832
+ assert outer.got == 1
833
+ assert outer.lastX == 25.0
834
+ assert !stage.input(PointerDown(x: 500.0, y: 5.0))
835
+
836
+ test "a press gives the keyboard focus; keys bubble from it"
837
+ stage := Stage.create()
838
+ outer := Catcher(width: 100.0, height: 100.0, got: 0, lastX: 0.0, eat: false)
839
+ inner := Catcher(width: 10.0, height: 10.0, got: 0, lastX: 0.0, eat: false)
840
+ outer.add(inner)
841
+ stage.root.add(outer)
842
+ assert !stage.input(KeyDown(key: Key.KeyA))
843
+ assert inner.got == 0
844
+ _ := stage.input(PointerDown(x: 5.0, y: 5.0))
845
+ assert !stage.input(KeyDown(key: Key.KeyA))
846
+ assert inner.got == 2
847
+ assert outer.got == 2
plum-std/game/pure/PureScene.plum CHANGED
@@ -10,36 +10,22 @@ import std/Io
10
10
  import std/game/Affine
11
11
  import std/game/Game
12
12
  import std/game/pure/Actions
13
+ import std/game/Input
13
14
 
14
15
  # A pure scene graph: a node is a record (`Node`) holding a `Transform` and
15
16
  # an `enum Kind`. Group, Sprite, Label and Button are built in; games add their
16
17
  # own behaviour with `Custom(actor: Actor)`, a behaviour-only trait their records
17
18
  # 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;
19
+ # `onInput`, `modify` and `route` take it, change it in place and hand it back;
19
20
  # readers borrow. Only `drawTree` talks to the host, through `std/game/Game`.
21
+ # Input is the shared `Input` of std/game/Input; `route` delivers it along a hit
22
+ # path, child first, then each ancestor, until a node consumes it.
20
23
 
21
24
  enum Touchable =
22
25
  | Enabled
23
26
  | Disabled
24
27
  | ChildrenOnly
25
28
 
26
- enum PointerKind =
27
- | Down
28
- | Up
29
- | Move
30
-
31
- # A pointer event in the actor's own coordinates; `over` says it is on the actor.
32
- record Pointer(kind: PointerKind, x: Float, y: Float, over: Bool)
33
-
34
- enum UiKind =
35
- | Press
36
- | Release
37
- | Click
38
- | Enter
39
- | Exit
40
-
41
- record UiEvent(id: Int, kind: UiKind)
42
-
43
29
  # The node's position, size, origin, scale and rotation.
44
30
  record Transform(x: Float, y: Float, width: Float, height: Float, originX: Float, originY: Float, scaleX: Float, scaleY: Float, rotation: Float) =
45
31
 
@@ -264,63 +250,71 @@ record Node(
264
250
 
265
251
  # --- input ---
266
252
 
253
+ # Handles `e` (in the node's own coordinates; `over` says the pointer is on the
267
- # Handles a pointer event and returns the new node with the events it raised.
254
+ # node) and returns the new node, the events it raised and whether it consumed `e`.
268
- # Only a Button reacts.
255
+ # A Button reacts to the pointer; a Custom node asks its actor's `onInput`.
269
- fun onPointer(self, e: Pointer) -> Handled =
256
+ fun onInput(self, e: Input, over: Bool) -> Handled =
270
257
  n := self
271
258
  ev := Slice[UiEvent]()
272
259
  match n.kind
273
260
  Button(text, upTex, downTex, wasPressed, wasOver) =>
274
261
  pressed := wasPressed
275
- over := wasOver
262
+ now := wasOver
263
+ match e
276
- if e.kind == Down
264
+ PointerDown(_, _) =>
277
- pressed = true
265
+ pressed = true
278
- over = e.over
266
+ now = over
279
- ev.push(UiEvent(id: n.id, kind: Press))
267
+ ev.push(UiEvent(id: n.id, kind: Press))
280
- else if e.kind == Move
268
+ PointerMove(_, _) =>
281
- over = e.over
269
+ now = over
282
- if e.over && !wasOver
270
+ if over && !wasOver
283
- ev.push(UiEvent(id: n.id, kind: Enter))
271
+ ev.push(UiEvent(id: n.id, kind: Enter))
284
- if !e.over && wasOver
272
+ if !over && wasOver
285
- ev.push(UiEvent(id: n.id, kind: Exit))
273
+ ev.push(UiEvent(id: n.id, kind: Exit))
286
- else
274
+ PointerUp(_, _) =>
287
- over = e.over
275
+ now = over
288
- if wasPressed
276
+ if wasPressed
289
- pressed = false
277
+ pressed = false
290
- ev.push(UiEvent(id: n.id, kind: Release))
278
+ ev.push(UiEvent(id: n.id, kind: Release))
291
- if e.over
279
+ if over
292
- ev.push(UiEvent(id: n.id, kind: Click))
280
+ ev.push(UiEvent(id: n.id, kind: Click))
281
+ _ => 0
293
- n = Node(..n, kind: Button(text: text, upTex: upTex, downTex: downTex, pressed: pressed, over: over))
282
+ n = Node(..n, kind: Button(text: text, upTex: upTex, downTex: downTex, pressed: pressed, over: now))
283
+ Custom(actor) =>
284
+ return actor.onInput(n, e, over)
294
285
  _ => 0
295
- Handled(node: n, events: ev)
286
+ Handled(node: n, events: ev, consumed: false)
296
287
 
297
- # Delivers `e` (in the coordinates of the parent) along `path`: the child on
288
+ # Delivers `e` (in the coordinates of the parent) along `path`: the child on the
298
- # the path first, then this node. A path that runs out reaches the deepest
289
+ # path first, then this node, unless a child consumed it. A path that runs out
299
- # actor it can.
290
+ # reaches the deepest actor it can.
300
- fun route(self, path: Slice[Int], depth: Int, e: Pointer) -> Handled =
291
+ fun route(self, path: Slice[Int], depth: Int, e: Input, over: Bool) -> Handled =
301
292
  events := Slice[UiEvent]()
302
- r := self.routeInto(path, depth, e, events)
293
+ self.routeInto(path, depth, e, over, events)
303
- Handled(node: r, events: events)
294
+
304
-
305
- # `route`, appending the raised events to `events` and returning the node.
295
+ # `route`, appending the raised events to `events` (the returned Handled holds it).
306
- fun routeInto(self, path: Slice[Int], depth: Int, e: Pointer, events: Slice[UiEvent]) -> Node =
296
+ fun routeInto(self, path: Slice[Int], depth: Int, e: Input, over: Bool, events: Slice[UiEvent]) -> Handled =
307
- lp := Vec2(x: e.x, y: e.y)
297
+ local := e
308
- inv := self.localMatrix().invert()
298
+ if q := self.localMatrix().invert()
309
- if q := inv
310
- lp = q.apply(lp)
299
+ local = e.through(q)
311
- local := Pointer(kind: e.kind, x: lp.x, y: lp.y, over: e.over)
300
+ consumed := false
312
301
  if depth < path.length()
313
302
  match self.kind
314
303
  Group(ks) =>
315
304
  i := path.at(depth)
316
305
  if i >= 0 && i < ks.length()
317
306
  c := ks.takeAt(i)
318
- ks.putAt(i, c.routeInto(path, depth + 1, local, events))
307
+ h := c.routeInto(path, depth + 1, local, over, events)
308
+ consumed = h.consumed
309
+ ks.putAt(i, h.intoNode())
319
310
  _ => 0
311
+ if consumed
312
+ return Handled(node: self, events: events, consumed: true)
320
- h := self.onPointer(local)
313
+ h := self.onInput(local, over)
321
314
  for j := range h.events.length()
322
315
  events.push(h.events.at(j))
323
- h.intoNode()
316
+ used := h.consumed
317
+ Handled(node: h.intoNode(), events: events, consumed: used)
324
318
 
325
319
  enum Kind =
326
320
  | Group(children: Slice[Node])
@@ -339,13 +333,20 @@ trait Actor =
339
333
  fun draw(self, io: Io, n: Node, m: Affine, tint: Color) =
340
334
  return
341
335
 
336
+ # Handles `e` (in the node's own coordinates; `over` says the pointer is on the
337
+ # node). Return `consumed: true` to keep it from the node's ancestors. The
338
+ # default ignores it.
339
+ fun onInput(self, n: Node, e: Input, over: Bool) -> Handled =
340
+ Handled(node: n, events: Slice[UiEvent](), consumed: false)
341
+
342
- # The node after handling an event, and the events it raised.
342
+ # The node after handling an input, the events it raised, and whether it consumed
343
+ # the input (its ancestors then do not see it).
343
- record Handled(node: Node, events: Slice[UiEvent]) =
344
+ record Handled(node: Node, events: Slice[UiEvent], consumed: Bool) =
344
345
 
345
- # The node, moved out; read `events` first, they stay behind.
346
+ # The node, moved out; read `events` and `consumed` first, they stay behind.
346
347
  fun intoNode(self) -> Node =
347
348
  match self
348
- Handled(n, _) => n
349
+ Handled(n, _, _) => n
349
350
 
350
351
  fun psKinds(h: Handled) -> Slice[UiKind] =
351
352
  out := Slice[UiKind]()
@@ -365,8 +366,11 @@ fun psClicks(h: Handled) -> Int =
365
366
  n = n + 1
366
367
  n
367
368
 
369
+ fun psDown() -> Input = PointerDown(x: 1.0, y: 1.0)
370
+
371
+ fun psUp() -> Input = PointerUp(x: 1.0, y: 1.0)
372
+
368
- fun psOverPtr(kind: PointerKind, over: Bool) -> Pointer =
373
+ fun psMove() -> Input = PointerMove(x: 1.0, y: 1.0)
369
- Pointer(kind: kind, x: 1.0, y: 1.0, over: over)
370
374
 
371
375
  # A game actor: counts its draws and moves its node right by 1 on each act.
372
376
  record Beacon(Actor, mut draws: Int) =
@@ -492,11 +496,11 @@ test "local_matrix_translates"
492
496
 
493
497
  test "button_click_needs_press_and_over"
494
498
  b := Node.button(7, "go", 1, 2, 0.0, 0.0, 10.0, 10.0)
495
- h1 := b.onPointer(psOverPtr(Down, true))
499
+ h1 := b.onInput(psDown(), true)
496
500
  ks := psKinds(h1)
497
501
  n1 := h1.events.length()
498
502
  id1 := h1.events.at(0).id
499
- h2 := h1.intoNode().onPointer(psOverPtr(Up, true))
503
+ h2 := h1.intoNode().onInput(psUp(), true)
500
504
  ks.push(h2.events.at(0).kind)
501
505
  ks.push(h2.events.at(1).kind)
502
506
  assert n1 == 1
@@ -509,25 +513,25 @@ test "button_click_needs_press_and_over"
509
513
 
510
514
  test "button_release_elsewhere_no_click"
511
515
  b := Node.button(7, "go", 1, 2, 0.0, 0.0, 10.0, 10.0)
512
- h1 := b.onPointer(psOverPtr(Down, true))
516
+ h1 := b.onInput(psDown(), true)
513
- h2 := h1.intoNode().onPointer(psOverPtr(Up, false))
517
+ h2 := h1.intoNode().onInput(psUp(), false)
514
518
  assert h2.events.length() == 1
515
519
  assert h2.events.at(0).kind == Release
516
520
  assert !psIsPressed(h2.intoNode())
517
521
 
518
522
  test "button_drag_off_and_back_one_click"
519
523
  b := Node.button(7, "go", 1, 2, 0.0, 0.0, 10.0, 10.0)
520
- a1 := b.onPointer(psOverPtr(Down, true)).intoNode()
524
+ a1 := b.onInput(psDown(), true).intoNode()
521
- h2 := a1.onPointer(psOverPtr(Move, false))
525
+ h2 := a1.onInput(psMove(), false)
522
526
  c2 := psClicks(h2)
523
- h3 := h2.intoNode().onPointer(psOverPtr(Move, true))
527
+ h3 := h2.intoNode().onInput(psMove(), true)
524
528
  c3 := psClicks(h3)
525
- h4 := h3.intoNode().onPointer(psOverPtr(Up, true))
529
+ h4 := h3.intoNode().onInput(psUp(), true)
526
530
  assert c2 + c3 + psClicks(h4) == 1
527
531
 
528
532
  test "on_pointer_hands_back_changed_actor"
529
533
  b := Node.button(7, "go", 1, 2, 0.0, 0.0, 10.0, 10.0)
530
- h := b.onPointer(psOverPtr(Down, true))
534
+ h := b.onInput(psDown(), true)
531
535
  assert psIsPressed(h.intoNode())
532
536
 
533
537
  test "custom_actor_act_and_draw_dispatch"
@@ -554,17 +558,17 @@ test "hit_box_and_children_of"
554
558
 
555
559
  test "button_move_raises_enter_then_exit"
556
560
  b := Node.button(7, "go", 1, 2, 0.0, 0.0, 10.0, 10.0)
557
- h1 := b.onPointer(psOverPtr(Move, true))
561
+ h1 := b.onInput(psMove(), true)
558
562
  assert h1.events.length() == 1
559
563
  assert h1.events.at(0).kind == Enter
560
- h2 := h1.intoNode().onPointer(psOverPtr(Move, false))
564
+ h2 := h1.intoNode().onInput(psMove(), false)
561
565
  assert h2.events.length() == 1
562
566
  assert h2.events.at(0).kind == Exit
563
567
  assert !psIsPressed(h2.intoNode())
564
568
 
565
569
  test "on_pointer_non_button_raises_nothing"
566
570
  s := Node.sprite(1, 0, 0.0, 0.0, 10.0, 10.0)
567
- h := s.onPointer(psOverPtr(Down, true))
571
+ h := s.onInput(psDown(), true)
568
572
  assert h.events.length() == 0
569
573
  assert h.intoNode().id == 1
570
574
 
@@ -638,10 +642,10 @@ test "modify_changes_first_match_only"
638
642
 
639
643
  test "route_child_first_then_group"
640
644
  g := ttButtonRoot()
641
- h1 := g.route(ttZero(), 0, Pointer(kind: Down, x: 20.0, y: 20.0, over: true))
645
+ h1 := g.route(ttZero(), 0, PointerDown(x: 20.0, y: 20.0), true)
642
646
  n1 := h1.events.length()
643
647
  k1 := h1.events.at(0).kind
644
- h2 := h1.intoNode().route(ttZero(), 0, Pointer(kind: Up, x: 20.0, y: 20.0, over: true))
648
+ h2 := h1.intoNode().route(ttZero(), 0, PointerUp(x: 20.0, y: 20.0), true)
645
649
  assert n1 == 1
646
650
  assert k1 == Press
647
651
  assert h2.events.length() == 2
@@ -651,7 +655,7 @@ test "route_child_first_then_group"
651
655
 
652
656
  test "stale_path_reaches_group_only"
653
657
  nine := Slice(9)
654
- h := ttButtonRoot().route(nine, 0, Pointer(kind: Down, x: 20.0, y: 20.0, over: true))
658
+ h := ttButtonRoot().route(nine, 0, PointerDown(x: 20.0, y: 20.0), true)
655
659
  assert h.events.length() == 0
656
660
 
657
661
  test "compaction_keeps_order"
plum-std/game/pure/SceneInput.plum CHANGED
@@ -10,35 +10,16 @@ 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/Input
13
14
 
14
- # The root of a pure scene tree, its camera and pointer capture. A Stage is an
15
+ # The root of a pure scene tree, its camera, pointer capture and keyboard focus. A
15
- # unique value: `input` and `frame` take it, change it in place and hand it back
16
+ # Stage is an unique value: `input` and `frame` take it, change it in place and hand
16
- # inside a StageHandled (`intoStage` moves it out). A press captures the
17
+ # it back inside a StageHandled (`intoStage` moves it out). A press captures the
17
18
  # actor it landed on: moves and the release go to that actor until the release,
18
- # even when the pointer leaves it (scene2d's touch focus). The id stored with
19
+ # even when the pointer leaves it (scene2d's touch focus). The pressed actor also
20
+ # takes the keyboard focus: keys go to it first (then its ancestors), and keys no
21
+ # node consumes come back in `StageHandled.keys` for the game. The id stored with a
19
- # the path guards against the tree changing under it.
22
+ # path guards against the tree changing under it.
20
-
21
- enum InputKind =
22
- | PointerDown
23
- | PointerUp
24
- | PointerMove
25
-
26
- # A pointer input in screen coordinates.
27
- record Input(kind: InputKind, x: Float, y: Float) =
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
23
 
43
24
  record Capture(path: Slice[Int], id: Int) =
44
25
 
@@ -52,73 +33,96 @@ record Capture(path: Slice[Int], id: Int) =
52
33
  return false
53
34
  true
54
35
 
36
+ # The stage after some inputs, the UI events raised and the keys no node consumed.
55
- record StageHandled(stage: Stage, events: Slice[UiEvent]) =
37
+ record StageHandled(stage: Stage, events: Slice[UiEvent], keys: Slice[Input]) =
56
38
 
57
- # The stage, moved out; read `events` first, they stay behind.
39
+ # The stage, moved out; read `events` and `keys` first, they stay behind.
58
40
  fun intoStage(self) -> Stage =
59
41
  match self
60
- StageHandled(s, _) => s
42
+ StageHandled(s, _, _) => s
61
43
 
62
- record Stage(root: Node, camera: Affine, captured: Capture?) =
44
+ record Stage(root: Node, camera: Affine, captured: Capture?, focus: Int?) =
63
45
 
64
- # A stage over `root` with no camera transform and nothing captured.
46
+ # A stage over `root` with no camera transform, nothing captured and no focus.
65
47
  fun create(root: Node) -> Stage =
66
- Stage(root: root, camera: Affine.Identity, captured: null)
48
+ Stage(root: root, camera: Affine.Identity, captured: null, focus: null)
67
49
 
68
50
  # Handles one input: routes it, returns the new stage and the events raised.
69
51
  fun input(self, e: Input) -> StageHandled =
52
+ if !e.isPointer()
53
+ return self.key(e)
70
54
  none := Slice[UiEvent]()
71
- inv := self.camera.invert()
72
- if inv == null
73
- return StageHandled(stage: self, events: none)
55
+ q := self.camera.invert() ?: return StageHandled(stage: self, events: none, keys: Slice[Input]())
74
- world := Vec2(x: e.x, y: e.y)
75
- if q := inv
76
- world = q.apply(world)
56
+ world := e.through(q)
57
+ at := world.position() ?: Vec2(x: 0.0, y: 0.0)
77
- hit := self.root.findHit(Affine.Identity, world)
58
+ hit := self.root.findHit(Affine.Identity, at)
78
59
  # A capture is valid only while its path still leads to its actor.
79
60
  cap: Capture? := null
80
61
  if c := self.captured
81
62
  if self.root.idAt(c.path, 0) == c.id
82
63
  cap = c
64
+ match world
83
- if e.kind == PointerDown
65
+ PointerDown(_, _) =>
84
- if p := hit
66
+ if p := hit
85
- id := self.root.idAt(p, 0)
67
+ id := self.root.idAt(p, 0)
86
- return self.deliver(p, Pointer(kind: Down, x: world.x, y: world.y, over: true), Capture(path: p, id: id))
68
+ return Stage(..self, focus: id).deliver(p, world, true, Capture(path: p, id: id))
87
- return StageHandled(stage: self.withCapture(null), events: none)
69
+ return StageHandled(stage: Stage(..self, captured: null, focus: null), events: none, keys: Slice[Input]())
70
+ _ => 0
88
71
  if c := cap
89
72
  over := false
90
73
  if p := hit
91
74
  over = c.within(p)
75
+ match world
92
- if e.kind == PointerUp
76
+ PointerUp(_, _) =>
93
- return self.deliver(c.path, Pointer(kind: Up, x: world.x, y: world.y, over: over), null)
77
+ return self.deliver(c.path, world, over, null)
78
+ _ => 0
94
- return self.deliver(c.path, Pointer(kind: Move, x: world.x, y: world.y, over: over), c)
79
+ return self.deliver(c.path, world, over, c)
95
80
  # Hover without a press: route to whatever is under the pointer.
81
+ match world
96
- if e.kind == PointerMove
82
+ PointerMove(_, _) =>
97
- if p := hit
83
+ if p := hit
98
- return self.deliver(p, Pointer(kind: Move, x: world.x, y: world.y, over: true), null)
84
+ return self.deliver(p, world, true, null)
85
+ _ => 0
99
- StageHandled(stage: self.withCapture(null), events: none)
86
+ StageHandled(stage: self.withCapture(null), events: none, keys: Slice[Input]())
87
+
88
+ # A key goes to the focused actor (then its ancestors); unconsumed, it is returned.
89
+ fun key(self, e: Input) -> StageHandled =
90
+ left := Slice(e)
91
+ if f := self.focus
92
+ if p := self.root.findPath(f)
93
+ events := Slice[UiEvent]()
94
+ match self
95
+ Stage(root, camera, captured, focus) =>
96
+ h := root.routeInto(p, 0, e, true, events)
97
+ if h.consumed
98
+ left = Slice[Input]()
99
+ return StageHandled(stage: Stage(root: h.intoNode(), camera: camera, captured: captured, focus: focus), events: events, keys: left)
100
+ StageHandled(stage: self, events: Slice[UiEvent](), keys: left)
100
101
 
101
102
  fun withCapture(self, c: Capture?) -> Stage =
102
103
  Stage(..self, captured: c)
103
104
 
104
- fun deliver(self, path: Slice[Int], p: Pointer, c: Capture?) -> StageHandled =
105
+ fun deliver(self, path: Slice[Int], e: Input, over: Bool, c: Capture?) -> StageHandled =
105
106
  events := Slice[UiEvent]()
106
107
  match self
107
- Stage(root, camera, _) =>
108
+ Stage(root, camera, _, focus) =>
108
- r := root.routeInto(path, 0, p, events)
109
+ h := root.routeInto(path, 0, e, over, events)
109
- StageHandled(stage: Stage(root: r, camera: camera, captured: c), events: events)
110
+ StageHandled(stage: Stage(root: h.intoNode(), camera: camera, captured: c, focus: focus), events: events, keys: Slice[Input]())
110
111
 
111
112
  # Handles the inputs in order, then advances the tree by `dt`.
112
113
  fun frame(self, dt: Float, inputs: Slice[Input]) -> StageHandled =
113
114
  cur := self
114
115
  out := Slice[UiEvent]()
116
+ keys := Slice[Input]()
115
117
  for i := range inputs.length()
116
118
  h := cur.input(inputs.at(i))
117
119
  for j := range h.events.length()
118
120
  out.push(h.events.at(j))
121
+ for j := range h.keys.length()
122
+ keys.push(h.keys.at(j))
119
123
  cur = h.intoStage()
120
124
  match cur
121
- Stage(root, camera, captured) => StageHandled(stage: Stage(root: root.update(dt), camera: camera, captured: captured), events: out)
125
+ Stage(root, camera, captured, focus) => StageHandled(stage: Stage(root: root.update(dt), camera: camera, captured: captured, focus: focus), events: out, keys: keys)
122
126
 
123
127
  # Paints the tree through the camera.
124
128
  fun draw(self, io: Io) =
@@ -146,13 +150,13 @@ fun inOverOf(a: Node) -> Bool =
146
150
  _ => false
147
151
 
148
152
  fun inDown(x: Float, y: Float) -> Input =
149
- Input(kind: PointerDown, x: x, y: y)
153
+ PointerDown(x: x, y: y)
150
154
 
151
155
  fun inUp(x: Float, y: Float) -> Input =
152
- Input(kind: PointerUp, x: x, y: y)
156
+ PointerUp(x: x, y: y)
153
157
 
154
158
  fun inMove(x: Float, y: Float) -> Input =
155
- Input(kind: PointerMove, x: x, y: y)
159
+ PointerMove(x: x, y: y)
156
160
 
157
161
  fun inClicks(events: Slice[UiEvent]) -> Int =
158
162
  n := 0
@@ -169,6 +173,29 @@ fun inXformTree() -> Node =
169
173
  me: Node := g2
170
174
  me
171
175
 
176
+ # A custom actor that moves its node right on its key and reports presses.
177
+ record KeyMover(Actor, key: Int) =
178
+ override fun onInput(self, n: Node, e: Input, over: Bool) -> Handled =
179
+ match e
180
+ KeyDown(k) =>
181
+ if k == self.key
182
+ return Handled(node: Node(..n, x: n.x + 1.0), events: Slice[UiEvent](), consumed: true)
183
+ PointerDown(_, _) =>
184
+ return Handled(node: n, events: Slice(UiEvent(id: n.id, kind: Press)), consumed: true)
185
+ _ => 0
186
+ Handled(node: n, events: Slice[UiEvent](), consumed: false)
187
+
188
+ fun inMoverTree() -> Node =
189
+ m: Node := Node.custom(8, 10.0, 10.0, 50.0, 50.0, KeyMover(key: Key.KeyA))
190
+ m2 := Node(..m, touchable: Enabled)
191
+ g: Node := Node.group(1, 0.0, 0.0, 800.0, 600.0, Slice(m2))
192
+ g
193
+
194
+ fun inFirstX(s: Stage) -> Float =
195
+ if k := s.root.children()
196
+ return k.at(0).x
197
+ 0.0 - 1.0
198
+
172
199
  test "press_sets_capture"
173
200
  h := Stage.create(inBtnTree()).input(inDown(20.0, 20.0))
174
201
  assert h.events.length() == 1
@@ -201,7 +228,7 @@ test "drag_off_and_back_one_click"
201
228
  test "stale_capture_dropped"
202
229
  h1 := Stage.create(inBtnTree()).input(inDown(20.0, 20.0))
203
230
  empty: Node := Node.group(1, 0.0, 0.0, 800.0, 600.0, Slice[Node]())
204
- swapped := Stage(root: empty, camera: h1.stage.camera, captured: h1.stage.captured)
231
+ swapped := Stage(root: empty, camera: h1.stage.camera, captured: h1.stage.captured, focus: null)
205
232
  h2 := swapped.input(inUp(20.0, 20.0))
206
233
  assert h2.events.length() == 0
207
234
  assert h2.stage.captured == null
@@ -213,7 +240,7 @@ test "hover_without_press_routes_move"
213
240
 
214
241
  test "camera_maps_screen_to_world"
215
242
  cam := Affine(a: 2.0, b: 0.0, c: 0.0, d: 2.0, e: 0.0, f: 0.0)
216
- s := Stage(root: inBtnTree(), camera: cam, captured: null)
243
+ s := Stage(root: inBtnTree(), camera: cam, captured: null, focus: null)
217
244
  h := s.input(inDown(40.0, 40.0))
218
245
  assert h.events.length() == 1
219
246
  assert h.events.at(0).kind == Press
@@ -243,13 +270,39 @@ test "hit_and_route_agree_for_rotated_scaled_group"
243
270
  t := inXformTree()
244
271
  hit := t.findHit(Affine.Identity, Vec2(x: 60.0, y: 40.0))
245
272
  assert hit != null
246
- h := Stage(root: t, camera: Affine.Identity, captured: null).input(Input(kind: PointerDown, x: 60.0, y: 40.0))
273
+ h := Stage(root: t, camera: Affine.Identity, captured: null, focus: null).input(inDown(60.0, 40.0))
247
274
  assert h.events.length() == 1
248
275
  assert h.events.at(0).id == 5
249
- miss := Stage(root: inXformTree(), camera: Affine.Identity, captured: null).input(Input(kind: PointerDown, x: 20.0, y: 20.0))
276
+ miss := Stage(root: inXformTree(), camera: Affine.Identity, captured: null, focus: null).input(inDown(20.0, 20.0))
250
277
  assert miss.events.length() == 0
251
278
 
252
279
  test "input_changes_stage_in_place"
253
- h := Stage.create(inBtnTree()).input(Input(kind: PointerDown, x: 20.0, y: 20.0))
280
+ h := Stage.create(inBtnTree()).input(inDown(20.0, 20.0))
254
281
  assert h.stage.captured != null
255
282
  assert inBtnPressed(h.stage)
283
+
284
+ test "custom_actor_gets_pointer_through_on_input"
285
+ h := Stage.create(inMoverTree()).input(inDown(20.0, 20.0))
286
+ assert h.events.length() == 1
287
+ assert h.events.at(0).id == 8
288
+ assert {h.stage.focus ?: -1} == 8
289
+
290
+ test "focused_actor_consumes_its_key"
291
+ h1 := Stage.create(inMoverTree()).input(inDown(20.0, 20.0))
292
+ h2 := h1.intoStage().input(KeyDown(key: Key.KeyA))
293
+ assert h2.keys.length() == 0
294
+ assert inFirstX(h2.stage) == 11.0
295
+
296
+ test "unconsumed_and_unfocused_keys_go_to_the_game"
297
+ h1 := Stage.create(inMoverTree()).input(KeyDown(key: Key.KeyA))
298
+ assert h1.keys.length() == 1
299
+ assert inFirstX(h1.stage) == 10.0
300
+ h2 := Stage.create(inMoverTree()).input(inDown(20.0, 20.0))
301
+ h3 := h2.intoStage().input(KeyDown(key: Key.KeyB))
302
+ assert h3.keys.length() == 1
303
+
304
+ test "frame_collects_unconsumed_keys"
305
+ ins := Slice(inDown(20.0, 20.0), KeyDown(key: Key.KeyA), KeyDown(key: Key.Space))
306
+ h := Stage.create(inMoverTree()).frame(0.0, ins)
307
+ assert h.keys.length() == 1
308
+ assert inFirstX(h.stage) == 11.0