plum

#treesitter#compiler#wasm

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

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


3c9dcee — Peter John 2026-11-02T20:04:16+05:30
T9: Scene on trait Actor
README.md CHANGED
@@ -1080,22 +1080,34 @@ Games run in the browser on Plum's own WebGPU renderer (`plum-game/web/plum_brid
1080
1080
 
1081
1081
  `plum-examples/game.plum` is a small arcade game: steer a ship with the arrow keys or WASD, collect orbs, dodge mines (Enter restarts). It draws shapes, text, additive orb glow (`blendMode(Blend.Add)`) and an animated sprite sheet (`plum-examples/assets/sprites.png`, generated by `plum-examples/tools/make_sprites.py`) and plays music and sound effects (`plum-examples/assets/*.wav`, generated by `plum-examples/tools/make_sounds.py`); both generators live outside the asset dir so they are not shipped. For a bigger game built on this engine see the Knights and Merchants port in the sibling repo `../kam_plum` (`./run.sh` there). Its rules are plain functions with `test` blocks, so `plum test plum-examples` covers them without a browser.
1082
1082
 
1083
- **`std/game/Scene`** is a scene-graph library written in Plum (see [`plum-examples/scene.plum`](plum-examples/scene.plum)): a tree of `Node`s (group, sprite, rect, circle, text) under a `Stage`, with parent-relative position, rotation, scale and colour, per-node `act` behaviours, a camera and hit testing; its demo renders a minimap into a texture with `textureCreate` and `renderTarget`. Use it for UI and other hierarchical scenes, where moving a parent should move everything in it. For a large world, keep the game objects in flat records and lists instead (for example `enum Entity = Unit | House` with an `update` and a `draw` that the enum dispatches to its variants) and draw them directly through `std/game/Game`; a tree of thousands of nodes costs a closure call and a matrix composition per node every frame.
1083
+ **`std/game/Scene`** is a scene-graph library written in Plum (see [`plum-examples/scene.plum`](plum-examples/scene.plum)): a tree of actors under a `Stage`, with parent-relative position, rotation, scale and colour, per-actor `act`, `draw` and `hit` behaviour, a camera and hit testing; its demo renders a minimap into a texture with `textureCreate` and `renderTarget`. Use it for UI and other hierarchical scenes, where moving a parent should move everything in it. It is libGDX Scene2D-style subclassing on a [trait](#traits): `Actor` is a trait holding the position, size, origin, scale, rotation, colour, visibility, children and culling fields, and `Group`, `Sprite`, `Rect`, `Circle` and `Text` are records embedding it. A game adds its own kinds the same way and overrides what it needs:
1084
1084
 
1085
- `std/game/Scene` follows libGDX Scene2D for draw order and culling. A node's z-index is its position in its parent's child list (draw order is list order, hit testing goes in reverse):
1085
+ ```plum
1086
+ record Walker(Actor, speed: Float) =
1087
+ fun act(self, dt: Float) =
1088
+ self.x = self.x + self.speed * dt
1089
+ fun draw(self, m: Affine, alpha: Float) =
1090
+ drawRectBox(m, alpha, self.width, self.height, self.color)
1091
+
1092
+ stage.root.add(Walker(x: 0.0, y: 0.0, width: 8.0, height: 8.0, originX: 0.0, originY: 0.0, scaleX: 1.0, scaleY: 1.0, rotation: 0.0, color: Color.Red, visible: true, children: List[Actor](), parent: null, scratch: List[Actor](), cull: false, cullX: 0.0, cullY: 0.0, cullW: 0.0, cullH: 0.0, speed: 30.0))
1093
+ ```
1094
+
1095
+ The default `act` acts the children (an override that keeps children calls `self.actChildren(dt)`), the default `draw` paints nothing and the default `hit(p)` tests the width by height box. Compare actors with `same(a, b)`, not `==` (a trait value is a new wrapper each time a record is converted). Every trait field is read and written through a dispatch over the program's actor kinds, so a hot loop that reads fields from outside the trait (a y-sort key over 20,000 actors) costs several times a closure-based node ([`BENCHMARK.md`](plum-game/web-test/BENCHMARK.md)); code inside the trait's default methods reads them directly. For a large world, keep the game objects in flat records and lists instead (for example `enum Entity = Unit | House`) and draw them directly through `std/game/Game`.
1096
+
1097
+ `std/game/Scene` follows libGDX Scene2D for draw order and culling. An actor's z-index is its position in its parent's child list (draw order is list order, hit testing goes in reverse):
1086
1098
 
1087
1099
  ```plum
1088
- n.zIndex() # index among its siblings, -1 without a parent
1100
+ a.zIndex() # index among its siblings, -1 without a parent
1089
- n.setZIndex(0) # move within the parent (clamped to 0..length-1)
1101
+ a.setZIndex(0) # move within the parent (clamped to 0..length-1)
1090
- n.toFront() # last child: drawn on top, hit first
1102
+ a.toFront() # last child: drawn on top, hit first
1091
- n.toBack() # first child: drawn first
1103
+ a.toBack() # first child: drawn first
1092
1104
 
1093
1105
  world.sortChildren(|a, b| a.y < b.y) # stable, in place, no allocation once warm
1094
1106
  world.setCullingArea(cam.x, cam.y, viewW, viewH) # in world's own coordinates
1095
1107
  world.clearCullingArea()
1096
1108
  ```
1097
1109
 
1098
- `sortChildren(before)` is a natural merge sort with a scratch list kept on the node: a list that is already sorted costs n - 1 comparisons and a nearly sorted one (a y-sorted layer re-sorted every frame) about 2n, with O(n log n) as the worst case; equal keys keep their order, so ties do not flicker. `setCullingArea` makes a group skip each direct child whose box (position and size, scaled about its origin; rotation ignored) lies outside the rectangle, in both `draw` and `hit`, with the child's whole subtree; children with no size (plain groups) are never culled. In [`plum-game/web-test/BENCHMARK.md`](plum-game/web-test/BENCHMARK.md) a 20,000-sprite y-sorted layer costs 1.15 ms per frame with culling and 4.20 ms without.
1110
+ `sortChildren(before)` is a natural merge sort with a scratch list kept on the actor: a list that is already sorted costs n - 1 comparisons and a nearly sorted one (a y-sorted layer re-sorted every frame) about 2n, with O(n log n) as the worst case; equal keys keep their order, so ties do not flicker. `setCullingArea` makes a group skip each direct child whose box (position and size, scaled about its origin; rotation ignored) lies outside the rectangle, in both `draw` and `hit`, with the child's whole subtree; children with no size (plain groups) are never culled. In [`plum-game/web-test/BENCHMARK.md`](plum-game/web-test/BENCHMARK.md) a 20,000-sprite y-sorted layer costs 1.15 ms per frame with culling and 4.20 ms without on the earlier closure-based `Node` (2.09 ms with culling on `trait Actor`, which pays a dispatch per field read in the sort).
1099
1111
 
1100
1112
  A layered world (a layer recipe for a large map) is a few groups under the stage root, drawn in order: `terrain` (static, culled), `world` (y-sorted every frame, culled), `effects` (culled) and `ui` (not culled, in screen space with a second `Stage` or camera). Each frame: `world.sortChildren(|a, b| a.y < b.y)`, then `setCullingArea` on `terrain`, `world` and `effects` from the camera view.
1101
1113
 
plum-examples/scene.plum CHANGED
@@ -36,47 +36,88 @@ primitive Layout =
36
36
  WalkMin = 500.0
37
37
  WalkMax = 1300.0
38
38
 
39
+ # Actor kinds of this game: `record X(Actor, ...)` embeds the scene's `Actor` trait, so
40
+ # an X goes wherever an Actor is expected and its own `act` and `draw` run in the tree.
41
+
42
+ # A group that turns `speed` radians per second and carries its children round.
43
+ record Orbit(Actor, speed: Float) =
44
+
45
+ fun create(speed: Float) -> Orbit =
46
+ Orbit(
47
+ x: 0.0, y: 0.0, width: 0.0, height: 0.0, originX: 0.0, originY: 0.0,
48
+ scaleX: 1.0, scaleY: 1.0, rotation: 0.0, color: Color.White, visible: true,
49
+ children: List[Actor](), parent: null, scratch: List[Actor](),
50
+ cull: false, cullX: 0.0, cullY: 0.0, cullW: 0.0, cullH: 0.0,
51
+ speed: speed)
52
+
53
+ fun act(self, dt: Float) =
54
+ self.rotation = self.rotation + self.speed * dt
55
+ self.actChildren(dt)
56
+
57
+ # A sprite that turns on its own axis, `speed` radians per second.
58
+ record Spinner(Actor, speed: Float, tex: Int, srcX: Float, srcY: Float, srcW: Float, srcH: Float) =
59
+
60
+ fun create(sheet: Int, sx: Float, sy: Float, size: Float, speed: Float) -> Spinner =
61
+ Spinner(
62
+ x: 0.0, y: 0.0, width: size, height: size, originX: size / 2.0, originY: size / 2.0,
63
+ scaleX: 1.0, scaleY: 1.0, rotation: 0.0, color: Color.White, visible: true,
64
+ children: List[Actor](), parent: null, scratch: List[Actor](),
65
+ cull: false, cullX: 0.0, cullY: 0.0, cullW: 0.0, cullH: 0.0,
66
+ speed: speed, tex: sheet, srcX: sx, srcY: sy, srcW: Layout.Cell, srcH: Layout.Cell)
67
+
68
+ fun act(self, dt: Float) =
69
+ self.rotation = self.rotation + self.speed * dt
70
+ self.actChildren(dt)
71
+
72
+ fun draw(self, m: Affine, alpha: Float) =
73
+ drawSpriteFrame(m, alpha, self.width, self.height, self.color, self.tex, self.srcX, self.srcY, self.srcW, self.srcH)
74
+
75
+ # A walker: moves along x at `pace` and bobs on a wave of its x.
76
+ record Walker(Actor, pace: Float, phase: Float, tex: Int, srcX: Float, srcY: Float, srcW: Float, srcH: Float) =
77
+
78
+ fun create(sheet: Int, sx: Float, pace: Float, phase: Float) -> Walker =
79
+ Walker(
80
+ x: 0.0, y: 0.0, width: 24.0, height: 24.0, originX: 0.0, originY: 0.0,
81
+ scaleX: 1.0, scaleY: 1.0, rotation: 0.0, color: Color.White, visible: true,
82
+ children: List[Actor](), parent: null, scratch: List[Actor](),
83
+ cull: false, cullX: 0.0, cullY: 0.0, cullW: 0.0, cullH: 0.0,
84
+ pace: pace, phase: phase, tex: sheet, srcX: sx, srcY: Layout.Cell, srcW: Layout.Cell, srcH: Layout.Cell)
85
+
86
+ fun act(self, dt: Float) =
87
+ nx := self.x + dt * self.pace
88
+ if nx > Layout.WalkMax
89
+ nx = 0.0 - Layout.WalkMin
90
+ if nx < 0.0 - Layout.WalkMin
91
+ nx = Layout.WalkMax
92
+ self.x = nx
93
+ self.y = 510.0 + 50.0 * Math.sin(nx / 90.0 + self.phase)
94
+
95
+ fun draw(self, m: Affine, alpha: Float) =
96
+ drawSpriteFrame(m, alpha, self.width, self.height, self.color, self.tex, self.srcX, self.srcY, self.srcW, self.srcH)
97
+
39
98
  # Everything `update` and `draw` need. `bodies` and `names` are parallel (every
40
99
  # planet and moon, each planet followed by its moons), as are `halos` (the glow shown when selected).
41
100
  record Sky(
42
101
  stage: Stage,
43
- bodies: List[Node],
102
+ bodies: List[Actor],
44
- halos: List[Node],
103
+ halos: List[Actor],
45
104
  names: List[Str],
46
- sun: Node,
105
+ sun: Spinner,
47
- label: Node,
106
+ label: Text,
48
- crowd: Node,
107
+ crowd: Group,
49
- front: Node?,
108
+ front: Actor?,
50
109
  selected: Int,
51
110
  clock: Float,
52
111
  paused: Bool,
53
112
  map: Int)
54
113
 
55
- # Gives the node a behaviour that turns it `speed` radians per second.
56
- fun spun(n: Node, speed: Float) -> Node =
57
- turn := |m, dt|
58
- m.rotation = m.rotation + speed * dt
59
- n.setAct(turn)
60
-
61
- # A walker's behaviour: it moves along x at `speed` and bobs on a wave of its x.
62
- fun walking(n: Node, pace: Float, phase: Float) -> Node =
63
- step: fn(Node, Float) := |m, dt|
64
- nx := m.x + dt * pace
65
- if nx > Layout.WalkMax
66
- nx = 0.0 - Layout.WalkMin
67
- if nx < 0.0 - Layout.WalkMin
68
- nx = Layout.WalkMax
69
- _ := m.at(nx, 510.0 + 50.0 * Math.sin(nx / 90.0 + phase))
70
- n.setAct(step)
71
-
72
114
  # A body sprite and its (hidden) halo in a group `slot` centred on the group's own
73
115
  # origin. The caller places the slot; the sprite spins on its own axis.
74
- fun addBody(sky: Sky, sheet: Int, name: Str, row: Int, frame: Int, size: Float, spin: Float) -> Node =
116
+ fun addBody(sky: Sky, sheet: Int, name: Str, row: Int, frame: Int, size: Float, spin: Float) -> Actor =
75
- slot := Node.group()
117
+ slot := Group.create()
76
- halo := Node.circle(size * 0.8, Color(r: 1.0, g: 0.9, b: 0.5, a: 0.35)).at(0.0 - size * 0.8, 0.0 - size * 0.8)
118
+ halo := Circle.create(size * 0.8, Color(r: 1.0, g: 0.9, b: 0.5, a: 0.35)).at(0.0 - size * 0.8, 0.0 - size * 0.8)
77
119
  halo.visible = false
78
- body := Node.sprite(sheet, Float(frame) * Layout.Cell, Float(row) * Layout.Cell, Layout.Cell, Layout.Cell, size, size).at(0.0 - size / 2.0, 0.0 - size / 2.0).centerOrigin()
120
+ body := Spinner.create(sheet, Float(frame) * Layout.Cell, Float(row) * Layout.Cell, size, spin).at(0.0 - size / 2.0, 0.0 - size / 2.0)
79
- body = spun(body, spin)
80
121
  slot.add(halo)
81
122
  slot.add(body)
82
123
  sky.halos.push(halo)
@@ -86,28 +127,32 @@ fun addBody(sky: Sky, sheet: Int, name: Str, row: Int, frame: Int, size: Float,
86
127
 
87
128
  fun newSky(sheet: Int) -> Sky =
88
129
  stage := Stage.create()
89
- backdrop := Node.rect(Layout.Width, Layout.Height, Color.rgb(0.04, 0.05, 0.1))
130
+ backdrop := Rect.create(Layout.Width, Layout.Height, Color.rgb(0.04, 0.05, 0.1))
90
131
  stage.root.add(backdrop)
91
132
  for i := range 40
92
- star := Node.circle(1.0 + Float(i % 3) * 0.5, Color(r: 0.7, g: 0.8, b: 1.0, a: 0.4)).at(Float({i * 7919} % 800), Float({i * 104729} % 600))
133
+ star := Circle.create(1.0 + Float(i % 3) * 0.5, Color(r: 0.7, g: 0.8, b: 1.0, a: 0.4)).at(Float({i * 7919} % 800), Float({i * 104729} % 600))
93
134
  stage.root.add(star)
94
- sun := spun(Node.sprite(sheet, 0.0, Layout.Cell, Layout.Cell, Layout.Cell, 96.0, 96.0).at(Layout.CenterX - 48.0, Layout.CenterY - 48.0).centerOrigin(), 0.3)
135
+ sun := Spinner.create(sheet, 0.0, Layout.Cell, 96.0, 0.3)
136
+ sun.x = Layout.CenterX - 48.0
137
+ sun.y = Layout.CenterY - 48.0
95
- label := Node.text("Click a planet", 24.0, Color.White).at(16.0, 12.0)
138
+ label := Text.create("Click a planet", 24.0, Color.White)
139
+ label.x = 16.0
140
+ label.y = 12.0
96
141
  sky := Sky(
97
- stage: stage, bodies: List[Node](), halos: List[Node](), names: List[Str](),
142
+ stage: stage, bodies: List[Actor](), halos: List[Actor](), names: List[Str](),
98
- sun: sun, label: label, crowd: Node.group(), front: null, selected: -1, clock: 0.0, paused: false, map: -1)
143
+ sun: sun, label: label, crowd: Group.create(), front: null, selected: -1, clock: 0.0, paused: false, map: -1)
99
144
  # faint orbit rings, added first so every body draws (and is hit) above them
100
145
  for i := range Layout.Planets
101
146
  r := 90.0 + Float(i) * 60.0
102
- stage.root.add(Node.circle(r, Color(r: 1.0, g: 1.0, b: 1.0, a: 0.04)).at(Layout.CenterX - r, Layout.CenterY - r))
147
+ stage.root.add(Circle.create(r, Color(r: 1.0, g: 1.0, b: 1.0, a: 0.04)).at(Layout.CenterX - r, Layout.CenterY - r))
103
148
  planetNames := List("Ember", "Cinder", "Ashen", "Glacier")
104
149
  moons := List(0, 1, 2, 1)
105
150
  for i := range Layout.Planets
106
- orbit := spun(Node.group().at(Layout.CenterX, Layout.CenterY), 0.9 / Float(1 + i))
151
+ orbit := Orbit.create(0.9 / Float(1 + i)).at(Layout.CenterX, Layout.CenterY)
107
152
  planet := addBody(sky, sheet, planetNames.at(i), 2, i, 40.0, 1.5).at(90.0 + Float(i) * 60.0, 0.0)
108
153
  orbit.add(planet)
109
154
  for m := range moons.at(i)
110
- moonOrbit := spun(Node.group(), 2.0 + Float(m))
155
+ moonOrbit := Orbit.create(2.0 + Float(m))
111
156
  moon := addBody(sky, sheet, "{planetNames.at(i)} moon {m + 1}", 1, m, 18.0, 3.0).at(36.0 + Float(m) * 16.0, 0.0)
112
157
  moonOrbit.add(moon)
113
158
  planet.add(moonOrbit)
@@ -117,8 +162,8 @@ fun newSky(sheet: Int) -> Sky =
117
162
  speed := 20.0 + Float(i % 7) * 9.0
118
163
  if i % 2 == 0
119
164
  speed = 0.0 - speed
120
- walker := Node.sprite(sheet, Float(i % 3) * Layout.Cell, Layout.Cell, Layout.Cell, Layout.Cell, 24.0, 24.0).at(Layout.WalkMax - 1800.0 + Float(i * 61 % 1800), 510.0)
165
+ walker := Walker.create(sheet, Float(i % 3) * Layout.Cell, speed, Float(i)).at(Layout.WalkMax - 1800.0 + Float(i * 61 % 1800), 510.0)
121
- sky.crowd.add(walking(walker, speed, Float(i)))
166
+ sky.crowd.add(walker)
122
167
  stage.root.add(sky.crowd)
123
168
  stage.root.add(label)
124
169
  sky
@@ -132,9 +177,9 @@ fun fit(sky: Sky, sw: Float, sh: Float) =
132
177
  sky.crowd.setCullingArea(sky.stage.camX, sky.stage.camY, sw / zoom, sh / zoom)
133
178
 
134
179
  # The index of a body (or its halo) in `bodies`, or -1.
135
- fun indexOf(sky: Sky, n: Node) -> Int =
180
+ fun indexOf(sky: Sky, n: Actor) -> Int =
136
181
  for i := range sky.bodies.length()
137
- if sky.bodies.at(i) == n || sky.halos.at(i) == n
182
+ if same(sky.bodies.at(i), n) || same(sky.halos.at(i), n)
138
183
  return i
139
184
  -1
140
185
 
@@ -155,7 +200,7 @@ fun clickAt(sky: Sky, x: Float, y: Float) =
155
200
  if n := sky.stage.hit(x, y)
156
201
  i = indexOf(sky, n)
157
202
  if p := n.parent
158
- if p == sky.crowd
203
+ if p.children == sky.crowd.children
159
204
  n.toFront()
160
205
  sky.front = n
161
206
  if i == sky.selected
@@ -226,6 +271,38 @@ export fun draw(sky: Sky) =
226
271
 
227
272
  # --- tests ---
228
273
 
274
+ # Test probes (the std library keeps its own tests free of extra Actor kinds).
275
+ # A group that turns by dt each act and acts its children too.
276
+ record ProbeSpinner(Actor) =
277
+
278
+ fun create() -> ProbeSpinner =
279
+ ProbeSpinner(
280
+ x: 0.0, y: 0.0, width: 0.0, height: 0.0, originX: 0.0, originY: 0.0,
281
+ scaleX: 1.0, scaleY: 1.0, rotation: 0.0, color: Color.White, visible: true,
282
+ children: List[Actor](), parent: null, scratch: List[Actor](),
283
+ cull: false, cullX: 0.0, cullY: 0.0, cullW: 0.0, cullH: 0.0)
284
+
285
+ fun act(self, dt: Float) =
286
+ self.rotation = self.rotation + dt
287
+ self.actChildren(dt)
288
+
289
+ # A walker: moves along x in its own `act` and counts the frames it draws itself in.
290
+ record ProbeWalker(Actor, speed: Float, drawn: Int) =
291
+
292
+ fun create(speed: Float) -> ProbeWalker =
293
+ ProbeWalker(
294
+ x: 0.0, y: 0.0, width: 10.0, height: 10.0, originX: 0.0, originY: 0.0,
295
+ scaleX: 1.0, scaleY: 1.0, rotation: 0.0, color: Color.White, visible: true,
296
+ children: List[Actor](), parent: null, scratch: List[Actor](),
297
+ cull: false, cullX: 0.0, cullY: 0.0, cullW: 0.0, cullH: 0.0,
298
+ speed: speed, drawn: 0)
299
+
300
+ fun act(self, dt: Float) =
301
+ self.x = self.x + self.speed * dt
302
+
303
+ fun draw(self, m: Affine, alpha: Float) =
304
+ self.drawn = self.drawn + 1
305
+
229
306
  fun near(a: Float, b: Float) -> Bool =
230
307
  {a - b}.abs() < 0.01
231
308
 
@@ -323,3 +400,38 @@ test "clicking a walker brings it to the front, even after the next sort"
323
400
  assert w.zIndex() == 59
324
401
  tick(sky, 0.1)
325
402
  assert w.zIndex() == 59
403
+
404
+ test "act runs behaviours depth first, including invisible actors"
405
+ stage := Stage.create()
406
+ g := ProbeSpinner.create()
407
+ stage.root.add(g)
408
+ k := ProbeSpinner.create()
409
+ g.add(k)
410
+ g.visible = false
411
+ stage.act(0.5)
412
+ stage.act(0.25)
413
+ assert near(g.rotation, 0.75)
414
+ assert near(k.rotation, 0.75)
415
+ assert stage.root.rotation == 0.0
416
+
417
+ test "walker_record: a Walker in a Stage moves via its own act and draws via its own draw"
418
+ stage := Stage.create()
419
+ w := ProbeWalker.create(4.0)
420
+ stage.root.add(w)
421
+ stage.root.add(Group.create())
422
+ stage.act(0.5)
423
+ stage.act(0.5)
424
+ assert near(w.x, 4.0)
425
+ assert w.drawn == 0
426
+ stage.root.drawTree(Affine.Identity, 1.0)
427
+ stage.root.drawTree(Affine.Identity, 1.0)
428
+ assert w.drawn == 2
429
+ w.visible = false
430
+ stage.root.drawTree(Affine.Identity, 1.0)
431
+ assert w.drawn == 2
432
+ w.visible = true
433
+ # a trait value reaches the same record
434
+ a: Actor := w
435
+ a.act(1.0)
436
+ assert near(w.x, 8.0)
437
+ assert same({stage.hit(8.5, 5.0) ?: crash "miss"}, w)
plum-game/web-test/BENCHMARK.md CHANGED
@@ -112,3 +112,18 @@ Fixture `stress_world.plum`: one world layer with 20,000 sprites in a 4000x2300
112
112
  | stress_scene, 5.8k nodes (reference) | 1.71 | 1.9 | 584 | 0.060 | 1.251 | 0.261 |
113
113
 
114
114
  With culling the frame costs about what the 2,000 visible sprites cost, not what the 20,000 nodes cost: 1.15 ms against 4.20 ms without it (3.7x), and below the 5k-node `stress_scene` (1.71 ms), which draws every node. Culling removes draw (2.92 -> 0.52 ms) and render (0.69 -> 0.09 ms, 20,000 against about 2,000 sprites reaching the batcher); what stays is `update` at about 0.42 ms, which is the y-sort's O(n) scan over all 20,000 children (n - 1 comparisons through the closure, about 20 ns each, plus the merge of the few displaced sprites) and the 500 walkers. So the remaining cost grows with total nodes through the sort and the `act` walk, but at a fraction of a draw, and the draw side now scales with what is on screen.
115
+
116
+ ## Scene on `trait Actor` vs closure `Node`
117
+
118
+ `std/game/Scene` moved from one `Node` record with `act: fn(Node, Float)` closures to `trait Actor` with records embedding it (`Group`, `Sprite`, ... and the game's own, such as `Walker`). Same machine and method as above (headed, gpu per-call, `--uncapped --measure 10 --frames-wait 6`, two runs per cell). The fixtures were ported 1:1 (same node counts and motion); in `stress_scene` each node kind became a record with its own `act` and `draw`.
119
+
120
+ | fixture | version | avg ms (2 runs) | p95 | update | draw | render |
121
+ |---|---|---|---|---|---|---|
122
+ | stress_scene, 5.8k nodes | closure `Node` | 1.60, 1.62 | 1.7-1.8 | 0.055 | 1.19-1.20 | 0.24 |
123
+ | stress_scene, 5.8k nodes | `trait Actor` | 1.69, 1.69 | 1.8 | 0.039 | 1.27 | 0.26 |
124
+ | stress_world, 20k nodes, culling on | closure `Node` | 1.09, 1.09 | 1.2 | 0.421 | 0.495 | 0.082 |
125
+ | stress_world, 20k nodes, culling on | `trait Actor` | 2.09, 2.12 | 2.3-2.4 | 1.29-1.31 | 0.59 | 0.09 |
126
+
127
+ - `stress_scene`: +5% (1.60 -> 1.69 ms), at the edge of noise. The per-node closure call became a trait dispatch, which is cheaper (`update` 0.055 -> 0.039 ms); `draw` grew by 0.08 ms because the draw walk reads child fields through the trait.
128
+ - `stress_world`: +92% (1.09 -> 2.09 ms), above the 10% threshold. All of it is `update`, which here is the y-sort: `sortChildren(|a, b| a.y < b.y)` makes 2 x 19,999 `a.y` reads per frame and every read of a trait field is a dispatch (`__get_y`) over the program's actor kinds, about 30-40 ns each against about 10 ns for a direct field read. Without the sort `update` is 0.01 ms. The cost scales with the number of actor kinds in the program: dropping two unused test-only kinds took `update` from 1.65 to 1.29 ms, which is why the Scene tests that need their own `act`/`draw` records live in `plum-examples/scene.plum` and not in `std/game/Scene.plum`. Draw is +0.1 ms (culling and transforms run as trait default methods, so they read fields directly, one dispatch per child).
129
+ - Reading the trait's fields from outside it (a game's sort key, hit tests on `.x`/`.y`) is the price of the trait. Default methods run on the concrete record and read its fields directly, which is why `local()`, `outside()`, `drawTree()` and `hitTree()` are trait methods rather than free functions.
plum-game/web-test/fixtures/stress_scene.plum CHANGED
@@ -1,7 +1,7 @@
1
1
  # The stress scene of stress.plum built as a Scene2D-like scene graph (std/game/Scene):
2
2
  # 5000 sprites in 50 slowly rotating groups, 500 rects, 200 circles and 50 texts
3
3
  # with a changing number, drawn through a camera. Every node moves in its own
4
- # `act` behaviour, so each frame runs ~5800 closures and a composed-transform draw.
4
+ # `act`, so each frame runs ~5800 trait-dispatched acts and a composed-transform draw.
5
5
  module stress_scene
6
6
  import std/Str
7
7
  import std/Int
@@ -26,31 +26,78 @@ record Demo(sheet: Int, stage: Stage)
26
26
  fun hue(i: Int) -> Color =
27
27
  Color(r: Float(i % 7) / 7.0, g: Float(i % 5) / 5.0, b: 0.4 + Float(i % 3) / 5.0, a: 0.8)
28
28
 
29
+ # The actor kinds of this scene: records embedding the Actor trait, each with its own `act`.
30
+ record SpinGroup(Actor) =
31
+ fun create() -> SpinGroup =
32
+ SpinGroup(
33
+ x: 0.0, y: 0.0, width: 0.0, height: 0.0, originX: 75.0, originY: 85.0,
34
+ scaleX: 1.0, scaleY: 1.0, rotation: 0.0, color: Color.White, visible: true,
35
+ children: List[Actor](), parent: null, scratch: List[Actor](),
36
+ cull: false, cullX: 0.0, cullY: 0.0, cullW: 0.0, cullH: 0.0)
29
- fun spinGroup(n: Node, dt: Float) =
37
+ fun act(self, dt: Float) =
30
- n.rotation = n.rotation + 0.004
38
+ self.rotation = self.rotation + 0.004
39
+ self.actChildren(dt)
31
40
 
32
41
  # Turns the sprite; its frame follows the angle, advancing every 6 frames.
42
+ record Dancer(Actor, tex: Int, srcX: Float) =
43
+ fun create(tex: Int, srcX: Float) -> Dancer =
44
+ Dancer(
45
+ x: 0.0, y: 0.0, width: 16.0, height: 16.0, originX: 8.0, originY: 8.0,
46
+ scaleX: 1.0, scaleY: 1.0, rotation: 0.0, color: Color.White, visible: true,
47
+ children: List[Actor](), parent: null, scratch: List[Actor](),
48
+ cull: false, cullX: 0.0, cullY: 0.0, cullW: 0.0, cullH: 0.0,
49
+ tex: tex, srcX: srcX)
33
- fun animateSprite(n: Node, dt: Float) =
50
+ fun act(self, dt: Float) =
34
- n.rotation = n.rotation + 0.02
51
+ self.rotation = self.rotation + 0.02
35
- n.srcX = Float(Int(n.rotation * 50.0) / 6 % 4 * 16)
52
+ self.srcX = Float(Int(self.rotation * 50.0) / 6 % 4 * 16)
53
+ fun draw(self, m: Affine, alpha: Float) =
54
+ drawSpriteFrame(m, alpha, self.width, self.height, self.color, self.tex, self.srcX, 0.0, 16.0, 16.0)
36
55
 
56
+ record DriftRect(Actor) =
57
+ fun create(c: Color) -> DriftRect =
58
+ DriftRect(
59
+ x: 0.0, y: 0.0, width: 12.0, height: 8.0, originX: 0.0, originY: 0.0,
60
+ scaleX: 1.0, scaleY: 1.0, rotation: 0.0, color: c, visible: true,
61
+ children: List[Actor](), parent: null, scratch: List[Actor](),
62
+ cull: false, cullX: 0.0, cullY: 0.0, cullW: 0.0, cullH: 0.0)
37
- fun driftRect(n: Node, dt: Float) =
63
+ fun act(self, dt: Float) =
38
- n.x = n.x + 2.0
64
+ self.x = self.x + 2.0
39
- if n.x > Float(Counts.WorldW)
65
+ if self.x > Float(Counts.WorldW)
40
- n.x = n.x - Float(Counts.WorldW)
66
+ self.x = self.x - Float(Counts.WorldW)
67
+ fun draw(self, m: Affine, alpha: Float) =
68
+ drawRectBox(m, alpha, self.width, self.height, self.color)
41
69
 
70
+ record DriftCircle(Actor) =
71
+ fun create(c: Color) -> DriftCircle =
72
+ DriftCircle(
73
+ x: 0.0, y: 0.0, width: 12.0, height: 12.0, originX: 0.0, originY: 0.0,
74
+ scaleX: 1.0, scaleY: 1.0, rotation: 0.0, color: c, visible: true,
75
+ children: List[Actor](), parent: null, scratch: List[Actor](),
76
+ cull: false, cullX: 0.0, cullY: 0.0, cullW: 0.0, cullH: 0.0)
42
- fun driftCircle(n: Node, dt: Float) =
77
+ fun act(self, dt: Float) =
43
- n.x = n.x + 3.0
78
+ self.x = self.x + 3.0
44
- n.y = n.y + 1.0
79
+ self.y = self.y + 1.0
45
- if n.x > Float(Counts.WorldW)
80
+ if self.x > Float(Counts.WorldW)
46
- n.x = n.x - Float(Counts.WorldW)
81
+ self.x = self.x - Float(Counts.WorldW)
47
- if n.y > Float(Counts.WorldH)
82
+ if self.y > Float(Counts.WorldH)
48
- n.y = n.y - Float(Counts.WorldH)
83
+ self.y = self.y - Float(Counts.WorldH)
84
+ fun draw(self, m: Affine, alpha: Float) =
85
+ drawDisc(m, alpha, self.width / 2.0, self.color)
49
86
 
50
- # `tex` is the number shown.
87
+ # `n` is the number shown.
88
+ record CountText(Actor, n: Int, text: Str) =
89
+ fun create(n: Int) -> CountText =
90
+ CountText(
91
+ x: 0.0, y: 0.0, width: 60.0, height: 14.0, originX: 0.0, originY: 0.0,
92
+ scaleX: 1.0, scaleY: 1.0, rotation: 0.0, color: Color.White, visible: true,
93
+ children: List[Actor](), parent: null, scratch: List[Actor](),
94
+ cull: false, cullX: 0.0, cullY: 0.0, cullW: 0.0, cullH: 0.0,
95
+ n: n, text: "n {n}")
51
- fun countText(n: Node, dt: Float) =
96
+ fun act(self, dt: Float) =
52
- n.tex = n.tex + 1
97
+ self.n = self.n + 1
53
- n.text = "n {n.tex}"
98
+ self.text = "n {self.n}"
99
+ fun draw(self, m: Affine, alpha: Float) =
100
+ drawLabel(m, alpha, self.text, 14.0, self.color)
54
101
 
55
102
  export fun init() -> Demo =
56
103
  sheet := textureLoad("sprites.png")
@@ -58,21 +105,21 @@ export fun init() -> Demo =
58
105
  stage.camScale = 0.8
59
106
  per := Counts.Sprites / Counts.Groups
60
107
  for gi := range Counts.Groups
61
- g := Node.group().at(Float(gi % 10 * 160), Float(gi / 10 * 180)).setOrigin(75.0, 85.0).setAct(spinGroup)
108
+ g := SpinGroup.create().at(Float(gi % 10 * 160), Float(gi / 10 * 180))
62
109
  for j := range per
63
110
  i := gi * per + j
111
+ s := Dancer.create(sheet, Float(i % 4 * 16))
64
- s := Node.sprite(sheet, Float(i % 4 * 16), 0.0, 16.0, 16.0, 16.0, 16.0).at(Float({j * j * 13 + j * 37} % 150), Float({j * j * 29 + j * 53} % 170)).centerOrigin().setAct(animateSprite)
112
+ s.x = Float({j * j * 13 + j * 37} % 150)
113
+ s.y = Float({j * j * 29 + j * 53} % 170)
65
114
  s.rotation = Float(i)
66
115
  g.add(s)
67
116
  stage.root.add(g)
68
117
  for i := range Counts.Rects
69
- stage.root.add(Node.rect(12.0, 8.0, hue(i)).at(Float(i * 61 % Counts.WorldW), Float(i * 29 % Counts.WorldH)).setAct(driftRect))
118
+ stage.root.add(DriftRect.create(hue(i)).at(Float(i * 61 % Counts.WorldW), Float(i * 29 % Counts.WorldH)))
70
119
  for i := range Counts.Circles
71
- stage.root.add(Node.circle(6.0, hue(i + 3)).at(Float(i * 83 % Counts.WorldW), Float(i * 47 % Counts.WorldH)).setAct(driftCircle))
120
+ stage.root.add(DriftCircle.create(hue(i + 3)).at(Float(i * 83 % Counts.WorldW), Float(i * 47 % Counts.WorldH)))
72
121
  for i := range Counts.Texts
73
- t := Node.text("n {i}", 14.0, Color.White).at(Float(i * 30 % Counts.WorldW), Float(i * 17 % Counts.WorldH)).setAct(countText)
122
+ stage.root.add(CountText.create(i).at(Float(i * 30 % Counts.WorldW), Float(i * 17 % Counts.WorldH)))
74
- t.tex = i
75
- stage.root.add(t)
76
123
  Demo(sheet: sheet, stage: stage)
77
124
 
78
125
  export fun update(g: Demo, dt: Float) =
plum-game/web-test/fixtures/stress_world.plum CHANGED
@@ -23,17 +23,17 @@ primitive World =
23
23
  ViewH = 720.0
24
24
  Culling = 1
25
25
 
26
- record Demo(sheet: Int, stage: Stage, layer: Node, frame: Int)
26
+ record Demo(sheet: Int, stage: Stage, layer: Group, frame: Int)
27
27
 
28
28
  fun spriteAt(i: Int) -> Vec2 =
29
29
  Vec2(x: Float({i * 7919 + 13} % World.Width), y: Float({i * i * 31 / 7 + i * 1013} % World.Height))
30
30
 
31
31
  fun build(sheet: Int) -> Demo =
32
32
  stage := Stage.create()
33
- layer := Node.group()
33
+ layer := Group.create()
34
34
  for i := range World.Sprites
35
35
  p := spriteAt(i)
36
- layer.add(Node.sprite(sheet, Float(i % 4 * 16), 0.0, 16.0, 16.0, 16.0, 16.0).at(p.x, p.y))
36
+ layer.add(Sprite.create(sheet, Float(i % 4 * 16), 0.0, 16.0, 16.0, 16.0, 16.0).at(p.x, p.y))
37
37
  stage.root.add(layer)
38
38
  Demo(sheet: sheet, stage: stage, layer: layer, frame: 0)
39
39
 
plum-std/game/Scene.plum CHANGED
@@ -8,20 +8,31 @@ import std/game/Vec2
8
8
  import std/game/Color
9
9
  import std/game/Game
10
10
 
11
- # A Scene2D-like scene graph: a tree of `Node`s under a `Stage`. A node has a
11
+ # A Scene2D-like scene graph: a tree of actors under a `Stage`. `Actor` is a trait;
12
+ # every kind of actor is a `record` that embeds it (`Group`, `Sprite`, `Rect`,
13
+ # `Circle`, `Text` here, your own `record Walker(Actor, speed: Float)` in a game).
12
- # position, size, origin (the pivot of its rotation and scale), scale, rotation
14
+ # An actor has a position, size, origin (the pivot of its rotation and scale),
13
- # (radians, clockwise), colour, visibility and children. Children are placed in
15
+ # scale, rotation (radians, clockwise), colour, visibility and children. Children
14
- # their parent's coordinates, so moving, turning or scaling a group moves
16
+ # are placed in their parent's coordinates, so moving, turning or scaling a group
15
- # everything in it. Colour alpha multiplies down the tree and an invisible node
17
+ # moves everything in it. Colour alpha multiplies down the tree and an invisible
16
- # hides its whole subtree.
18
+ # actor hides its whole subtree.
19
+ #
20
+ # A record overrides the default methods `act(dt)` (its behaviour), `draw(m, alpha)`
21
+ # (what it paints, with its stage transform `m`) and `hit(p)` (whether a point in
22
+ # its own coordinates is on it). The default `act` runs the children's `act`; an
23
+ # override that has children calls `self.actChildren(dt)` itself.
17
24
  #
18
25
  # stage := Stage.create()
19
- # ring := Node.group()
26
+ # ring := Group.create()
20
27
  # stage.root.add(ring)
21
- # ring.add(Node.rect(40.0, 20.0, Color.Red).at(10.0, 5.0))
28
+ # ring.add(Rect.create(40.0, 20.0, Color.Red).at(10.0, 5.0))
22
- # stage.act(dt) # runs every node's `act` behaviour, depth first
29
+ # stage.act(dt) # runs every actor's `act`, depth first
23
30
  # stage.draw() # draws through `std/game/Game`
24
31
  #
32
+ # Actors are compared with `same(a, b)` (a trait value is a fresh wrapper each time
33
+ # it is converted, so `==` would not tell). A record built for the tree sets every
34
+ # trait field; copy `Group.create()` for the list.
35
+ #
25
36
  # The transform maths (`Affine`, `localMatrix`, `worldMatrix`, `flatten`,
26
37
  # `hitNode`) never calls the host, so it runs under `plum test`.
27
38
  #
@@ -31,14 +42,6 @@ import std/game/Game
31
42
  # scale, since the host has no rotated shapes. Non-uniform scale under rotation
32
43
  # (skew) is not supported for drawing; hit testing handles it exactly.
33
44
 
34
- # What a node draws.
35
- primitive NodeKind =
36
- Group = 0
37
- Sprite = 1
38
- Rect = 2
39
- Circle = 3
40
- Text = 4
41
-
42
45
  # A 2D affine transform: x' = a*x + c*y + e, y' = b*x + d*y + f.
43
46
  struct Affine(a: Float, b: Float, c: Float, d: Float, e: Float, f: Float) =
44
47
 
@@ -80,91 +83,77 @@ struct Affine(a: Float, b: Float, c: Float, d: Float, e: Float, f: Float) =
80
83
  fun rotation(self) -> Float =
81
84
  Math.atan2(self.b, self.a)
82
85
 
83
- record Node(
86
+ trait Actor(
84
87
  x: Float, y: Float, width: Float, height: Float,
85
88
  originX: Float, originY: Float,
86
89
  scaleX: Float, scaleY: Float,
87
90
  rotation: Float,
88
91
  color: Color,
89
92
  visible: Bool,
90
- children: List[Node],
93
+ children: List[Actor],
91
- parent: Node?,
94
+ parent: Actor?,
92
- kind: Int,
93
- tex: Int, srcX: Float, srcY: Float, srcW: Float, srcH: Float,
94
- text: Str, size: Float,
95
+ scratch: List[Actor],
95
- acting: Bool,
96
- act: fn(Node, Float),
97
- cull: Bool, cullX: Float, cullY: Float, cullW: Float, cullH: Float,
96
+ cull: Bool, cullX: Float, cullY: Float, cullW: Float, cullH: Float) =
98
- scratch: List[Node]) =
97
+
99
-
100
- fun make(kind: Int, w: Float, h: Float, c: Color) -> Node =
101
- Node(
102
- x: 0.0, y: 0.0, width: w, height: h, originX: 0.0, originY: 0.0,
103
- scaleX: 1.0, scaleY: 1.0, rotation: 0.0, color: c, visible: true,
104
- children: List[Node](), parent: null, kind: kind,
105
- tex: -1, srcX: 0.0, srcY: 0.0, srcW: 0.0, srcH: 0.0, text: "", size: 0.0,
106
- acting: false, act: noAct,
107
- cull: false, cullX: 0.0, cullY: 0.0, cullW: 0.0, cullH: 0.0, scratch: List[Node]())
98
+ # This actor's behaviour. The default acts the children; an override that keeps
108
-
109
- # A node that only holds children.
99
+ # children calls `self.actChildren(dt)` too.
110
- fun group() -> Node =
100
+ fun act(self, dt: Float) =
101
+ self.actChildren(dt)
102
+
111
- Node.make(NodeKind.Group, 0.0, 0.0, Color.White)
103
+ fun actChildren(self, dt: Float) =
112
-
104
+ i := 0
105
+ while i < self.children.length()
106
+ self.children.at(i).act(dt)
107
+ i = i + 1
108
+
113
- # A texture region (sx, sy, sw, sh in texture pixels) drawn w by h.
109
+ # Paints this actor only, given its stage transform `m` and accumulated alpha.
114
- fun sprite(tex: Int, sx: Float, sy: Float, sw: Float, sh: Float, w: Float, h: Float) -> Node =
115
- n := Node.make(NodeKind.Sprite, w, h, Color.White)
110
+ # The default paints nothing (a group).
116
- n.tex = tex
117
- n.srcX = sx
118
- n.srcY = sy
119
- n.srcW = sw
120
- n.srcH = sh
121
- n
122
-
123
- fun rect(w: Float, h: Float, c: Color) -> Node =
111
+ fun draw(self, m: Affine, alpha: Float) =
124
- Node.make(NodeKind.Rect, w, h, c)
112
+ return
125
-
113
+
126
- # A circle of radius r whose bounding box has its top-left at the node's position.
114
+ # Whether `p`, a point in this actor's own coordinates, is on it: inside its
115
+ # width by height box. A group with no size is never hit itself.
127
- fun circle(r: Float, c: Color) -> Node =
116
+ fun hit(self, p: Vec2) -> Bool =
128
- Node.make(NodeKind.Circle, r * 2.0, r * 2.0, c)
129
-
130
- # Text with its top-left at the node's position; the size is an estimate of the extent.
117
+ self.width > 0.0 && self.height > 0.0 && p.x >= 0.0 && p.x <= self.width && p.y >= 0.0 && p.y <= self.height
131
- fun text(s: Str, size: Float, c: Color) -> Node =
118
+
132
- n := Node.make(NodeKind.Text, size * 0.6 * Float(s.length()), size, c)
133
- n.text = s
134
- n.size = size
135
- n
136
-
137
- fun at(self, x: Float, y: Float) -> Node =
119
+ fun at(self, x: Float, y: Float) -> Actor =
138
120
  self.x = x
139
121
  self.y = y
140
122
  self
141
123
 
142
- fun setOrigin(self, ox: Float, oy: Float) -> Node =
124
+ fun setOrigin(self, ox: Float, oy: Float) -> Actor =
143
125
  self.originX = ox
144
126
  self.originY = oy
145
127
  self
146
128
 
147
- fun centerOrigin(self) -> Node =
129
+ fun centerOrigin(self) -> Actor =
148
130
  self.originX = self.width / 2.0
149
131
  self.originY = self.height / 2.0
150
132
  self
151
133
 
134
+ # The index of `c` among this actor's children, -1 if it is not one.
152
- fun setAct(self, f: fn(Node, Float)) -> Node =
135
+ fun childIndex(self, c: Actor) -> Int =
136
+ for i := range self.children.length()
137
+ if self.children.at(i).children == c.children
153
- self.act = f
138
+ return i
154
- self.acting = true
155
- self
139
+ -1
156
140
 
157
141
  # Appends a child (taking it from its previous parent) so it draws after its siblings.
158
- fun add(self, child: Node) =
142
+ fun add(self, child: Actor) =
159
143
  if p := child.parent
160
- p.children.remove(child)
144
+ p.detach(child)
161
145
  child.parent = self
162
146
  self.children.push(child)
163
147
 
148
+ fun detach(self, child: Actor) =
149
+ i := self.childIndex(child)
150
+ if i >= 0
151
+ self.children.removeAt(i)
152
+
164
- fun remove(self, child: Node) =
153
+ fun remove(self, child: Actor) =
165
154
  if p := child.parent
166
- if p == self
167
- self.children.remove(child)
155
+ if p.children == self.children
156
+ self.detach(child)
168
157
  child.parent = null
169
158
 
170
159
  fun clear(self) =
@@ -172,15 +161,13 @@ record Node(
172
161
  self.children.at(i).parent = null
173
162
  self.children.clear()
174
163
 
175
- # The node's index among its siblings (its draw order), -1 without a parent.
164
+ # The actor's index among its siblings (its draw order), -1 without a parent.
176
165
  fun zIndex(self) -> Int =
177
166
  if p := self.parent
178
- for i := range p.children.length()
179
- if p.children.at(i) == self
167
+ return p.childIndex(self)
180
- return i
181
168
  -1
182
169
 
183
- # Moves the node to index `i` of its parent's children (clamped to the valid
170
+ # Moves the actor to index `i` of its parent's children (clamped to the valid
184
171
  # range), shifting the siblings in between. Without a parent it does nothing.
185
172
  fun setZIndex(self, i: Int) =
186
173
  if p := self.parent
@@ -192,13 +179,14 @@ record Node(
192
179
  dst = p.children.length() - 1
193
180
  d := p.children.data
194
181
  base := p.children.start
182
+ me: Actor := self
195
183
  while src < dst
196
184
  d.set(base + src, d.get(base + src + 1))
197
185
  src = src + 1
198
186
  while src > dst
199
187
  d.set(base + src, d.get(base + src - 1))
200
188
  src = src - 1
201
- d.set(base + dst, self)
189
+ d.set(base + dst, me)
202
190
 
203
191
  # Draws last (on top) and is hit first.
204
192
  fun toFront(self) =
@@ -216,9 +204,9 @@ record Node(
216
204
  # is already sorted costs n - 1 comparisons, a nearly sorted one about 2n (one
217
205
  # scan to find the runs, one to confirm the merge) plus the distance each
218
206
  # displaced child moves; the worst case is O(n log n). The merge buffer
219
- # is the node's own `scratch` list, which keeps its capacity between calls, so
207
+ # is the actor's own `scratch` list, which keeps its capacity between calls, so
220
208
  # sorting every frame allocates nothing once it has grown.
221
- fun sortChildren(self, before: fn(Node, Node) -> Bool) =
209
+ fun sortChildren(self, before: fn(Actor, Actor) -> Bool) =
222
210
  n := self.children.length()
223
211
  d := self.children.data
224
212
  base := self.children.start
@@ -238,8 +226,8 @@ record Node(
238
226
  i = b
239
227
  runs = {runs + 1} / 2
240
228
 
241
- # Culls this node's direct children against the rectangle (x, y, w, h), in this
229
+ # Culls this actor's direct children against the rectangle (x, y, w, h), in this
242
- # node's own coordinates: a child whose box (position and size, scaled about its
230
+ # actor's own coordinates: a child whose box (position and size, scaled about its
243
231
  # origin; rotation ignored) misses it is neither drawn nor hit, with its whole
244
232
  # subtree. A child with no size (a plain group) is never culled.
245
233
  fun setCullingArea(self, x: Float, y: Float, w: Float, h: Float) =
@@ -252,19 +240,188 @@ record Node(
252
240
  fun clearCullingArea(self) =
253
241
  self.cull = false
254
242
 
243
+ # The transform into the parent's coordinates: translate to the position,
244
+ # rotate and scale about the origin.
245
+ fun local(self) -> Affine =
246
+ cs := Math.cos(self.rotation)
247
+ sn := Math.sin(self.rotation)
248
+ a := cs * self.scaleX
249
+ b := sn * self.scaleX
250
+ c := 0.0 - sn * self.scaleY
251
+ d := cs * self.scaleY
252
+ Affine(
253
+ a: a, b: b, c: c, d: d,
254
+ e: self.x + self.originX - {a * self.originX + c * self.originY},
255
+ f: self.y + self.originY - {b * self.originX + d * self.originY})
256
+
257
+ # Whether this actor's box (in its parent's coordinates) lies outside the
258
+ # rectangle (ax, ay, aw, ah). An actor with no size is never outside.
259
+ fun outside(self, ax: Float, ay: Float, aw: Float, ah: Float) -> Bool =
260
+ if self.width == 0.0 && self.height == 0.0
261
+ return false
262
+ w := self.width * self.scaleX
263
+ h := self.height * self.scaleY
264
+ x := self.x + self.originX - self.originX * self.scaleX
265
+ y := self.y + self.originY - self.originY * self.scaleY
266
+ if w < 0.0
267
+ x = x + w
268
+ w = 0.0 - w
269
+ if h < 0.0
270
+ y = y + h
271
+ h = 0.0 - h
272
+ x > ax + aw || x + w < ax || y > ay + ah || y + h < ay
273
+
274
+ # Whether this actor culls its child `c`.
275
+ fun culls(self, c: Actor) -> Bool =
276
+ self.cull && c.outside(self.cullX, self.cullY, self.cullW, self.cullH)
277
+
278
+ # Draws this actor and then its children, given the parent's stage transform.
279
+ fun drawTree(self, parent: Affine, alpha: Float) =
280
+ if !self.visible
281
+ return
282
+ m := parent * self.local()
283
+ a := alpha * self.color.a
284
+ self.draw(m, a)
285
+ n := self.children.length()
286
+ for i := range n
287
+ kid := self.children.at(i)
288
+ if !self.culls(kid)
289
+ kid.drawTree(m, a)
290
+
291
+ fun collect(self, parent: Affine, alpha: Float, out: List[Drawn]) =
292
+ if !self.visible
293
+ return
294
+ m := parent * self.local()
295
+ a := alpha * self.color.a
296
+ me: Actor := self
297
+ out.push(Drawn(node: me, m: m, alpha: a))
298
+ n := self.children.length()
299
+ for i := range n
300
+ kid := self.children.at(i)
301
+ if !self.culls(kid)
302
+ kid.collect(m, a, out)
303
+
304
+ # The topmost visible actor under `p`, a point in the parent's coordinates:
305
+ # children before the actor itself, later siblings before earlier ones.
306
+ fun hitTree(self, p: Vec2) -> Actor? =
307
+ if !self.visible
308
+ return null
309
+ q := self.local().inverse().apply(p)
310
+ n := self.children.length()
311
+ for i := range n
312
+ child := self.children.at(n - 1 - i)
313
+ if !self.culls(child)
314
+ if found := child.hitTree(q)
315
+ return found
316
+ if self.hit(q)
317
+ me: Actor := self
318
+ return me
319
+ null
320
+
255
- # A point in this node's coordinates, in the stage's.
321
+ # A point in this actor's coordinates, in the stage's.
256
322
  fun localToStage(self, p: Vec2) -> Vec2 =
323
+ me: Actor := self
257
- worldMatrix(self).apply(p)
324
+ worldMatrix(me).apply(p)
258
325
 
259
- # A point in the stage's coordinates, in this node's.
326
+ # A point in the stage's coordinates, in this actor's.
260
327
  fun stageToLocal(self, p: Vec2) -> Vec2 =
328
+ me: Actor := self
261
- worldMatrix(self).inverse().apply(p)
329
+ worldMatrix(me).inverse().apply(p)
330
+
331
+ # Whether two actor values are the same actor (they share the children list).
332
+ fun same(a: Actor, b: Actor) -> Bool =
333
+ a.children == b.children
334
+
335
+ # An actor that only holds children.
336
+ record Group(Actor) =
337
+
338
+ fun create() -> Group =
339
+ Group(
340
+ x: 0.0, y: 0.0, width: 0.0, height: 0.0, originX: 0.0, originY: 0.0,
341
+ scaleX: 1.0, scaleY: 1.0, rotation: 0.0, color: Color.White, visible: true,
342
+ children: List[Actor](), parent: null, scratch: List[Actor](),
343
+ cull: false, cullX: 0.0, cullY: 0.0, cullW: 0.0, cullH: 0.0)
344
+
345
+ # A texture region (srcX, srcY, srcW, srcH in texture pixels) drawn width by height.
346
+ record Sprite(Actor, tex: Int, srcX: Float, srcY: Float, srcW: Float, srcH: Float) =
347
+
348
+ fun create(tex: Int, sx: Float, sy: Float, sw: Float, sh: Float, w: Float, h: Float) -> Sprite =
349
+ Sprite(
350
+ x: 0.0, y: 0.0, width: w, height: h, originX: 0.0, originY: 0.0,
351
+ scaleX: 1.0, scaleY: 1.0, rotation: 0.0, color: Color.White, visible: true,
352
+ children: List[Actor](), parent: null, scratch: List[Actor](),
353
+ cull: false, cullX: 0.0, cullY: 0.0, cullW: 0.0, cullH: 0.0,
354
+ tex: tex, srcX: sx, srcY: sy, srcW: sw, srcH: sh)
355
+
356
+ fun draw(self, m: Affine, alpha: Float) =
357
+ drawSpriteFrame(m, alpha, self.width, self.height, self.color, self.tex, self.srcX, self.srcY, self.srcW, self.srcH)
358
+
359
+ record Rect(Actor) =
360
+
361
+ fun create(w: Float, h: Float, c: Color) -> Rect =
362
+ Rect(
363
+ x: 0.0, y: 0.0, width: w, height: h, originX: 0.0, originY: 0.0,
364
+ scaleX: 1.0, scaleY: 1.0, rotation: 0.0, color: c, visible: true,
365
+ children: List[Actor](), parent: null, scratch: List[Actor](),
366
+ cull: false, cullX: 0.0, cullY: 0.0, cullW: 0.0, cullH: 0.0)
367
+
368
+ fun draw(self, m: Affine, alpha: Float) =
369
+ drawRectBox(m, alpha, self.width, self.height, self.color)
370
+
371
+ record Circle(Actor) =
372
+
373
+ # A circle of radius r whose bounding box has its top-left at the actor's position.
374
+ fun create(r: Float, c: Color) -> Circle =
375
+ Circle(
376
+ x: 0.0, y: 0.0, width: r * 2.0, height: r * 2.0, originX: 0.0, originY: 0.0,
377
+ scaleX: 1.0, scaleY: 1.0, rotation: 0.0, color: c, visible: true,
378
+ children: List[Actor](), parent: null, scratch: List[Actor](),
379
+ cull: false, cullX: 0.0, cullY: 0.0, cullW: 0.0, cullH: 0.0)
380
+
381
+ fun draw(self, m: Affine, alpha: Float) =
382
+ drawDisc(m, alpha, self.width / 2.0, self.color)
383
+
384
+ record Text(Actor, text: Str, size: Float) =
385
+
386
+ # Text with its top-left at the actor's position; the size is an estimate of the extent.
387
+ fun create(s: Str, size: Float, c: Color) -> Text =
388
+ Text(
389
+ x: 0.0, y: 0.0, width: size * 0.6 * Float(s.length()), height: size, originX: 0.0, originY: 0.0,
390
+ scaleX: 1.0, scaleY: 1.0, rotation: 0.0, color: c, visible: true,
391
+ children: List[Actor](), parent: null, scratch: List[Actor](),
392
+ cull: false, cullX: 0.0, cullY: 0.0, cullW: 0.0, cullH: 0.0,
393
+ text: s, size: size)
394
+
395
+ fun draw(self, m: Affine, alpha: Float) =
396
+ drawLabel(m, alpha, self.text, self.size, self.color)
397
+
398
+ # Draws a `w` by `h` rectangle at the transform `m`, for your own rect-like records.
399
+ fun drawRectBox(m: Affine, alpha: Float, w: Float, h: Float, color: Color) =
400
+ sx := w * m.scaleX()
401
+ sy := h * m.scaleY()
402
+ c := m.apply(Vec2(x: w / 2.0, y: h / 2.0))
403
+ drawRect(c.x - sx / 2.0, c.y - sy / 2.0, sx, sy, color.withAlpha(alpha))
404
+
405
+ # Draws a circle of radius `r` whose bounding box has its top-left at the transform `m`.
406
+ fun drawDisc(m: Affine, alpha: Float, r: Float, color: Color) =
407
+ c := m.apply(Vec2(x: r, y: r))
408
+ drawCircle(c.x, c.y, r * m.scaleX(), color.withAlpha(alpha))
409
+
410
+ # Draws text with its top-left at the transform `m`.
411
+ fun drawLabel(m: Affine, alpha: Float, s: Str, size: Float, color: Color) =
412
+ p := m.apply(Vec2(x: 0.0, y: 0.0))
413
+ drawText(s, p.x, p.y, size * m.scaleY(), color.withAlpha(alpha))
414
+
415
+ # Draws a texture region `w` by `h` at the transform `m`: for your own sprite-like records.
416
+ fun drawSpriteFrame(m: Affine, alpha: Float, w: Float, h: Float, color: Color, tex: Int, srcX: Float, srcY: Float, srcW: Float, srcH: Float) =
417
+ sx := w * m.scaleX()
418
+ sy := h * m.scaleY()
419
+ c := m.apply(Vec2(x: w / 2.0, y: h / 2.0))
420
+ drawTextureEx(tex, srcX, srcY, srcW, srcH, c.x - sx / 2.0, c.y - sy / 2.0, sx, sy, m.rotation(), false, color.withAlpha(alpha))
262
421
 
263
- fun noAct(n: Node, dt: Float) =
264
- n.x = n.x
265
422
 
266
423
  # The end of the ascending run of `d` starting at `i` (exclusive), within n.
267
- fun sortRunEnd(d: Array[Node], base: Int, i: Int, n: Int, before: fn(Node, Node) -> Bool) -> Int =
424
+ fun sortRunEnd(d: Array[Actor], base: Int, i: Int, n: Int, before: fn(Actor, Actor) -> Bool) -> Int =
268
425
  j := i + 1
269
426
  while j < n && !before(d.get(base + j), d.get(base + j - 1))
270
427
  j = j + 1
@@ -273,7 +430,7 @@ fun sortRunEnd(d: Array[Node], base: Int, i: Int, n: Int, before: fn(Node, Node)
273
430
  # Merges the sorted runs [lo, a) and [a, b) of `d`, stably. The left elements that
274
431
  # already precede the first right one and the right elements that already follow
275
432
  # the last left one are found by binary search and left alone.
276
- fun sortMerge(scratch: List[Node], d: Array[Node], base: Int, lo: Int, a: Int, b: Int, before: fn(Node, Node) -> Bool) =
433
+ fun sortMerge(scratch: List[Actor], d: Array[Actor], base: Int, lo: Int, a: Int, b: Int, before: fn(Actor, Actor) -> Bool) =
277
434
  first := d.get(base + a)
278
435
  l := lo
279
436
  h := a
@@ -318,136 +475,57 @@ fun sortMerge(scratch: List[Node], d: Array[Node], base: Int, lo: Int, a: Int, b
318
475
  i = i + 1
319
476
  o = o + 1
320
477
 
321
- # Whether `parent` culls `c`: the child's box in the parent's coordinates lies
478
+
322
- # outside the parent's culling area.
323
- fun culled(parent: Node, c: Node) -> Bool =
324
- if !parent.cull || c.width == 0.0 && c.height == 0.0
325
- return false
326
- w := c.width * c.scaleX
327
- h := c.height * c.scaleY
328
- x := c.x + c.originX - c.originX * c.scaleX
329
- y := c.y + c.originY - c.originY * c.scaleY
330
- if w < 0.0
331
- x = x + w
332
- w = 0.0 - w
333
- if h < 0.0
334
- y = y + h
335
- h = 0.0 - h
336
- x > parent.cullX + parent.cullW || x + w < parent.cullX || y > parent.cullY + parent.cullH || y + h < parent.cullY
337
-
338
- # The node's transform into its parent's coordinates: translate to the position,
479
+ # The actor's transform into its parent's coordinates.
339
- # rotate and scale about the origin.
340
- fun localMatrix(n: Node) -> Affine =
480
+ fun localMatrix(n: Actor) -> Affine =
341
- cs := Math.cos(n.rotation)
342
- sn := Math.sin(n.rotation)
343
- a := cs * n.scaleX
344
- b := sn * n.scaleX
345
- c := 0.0 - sn * n.scaleY
346
- d := cs * n.scaleY
347
- Affine(
481
+ n.local()
348
- a: a, b: b, c: c, d: d,
482
+
349
- e: n.x + n.originX - {a * n.originX + c * n.originY},
350
- f: n.y + n.originY - {b * n.originX + d * n.originY})
351
-
352
- # The node's transform into stage coordinates.
483
+ # The actor's transform into stage coordinates.
353
- fun worldMatrix(n: Node) -> Affine =
484
+ fun worldMatrix(n: Actor) -> Affine =
354
485
  if p := n.parent
355
- worldMatrix(p) * localMatrix(n)
486
+ worldMatrix(p) * n.local()
356
487
  else
357
- localMatrix(n)
488
+ n.local()
358
489
 
359
- # One entry of the draw list: a visible node, its stage transform and its alpha.
490
+ # One entry of the draw list: a visible actor, its stage transform and its alpha.
360
- record Drawn(node: Node, m: Affine, alpha: Float)
491
+ record Drawn(node: Actor, m: Affine, alpha: Float)
361
-
362
- fun collect(n: Node, parent: Affine, alpha: Float, out: List[Drawn]) =
363
- if !n.visible
364
- return
365
- m := parent * localMatrix(n)
366
- a := alpha * n.color.a
367
- out.push(Drawn(node: n, m: m, alpha: a))
368
- for i := range n.children.length()
369
- kid := n.children.at(i)
370
- if !culled(n, kid)
371
- collect(kid, m, a, out)
372
492
 
373
493
  # What `draw` would draw, in order: a parent before its children, siblings in
374
494
  # list order, invisible and culled subtrees left out.
375
- fun flatten(root: Node) -> List[Drawn] =
495
+ fun flatten(root: Actor) -> List[Drawn] =
376
496
  out := List[Drawn]()
377
- collect(root, Affine.Identity, 1.0, out)
497
+ root.collect(Affine.Identity, 1.0, out)
378
498
  out
379
499
 
380
- fun drawNode(n: Node, parent: Affine, alpha: Float) =
500
+ fun drawNode(n: Actor, parent: Affine, alpha: Float) =
381
- if !n.visible
382
- return
383
- m := parent * localMatrix(n)
384
- a := alpha * n.color.a
385
- if n.kind == NodeKind.Sprite
386
- sx := n.width * m.scaleX()
387
- sy := n.height * m.scaleY()
388
- c := m.apply(Vec2(x: n.width / 2.0, y: n.height / 2.0))
389
- drawTextureEx(n.tex, n.srcX, n.srcY, n.srcW, n.srcH, c.x - sx / 2.0, c.y - sy / 2.0, sx, sy, m.rotation(), false, n.color.withAlpha(a))
390
- else if n.kind == NodeKind.Rect
391
- sx := n.width * m.scaleX()
392
- sy := n.height * m.scaleY()
393
- c := m.apply(Vec2(x: n.width / 2.0, y: n.height / 2.0))
394
- drawRect(c.x - sx / 2.0, c.y - sy / 2.0, sx, sy, n.color.withAlpha(a))
395
- else if n.kind == NodeKind.Circle
396
- r := n.width / 2.0
397
- c := m.apply(Vec2(x: r, y: r))
398
- drawCircle(c.x, c.y, r * m.scaleX(), n.color.withAlpha(a))
399
- else if n.kind == NodeKind.Text
400
- p := m.apply(Vec2(x: 0.0, y: 0.0))
401
- drawText(n.text, p.x, p.y, n.size * m.scaleY(), n.color.withAlpha(a))
402
- for i := range n.children.length()
403
- kid := n.children.at(i)
404
- if !culled(n, kid)
405
- drawNode(kid, m, a)
501
+ n.drawTree(parent, alpha)
406
-
502
+
407
- fun actNode(n: Node, dt: Float) =
503
+ fun actNode(n: Actor, dt: Float) =
408
- if n.acting
504
+ n.act(dt)
409
- f := n.act
410
- f(n, dt)
411
- i := 0
412
- while i < n.children.length()
413
- actNode(n.children.at(i), dt)
414
- i = i + 1
415
505
 
416
- # The topmost visible node under `p`, a point in the node's parent's coordinates:
506
+ # The topmost visible actor under `p`, a point in the actor's parent's coordinates.
417
- # children before the node itself, later siblings before earlier ones. A node is
418
- # hit inside its width by height box; a group with no size is never hit itself.
419
- fun hitNode(n: Node, p: Vec2) -> Node? =
507
+ fun hitNode(n: Actor, p: Vec2) -> Actor? =
420
- if !n.visible
421
- return null
508
+ n.hitTree(p)
422
- q := localMatrix(n).inverse().apply(p)
509
+
423
- for i := range n.children.length()
424
- child := n.children.at(n.children.length() - 1 - i)
425
- if !culled(n, child)
426
- if found := hitNode(child, q)
427
- return found
428
- if n.width > 0.0 && n.height > 0.0 && q.x >= 0.0 && q.x <= n.width && q.y >= 0.0 && q.y <= n.height
429
- return n
430
- null
431
-
432
- # A root node plus a camera (the world point at the screen's top-left and the
510
+ # A root actor plus a camera (the world point at the screen's top-left and the
433
511
  # pixels per world unit, as in `setCamera`).
434
- record Stage(root: Node, camX: Float, camY: Float, camScale: Float) =
512
+ record Stage(root: Actor, camX: Float, camY: Float, camScale: Float) =
435
513
 
436
514
  fun create() -> Stage =
437
- Stage(root: Node.group(), camX: 0.0, camY: 0.0, camScale: 1.0)
515
+ Stage(root: Group.create(), camX: 0.0, camY: 0.0, camScale: 1.0)
438
516
 
439
- # Runs every node's behaviour, parents before children, in insertion order.
517
+ # Runs every actor's `act`, parents before children, in insertion order.
440
518
  fun act(self, dt: Float) =
441
- actNode(self.root, dt)
519
+ self.root.act(dt)
442
520
 
443
521
  fun draw(self) =
444
522
  setCamera(self.camX, self.camY, self.camScale)
445
- drawNode(self.root, Affine.Identity, 1.0)
523
+ self.root.drawTree(Affine.Identity, 1.0)
446
524
  resetCamera()
447
525
 
448
- # The topmost visible node at a stage point.
526
+ # The topmost visible actor at a stage point.
449
- fun hit(self, x: Float, y: Float) -> Node? =
527
+ fun hit(self, x: Float, y: Float) -> Actor? =
450
- hitNode(self.root, Vec2(x: x, y: y))
528
+ self.root.hitTree(Vec2(x: x, y: y))
451
529
 
452
530
  # A screen pixel in stage coordinates under this camera.
453
531
  fun screenToStage(self, sx: Float, sy: Float) -> Vec2 =
@@ -461,23 +539,20 @@ fun sceneNear(a: Float, b: Float) -> Bool =
461
539
  fun sceneNearV(p: Vec2, x: Float, y: Float) -> Bool =
462
540
  sceneNear(p.x, x) && sceneNear(p.y, y)
463
541
 
464
- fun sceneKids(n: Int) -> Node =
542
+ fun sceneKids(n: Int) -> Group =
465
- g := Node.group()
543
+ g := Group.create()
466
544
  for i := range n
467
- g.add(Node.rect(1.0, 1.0, Color.Red).at(Float(i), 0.0))
545
+ g.add(Rect.create(1.0, 1.0, Color.Red).at(Float(i), 0.0))
468
546
  g
469
547
 
470
- fun sceneOrder(g: Node) -> Str =
548
+ fun sceneOrder(g: Actor) -> Str =
471
549
  s := ""
472
550
  for i := range g.children.length()
473
551
  s = "{s}{Int(g.children.at(i).x)},"
474
552
  s
475
553
 
476
- fun sceneSpin(n: Node, dt: Float) =
477
- n.rotation = n.rotation + dt
478
-
479
- test "a node translates; rotation and scale pivot about the origin"
554
+ test "an actor translates; rotation and scale pivot about the origin"
480
- n := Node.rect(10.0, 10.0, Color.Red).at(100.0, 50.0)
555
+ n := Rect.create(10.0, 10.0, Color.Red).at(100.0, 50.0)
481
556
  assert sceneNearV(localMatrix(n).apply(Vec2(x: 0.0, y: 0.0)), 100.0, 50.0)
482
557
  n.originX = 5.0
483
558
  n.originY = 5.0
@@ -489,11 +564,11 @@ test "a node translates; rotation and scale pivot about the origin"
489
564
  assert sceneNearV(localMatrix(n).apply(Vec2(x: 10.0, y: 10.0)), 115.0, 60.0)
490
565
 
491
566
  test "matrices compose, and inverse undoes them"
492
- g := Node.group().at(100.0, 0.0)
567
+ g := Group.create().at(100.0, 0.0)
493
568
  g.rotation = Float.HalfPi
494
569
  g.scaleX = 2.0
495
570
  g.scaleY = 2.0
496
- k := Node.rect(10.0, 10.0, Color.Red).at(10.0, 0.0)
571
+ k := Rect.create(10.0, 10.0, Color.Red).at(10.0, 0.0)
497
572
  g.add(k)
498
573
  w := worldMatrix(k)
499
574
  assert sceneNearV(w.apply(Vec2(x: 0.0, y: 0.0)), 100.0, 20.0)
@@ -506,112 +581,109 @@ test "matrices compose, and inverse undoes them"
506
581
  assert sceneNearV(k.stageToLocal(Vec2(x: 80.0, y: 40.0)), 10.0, 10.0)
507
582
 
508
583
  test "add reparents, remove and clear detach"
509
- a := Node.group()
584
+ a := Group.create()
510
- b := Node.group()
585
+ b := Group.create()
511
- k := Node.group()
586
+ k := Group.create()
512
587
  a.add(k)
513
588
  assert a.children.length() == 1
514
589
  b.add(k)
515
590
  assert a.children.length() == 0
516
591
  assert b.children.length() == 1
517
- assert {k.parent ?: crash "no parent"} == b
592
+ assert same({k.parent ?: crash "no parent"}, b)
518
593
  b.remove(k)
519
594
  assert b.children.length() == 0
520
595
  assert k.parent == null
521
596
  b.add(k)
522
- extra := Node.group()
597
+ extra := Group.create()
523
598
  b.add(extra)
524
599
  b.clear()
525
600
  assert b.children.length() == 0
526
601
  assert k.parent == null
527
602
 
528
603
  test "draw order is parent first, then children in insertion order"
529
- root := Node.group()
604
+ root := Group.create()
530
- a := Node.group()
605
+ a := Group.create()
531
606
  root.add(a)
532
- b := Node.group()
607
+ b := Group.create()
533
608
  root.add(b)
534
- a1 := Node.rect(1.0, 1.0, Color.Red)
609
+ a1 := Rect.create(1.0, 1.0, Color.Red)
535
610
  a.add(a1)
536
- a2 := Node.rect(1.0, 1.0, Color.Red)
611
+ a2 := Rect.create(1.0, 1.0, Color.Red)
537
612
  a.add(a2)
538
- b1 := Node.rect(1.0, 1.0, Color.Red)
613
+ b1 := Rect.create(1.0, 1.0, Color.Red)
539
614
  b.add(b1)
540
615
  list := flatten(root)
541
616
  assert list.length() == 6
542
- assert list.at(0).node == root
617
+ assert same(list.at(0).node, root)
543
- assert list.at(1).node == a
618
+ assert same(list.at(1).node, a)
544
- assert list.at(2).node == a1
619
+ assert same(list.at(2).node, a1)
545
- assert list.at(3).node == a2
620
+ assert same(list.at(3).node, a2)
546
- assert list.at(4).node == b
621
+ assert same(list.at(4).node, b)
547
- assert list.at(5).node == b1
622
+ assert same(list.at(5).node, b1)
548
623
 
549
624
  test "invisible subtrees are skipped and alpha multiplies down"
550
- root := Node.group()
625
+ root := Group.create()
551
626
  root.color = Color.White.withAlpha(0.5)
552
- g := Node.group()
627
+ g := Group.create()
553
628
  root.add(g)
554
629
  g.color = Color.White.withAlpha(0.5)
555
- k := Node.rect(1.0, 1.0, Color.Red)
630
+ k := Rect.create(1.0, 1.0, Color.Red)
556
631
  g.add(k)
557
- hidden := Node.group()
632
+ hidden := Group.create()
558
633
  root.add(hidden)
559
634
  hidden.visible = false
560
- hidden.add(Node.rect(1.0, 1.0, Color.Red))
635
+ hidden.add(Rect.create(1.0, 1.0, Color.Red))
561
636
  list := flatten(root)
562
637
  assert list.length() == 3
563
638
  assert sceneNear(list.at(2).alpha, 0.25)
564
639
  g.visible = false
565
640
  assert flatten(root).length() == 1
566
641
 
567
- test "hit finds the topmost node through rotated and scaled groups"
642
+ test "hit finds the topmost actor through rotated and scaled groups"
568
- root := Node.group()
643
+ root := Group.create()
569
- g := Node.group().at(100.0, 0.0)
644
+ g := Group.create().at(100.0, 0.0)
570
645
  root.add(g)
571
646
  g.rotation = Float.HalfPi
572
647
  g.scaleX = 2.0
573
648
  g.scaleY = 2.0
574
- low := Node.rect(30.0, 30.0, Color.Red)
649
+ low := Rect.create(30.0, 30.0, Color.Red)
575
650
  g.add(low)
576
- top := Node.rect(10.0, 10.0, Color.Blue).at(10.0, 0.0)
651
+ top := Rect.create(10.0, 10.0, Color.Blue).at(10.0, 0.0)
577
652
  g.add(top)
578
653
  stage := Stage.create()
579
654
  stage.root.add(g)
580
- assert {stage.hit(90.0, 30.0) ?: crash "miss"} == top
655
+ assert same({stage.hit(90.0, 30.0) ?: crash "miss"}, top)
581
- assert {stage.hit(70.0, 10.0) ?: crash "miss"} == low
656
+ assert same({stage.hit(70.0, 10.0) ?: crash "miss"}, low)
582
- assert {stage.hit(60.0, 30.0) ?: crash "miss"} == low
657
+ assert same({stage.hit(60.0, 30.0) ?: crash "miss"}, low)
583
658
  assert stage.hit(10.0, 10.0) == null
584
659
  assert stage.hit(150.0, 30.0) == null
585
660
  top.visible = false
586
- assert {stage.hit(90.0, 30.0) ?: crash "miss"} == low
661
+ assert same({stage.hit(90.0, 30.0) ?: crash "miss"}, low)
587
662
  g.visible = false
588
663
  assert stage.hit(90.0, 30.0) == null
589
664
 
590
665
  test "hit prefers later siblings and a child over its parent"
591
666
  stage := Stage.create()
592
- a := Node.rect(50.0, 50.0, Color.Red)
667
+ a := Rect.create(50.0, 50.0, Color.Red)
593
668
  stage.root.add(a)
594
- b := Node.rect(50.0, 50.0, Color.Blue).at(25.0, 0.0)
669
+ b := Rect.create(50.0, 50.0, Color.Blue).at(25.0, 0.0)
595
670
  stage.root.add(b)
596
- inner := Node.rect(10.0, 10.0, Color.Green)
671
+ inner := Rect.create(10.0, 10.0, Color.Green)
597
672
  b.add(inner)
598
- assert {stage.hit(10.0, 10.0) ?: crash "miss"} == a
673
+ assert same({stage.hit(10.0, 10.0) ?: crash "miss"}, a)
599
- assert {stage.hit(30.0, 5.0) ?: crash "miss"} == inner
674
+ assert same({stage.hit(30.0, 5.0) ?: crash "miss"}, inner)
600
- assert {stage.hit(60.0, 40.0) ?: crash "miss"} == b
675
+ assert same({stage.hit(60.0, 40.0) ?: crash "miss"}, b)
601
- assert {stage.hit(40.0, 10.0) ?: crash "miss"} == b
676
+ assert same({stage.hit(40.0, 10.0) ?: crash "miss"}, b)
602
677
 
603
- test "act runs behaviours depth first, including invisible nodes"
678
+ test "the default act runs the children's act, invisible ones included"
604
679
  stage := Stage.create()
605
- g := Node.group().setAct(sceneSpin)
680
+ g := Group.create()
606
681
  stage.root.add(g)
607
- k := Node.group().setAct(sceneSpin)
682
+ g.add(Rect.create(1.0, 1.0, Color.Red))
608
- g.add(k)
609
683
  g.visible = false
610
684
  stage.act(0.5)
611
- stage.act(0.25)
612
- assert sceneNear(g.rotation, 0.75)
613
- assert sceneNear(k.rotation, 0.75)
614
685
  assert stage.root.rotation == 0.0
686
+ assert g.children.length() == 1
615
687
 
616
688
  test "the screen maps to the stage through the camera"
617
689
  stage := Stage.create()
@@ -622,11 +694,11 @@ test "the screen maps to the stage through the camera"
622
694
  assert sceneNearV(p, 120.0, 60.0)
623
695
 
624
696
  test "sizes of sprites, circles and text"
625
- assert Node.circle(5.0, Color.Red).width == 10.0
697
+ assert Circle.create(5.0, Color.Red).width == 10.0
626
- s := Node.sprite(3, 16.0, 0.0, 16.0, 16.0, 32.0, 32.0)
698
+ s := Sprite.create(3, 16.0, 0.0, 16.0, 16.0, 32.0, 32.0)
627
699
  assert s.tex == 3
628
- assert s.kind == NodeKind.Sprite
700
+ assert s.width == 32.0
629
- assert Node.text("ab", 10.0, Color.White).height == 10.0
701
+ assert Text.create("ab", 10.0, Color.White).height == 10.0
630
702
 
631
703
  test "setZIndex, toFront and toBack reorder the siblings"
632
704
  g := sceneKids(4)
@@ -646,36 +718,36 @@ test "setZIndex, toFront and toBack reorder the siblings"
646
718
  assert sceneOrder(g) == "3,2,0,1,"
647
719
  b.setZIndex(-5)
648
720
  assert sceneOrder(g) == "1,3,2,0,"
649
- assert Node.group().zIndex() == -1
721
+ assert Group.create().zIndex() == -1
650
- Node.group().toFront()
722
+ Group.create().toFront()
651
723
  assert g.children.length() == 4
652
724
 
653
725
  test "sortChildren is stable and keeps ties in order"
654
- g := Node.group()
726
+ g := Group.create()
655
727
  for i := range 10
656
- k := Node.rect(1.0, 1.0, Color.Red).at(Float(i), Float(i * 7 % 3))
728
+ k := Rect.create(1.0, 1.0, Color.Red).at(Float(i), Float(i * 7 % 3))
657
729
  g.add(k)
658
730
  g.sortChildren(|a, b| a.y < b.y)
659
731
  assert sceneOrder(g) == "0,3,6,9,1,4,7,2,5,8,"
660
732
  g.sortChildren(|a, b| a.y < b.y)
661
733
  assert sceneOrder(g) == "0,3,6,9,1,4,7,2,5,8,"
662
734
 
663
- test "sortChildren sorts 5000 random nodes and updates hit order"
735
+ test "sortChildren sorts 5000 random actors and updates hit order"
664
- g := Node.group()
736
+ g := Group.create()
665
737
  for i := range 5000
666
- g.add(Node.rect(1.0, 1.0, Color.Red).at(Float(i), Float({i * 7919 + 13} % 5003)))
738
+ g.add(Rect.create(1.0, 1.0, Color.Red).at(Float(i), Float({i * 7919 + 13} % 5003)))
667
739
  g.sortChildren(|a, b| a.y < b.y)
668
740
  assert g.children.length() == 5000
669
741
  for i := range 4999
670
742
  assert g.children.at(i).y <= g.children.at(i + 1).y
671
- assert {g.children.at(10).parent ?: crash "orphan"} == g
743
+ assert same({g.children.at(10).parent ?: crash "orphan"}, g)
672
744
 
673
745
  test "sorting a nearly sorted list takes about n comparisons and reuses the scratch buffer"
674
- g := Node.group()
746
+ g := Group.create()
675
747
  for i := range 5000
676
- g.add(Node.rect(1.0, 1.0, Color.Red).at(0.0, Float(i)))
748
+ g.add(Rect.create(1.0, 1.0, Color.Red).at(0.0, Float(i)))
677
749
  c := List(0)
678
- less: fn(Node, Node) -> Bool := |a, b|
750
+ less: fn(Actor, Actor) -> Bool := |a, b|
679
751
  _ := c.set(0, c.at(0) + 1)
680
752
  a.y < b.y
681
753
  g.sortChildren(less)
@@ -698,22 +770,22 @@ test "sorting a nearly sorted list takes about n comparisons and reuses the scra
698
770
  _ := c.set(0, 0)
699
771
  g.children.clear()
700
772
  for i := range 5000
701
- g.add(Node.rect(1.0, 1.0, Color.Red).at(0.0, Float({i * 7919 + 13} % 5003)))
773
+ g.add(Rect.create(1.0, 1.0, Color.Red).at(0.0, Float({i * 7919 + 13} % 5003)))
702
774
  g.sortChildren(less)
703
775
  assert c.at(0) < 130000
704
776
 
705
777
  test "culling skips children outside the area in flatten and draw order"
706
- g := Node.group()
778
+ g := Group.create()
707
- inside := Node.rect(10.0, 10.0, Color.Red).at(20.0, 20.0)
779
+ inside := Rect.create(10.0, 10.0, Color.Red).at(20.0, 20.0)
708
- outside := Node.rect(10.0, 10.0, Color.Red).at(500.0, 20.0)
780
+ outside := Rect.create(10.0, 10.0, Color.Red).at(500.0, 20.0)
709
781
  g.add(inside)
710
782
  g.add(outside)
711
- outside.add(Node.rect(5.0, 5.0, Color.Red))
783
+ outside.add(Rect.create(5.0, 5.0, Color.Red))
712
784
  assert flatten(g).length() == 4
713
785
  g.setCullingArea(0.0, 0.0, 100.0, 100.0)
714
786
  list := flatten(g)
715
787
  assert list.length() == 2
716
- assert list.at(1).node == inside
788
+ assert same(list.at(1).node, inside)
717
789
  g.setCullingArea(0.0, 100.0, 100.0, 100.0)
718
790
  assert flatten(g).length() == 1
719
791
  g.clearCullingArea()
@@ -721,24 +793,24 @@ test "culling skips children outside the area in flatten and draw order"
721
793
 
722
794
  test "hit does not return culled children, and a scaled child near the edge is kept"
723
795
  stage := Stage.create()
724
- k := Node.rect(10.0, 10.0, Color.Red).at(105.0, 5.0)
796
+ k := Rect.create(10.0, 10.0, Color.Red).at(105.0, 5.0)
725
797
  stage.root.add(k)
726
798
  stage.root.setCullingArea(0.0, 0.0, 100.0, 100.0)
727
799
  assert stage.hit(107.0, 7.0) == null
728
800
  k.scaleX = 4.0
729
801
  k.originX = 10.0
730
- assert {stage.hit(90.0, 7.0) ?: crash "miss"} == k
802
+ assert same({stage.hit(90.0, 7.0) ?: crash "miss"}, k)
731
803
  assert flatten(stage.root).length() == 2
732
804
  k.scaleX = 1.0
733
805
  k.originX = 0.0
734
806
  assert stage.hit(107.0, 7.0) == null
735
807
  stage.root.clearCullingArea()
736
- assert {stage.hit(107.0, 7.0) ?: crash "miss"} == k
808
+ assert same({stage.hit(107.0, 7.0) ?: crash "miss"}, k)
737
809
 
738
810
  test "a child without a size is never culled"
739
- g := Node.group()
811
+ g := Group.create()
740
- slot := Node.group().at(900.0, 900.0)
812
+ slot := Group.create().at(900.0, 900.0)
741
813
  g.add(slot)
742
- slot.add(Node.rect(10.0, 10.0, Color.Red).at(-890.0, -890.0))
814
+ slot.add(Rect.create(10.0, 10.0, Color.Red).at(-890.0, -890.0))
743
815
  g.setCullingArea(0.0, 0.0, 100.0, 100.0)
744
816
  assert flatten(g).length() == 3