plum

#treesitter#compiler#wasm

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

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


32ce1ab — Peter John 2026-11-02T16:18:53+05:30
Add std/Scene (Plum scene graph), SceneCmd, scene example and stress fixtures
plum-examples/scene.plum ADDED
@@ -0,0 +1,235 @@
1
+ # Clockwork: a scene-graph demo (`plum game plum-examples/scene.plum`). A sun, four
2
+ # planets and their moons orbit by nesting rotating groups (std/Scene): each orbit
3
+ # is a group whose rotation carries everything inside it, so a moon only ever
4
+ # says "I sit 40 units from my planet" and the maths composes the rest. Click a
5
+ # body to select it (a halo and its name), click again or the background to clear;
6
+ # Space pauses.
7
+ #
8
+ # The scene is built and queried by plain functions that never call the host, so
9
+ # the `test` blocks below run under `plum test`; only `init`, `update` and `draw`
10
+ # talk to the host.
11
+ module scene
12
+ import std/Str
13
+ import std/Int
14
+ import std/Float
15
+ import std/List
16
+ import std/Math
17
+ import std/Vec2
18
+ import std/Color
19
+ import std/Game
20
+ import std/Scene
21
+
22
+ primitive Layout =
23
+ Width = 800.0
24
+ Height = 600.0
25
+ CenterX = 400.0
26
+ CenterY = 300.0
27
+ Cell = 16.0
28
+ Planets = 4
29
+
30
+ # Everything `update` and `draw` need. `bodies` and `names` are parallel (every
31
+ # planet and moon, each planet followed by its moons), as are `halos` (the glow shown when selected).
32
+ record Sky(
33
+ stage: Stage,
34
+ bodies: List[Node],
35
+ halos: List[Node],
36
+ names: List[Str],
37
+ sun: Node,
38
+ label: Node,
39
+ selected: Int,
40
+ clock: Float,
41
+ paused: Bool)
42
+
43
+ # Gives the node a behaviour that turns it `speed` radians per second.
44
+ fun spun(n: Node, speed: Float) -> Node =
45
+ turn := |m, dt|
46
+ m.rotation = m.rotation + speed * dt
47
+ n.setAct(turn)
48
+
49
+ # A body sprite and its (hidden) halo in a group `slot` centred on the group's own
50
+ # origin. The caller places the slot; the sprite spins on its own axis.
51
+ fun addBody(sky: Sky, sheet: Int, name: Str, row: Int, frame: Int, size: Float, spin: Float) -> Node =
52
+ slot := Node.group()
53
+ 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)
54
+ halo.visible = false
55
+ 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()
56
+ body = spun(body, spin)
57
+ slot.add(halo)
58
+ slot.add(body)
59
+ sky.halos.push(halo)
60
+ sky.bodies.push(body)
61
+ sky.names.push(name)
62
+ slot
63
+
64
+ fun newSky(sheet: Int) -> Sky =
65
+ stage := Stage.create()
66
+ backdrop := Node.rect(Layout.Width, Layout.Height, Color.rgb(0.04, 0.05, 0.1))
67
+ stage.root.add(backdrop)
68
+ for i := range 40
69
+ 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))
70
+ stage.root.add(star)
71
+ 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)
72
+ label := Node.text("Click a planet", 24.0, Color.White).at(16.0, 12.0)
73
+ sky := Sky(
74
+ stage: stage, bodies: List[Node](), halos: List[Node](), names: List[Str](),
75
+ sun: sun, label: label, selected: -1, clock: 0.0, paused: false)
76
+ # faint orbit rings, added first so every body draws (and is hit) above them
77
+ for i := range Layout.Planets
78
+ r := 90.0 + Float(i) * 60.0
79
+ 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))
80
+ planetNames := List("Ember", "Cinder", "Ashen", "Glacier")
81
+ moons := List(0, 1, 2, 1)
82
+ for i := range Layout.Planets
83
+ orbit := spun(Node.group().at(Layout.CenterX, Layout.CenterY), 0.9 / Float(1 + i))
84
+ planet := addBody(sky, sheet, planetNames.at(i), 2, i, 40.0, 1.5).at(90.0 + Float(i) * 60.0, 0.0)
85
+ orbit.add(planet)
86
+ for m := range moons.at(i)
87
+ moonOrbit := spun(Node.group(), 2.0 + Float(m))
88
+ 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)
89
+ moonOrbit.add(moon)
90
+ planet.add(moonOrbit)
91
+ stage.root.add(orbit)
92
+ stage.root.add(sun)
93
+ stage.root.add(label)
94
+ sky
95
+
96
+ # Fits the 800 by 600 scene into a screen of `sw` by `sh` pixels, centred.
97
+ fun fit(sky: Sky, sw: Float, sh: Float) =
98
+ zoom := {sw / Layout.Width}.min(sh / Layout.Height)
99
+ sky.stage.camScale = zoom
100
+ sky.stage.camX = 0.0 - {sw / zoom - Layout.Width} / 2.0
101
+ sky.stage.camY = 0.0 - {sh / zoom - Layout.Height} / 2.0
102
+
103
+ # The index of a body (or its halo) in `bodies`, or -1.
104
+ fun indexOf(sky: Sky, n: Node) -> Int =
105
+ for i := range sky.bodies.length()
106
+ if sky.bodies.at(i) == n || sky.halos.at(i) == n
107
+ return i
108
+ -1
109
+
110
+ # Selects body `i` (or nothing for -1): shows its halo and name.
111
+ fun select(sky: Sky, i: Int) =
112
+ sky.selected = i
113
+ for k := range sky.halos.length()
114
+ sky.halos.at(k).visible = k == i
115
+ if i < 0
116
+ sky.label.text = "Click a planet"
117
+ else
118
+ sky.label.text = sky.names.at(i)
119
+
120
+ # A click at stage point (x, y): selects the body there, deselects it if it was
121
+ # selected already, and clears the selection on empty space.
122
+ fun clickAt(sky: Sky, x: Float, y: Float) =
123
+ i := -1
124
+ if n := sky.stage.hit(x, y)
125
+ i = indexOf(sky, n)
126
+ if i == sky.selected
127
+ i = -1
128
+ select(sky, i)
129
+
130
+ # Where a body's centre is on the stage.
131
+ fun centerOf(sky: Sky, i: Int) -> Vec2 =
132
+ b := sky.bodies.at(i)
133
+ b.localToStage(Vec2(x: b.width / 2.0, y: b.height / 2.0))
134
+
135
+ # Advances the clock and every behaviour, and animates the sun's frames.
136
+ fun tick(sky: Sky, dt: Float) =
137
+ if sky.paused
138
+ return
139
+ sky.clock = sky.clock + dt
140
+ sky.stage.act(dt)
141
+ sky.sun.srcX = Float(Int(sky.clock * 6.0) % 4) * Layout.Cell
142
+
143
+ # --- host glue ---
144
+
145
+ export fun init() -> Sky =
146
+ newSky(textureLoad("sprites.png"))
147
+
148
+ export fun update(sky: Sky, dt: Float) =
149
+ if isKeyPressed(Key.Space)
150
+ sky.paused = !sky.paused
151
+ tick(sky, dt)
152
+ fit(sky, screenWidth(), screenHeight())
153
+ if isMousePressed(Mouse.Left)
154
+ m := mousePos()
155
+ p := sky.stage.screenToStage(m.x, m.y)
156
+ clickAt(sky, p.x, p.y)
157
+
158
+ export fun draw(sky: Sky) =
159
+ clear(Color.Black)
160
+ sky.stage.draw()
161
+
162
+ # --- tests ---
163
+
164
+ fun near(a: Float, b: Float) -> Bool =
165
+ {a - b}.abs() < 0.01
166
+
167
+ test "the scene has a sun, four planets and their moons"
168
+ sky := newSky(-1)
169
+ assert sky.bodies.length() == 8
170
+ assert sky.halos.length() == 8
171
+ assert sky.names.length() == 8
172
+ assert sky.names.at(0) == "Ember"
173
+ assert sky.names.at(2) == "Cinder moon 1"
174
+ assert sky.names.at(6) == "Glacier"
175
+ assert flatten(sky.stage.root).length() > 60
176
+
177
+ test "planets start on the right of the sun and orbit clockwise"
178
+ sky := newSky(-1)
179
+ c := centerOf(sky, 0)
180
+ assert near(c.x, 490.0)
181
+ assert near(c.y, 300.0)
182
+ # planet 0 turns 0.9 rad/s: a quarter turn takes HalfPi / 0.9 seconds
183
+ tick(sky, Float.HalfPi / 0.9)
184
+ c = centerOf(sky, 0)
185
+ assert near(c.x, 400.0)
186
+ assert near(c.y, 390.0)
187
+
188
+ test "a moon stays its orbit radius from its planet while both move"
189
+ sky := newSky(-1)
190
+ for i := range 50
191
+ tick(sky, 0.1)
192
+ pc := centerOf(sky, 3)
193
+ mc := centerOf(sky, 4)
194
+ assert near(pc.distance(mc), 36.0)
195
+ sc := Vec2(x: Layout.CenterX, y: Layout.CenterY)
196
+ assert near(sc.distance(pc), 210.0)
197
+
198
+ test "clicking selects a body even after it has moved, and again to deselect"
199
+ sky := newSky(-1)
200
+ tick(sky, 3.0)
201
+ c := centerOf(sky, 1)
202
+ clickAt(sky, c.x, c.y)
203
+ assert sky.selected == 1
204
+ assert sky.label.text == "Cinder"
205
+ assert sky.halos.at(1).visible
206
+ assert !sky.halos.at(0).visible
207
+ clickAt(sky, c.x, c.y)
208
+ assert sky.selected == -1
209
+ assert !sky.halos.at(1).visible
210
+ assert sky.label.text == "Click a planet"
211
+
212
+ test "clicking a moon or empty space"
213
+ sky := newSky(-1)
214
+ m := centerOf(sky, 2)
215
+ clickAt(sky, m.x, m.y)
216
+ assert sky.names.at(sky.selected) == "Cinder moon 1"
217
+ clickAt(sky, 2.0, 2.0)
218
+ assert sky.selected == -1
219
+
220
+ test "pausing freezes the scene"
221
+ sky := newSky(-1)
222
+ sky.paused = true
223
+ tick(sky, 1.0)
224
+ c := centerOf(sky, 0)
225
+ assert near(c.x, 490.0)
226
+ assert sky.clock == 0.0
227
+
228
+ test "fit centres the scene and screen points map back"
229
+ sky := newSky(-1)
230
+ fit(sky, 1600.0, 600.0)
231
+ assert near(sky.stage.camScale, 1.0)
232
+ assert near(sky.stage.camX, -400.0)
233
+ p := sky.stage.screenToStage(800.0, 300.0)
234
+ assert near(p.x, 400.0)
235
+ assert near(p.y, 300.0)
plum-game/web-test/fixtures/stress_cmd.plum ADDED
@@ -0,0 +1,50 @@
1
+ # Stress scene for comparing web renderers (plum-game/web-test/BENCHMARK.md):
2
+ # 5000 sprites, 500 rects, 200 circles and 50 texts with a changing number, drawn
3
+ # through a camera. Everything moves as a function of the frame counter, so every
4
+ # run draws the same frames.
5
+ module stress_cmd
6
+ import std/Str
7
+ import std/Int
8
+ import std/Float
9
+ import std/Vec2
10
+ import std/Color
11
+ import std/GameCmd
12
+
13
+ primitive Scene =
14
+ Sprites = 5000
15
+ Rects = 500
16
+ Circles = 200
17
+ Texts = 50
18
+ WorldW = 1600
19
+ WorldH = 900
20
+
21
+ record Stress(sheet: Int, t: Int)
22
+
23
+ export fun init() -> Stress =
24
+ Stress(sheet: textureLoad("sprites.png"), t: 0)
25
+
26
+ export fun update(g: Stress, dt: Float) =
27
+ g.t = g.t + 1
28
+
29
+ fun hue(i: Int) -> Color =
30
+ Color(r: Float(i % 7) / 7.0, g: Float(i % 5) / 5.0, b: 0.4 + Float(i % 3) / 5.0, a: 0.8)
31
+
32
+ export fun draw(g: Stress) =
33
+ clear(Color.rgb(0.05, 0.06, 0.1))
34
+ setCamera(0.0, 0.0, 0.8)
35
+ for i := range Scene.Sprites
36
+ x := {i * 37 + g.t * {1 + i % 5}} % Scene.WorldW
37
+ y := {i * 53 + g.t * {1 + i % 3}} % Scene.WorldH
38
+ frame := {i + g.t / 6} % 4
39
+ drawTextureEx(g.sheet, Float(frame * 16), 0.0, 16.0, 16.0, Float(x), Float(y), 16.0, 16.0, Float(g.t) * 0.02 + Float(i), i % 2 == 0, Color.White)
40
+ for i := range Scene.Rects
41
+ x := {i * 61 + g.t * 2} % Scene.WorldW
42
+ y := {i * 29} % Scene.WorldH
43
+ drawRect(Float(x), Float(y), 12.0, 8.0, hue(i))
44
+ for i := range Scene.Circles
45
+ x := {i * 83 + g.t * 3} % Scene.WorldW
46
+ y := {i * 47 + g.t} % Scene.WorldH
47
+ drawCircle(Float(x), Float(y), 6.0, hue(i + 3))
48
+ for i := range Scene.Texts
49
+ drawText("n {g.t + i}", Float(i * 30 % Scene.WorldW), Float(i * 17 % Scene.WorldH), 14.0, Color.White)
50
+ resetCamera()
plum-game/web-test/fixtures/stress_scene.plum ADDED
@@ -0,0 +1,83 @@
1
+ # The stress scene of stress.plum built as a Scene2D-like scene graph (std/Scene):
2
+ # 5000 sprites in 50 slowly rotating groups, 500 rects, 200 circles and 50 texts
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.
5
+ module stress_scene
6
+ import std/Str
7
+ import std/Int
8
+ import std/Float
9
+ import std/List
10
+ import std/Vec2
11
+ import std/Color
12
+ import std/Game
13
+ import std/Scene
14
+
15
+ primitive Counts =
16
+ Sprites = 5000
17
+ Rects = 500
18
+ Circles = 200
19
+ Texts = 50
20
+ Groups = 50
21
+ WorldW = 1600
22
+ WorldH = 900
23
+
24
+ record Demo(sheet: Int, stage: Stage)
25
+
26
+ fun hue(i: Int) -> Color =
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
+
29
+ fun spinGroup(n: Node, dt: Float) =
30
+ n.rotation = n.rotation + 0.004
31
+
32
+ # Turns the sprite; its frame follows the angle, advancing every 6 frames.
33
+ fun animateSprite(n: Node, dt: Float) =
34
+ n.rotation = n.rotation + 0.02
35
+ n.srcX = Float(Int(n.rotation * 50.0) / 6 % 4 * 16)
36
+
37
+ fun driftRect(n: Node, dt: Float) =
38
+ n.x = n.x + 2.0
39
+ if n.x > Float(Counts.WorldW)
40
+ n.x = n.x - Float(Counts.WorldW)
41
+
42
+ fun driftCircle(n: Node, dt: Float) =
43
+ n.x = n.x + 3.0
44
+ n.y = n.y + 1.0
45
+ if n.x > Float(Counts.WorldW)
46
+ n.x = n.x - Float(Counts.WorldW)
47
+ if n.y > Float(Counts.WorldH)
48
+ n.y = n.y - Float(Counts.WorldH)
49
+
50
+ # `tex` is the number shown.
51
+ fun countText(n: Node, dt: Float) =
52
+ n.tex = n.tex + 1
53
+ n.text = "n {n.tex}"
54
+
55
+ export fun init() -> Demo =
56
+ sheet := textureLoad("sprites.png")
57
+ stage := Stage.create()
58
+ stage.camScale = 0.8
59
+ per := Counts.Sprites / Counts.Groups
60
+ 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)
62
+ for j := range per
63
+ i := gi * per + j
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)
65
+ s.rotation = Float(i)
66
+ g.add(s)
67
+ stage.root.add(g)
68
+ 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))
70
+ 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))
72
+ 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)
74
+ t.tex = i
75
+ stage.root.add(t)
76
+ Demo(sheet: sheet, stage: stage)
77
+
78
+ export fun update(g: Demo, dt: Float) =
79
+ g.stage.act(dt)
80
+
81
+ export fun draw(g: Demo) =
82
+ clear(Color.rgb(0.05, 0.06, 0.1))
83
+ g.stage.draw()
plum-game/web-test/fixtures/stress_scene_cmd.plum ADDED
@@ -0,0 +1,83 @@
1
+ # The stress scene of stress.plum built as a Scene2D-like scene graph (std/Scene):
2
+ # 5000 sprites in 50 slowly rotating groups, 500 rects, 200 circles and 50 texts
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.
5
+ module stress_scene_cmd
6
+ import std/Str
7
+ import std/Int
8
+ import std/Float
9
+ import std/List
10
+ import std/Vec2
11
+ import std/Color
12
+ import std/GameCmd
13
+ import std/SceneCmd
14
+
15
+ primitive Counts =
16
+ Sprites = 5000
17
+ Rects = 500
18
+ Circles = 200
19
+ Texts = 50
20
+ Groups = 50
21
+ WorldW = 1600
22
+ WorldH = 900
23
+
24
+ record Demo(sheet: Int, stage: Stage)
25
+
26
+ fun hue(i: Int) -> Color =
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
+
29
+ fun spinGroup(n: Node, dt: Float) =
30
+ n.rotation = n.rotation + 0.004
31
+
32
+ # Turns the sprite; its frame follows the angle, advancing every 6 frames.
33
+ fun animateSprite(n: Node, dt: Float) =
34
+ n.rotation = n.rotation + 0.02
35
+ n.srcX = Float(Int(n.rotation * 50.0) / 6 % 4 * 16)
36
+
37
+ fun driftRect(n: Node, dt: Float) =
38
+ n.x = n.x + 2.0
39
+ if n.x > Float(Counts.WorldW)
40
+ n.x = n.x - Float(Counts.WorldW)
41
+
42
+ fun driftCircle(n: Node, dt: Float) =
43
+ n.x = n.x + 3.0
44
+ n.y = n.y + 1.0
45
+ if n.x > Float(Counts.WorldW)
46
+ n.x = n.x - Float(Counts.WorldW)
47
+ if n.y > Float(Counts.WorldH)
48
+ n.y = n.y - Float(Counts.WorldH)
49
+
50
+ # `tex` is the number shown.
51
+ fun countText(n: Node, dt: Float) =
52
+ n.tex = n.tex + 1
53
+ n.text = "n {n.tex}"
54
+
55
+ export fun init() -> Demo =
56
+ sheet := textureLoad("sprites.png")
57
+ stage := Stage.create()
58
+ stage.camScale = 0.8
59
+ per := Counts.Sprites / Counts.Groups
60
+ 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)
62
+ for j := range per
63
+ i := gi * per + j
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)
65
+ s.rotation = Float(i)
66
+ g.add(s)
67
+ stage.root.add(g)
68
+ 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))
70
+ 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))
72
+ 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)
74
+ t.tex = i
75
+ stage.root.add(t)
76
+ Demo(sheet: sheet, stage: stage)
77
+
78
+ export fun update(g: Demo, dt: Float) =
79
+ g.stage.act(dt)
80
+
81
+ export fun draw(g: Demo) =
82
+ clear(Color.rgb(0.05, 0.06, 0.1))
83
+ g.stage.draw()
plum-std/Scene.plum ADDED
@@ -0,0 +1,457 @@
1
+ module std
2
+ import std/Int
3
+ import std/Float
4
+ import std/Str
5
+ import std/List
6
+ import std/Math
7
+ import std/Vec2
8
+ import std/Color
9
+ import std/Game
10
+
11
+ # A Scene2D-like scene graph: a tree of `Node`s under a `Stage`. A node has a
12
+ # position, size, origin (the pivot of its rotation and scale), scale, rotation
13
+ # (radians, clockwise), colour, visibility and children. Children are placed in
14
+ # their parent's coordinates, so moving, turning or scaling a group moves
15
+ # everything in it. Colour alpha multiplies down the tree and an invisible node
16
+ # hides its whole subtree.
17
+ #
18
+ # stage := Stage.create()
19
+ # ring := Node.group()
20
+ # stage.root.add(ring)
21
+ # ring.add(Node.rect(40.0, 20.0, Color.Red).at(10.0, 5.0))
22
+ # stage.act(dt) # runs every node's `act` behaviour, depth first
23
+ # stage.draw() # draws through `std/Game`
24
+ #
25
+ # The transform maths (`Affine`, `localMatrix`, `worldMatrix`, `flatten`,
26
+ # `hitNode`) never calls the host, so it runs under `plum test`.
27
+ #
28
+ # Limitations: a sprite is drawn with the composed centre, size and rotation.
29
+ # A rect, circle or text under a rotated or non-uniformly scaled parent is drawn
30
+ # axis-aligned at its transformed centre (text: top-left) with the composed
31
+ # scale, since the host has no rotated shapes. Non-uniform scale under rotation
32
+ # (skew) is not supported for drawing; hit testing handles it exactly.
33
+
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
+ # A 2D affine transform: x' = a*x + c*y + e, y' = b*x + d*y + f.
43
+ struct Affine(a: Float, b: Float, c: Float, d: Float, e: Float, f: Float) =
44
+
45
+ Identity = Affine(a: 1.0, b: 0.0, c: 0.0, d: 1.0, e: 0.0, f: 0.0)
46
+
47
+ # `p * q` applies q first, then p.
48
+ fun mul(self, o: Affine) -> Affine =
49
+ Affine(
50
+ a: self.a * o.a + self.c * o.b,
51
+ b: self.b * o.a + self.d * o.b,
52
+ c: self.a * o.c + self.c * o.d,
53
+ d: self.b * o.c + self.d * o.d,
54
+ e: self.a * o.e + self.c * o.f + self.e,
55
+ f: self.b * o.e + self.d * o.f + self.f)
56
+
57
+ fun apply(self, p: Vec2) -> Vec2 =
58
+ Vec2(x: self.a * p.x + self.c * p.y + self.e, y: self.b * p.x + self.d * p.y + self.f)
59
+
60
+ # The transform that undoes this one (the identity if it is singular).
61
+ fun inverse(self) -> Affine =
62
+ det := self.a * self.d - self.b * self.c
63
+ if det == 0.0
64
+ return Affine.Identity
65
+ ia := self.d / det
66
+ ib := 0.0 - self.b / det
67
+ ic := 0.0 - self.c / det
68
+ id := self.a / det
69
+ Affine(
70
+ a: ia, b: ib, c: ic, d: id,
71
+ e: 0.0 - {ia * self.e + ic * self.f},
72
+ f: 0.0 - {ib * self.e + id * self.f})
73
+
74
+ fun scaleX(self) -> Float =
75
+ Math.hypot(self.a, self.b)
76
+
77
+ fun scaleY(self) -> Float =
78
+ Math.hypot(self.c, self.d)
79
+
80
+ fun rotation(self) -> Float =
81
+ Math.atan2(self.b, self.a)
82
+
83
+ record Node(
84
+ x: Float, y: Float, width: Float, height: Float,
85
+ originX: Float, originY: Float,
86
+ scaleX: Float, scaleY: Float,
87
+ rotation: Float,
88
+ color: Color,
89
+ visible: Bool,
90
+ children: List[Node],
91
+ parent: Node?,
92
+ kind: Int,
93
+ tex: Int, srcX: Float, srcY: Float, srcW: Float, srcH: Float,
94
+ text: Str, size: Float,
95
+ acting: Bool,
96
+ act: fn(Node, Float)) =
97
+
98
+ fun make(kind: Int, w: Float, h: Float, c: Color) -> Node =
99
+ Node(
100
+ x: 0.0, y: 0.0, width: w, height: h, originX: 0.0, originY: 0.0,
101
+ scaleX: 1.0, scaleY: 1.0, rotation: 0.0, color: c, visible: true,
102
+ children: List[Node](), parent: null, kind: kind,
103
+ tex: -1, srcX: 0.0, srcY: 0.0, srcW: 0.0, srcH: 0.0, text: "", size: 0.0,
104
+ acting: false, act: noAct)
105
+
106
+ # A node that only holds children.
107
+ fun group() -> Node =
108
+ Node.make(NodeKind.Group, 0.0, 0.0, Color.White)
109
+
110
+ # A texture region (sx, sy, sw, sh in texture pixels) drawn w by h.
111
+ fun sprite(tex: Int, sx: Float, sy: Float, sw: Float, sh: Float, w: Float, h: Float) -> Node =
112
+ n := Node.make(NodeKind.Sprite, w, h, Color.White)
113
+ n.tex = tex
114
+ n.srcX = sx
115
+ n.srcY = sy
116
+ n.srcW = sw
117
+ n.srcH = sh
118
+ n
119
+
120
+ fun rect(w: Float, h: Float, c: Color) -> Node =
121
+ Node.make(NodeKind.Rect, w, h, c)
122
+
123
+ # A circle of radius r whose bounding box has its top-left at the node's position.
124
+ fun circle(r: Float, c: Color) -> Node =
125
+ Node.make(NodeKind.Circle, r * 2.0, r * 2.0, c)
126
+
127
+ # Text with its top-left at the node's position; the size is an estimate of the extent.
128
+ fun text(s: Str, size: Float, c: Color) -> Node =
129
+ n := Node.make(NodeKind.Text, size * 0.6 * Float(s.length()), size, c)
130
+ n.text = s
131
+ n.size = size
132
+ n
133
+
134
+ fun at(self, x: Float, y: Float) -> Node =
135
+ self.x = x
136
+ self.y = y
137
+ self
138
+
139
+ fun setOrigin(self, ox: Float, oy: Float) -> Node =
140
+ self.originX = ox
141
+ self.originY = oy
142
+ self
143
+
144
+ fun centerOrigin(self) -> Node =
145
+ self.originX = self.width / 2.0
146
+ self.originY = self.height / 2.0
147
+ self
148
+
149
+ fun setAct(self, f: fn(Node, Float)) -> Node =
150
+ self.act = f
151
+ self.acting = true
152
+ self
153
+
154
+ # Appends a child (taking it from its previous parent) so it draws after its siblings.
155
+ fun add(self, child: Node) =
156
+ if p := child.parent
157
+ p.children.remove(child)
158
+ child.parent = self
159
+ self.children.push(child)
160
+
161
+ fun remove(self, child: Node) =
162
+ if p := child.parent
163
+ if p == self
164
+ self.children.remove(child)
165
+ child.parent = null
166
+
167
+ fun clear(self) =
168
+ for i := range self.children.length()
169
+ self.children.at(i).parent = null
170
+ self.children.clear()
171
+
172
+ # A point in this node's coordinates, in the stage's.
173
+ fun localToStage(self, p: Vec2) -> Vec2 =
174
+ worldMatrix(self).apply(p)
175
+
176
+ # A point in the stage's coordinates, in this node's.
177
+ fun stageToLocal(self, p: Vec2) -> Vec2 =
178
+ worldMatrix(self).inverse().apply(p)
179
+
180
+ fun noAct(n: Node, dt: Float) =
181
+ n.x = n.x
182
+
183
+ # The node's transform into its parent's coordinates: translate to the position,
184
+ # rotate and scale about the origin.
185
+ fun localMatrix(n: Node) -> Affine =
186
+ cs := Math.cos(n.rotation)
187
+ sn := Math.sin(n.rotation)
188
+ a := cs * n.scaleX
189
+ b := sn * n.scaleX
190
+ c := 0.0 - sn * n.scaleY
191
+ d := cs * n.scaleY
192
+ Affine(
193
+ a: a, b: b, c: c, d: d,
194
+ e: n.x + n.originX - {a * n.originX + c * n.originY},
195
+ f: n.y + n.originY - {b * n.originX + d * n.originY})
196
+
197
+ # The node's transform into stage coordinates.
198
+ fun worldMatrix(n: Node) -> Affine =
199
+ if p := n.parent
200
+ worldMatrix(p) * localMatrix(n)
201
+ else
202
+ localMatrix(n)
203
+
204
+ # One entry of the draw list: a visible node, its stage transform and its alpha.
205
+ record Drawn(node: Node, m: Affine, alpha: Float)
206
+
207
+ fun collect(n: Node, parent: Affine, alpha: Float, out: List[Drawn]) =
208
+ if !n.visible
209
+ return
210
+ m := parent * localMatrix(n)
211
+ a := alpha * n.color.a
212
+ out.push(Drawn(node: n, m: m, alpha: a))
213
+ for i := range n.children.length()
214
+ collect(n.children.at(i), m, a, out)
215
+
216
+ # What `draw` would draw, in order: a parent before its children, siblings in
217
+ # insertion order, invisible subtrees left out.
218
+ fun flatten(root: Node) -> List[Drawn] =
219
+ out := List[Drawn]()
220
+ collect(root, Affine.Identity, 1.0, out)
221
+ out
222
+
223
+ fun drawNode(n: Node, parent: Affine, alpha: Float) =
224
+ if !n.visible
225
+ return
226
+ m := parent * localMatrix(n)
227
+ a := alpha * n.color.a
228
+ if n.kind == NodeKind.Sprite
229
+ sx := n.width * m.scaleX()
230
+ sy := n.height * m.scaleY()
231
+ c := m.apply(Vec2(x: n.width / 2.0, y: n.height / 2.0))
232
+ 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))
233
+ else if n.kind == NodeKind.Rect
234
+ sx := n.width * m.scaleX()
235
+ sy := n.height * m.scaleY()
236
+ c := m.apply(Vec2(x: n.width / 2.0, y: n.height / 2.0))
237
+ drawRect(c.x - sx / 2.0, c.y - sy / 2.0, sx, sy, n.color.withAlpha(a))
238
+ else if n.kind == NodeKind.Circle
239
+ r := n.width / 2.0
240
+ c := m.apply(Vec2(x: r, y: r))
241
+ drawCircle(c.x, c.y, r * m.scaleX(), n.color.withAlpha(a))
242
+ else if n.kind == NodeKind.Text
243
+ p := m.apply(Vec2(x: 0.0, y: 0.0))
244
+ drawText(n.text, p.x, p.y, n.size * m.scaleY(), n.color.withAlpha(a))
245
+ for i := range n.children.length()
246
+ drawNode(n.children.at(i), m, a)
247
+
248
+ fun actNode(n: Node, dt: Float) =
249
+ if n.acting
250
+ f := n.act
251
+ f(n, dt)
252
+ i := 0
253
+ while i < n.children.length()
254
+ actNode(n.children.at(i), dt)
255
+ i = i + 1
256
+
257
+ # The topmost visible node under `p`, a point in the node's parent's coordinates:
258
+ # children before the node itself, later siblings before earlier ones. A node is
259
+ # hit inside its width by height box; a group with no size is never hit itself.
260
+ fun hitNode(n: Node, p: Vec2) -> Node? =
261
+ if !n.visible
262
+ return null
263
+ q := localMatrix(n).inverse().apply(p)
264
+ for i := range n.children.length()
265
+ child := n.children.at(n.children.length() - 1 - i)
266
+ if found := hitNode(child, q)
267
+ return found
268
+ 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
269
+ return n
270
+ null
271
+
272
+ # A root node plus a camera (the world point at the screen's top-left and the
273
+ # pixels per world unit, as in `setCamera`).
274
+ record Stage(root: Node, camX: Float, camY: Float, camScale: Float) =
275
+
276
+ fun create() -> Stage =
277
+ Stage(root: Node.group(), camX: 0.0, camY: 0.0, camScale: 1.0)
278
+
279
+ # Runs every node's behaviour, parents before children, in insertion order.
280
+ fun act(self, dt: Float) =
281
+ actNode(self.root, dt)
282
+
283
+ fun draw(self) =
284
+ setCamera(self.camX, self.camY, self.camScale)
285
+ drawNode(self.root, Affine.Identity, 1.0)
286
+ resetCamera()
287
+
288
+ # The topmost visible node at a stage point.
289
+ fun hit(self, x: Float, y: Float) -> Node? =
290
+ hitNode(self.root, Vec2(x: x, y: y))
291
+
292
+ # A screen pixel in stage coordinates under this camera.
293
+ fun screenToStage(self, sx: Float, sy: Float) -> Vec2 =
294
+ Vec2(x: sx / self.camScale + self.camX, y: sy / self.camScale + self.camY)
295
+
296
+ # --- tests ---
297
+
298
+ fun sceneNear(a: Float, b: Float) -> Bool =
299
+ {a - b}.abs() < 0.0001
300
+
301
+ fun sceneNearV(p: Vec2, x: Float, y: Float) -> Bool =
302
+ sceneNear(p.x, x) && sceneNear(p.y, y)
303
+
304
+ fun sceneSpin(n: Node, dt: Float) =
305
+ n.rotation = n.rotation + dt
306
+
307
+ test "a node translates; rotation and scale pivot about the origin"
308
+ n := Node.rect(10.0, 10.0, Color.Red).at(100.0, 50.0)
309
+ assert sceneNearV(localMatrix(n).apply(Vec2(x: 0.0, y: 0.0)), 100.0, 50.0)
310
+ n.originX = 5.0
311
+ n.originY = 5.0
312
+ n.rotation = Float.HalfPi
313
+ assert sceneNearV(localMatrix(n).apply(Vec2(x: 5.0, y: 5.0)), 105.0, 55.0)
314
+ assert sceneNearV(localMatrix(n).apply(Vec2(x: 10.0, y: 5.0)), 105.0, 60.0)
315
+ n.rotation = 0.0
316
+ n.scaleX = 2.0
317
+ assert sceneNearV(localMatrix(n).apply(Vec2(x: 10.0, y: 10.0)), 115.0, 60.0)
318
+
319
+ test "matrices compose, and inverse undoes them"
320
+ g := Node.group().at(100.0, 0.0)
321
+ g.rotation = Float.HalfPi
322
+ g.scaleX = 2.0
323
+ g.scaleY = 2.0
324
+ k := Node.rect(10.0, 10.0, Color.Red).at(10.0, 0.0)
325
+ g.add(k)
326
+ w := worldMatrix(k)
327
+ assert sceneNearV(w.apply(Vec2(x: 0.0, y: 0.0)), 100.0, 20.0)
328
+ assert sceneNearV(w.apply(Vec2(x: 10.0, y: 10.0)), 80.0, 40.0)
329
+ assert sceneNear(w.scaleX(), 2.0)
330
+ assert sceneNear(w.rotation(), Float.HalfPi)
331
+ back := w.inverse().apply(w.apply(Vec2(x: 3.0, y: 7.0)))
332
+ assert sceneNearV(back, 3.0, 7.0)
333
+ assert sceneNearV(k.localToStage(Vec2(x: 10.0, y: 10.0)), 80.0, 40.0)
334
+ assert sceneNearV(k.stageToLocal(Vec2(x: 80.0, y: 40.0)), 10.0, 10.0)
335
+
336
+ test "add reparents, remove and clear detach"
337
+ a := Node.group()
338
+ b := Node.group()
339
+ k := Node.group()
340
+ a.add(k)
341
+ assert a.children.length() == 1
342
+ b.add(k)
343
+ assert a.children.length() == 0
344
+ assert b.children.length() == 1
345
+ assert {k.parent ?: crash "no parent"} == b
346
+ b.remove(k)
347
+ assert b.children.length() == 0
348
+ assert k.parent == null
349
+ b.add(k)
350
+ extra := Node.group()
351
+ b.add(extra)
352
+ b.clear()
353
+ assert b.children.length() == 0
354
+ assert k.parent == null
355
+
356
+ test "draw order is parent first, then children in insertion order"
357
+ root := Node.group()
358
+ a := Node.group()
359
+ root.add(a)
360
+ b := Node.group()
361
+ root.add(b)
362
+ a1 := Node.rect(1.0, 1.0, Color.Red)
363
+ a.add(a1)
364
+ a2 := Node.rect(1.0, 1.0, Color.Red)
365
+ a.add(a2)
366
+ b1 := Node.rect(1.0, 1.0, Color.Red)
367
+ b.add(b1)
368
+ list := flatten(root)
369
+ assert list.length() == 6
370
+ assert list.at(0).node == root
371
+ assert list.at(1).node == a
372
+ assert list.at(2).node == a1
373
+ assert list.at(3).node == a2
374
+ assert list.at(4).node == b
375
+ assert list.at(5).node == b1
376
+
377
+ test "invisible subtrees are skipped and alpha multiplies down"
378
+ root := Node.group()
379
+ root.color = Color.White.withAlpha(0.5)
380
+ g := Node.group()
381
+ root.add(g)
382
+ g.color = Color.White.withAlpha(0.5)
383
+ k := Node.rect(1.0, 1.0, Color.Red)
384
+ g.add(k)
385
+ hidden := Node.group()
386
+ root.add(hidden)
387
+ hidden.visible = false
388
+ hidden.add(Node.rect(1.0, 1.0, Color.Red))
389
+ list := flatten(root)
390
+ assert list.length() == 3
391
+ assert sceneNear(list.at(2).alpha, 0.25)
392
+ g.visible = false
393
+ assert flatten(root).length() == 1
394
+
395
+ test "hit finds the topmost node through rotated and scaled groups"
396
+ root := Node.group()
397
+ g := Node.group().at(100.0, 0.0)
398
+ root.add(g)
399
+ g.rotation = Float.HalfPi
400
+ g.scaleX = 2.0
401
+ g.scaleY = 2.0
402
+ low := Node.rect(30.0, 30.0, Color.Red)
403
+ g.add(low)
404
+ top := Node.rect(10.0, 10.0, Color.Blue).at(10.0, 0.0)
405
+ g.add(top)
406
+ stage := Stage.create()
407
+ stage.root.add(g)
408
+ assert {stage.hit(90.0, 30.0) ?: crash "miss"} == top
409
+ assert {stage.hit(70.0, 10.0) ?: crash "miss"} == low
410
+ assert {stage.hit(60.0, 30.0) ?: crash "miss"} == low
411
+ assert stage.hit(10.0, 10.0) == null
412
+ assert stage.hit(150.0, 30.0) == null
413
+ top.visible = false
414
+ assert {stage.hit(90.0, 30.0) ?: crash "miss"} == low
415
+ g.visible = false
416
+ assert stage.hit(90.0, 30.0) == null
417
+
418
+ test "hit prefers later siblings and a child over its parent"
419
+ stage := Stage.create()
420
+ a := Node.rect(50.0, 50.0, Color.Red)
421
+ stage.root.add(a)
422
+ b := Node.rect(50.0, 50.0, Color.Blue).at(25.0, 0.0)
423
+ stage.root.add(b)
424
+ inner := Node.rect(10.0, 10.0, Color.Green)
425
+ b.add(inner)
426
+ assert {stage.hit(10.0, 10.0) ?: crash "miss"} == a
427
+ assert {stage.hit(30.0, 5.0) ?: crash "miss"} == inner
428
+ assert {stage.hit(60.0, 40.0) ?: crash "miss"} == b
429
+ assert {stage.hit(40.0, 10.0) ?: crash "miss"} == b
430
+
431
+ test "act runs behaviours depth first, including invisible nodes"
432
+ stage := Stage.create()
433
+ g := Node.group().setAct(sceneSpin)
434
+ stage.root.add(g)
435
+ k := Node.group().setAct(sceneSpin)
436
+ g.add(k)
437
+ g.visible = false
438
+ stage.act(0.5)
439
+ stage.act(0.25)
440
+ assert sceneNear(g.rotation, 0.75)
441
+ assert sceneNear(k.rotation, 0.75)
442
+ assert stage.root.rotation == 0.0
443
+
444
+ test "the screen maps to the stage through the camera"
445
+ stage := Stage.create()
446
+ stage.camX = 100.0
447
+ stage.camY = 50.0
448
+ stage.camScale = 2.0
449
+ p := stage.screenToStage(40.0, 20.0)
450
+ assert sceneNearV(p, 120.0, 60.0)
451
+
452
+ test "sizes of sprites, circles and text"
453
+ assert Node.circle(5.0, Color.Red).width == 10.0
454
+ s := Node.sprite(3, 16.0, 0.0, 16.0, 16.0, 32.0, 32.0)
455
+ assert s.tex == 3
456
+ assert s.kind == NodeKind.Sprite
457
+ assert Node.text("ab", 10.0, Color.White).height == 10.0
plum-std/SceneCmd.plum ADDED
@@ -0,0 +1,458 @@
1
+ module std
2
+ import std/Int
3
+ import std/Float
4
+ import std/Str
5
+ import std/List
6
+ import std/Math
7
+ import std/Vec2
8
+ import std/Color
9
+ import std/GameCmd
10
+
11
+ # Identical to std/Scene except that it draws through std/GameCmd (the command buffer): a copy of Scene.plum with its import changed, keep the two in sync.
12
+ # A Scene2D-like scene graph: a tree of `Node`s under a `Stage`. A node has a
13
+ # position, size, origin (the pivot of its rotation and scale), scale, rotation
14
+ # (radians, clockwise), colour, visibility and children. Children are placed in
15
+ # their parent's coordinates, so moving, turning or scaling a group moves
16
+ # everything in it. Colour alpha multiplies down the tree and an invisible node
17
+ # hides its whole subtree.
18
+ #
19
+ # stage := Stage.create()
20
+ # ring := Node.group()
21
+ # stage.root.add(ring)
22
+ # ring.add(Node.rect(40.0, 20.0, Color.Red).at(10.0, 5.0))
23
+ # stage.act(dt) # runs every node's `act` behaviour, depth first
24
+ # stage.draw() # draws through `std/Game`
25
+ #
26
+ # The transform maths (`Affine`, `localMatrix`, `worldMatrix`, `flatten`,
27
+ # `hitNode`) never calls the host, so it runs under `plum test`.
28
+ #
29
+ # Limitations: a sprite is drawn with the composed centre, size and rotation.
30
+ # A rect, circle or text under a rotated or non-uniformly scaled parent is drawn
31
+ # axis-aligned at its transformed centre (text: top-left) with the composed
32
+ # scale, since the host has no rotated shapes. Non-uniform scale under rotation
33
+ # (skew) is not supported for drawing; hit testing handles it exactly.
34
+
35
+ # What a node draws.
36
+ primitive NodeKind =
37
+ Group = 0
38
+ Sprite = 1
39
+ Rect = 2
40
+ Circle = 3
41
+ Text = 4
42
+
43
+ # A 2D affine transform: x' = a*x + c*y + e, y' = b*x + d*y + f.
44
+ struct Affine(a: Float, b: Float, c: Float, d: Float, e: Float, f: Float) =
45
+
46
+ Identity = Affine(a: 1.0, b: 0.0, c: 0.0, d: 1.0, e: 0.0, f: 0.0)
47
+
48
+ # `p * q` applies q first, then p.
49
+ fun mul(self, o: Affine) -> Affine =
50
+ Affine(
51
+ a: self.a * o.a + self.c * o.b,
52
+ b: self.b * o.a + self.d * o.b,
53
+ c: self.a * o.c + self.c * o.d,
54
+ d: self.b * o.c + self.d * o.d,
55
+ e: self.a * o.e + self.c * o.f + self.e,
56
+ f: self.b * o.e + self.d * o.f + self.f)
57
+
58
+ fun apply(self, p: Vec2) -> Vec2 =
59
+ Vec2(x: self.a * p.x + self.c * p.y + self.e, y: self.b * p.x + self.d * p.y + self.f)
60
+
61
+ # The transform that undoes this one (the identity if it is singular).
62
+ fun inverse(self) -> Affine =
63
+ det := self.a * self.d - self.b * self.c
64
+ if det == 0.0
65
+ return Affine.Identity
66
+ ia := self.d / det
67
+ ib := 0.0 - self.b / det
68
+ ic := 0.0 - self.c / det
69
+ id := self.a / det
70
+ Affine(
71
+ a: ia, b: ib, c: ic, d: id,
72
+ e: 0.0 - {ia * self.e + ic * self.f},
73
+ f: 0.0 - {ib * self.e + id * self.f})
74
+
75
+ fun scaleX(self) -> Float =
76
+ Math.hypot(self.a, self.b)
77
+
78
+ fun scaleY(self) -> Float =
79
+ Math.hypot(self.c, self.d)
80
+
81
+ fun rotation(self) -> Float =
82
+ Math.atan2(self.b, self.a)
83
+
84
+ record Node(
85
+ x: Float, y: Float, width: Float, height: Float,
86
+ originX: Float, originY: Float,
87
+ scaleX: Float, scaleY: Float,
88
+ rotation: Float,
89
+ color: Color,
90
+ visible: Bool,
91
+ children: List[Node],
92
+ parent: Node?,
93
+ kind: Int,
94
+ tex: Int, srcX: Float, srcY: Float, srcW: Float, srcH: Float,
95
+ text: Str, size: Float,
96
+ acting: Bool,
97
+ act: fn(Node, Float)) =
98
+
99
+ fun make(kind: Int, w: Float, h: Float, c: Color) -> Node =
100
+ Node(
101
+ x: 0.0, y: 0.0, width: w, height: h, originX: 0.0, originY: 0.0,
102
+ scaleX: 1.0, scaleY: 1.0, rotation: 0.0, color: c, visible: true,
103
+ children: List[Node](), parent: null, kind: kind,
104
+ tex: -1, srcX: 0.0, srcY: 0.0, srcW: 0.0, srcH: 0.0, text: "", size: 0.0,
105
+ acting: false, act: noAct)
106
+
107
+ # A node that only holds children.
108
+ fun group() -> Node =
109
+ Node.make(NodeKind.Group, 0.0, 0.0, Color.White)
110
+
111
+ # A texture region (sx, sy, sw, sh in texture pixels) drawn w by h.
112
+ fun sprite(tex: Int, sx: Float, sy: Float, sw: Float, sh: Float, w: Float, h: Float) -> Node =
113
+ n := Node.make(NodeKind.Sprite, w, h, Color.White)
114
+ n.tex = tex
115
+ n.srcX = sx
116
+ n.srcY = sy
117
+ n.srcW = sw
118
+ n.srcH = sh
119
+ n
120
+
121
+ fun rect(w: Float, h: Float, c: Color) -> Node =
122
+ Node.make(NodeKind.Rect, w, h, c)
123
+
124
+ # A circle of radius r whose bounding box has its top-left at the node's position.
125
+ fun circle(r: Float, c: Color) -> Node =
126
+ Node.make(NodeKind.Circle, r * 2.0, r * 2.0, c)
127
+
128
+ # Text with its top-left at the node's position; the size is an estimate of the extent.
129
+ fun text(s: Str, size: Float, c: Color) -> Node =
130
+ n := Node.make(NodeKind.Text, size * 0.6 * Float(s.length()), size, c)
131
+ n.text = s
132
+ n.size = size
133
+ n
134
+
135
+ fun at(self, x: Float, y: Float) -> Node =
136
+ self.x = x
137
+ self.y = y
138
+ self
139
+
140
+ fun setOrigin(self, ox: Float, oy: Float) -> Node =
141
+ self.originX = ox
142
+ self.originY = oy
143
+ self
144
+
145
+ fun centerOrigin(self) -> Node =
146
+ self.originX = self.width / 2.0
147
+ self.originY = self.height / 2.0
148
+ self
149
+
150
+ fun setAct(self, f: fn(Node, Float)) -> Node =
151
+ self.act = f
152
+ self.acting = true
153
+ self
154
+
155
+ # Appends a child (taking it from its previous parent) so it draws after its siblings.
156
+ fun add(self, child: Node) =
157
+ if p := child.parent
158
+ p.children.remove(child)
159
+ child.parent = self
160
+ self.children.push(child)
161
+
162
+ fun remove(self, child: Node) =
163
+ if p := child.parent
164
+ if p == self
165
+ self.children.remove(child)
166
+ child.parent = null
167
+
168
+ fun clear(self) =
169
+ for i := range self.children.length()
170
+ self.children.at(i).parent = null
171
+ self.children.clear()
172
+
173
+ # A point in this node's coordinates, in the stage's.
174
+ fun localToStage(self, p: Vec2) -> Vec2 =
175
+ worldMatrix(self).apply(p)
176
+
177
+ # A point in the stage's coordinates, in this node's.
178
+ fun stageToLocal(self, p: Vec2) -> Vec2 =
179
+ worldMatrix(self).inverse().apply(p)
180
+
181
+ fun noAct(n: Node, dt: Float) =
182
+ n.x = n.x
183
+
184
+ # The node's transform into its parent's coordinates: translate to the position,
185
+ # rotate and scale about the origin.
186
+ fun localMatrix(n: Node) -> Affine =
187
+ cs := Math.cos(n.rotation)
188
+ sn := Math.sin(n.rotation)
189
+ a := cs * n.scaleX
190
+ b := sn * n.scaleX
191
+ c := 0.0 - sn * n.scaleY
192
+ d := cs * n.scaleY
193
+ Affine(
194
+ a: a, b: b, c: c, d: d,
195
+ e: n.x + n.originX - {a * n.originX + c * n.originY},
196
+ f: n.y + n.originY - {b * n.originX + d * n.originY})
197
+
198
+ # The node's transform into stage coordinates.
199
+ fun worldMatrix(n: Node) -> Affine =
200
+ if p := n.parent
201
+ worldMatrix(p) * localMatrix(n)
202
+ else
203
+ localMatrix(n)
204
+
205
+ # One entry of the draw list: a visible node, its stage transform and its alpha.
206
+ record Drawn(node: Node, m: Affine, alpha: Float)
207
+
208
+ fun collect(n: Node, parent: Affine, alpha: Float, out: List[Drawn]) =
209
+ if !n.visible
210
+ return
211
+ m := parent * localMatrix(n)
212
+ a := alpha * n.color.a
213
+ out.push(Drawn(node: n, m: m, alpha: a))
214
+ for i := range n.children.length()
215
+ collect(n.children.at(i), m, a, out)
216
+
217
+ # What `draw` would draw, in order: a parent before its children, siblings in
218
+ # insertion order, invisible subtrees left out.
219
+ fun flatten(root: Node) -> List[Drawn] =
220
+ out := List[Drawn]()
221
+ collect(root, Affine.Identity, 1.0, out)
222
+ out
223
+
224
+ fun drawNode(n: Node, parent: Affine, alpha: Float) =
225
+ if !n.visible
226
+ return
227
+ m := parent * localMatrix(n)
228
+ a := alpha * n.color.a
229
+ if n.kind == NodeKind.Sprite
230
+ sx := n.width * m.scaleX()
231
+ sy := n.height * m.scaleY()
232
+ c := m.apply(Vec2(x: n.width / 2.0, y: n.height / 2.0))
233
+ 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))
234
+ else if n.kind == NodeKind.Rect
235
+ sx := n.width * m.scaleX()
236
+ sy := n.height * m.scaleY()
237
+ c := m.apply(Vec2(x: n.width / 2.0, y: n.height / 2.0))
238
+ drawRect(c.x - sx / 2.0, c.y - sy / 2.0, sx, sy, n.color.withAlpha(a))
239
+ else if n.kind == NodeKind.Circle
240
+ r := n.width / 2.0
241
+ c := m.apply(Vec2(x: r, y: r))
242
+ drawCircle(c.x, c.y, r * m.scaleX(), n.color.withAlpha(a))
243
+ else if n.kind == NodeKind.Text
244
+ p := m.apply(Vec2(x: 0.0, y: 0.0))
245
+ drawText(n.text, p.x, p.y, n.size * m.scaleY(), n.color.withAlpha(a))
246
+ for i := range n.children.length()
247
+ drawNode(n.children.at(i), m, a)
248
+
249
+ fun actNode(n: Node, dt: Float) =
250
+ if n.acting
251
+ f := n.act
252
+ f(n, dt)
253
+ i := 0
254
+ while i < n.children.length()
255
+ actNode(n.children.at(i), dt)
256
+ i = i + 1
257
+
258
+ # The topmost visible node under `p`, a point in the node's parent's coordinates:
259
+ # children before the node itself, later siblings before earlier ones. A node is
260
+ # hit inside its width by height box; a group with no size is never hit itself.
261
+ fun hitNode(n: Node, p: Vec2) -> Node? =
262
+ if !n.visible
263
+ return null
264
+ q := localMatrix(n).inverse().apply(p)
265
+ for i := range n.children.length()
266
+ child := n.children.at(n.children.length() - 1 - i)
267
+ if found := hitNode(child, q)
268
+ return found
269
+ 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
270
+ return n
271
+ null
272
+
273
+ # A root node plus a camera (the world point at the screen's top-left and the
274
+ # pixels per world unit, as in `setCamera`).
275
+ record Stage(root: Node, camX: Float, camY: Float, camScale: Float) =
276
+
277
+ fun create() -> Stage =
278
+ Stage(root: Node.group(), camX: 0.0, camY: 0.0, camScale: 1.0)
279
+
280
+ # Runs every node's behaviour, parents before children, in insertion order.
281
+ fun act(self, dt: Float) =
282
+ actNode(self.root, dt)
283
+
284
+ fun draw(self) =
285
+ setCamera(self.camX, self.camY, self.camScale)
286
+ drawNode(self.root, Affine.Identity, 1.0)
287
+ resetCamera()
288
+
289
+ # The topmost visible node at a stage point.
290
+ fun hit(self, x: Float, y: Float) -> Node? =
291
+ hitNode(self.root, Vec2(x: x, y: y))
292
+
293
+ # A screen pixel in stage coordinates under this camera.
294
+ fun screenToStage(self, sx: Float, sy: Float) -> Vec2 =
295
+ Vec2(x: sx / self.camScale + self.camX, y: sy / self.camScale + self.camY)
296
+
297
+ # --- tests ---
298
+
299
+ fun sceneNear(a: Float, b: Float) -> Bool =
300
+ {a - b}.abs() < 0.0001
301
+
302
+ fun sceneNearV(p: Vec2, x: Float, y: Float) -> Bool =
303
+ sceneNear(p.x, x) && sceneNear(p.y, y)
304
+
305
+ fun sceneSpin(n: Node, dt: Float) =
306
+ n.rotation = n.rotation + dt
307
+
308
+ test "a node translates; rotation and scale pivot about the origin"
309
+ n := Node.rect(10.0, 10.0, Color.Red).at(100.0, 50.0)
310
+ assert sceneNearV(localMatrix(n).apply(Vec2(x: 0.0, y: 0.0)), 100.0, 50.0)
311
+ n.originX = 5.0
312
+ n.originY = 5.0
313
+ n.rotation = Float.HalfPi
314
+ assert sceneNearV(localMatrix(n).apply(Vec2(x: 5.0, y: 5.0)), 105.0, 55.0)
315
+ assert sceneNearV(localMatrix(n).apply(Vec2(x: 10.0, y: 5.0)), 105.0, 60.0)
316
+ n.rotation = 0.0
317
+ n.scaleX = 2.0
318
+ assert sceneNearV(localMatrix(n).apply(Vec2(x: 10.0, y: 10.0)), 115.0, 60.0)
319
+
320
+ test "matrices compose, and inverse undoes them"
321
+ g := Node.group().at(100.0, 0.0)
322
+ g.rotation = Float.HalfPi
323
+ g.scaleX = 2.0
324
+ g.scaleY = 2.0
325
+ k := Node.rect(10.0, 10.0, Color.Red).at(10.0, 0.0)
326
+ g.add(k)
327
+ w := worldMatrix(k)
328
+ assert sceneNearV(w.apply(Vec2(x: 0.0, y: 0.0)), 100.0, 20.0)
329
+ assert sceneNearV(w.apply(Vec2(x: 10.0, y: 10.0)), 80.0, 40.0)
330
+ assert sceneNear(w.scaleX(), 2.0)
331
+ assert sceneNear(w.rotation(), Float.HalfPi)
332
+ back := w.inverse().apply(w.apply(Vec2(x: 3.0, y: 7.0)))
333
+ assert sceneNearV(back, 3.0, 7.0)
334
+ assert sceneNearV(k.localToStage(Vec2(x: 10.0, y: 10.0)), 80.0, 40.0)
335
+ assert sceneNearV(k.stageToLocal(Vec2(x: 80.0, y: 40.0)), 10.0, 10.0)
336
+
337
+ test "add reparents, remove and clear detach"
338
+ a := Node.group()
339
+ b := Node.group()
340
+ k := Node.group()
341
+ a.add(k)
342
+ assert a.children.length() == 1
343
+ b.add(k)
344
+ assert a.children.length() == 0
345
+ assert b.children.length() == 1
346
+ assert {k.parent ?: crash "no parent"} == b
347
+ b.remove(k)
348
+ assert b.children.length() == 0
349
+ assert k.parent == null
350
+ b.add(k)
351
+ extra := Node.group()
352
+ b.add(extra)
353
+ b.clear()
354
+ assert b.children.length() == 0
355
+ assert k.parent == null
356
+
357
+ test "draw order is parent first, then children in insertion order"
358
+ root := Node.group()
359
+ a := Node.group()
360
+ root.add(a)
361
+ b := Node.group()
362
+ root.add(b)
363
+ a1 := Node.rect(1.0, 1.0, Color.Red)
364
+ a.add(a1)
365
+ a2 := Node.rect(1.0, 1.0, Color.Red)
366
+ a.add(a2)
367
+ b1 := Node.rect(1.0, 1.0, Color.Red)
368
+ b.add(b1)
369
+ list := flatten(root)
370
+ assert list.length() == 6
371
+ assert list.at(0).node == root
372
+ assert list.at(1).node == a
373
+ assert list.at(2).node == a1
374
+ assert list.at(3).node == a2
375
+ assert list.at(4).node == b
376
+ assert list.at(5).node == b1
377
+
378
+ test "invisible subtrees are skipped and alpha multiplies down"
379
+ root := Node.group()
380
+ root.color = Color.White.withAlpha(0.5)
381
+ g := Node.group()
382
+ root.add(g)
383
+ g.color = Color.White.withAlpha(0.5)
384
+ k := Node.rect(1.0, 1.0, Color.Red)
385
+ g.add(k)
386
+ hidden := Node.group()
387
+ root.add(hidden)
388
+ hidden.visible = false
389
+ hidden.add(Node.rect(1.0, 1.0, Color.Red))
390
+ list := flatten(root)
391
+ assert list.length() == 3
392
+ assert sceneNear(list.at(2).alpha, 0.25)
393
+ g.visible = false
394
+ assert flatten(root).length() == 1
395
+
396
+ test "hit finds the topmost node through rotated and scaled groups"
397
+ root := Node.group()
398
+ g := Node.group().at(100.0, 0.0)
399
+ root.add(g)
400
+ g.rotation = Float.HalfPi
401
+ g.scaleX = 2.0
402
+ g.scaleY = 2.0
403
+ low := Node.rect(30.0, 30.0, Color.Red)
404
+ g.add(low)
405
+ top := Node.rect(10.0, 10.0, Color.Blue).at(10.0, 0.0)
406
+ g.add(top)
407
+ stage := Stage.create()
408
+ stage.root.add(g)
409
+ assert {stage.hit(90.0, 30.0) ?: crash "miss"} == top
410
+ assert {stage.hit(70.0, 10.0) ?: crash "miss"} == low
411
+ assert {stage.hit(60.0, 30.0) ?: crash "miss"} == low
412
+ assert stage.hit(10.0, 10.0) == null
413
+ assert stage.hit(150.0, 30.0) == null
414
+ top.visible = false
415
+ assert {stage.hit(90.0, 30.0) ?: crash "miss"} == low
416
+ g.visible = false
417
+ assert stage.hit(90.0, 30.0) == null
418
+
419
+ test "hit prefers later siblings and a child over its parent"
420
+ stage := Stage.create()
421
+ a := Node.rect(50.0, 50.0, Color.Red)
422
+ stage.root.add(a)
423
+ b := Node.rect(50.0, 50.0, Color.Blue).at(25.0, 0.0)
424
+ stage.root.add(b)
425
+ inner := Node.rect(10.0, 10.0, Color.Green)
426
+ b.add(inner)
427
+ assert {stage.hit(10.0, 10.0) ?: crash "miss"} == a
428
+ assert {stage.hit(30.0, 5.0) ?: crash "miss"} == inner
429
+ assert {stage.hit(60.0, 40.0) ?: crash "miss"} == b
430
+ assert {stage.hit(40.0, 10.0) ?: crash "miss"} == b
431
+
432
+ test "act runs behaviours depth first, including invisible nodes"
433
+ stage := Stage.create()
434
+ g := Node.group().setAct(sceneSpin)
435
+ stage.root.add(g)
436
+ k := Node.group().setAct(sceneSpin)
437
+ g.add(k)
438
+ g.visible = false
439
+ stage.act(0.5)
440
+ stage.act(0.25)
441
+ assert sceneNear(g.rotation, 0.75)
442
+ assert sceneNear(k.rotation, 0.75)
443
+ assert stage.root.rotation == 0.0
444
+
445
+ test "the screen maps to the stage through the camera"
446
+ stage := Stage.create()
447
+ stage.camX = 100.0
448
+ stage.camY = 50.0
449
+ stage.camScale = 2.0
450
+ p := stage.screenToStage(40.0, 20.0)
451
+ assert sceneNearV(p, 120.0, 60.0)
452
+
453
+ test "sizes of sprites, circles and text"
454
+ assert Node.circle(5.0, Color.Red).width == 10.0
455
+ s := Node.sprite(3, 16.0, 0.0, 16.0, 16.0, 32.0, 32.0)
456
+ assert s.tex == 3
457
+ assert s.kind == NodeKind.Sprite
458
+ assert Node.text("ab", 10.0, Color.White).height == 10.0