plum
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
|