plum

#treesitter#compiler#wasm

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

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


ab19e82 — Peter John 2026-11-02T20:45:48+05:30
examples: Orb Run on the scene graph (game_scene.plum); README immediate vs scene
README.md CHANGED
@@ -1128,6 +1128,8 @@ The page calls `init` once after the assets are loaded, then `update` and `draw`
1128
1128
 
1129
1129
  **Headless tests:** the `plum-game` crate (a standalone crate, wasmtime only) runs a compiled game with no browser against a `Recorder` backend that logs every host call as a string and answers input from scripted values, so `cargo test --manifest-path plum-game/Cargo.toml` checks games' draw-call sequences (`plum-game/tests/host_test.rs`). `plum-game <wasm> [--assets DIR] [--frames N]` does the same from the command line and prints the host calls of the last frame.
1130
1130
 
1131
+ **Immediate mode or Scene?** [`plum-examples/game.plum`](plum-examples/game.plum) is immediate mode: one state record, a pure `update`, and `draw` issues the calls each frame; it suits flat arcade games with a few kinds of thing. [`plum-examples/scene.plum`](plum-examples/scene.plum) and [`plum-examples/game_scene.plum`](plum-examples/game_scene.plum) are retained mode on `trait Actor`: a tree with transforms, z-order, culling, hit-testing and per-kind `act`/`draw`, which suits UI, layered worlds and unit crowds. `game_scene.plum` is the same Orb Run as `game.plum` rebuilt on the scene graph, so you can compare the two styles on identical rules.
1132
+
1131
1133
  **Browser check:** `cargo run --manifest-path plum-game/web-test/Cargo.toml -- plum-examples/game.plum --screenshot /tmp/orb-run.png` builds the static web version, serves it and drives headless Chromium (software WebGPU; `--browser webkit` drives Playwright's WebKit, the Safari engine, instead) through [playwright-rs](https://crates.io/crates/playwright-rs): it fails if the canvas stays blank or stops changing, or the page logs an error, and reports whether audio started. It also takes `--assets DIR`, `--require-audio`, `--measure SECS` (with `--json`, `draws` is the GPU draw calls per frame), `--headed`, `--dpr N` (a device pixel ratio of N), `--lose-device` (destroys the GPU device after a few frames and requires rendering to resume) and `--expect-pixel x,y,r,g,b[,tol]` (repeatable; the screenshot pixel at CSS (x, y)), `--expect-unsupported-webgpu` (hides `navigator.gpu` and requires the page's "WebGPU is not supported" message while plum.wasm still loads) and `--no-module-introspection` (makes `WebAssembly.Module.imports/exports/customSections` throw, as Safari does). `plum-game/web-test/fixtures/` has `interleave.plum`, `text_sizes.plum` and `rendertarget.plum` for these. One-time setup: `cargo run --manifest-path plum-game/web-test/Cargo.toml --example install-browsers`. See [`plum-game/web-test/README.md`](plum-game/web-test/README.md); `docs/game.png` above was made with `--screenshot`. [`plum-game/web-test/BENCHMARK.md`](plum-game/web-test/BENCHMARK.md) records the renderer benchmark that led to this design.
1132
1134
 
1133
1135
  **Asset root:** textures and files are read relative to the asset root. A path that escapes the root (`..` or absolute) fails like a missing file: `-1`, `""` or `false`. The headless runner's root is `--assets` or `assets` beside the wasm; it reads image headers for `textureWidth`/`textureHeight` and records sounds without decoding them.
plum-examples/game_scene.plum ADDED
@@ -0,0 +1,600 @@
1
+ # Orb Run on the scene graph (`plum game plum-examples/game_scene.plum`): the same game as
2
+ # game.plum (same arena, rules, sprites, sounds, keys and scoring), rebuilt in retained
3
+ # mode on std/game/Scene. Steer the ship with the arrow keys or WASD, collect the
4
+ # drifting orbs and avoid the mines. Three lives; Enter or R restarts after the game
5
+ # is over.
6
+ #
7
+ # What differs from game.plum. There, one `World` record holds plain `Body` values,
8
+ # `step` is the only place that moves anything and `draw` issues every call by hand.
9
+ # Here each kind is a record embedding the scene's `Actor` trait (`Ship`, `Orb`,
10
+ # `Mine`, `Star`) with its own `act` (motion, spin, blink, twinkle) and `draw`
11
+ # (its sprite frames), and the world is a tree: layer Groups under `stage.root`
12
+ # (backdrop, stars, mines, orbs, ship, HUD), so `stage.act(dt)` and `stage.draw()` do
13
+ # the per-kind work and z-order is the order of the layers. Collisions, scoring,
14
+ # spawning, lives and restart stay in plain functions over `World`.
15
+ #
16
+ # Prefer game.plum's immediate mode for a flat arcade game with a handful of kinds
17
+ # where one state record and one pure `update` are easiest to test. Prefer this
18
+ # retained mode when the game has layers, parent-relative transforms, hit-testing
19
+ # or many things that each behave and draw differently.
20
+ #
21
+ # The rules never call the host, so the `test` blocks below run under `plum test`;
22
+ # only `init`, `update` and `draw` talk to the host.
23
+ module game_scene
24
+ import std/Str
25
+ import std/Int
26
+ import std/Float
27
+ import std/List
28
+ import std/Math
29
+ import std/Random
30
+ import std/game/Vec2
31
+ import std/game/Color
32
+ import std/game/Game
33
+ import std/game/Scene
34
+
35
+ primitive Rules =
36
+ ArenaW = 800.0
37
+ ArenaH = 600.0
38
+ ShipRadius = 10.0
39
+ OrbRadius = 10.0
40
+ MineRadius = 11.0
41
+ Thrust = 900.0
42
+ Drag = 2.2
43
+ OrbCount = 3
44
+ StartLives = 3
45
+ HurtTime = 1.5
46
+ OrbPoints = 10
47
+ Cell = 16.0
48
+ Zoom = 3.0
49
+
50
+ # A position and velocity: what a spawn returns and what drifters are bounced with.
51
+ record Motion(x: Float, y: Float, vx: Float, vy: Float)
52
+
53
+ # Which of `count` animation frames is showing after `clock` seconds at `fps` frames per second.
54
+ fun frameOf(clock: Float, count: Int, fps: Float) -> Int =
55
+ Int(clock * fps) % count
56
+
57
+ # A random spot at least `gap` away from (ax, ay), drifting at up to `speed`.
58
+ fun spawn(rng: Random, ax: Float, ay: Float, gap: Float, speed: Float) -> Motion =
59
+ margin := 30.0
60
+ b := Motion(x: 0.0, y: 0.0, vx: 0.0, vy: 0.0)
61
+ tries := 0
62
+ while tries < 20
63
+ b.x = margin + rng.nextFloat() * {Rules.ArenaW - 2.0 * margin}
64
+ b.y = margin + rng.nextFloat() * {Rules.ArenaH - 2.0 * margin}
65
+ if Math.hypot(b.x - ax, b.y - ay) >= gap
66
+ break
67
+ tries = tries + 1
68
+ heading := rng.nextFloat() * Float.Tau
69
+ pace := speed * {0.4 + 0.6 * rng.nextFloat()}
70
+ b.vx = Math.cos(heading) * pace
71
+ b.vy = Math.sin(heading) * pace
72
+ b
73
+
74
+ # Moves a drifter and bounces it off the arena walls.
75
+ fun drift(b: Motion, r: Float, dt: Float) =
76
+ b.x = b.x + b.vx * dt
77
+ b.y = b.y + b.vy * dt
78
+ if b.x < r || b.x > Rules.ArenaW - r
79
+ b.vx = 0.0 - b.vx
80
+ b.x = b.x.max(r).min(Rules.ArenaW - r)
81
+ if b.y < r || b.y > Rules.ArenaH - r
82
+ b.vy = 0.0 - b.vy
83
+ b.y = b.y.max(r).min(Rules.ArenaH - r)
84
+
85
+ # How many mines are out: more as the score grows.
86
+ fun mineCount(score: Int) -> Int =
87
+ {3 + score / 50}.min(8)
88
+
89
+ # Actor kinds of this game. Each is a sprite-sized actor centred on its position (x, y)
90
+ # (origin at the middle of the box, so `rotation` turns it about its centre).
91
+
92
+ # The player. `ax` and `ay` are the input axes (-1..1) for the next `act`.
93
+ record Ship(Actor, vx: Float, vy: Float, ax: Float, ay: Float, hurt: Float, clock: Float, tex: Int) =
94
+
95
+ fun create(sheet: Int, x: Float, y: Float) -> Ship =
96
+ size := Rules.Cell * Rules.Zoom
97
+ Ship(
98
+ x: x, y: y, width: size, height: size, originX: size / 2.0, originY: size / 2.0,
99
+ scaleX: 1.0, scaleY: 1.0, rotation: 0.0, color: Color.White, visible: true,
100
+ children: List[Actor](), parent: null, scratch: List[Actor](),
101
+ cull: false, cullX: 0.0, cullY: 0.0, cullW: 0.0, cullH: 0.0,
102
+ vx: 0.0, vy: 0.0, ax: 0.0, ay: 0.0, hurt: 0.0, clock: 0.0, tex: sheet)
103
+
104
+ # Accelerates by the input axes, applies drag, keeps the ship in the arena and
105
+ # blinks it while it is hurt (the blink is just `visible`).
106
+ fun act(self, dt: Float) =
107
+ self.clock = self.clock + dt
108
+ self.hurt = {self.hurt - dt}.max(0.0)
109
+ self.vx = self.vx + self.ax * Rules.Thrust * dt
110
+ self.vy = self.vy + self.ay * Rules.Thrust * dt
111
+ keep := {1.0 - Rules.Drag * dt}.max(0.0)
112
+ self.vx = self.vx * keep
113
+ self.vy = self.vy * keep
114
+ self.x = self.x + self.vx * dt
115
+ self.y = self.y + self.vy * dt
116
+ r := Rules.ShipRadius
117
+ if self.x < r
118
+ self.x = r
119
+ self.vx = 0.0
120
+ if self.x > Rules.ArenaW - r
121
+ self.x = Rules.ArenaW - r
122
+ self.vx = 0.0
123
+ if self.y < r
124
+ self.y = r
125
+ self.vy = 0.0
126
+ if self.y > Rules.ArenaH - r
127
+ self.y = Rules.ArenaH - r
128
+ self.vy = 0.0
129
+ if self.vx * self.vx + self.vy * self.vy > 400.0
130
+ self.rotation = Math.atan2(self.vx, 0.0 - self.vy)
131
+ self.visible = !{self.hurt > 0.0 && frameOf(self.clock, 2, 10.0) == 0}
132
+
133
+ fun draw(self, m: Affine, alpha: Float) =
134
+ c := m.apply(Vec2(x: self.width / 2.0, y: self.height / 2.0))
135
+ drawCircle(c.x, c.y, 17.0 * m.scaleX(), Color(r: 0.4, g: 0.8, b: 1.0, a: 0.12 * alpha))
136
+ frame := Float(frameOf(self.clock, 4, 14.0))
137
+ drawSpriteFrame(m, alpha, self.width, self.height, self.color, self.tex, frame * Rules.Cell, 0.0, Rules.Cell, Rules.Cell)
138
+
139
+ # A glowing orb drifting about. `phase` offsets its animation from the other orbs.
140
+ record Orb(Actor, vx: Float, vy: Float, phase: Float, clock: Float, tex: Int) =
141
+
142
+ fun create(sheet: Int, phase: Float, at: Motion) -> Orb =
143
+ size := Rules.Cell * Rules.Zoom
144
+ Orb(
145
+ x: at.x, y: at.y, width: size, height: size, originX: size / 2.0, originY: size / 2.0,
146
+ scaleX: 1.0, scaleY: 1.0, rotation: 0.0, color: Color.White, visible: true,
147
+ children: List[Actor](), parent: null, scratch: List[Actor](),
148
+ cull: false, cullX: 0.0, cullY: 0.0, cullW: 0.0, cullH: 0.0,
149
+ vx: at.vx, vy: at.vy, phase: phase, clock: 0.0, tex: sheet)
150
+
151
+ fun motion(self) -> Motion =
152
+ Motion(x: self.x, y: self.y, vx: self.vx, vy: self.vy)
153
+
154
+ fun place(self, m: Motion) =
155
+ self.x = m.x
156
+ self.y = m.y
157
+ self.vx = m.vx
158
+ self.vy = m.vy
159
+
160
+ fun act(self, dt: Float) =
161
+ self.clock = self.clock + dt
162
+ m := self.motion()
163
+ drift(m, Rules.OrbRadius, dt)
164
+ self.place(m)
165
+
166
+ fun draw(self, m: Affine, alpha: Float) =
167
+ c := m.apply(Vec2(x: self.width / 2.0, y: self.height / 2.0))
168
+ k := m.scaleX()
169
+ blendMode(Blend.Add)
170
+ drawCircle(c.x, c.y, 26.0 * k, Color(r: 0.35, g: 0.22, b: 0.0, a: 0.5 * alpha))
171
+ drawCircle(c.x, c.y, 17.0 * k, Color(r: 0.6, g: 0.4, b: 0.05, a: 0.5 * alpha))
172
+ blendMode(Blend.Normal)
173
+ frame := Float(frameOf(self.clock + self.phase, 4, 8.0))
174
+ drawSpriteFrame(m, alpha, self.width, self.height, self.color, self.tex, frame * Rules.Cell, Rules.Cell, Rules.Cell, Rules.Cell)
175
+
176
+ # A spinning mine drifting about.
177
+ record Mine(Actor, vx: Float, vy: Float, clock: Float, tex: Int) =
178
+
179
+ fun create(sheet: Int, at: Motion) -> Mine =
180
+ size := Rules.Cell * Rules.Zoom
181
+ Mine(
182
+ x: at.x, y: at.y, width: size, height: size, originX: size / 2.0, originY: size / 2.0,
183
+ scaleX: 1.0, scaleY: 1.0, rotation: 0.0, color: Color.White, visible: true,
184
+ children: List[Actor](), parent: null, scratch: List[Actor](),
185
+ cull: false, cullX: 0.0, cullY: 0.0, cullW: 0.0, cullH: 0.0,
186
+ vx: at.vx, vy: at.vy, clock: 0.0, tex: sheet)
187
+
188
+ fun motion(self) -> Motion =
189
+ Motion(x: self.x, y: self.y, vx: self.vx, vy: self.vy)
190
+
191
+ fun place(self, m: Motion) =
192
+ self.x = m.x
193
+ self.y = m.y
194
+ self.vx = m.vx
195
+ self.vy = m.vy
196
+
197
+ fun act(self, dt: Float) =
198
+ self.clock = self.clock + dt
199
+ m := self.motion()
200
+ drift(m, Rules.MineRadius, dt)
201
+ self.place(m)
202
+ self.rotation = self.rotation + 2.0 * dt
203
+
204
+ fun draw(self, m: Affine, alpha: Float) =
205
+ frame := Float(frameOf(self.clock, 4, 4.0))
206
+ drawSpriteFrame(m, alpha, self.width, self.height, self.color, self.tex, frame * Rules.Cell, 2.0 * Rules.Cell, Rules.Cell, Rules.Cell)
207
+
208
+ # A background star that twinkles.
209
+ record Star(Actor, seed: Float, clock: Float) =
210
+
211
+ fun create(i: Int) -> Star =
212
+ r := 1.0 + Float(i % 3) * 0.5
213
+ Star(
214
+ x: Float({i * 7919} % 800) - r, y: Float({i * 104729} % 600) - r, width: r * 2.0, height: r * 2.0,
215
+ originX: 0.0, originY: 0.0, scaleX: 1.0, scaleY: 1.0, rotation: 0.0, color: Color.White, visible: true,
216
+ children: List[Actor](), parent: null, scratch: List[Actor](),
217
+ cull: false, cullX: 0.0, cullY: 0.0, cullW: 0.0, cullH: 0.0,
218
+ seed: Float(i), clock: 0.0)
219
+
220
+ fun act(self, dt: Float) =
221
+ self.clock = self.clock + dt
222
+
223
+ fun draw(self, m: Affine, alpha: Float) =
224
+ twinkle := 0.25 + 0.2 * Math.sin(self.clock * 2.0 + self.seed)
225
+ drawDisc(m, alpha, self.width / 2.0, Color(r: 0.7, g: 0.8, b: 1.0, a: twinkle))
226
+
227
+ # The whole game: the stage with its layers, typed handles to the actors the rules
228
+ # touch, and the run's state. `mines` and `orbs` are the layer Groups; `mineList`
229
+ # and `orbList` are the same actors, typed.
230
+ record World(
231
+ stage: Stage,
232
+ ship: Ship,
233
+ mines: Group,
234
+ orbs: Group,
235
+ mineList: List[Mine],
236
+ orbList: List[Orb],
237
+ overlay: Group,
238
+ scoreText: Text,
239
+ bestText: Text,
240
+ lifeIcons: List[Sprite],
241
+ rng: Random,
242
+ score: Int,
243
+ best: Int,
244
+ lives: Int,
245
+ over: Bool,
246
+ clock: Float,
247
+ sheet: Int,
248
+ pickupSound: Int,
249
+ hitSound: Int,
250
+ overSound: Int,
251
+ musicSound: Int)
252
+
253
+ fun newWorld(seed: Int, sheet: Int) -> World =
254
+ rng := Random(seed: seed)
255
+ stage := Stage.create()
256
+ ship := Ship.create(sheet, Rules.ArenaW / 2.0, Rules.ArenaH / 2.0)
257
+ stars := Group.create()
258
+ for i := range 40
259
+ stars.add(Star.create(i))
260
+ mines := Group.create()
261
+ orbs := Group.create()
262
+ hud := Group.create()
263
+ scoreText := Text.create("Score 0", 28.0, Color.White)
264
+ scoreText.x = 16.0
265
+ scoreText.y = 12.0
266
+ bestText := Text.create("Best 0", 20.0, Color.Yellow)
267
+ bestText.x = 16.0
268
+ bestText.y = 44.0
269
+ hud.add(scoreText)
270
+ hud.add(bestText)
271
+ icons := List[Sprite]()
272
+ for i := range Rules.StartLives
273
+ icon := Sprite.create(sheet, 0.0, 0.0, Rules.Cell, Rules.Cell, 32.0, 32.0)
274
+ icon.x = Rules.ArenaW - 40.0 - Float(i) * 36.0
275
+ icon.y = 12.0
276
+ hud.add(icon)
277
+ icons.push(icon)
278
+ overlay := Group.create()
279
+ overlay.visible = false
280
+ overlay.add(Rect.create(Rules.ArenaW, 120.0, Color(r: 0.0, g: 0.0, b: 0.0, a: 0.6)).at(0.0, 240.0))
281
+ overlay.add(Text.create("Game over", 48.0, Color.Red).at(300.0, 258.0))
282
+ overlay.add(Text.create("Press Enter to play again", 24.0, Color.White).at(270.0, 316.0))
283
+ hud.add(overlay)
284
+ stage.root.add(Rect.create(Rules.ArenaW, Rules.ArenaH, Color.rgb(0.07, 0.08, 0.16)))
285
+ stage.root.add(stars)
286
+ stage.root.add(mines)
287
+ stage.root.add(orbs)
288
+ stage.root.add(ship)
289
+ stage.root.add(hud)
290
+ w := World(
291
+ stage: stage, ship: ship, mines: mines, orbs: orbs, mineList: List[Mine](), orbList: List[Orb](),
292
+ overlay: overlay, scoreText: scoreText, bestText: bestText, lifeIcons: icons,
293
+ rng: rng, score: 0, best: 0, lives: Rules.StartLives, over: false, clock: 0.0, sheet: sheet,
294
+ pickupSound: -1, hitSound: -1, overSound: -1, musicSound: -1)
295
+ for i := range Rules.OrbCount
296
+ orb := Orb.create(sheet, Float(i) * 0.1, spawn(rng, ship.x, ship.y, 120.0, 40.0))
297
+ orbs.add(orb)
298
+ w.orbList.push(orb)
299
+ for i := range mineCount(0)
300
+ addMine(w)
301
+ w
302
+
303
+ # Adds one mine, far from the ship, to the mines layer.
304
+ fun addMine(w: World) =
305
+ mine := Mine.create(w.sheet, spawn(w.rng, w.ship.x, w.ship.y, 220.0, 90.0))
306
+ w.mines.add(mine)
307
+ w.mineList.push(mine)
308
+
309
+ # Starts a fresh game, keeping the best score and the random stream.
310
+ fun restart(w: World) =
311
+ w.best = w.best.max(w.score)
312
+ w.score = 0
313
+ w.lives = Rules.StartLives
314
+ w.over = false
315
+ ship := w.ship
316
+ ship.hurt = 0.0
317
+ ship.visible = true
318
+ ship.x = Rules.ArenaW / 2.0
319
+ ship.y = Rules.ArenaH / 2.0
320
+ ship.vx = 0.0
321
+ ship.vy = 0.0
322
+ for i := range w.orbList.length()
323
+ w.orbList.at(i).place(spawn(w.rng, ship.x, ship.y, 120.0, 40.0))
324
+ while w.mineList.length() > mineCount(0)
325
+ gone := w.mineList.pop()!
326
+ w.mines.remove(gone)
327
+ for i := range w.mineList.length()
328
+ w.mineList.at(i).place(spawn(w.rng, ship.x, ship.y, 220.0, 90.0))
329
+ sync(w)
330
+
331
+ # Copies the score, best score, lives and game-over panel into the HUD actors.
332
+ fun sync(w: World) =
333
+ w.scoreText.text = "Score {w.score}"
334
+ w.bestText.text = "Best {w.best.max(w.score)}"
335
+ for i := range w.lifeIcons.length()
336
+ w.lifeIcons.at(i).visible = i < w.lives
337
+ w.overlay.visible = w.over
338
+
339
+ fun overlaps(ax: Float, ay: Float, ra: Float, bx: Float, by: Float, rb: Float) -> Bool =
340
+ Math.hypot(ax - bx, ay - by) < ra + rb
341
+
342
+ # One simulation step: every actor acts through the stage, then the rules look at
343
+ # the result. Does nothing once the game is over.
344
+ fun step(w: World, ax: Float, ay: Float, dt: Float) =
345
+ w.clock = w.clock + dt
346
+ if w.over
347
+ return
348
+ w.ship.ax = ax
349
+ w.ship.ay = ay
350
+ w.stage.act(dt)
351
+ ship := w.ship
352
+ for i := range w.orbList.length()
353
+ orb := w.orbList.at(i)
354
+ if overlaps(ship.x, ship.y, Rules.ShipRadius, orb.x, orb.y, Rules.OrbRadius)
355
+ w.score = w.score + Rules.OrbPoints
356
+ orb.place(spawn(w.rng, ship.x, ship.y, 120.0, 40.0))
357
+ while w.mineList.length() < mineCount(w.score)
358
+ addMine(w)
359
+ for i := range w.mineList.length()
360
+ mine := w.mineList.at(i)
361
+ if ship.hurt <= 0.0 && overlaps(ship.x, ship.y, Rules.ShipRadius, mine.x, mine.y, Rules.MineRadius)
362
+ w.lives = w.lives - 1
363
+ ship.hurt = Rules.HurtTime
364
+ if w.lives <= 0
365
+ w.over = true
366
+ w.best = w.best.max(w.score)
367
+ ship.visible = false
368
+ sync(w)
369
+
370
+ # Fits the arena into a screen of `sw` by `sh` pixels, centred.
371
+ fun fit(w: World, sw: Float, sh: Float) =
372
+ zoom := {sw / Rules.ArenaW}.min(sh / Rules.ArenaH)
373
+ w.stage.camScale = zoom
374
+ w.stage.camX = 0.0 - {sw / zoom - Rules.ArenaW} / 2.0
375
+ w.stage.camY = 0.0 - {sh / zoom - Rules.ArenaH} / 2.0
376
+
377
+ # --- host glue: input, sound, drawing ---
378
+
379
+ fun musicVolume() -> Float =
380
+ 0.3
381
+
382
+ # Restarts the music loop from the top.
383
+ fun startMusic(w: World) =
384
+ soundStop(w.musicSound)
385
+ soundLoop(w.musicSound, musicVolume())
386
+
387
+ export fun init() -> World =
388
+ w := newWorld(2024, textureLoad("sprites.png"))
389
+ w.pickupSound = soundLoad("pickup.wav")
390
+ w.hitSound = soundLoad("hit.wav")
391
+ w.overSound = soundLoad("gameover.wav")
392
+ w.musicSound = soundLoad("music.wav")
393
+ startMusic(w)
394
+ w
395
+
396
+ fun axis(negative: Bool, positive: Bool) -> Float =
397
+ if negative && !positive
398
+ -1.0
399
+ else if positive && !negative
400
+ 1.0
401
+ else
402
+ 0.0
403
+
404
+ export fun update(w: World, dt: Float) =
405
+ if w.over && {isKeyPressed(Key.Enter) || isKeyPressed(Key.KeyR)}
406
+ restart(w)
407
+ startMusic(w)
408
+ left := isKeyDown(Key.Left) || isKeyDown(Key.KeyA)
409
+ right := isKeyDown(Key.Right) || isKeyDown(Key.KeyD)
410
+ up := isKeyDown(Key.Up) || isKeyDown(Key.KeyW)
411
+ down := isKeyDown(Key.Down) || isKeyDown(Key.KeyS)
412
+ score := w.score
413
+ lives := w.lives
414
+ step(w, axis(left, right), axis(up, down), dt)
415
+ if w.score > score
416
+ soundPlay(w.pickupSound, 0.8)
417
+ if w.lives < lives
418
+ if w.over
419
+ soundStop(w.musicSound)
420
+ soundPlay(w.overSound, 1.0)
421
+ else
422
+ soundPlay(w.hitSound, 1.0)
423
+
424
+ export fun draw(w: World) =
425
+ fit(w, screenWidth(), screenHeight())
426
+ clear(Color.rgb(0.04, 0.05, 0.1))
427
+ w.stage.draw()
428
+
429
+ # --- tests ---
430
+
431
+ fun near(a: Float, b: Float) -> Bool =
432
+ {a - b}.abs() < 0.01
433
+
434
+ # A world with the orbs and mines moved out of the way.
435
+ fun emptyWorld() -> World =
436
+ w := newWorld(7, -1)
437
+ for i := range w.orbList.length()
438
+ w.orbList.at(i).place(Motion(x: 5000.0, y: 0.0, vx: 0.0, vy: 0.0))
439
+ for i := range w.mineList.length()
440
+ w.mineList.at(i).place(Motion(x: 5000.0, y: 0.0, vx: 0.0, vy: 0.0))
441
+ w
442
+
443
+ test "steering accelerates and drag slows the ship"
444
+ ship := Ship.create(-1, 400.0, 300.0)
445
+ ship.ax = 1.0
446
+ ship.act(0.1)
447
+ assert ship.vx > 0.0
448
+ assert ship.x > 400.0
449
+ fast := ship.vx
450
+ ship.ax = 0.0
451
+ for i := range 30
452
+ ship.act(0.1)
453
+ assert ship.vx < fast / 4.0
454
+
455
+ test "the ship stops at the arena walls"
456
+ ship := Ship.create(-1, 20.0, 20.0)
457
+ ship.ax = -1.0
458
+ ship.ay = -1.0
459
+ for i := range 20
460
+ ship.act(0.1)
461
+ assert ship.x == Rules.ShipRadius
462
+ assert ship.y == Rules.ShipRadius
463
+ assert ship.vx == 0.0
464
+
465
+ test "drifters bounce off the walls"
466
+ orb := Orb.create(-1, 0.0, Motion(x: 795.0, y: 300.0, vx: 100.0, vy: 0.0))
467
+ orb.act(0.1)
468
+ assert orb.vx == -100.0
469
+ assert orb.x == Rules.ArenaW - 10.0
470
+
471
+ test "a mine spins as it drifts"
472
+ mine := Mine.create(-1, Motion(x: 400.0, y: 300.0, vx: 10.0, vy: 0.0))
473
+ mine.act(0.5)
474
+ assert near(mine.rotation, 1.0)
475
+ assert near(mine.x, 405.0)
476
+
477
+ test "the scene has a backdrop, stars, mines, orbs, ship and HUD layers"
478
+ w := newWorld(7, -1)
479
+ assert w.stage.root.children.length() == 6
480
+ assert w.stage.root.children.at(1).children.length() == 40
481
+ assert w.mines.children.length() == mineCount(0)
482
+ assert w.orbs.children.length() == Rules.OrbCount
483
+ assert same(w.stage.root.children.at(4), w.ship)
484
+ assert same(w.stage.root.children.at(2), w.mines)
485
+ assert same(w.stage.root.children.at(3), w.orbs)
486
+
487
+ test "collecting an orb scores and respawns it elsewhere, still in the orbs layer"
488
+ w := emptyWorld()
489
+ orb := w.orbList.at(0)
490
+ orb.place(Motion(x: w.ship.x, y: w.ship.y, vx: 0.0, vy: 0.0))
491
+ step(w, 0.0, 0.0, 0.016)
492
+ assert w.score == Rules.OrbPoints
493
+ assert Math.hypot(orb.x - w.ship.x, orb.y - w.ship.y) >= 120.0
494
+ assert orb.x < 5000.0
495
+ assert w.orbs.children.length() == Rules.OrbCount
496
+ assert same({orb.parent ?: crash "orphaned"}, w.orbs)
497
+ assert w.scoreText.text == "Score 10"
498
+
499
+ test "a mine costs one life and makes the ship flash through its own act"
500
+ w := emptyWorld()
501
+ mine := w.mineList.at(0)
502
+ mine.place(Motion(x: w.ship.x, y: w.ship.y, vx: 0.0, vy: 0.0))
503
+ step(w, 0.0, 0.0, 0.016)
504
+ assert w.lives == Rules.StartLives - 1
505
+ assert w.ship.hurt > 0.0
506
+ assert !w.lifeIcons.at(2).visible
507
+ assert w.lifeIcons.at(1).visible
508
+ mine.place(Motion(x: 5000.0, y: 0.0, vx: 0.0, vy: 0.0))
509
+ hidden := 0
510
+ shown := 0
511
+ for i := range 100
512
+ step(w, 0.0, 0.0, 0.016)
513
+ if w.ship.visible
514
+ shown = shown + 1
515
+ else
516
+ hidden = hidden + 1
517
+ assert hidden > 0
518
+ assert shown > 0
519
+ assert w.ship.hurt == 0.0
520
+ assert w.ship.visible
521
+ assert w.lives == Rules.StartLives - 1
522
+
523
+ test "the ship is briefly safe after a hit"
524
+ w := emptyWorld()
525
+ mine := w.mineList.at(0)
526
+ mine.place(Motion(x: w.ship.x, y: w.ship.y, vx: 0.0, vy: 0.0))
527
+ step(w, 0.0, 0.0, 0.016)
528
+ step(w, 0.0, 0.0, 0.016)
529
+ assert w.lives == Rules.StartLives - 1
530
+
531
+ test "the game ends at zero lives and then ignores steps"
532
+ w := emptyWorld()
533
+ w.lives = 1
534
+ w.score = 40
535
+ mine := w.mineList.at(0)
536
+ mine.place(Motion(x: w.ship.x, y: w.ship.y, vx: 0.0, vy: 0.0))
537
+ step(w, 0.0, 0.0, 0.016)
538
+ assert w.over
539
+ assert w.lives == 0
540
+ assert w.best == 40
541
+ assert w.overlay.visible
542
+ assert !w.ship.visible
543
+ x := w.ship.x
544
+ step(w, 1.0, 0.0, 0.5)
545
+ assert w.ship.x == x
546
+
547
+ test "restart resets the run and keeps the best score"
548
+ w := emptyWorld()
549
+ w.score = 90
550
+ w.lives = 0
551
+ w.over = true
552
+ w.overlay.visible = true
553
+ restart(w)
554
+ assert !w.over
555
+ assert !w.overlay.visible
556
+ assert w.ship.visible
557
+ assert w.score == 0
558
+ assert w.best == 90
559
+ assert w.lives == Rules.StartLives
560
+ assert w.ship.x == Rules.ArenaW / 2.0
561
+ assert w.mineList.length() == mineCount(0)
562
+ assert w.bestText.text == "Best 90"
563
+
564
+ test "more mines appear as the score grows and restart removes them from the layer"
565
+ assert mineCount(0) == 3
566
+ assert mineCount(100) == 5
567
+ assert mineCount(10000) == 8
568
+ w := emptyWorld()
569
+ w.score = 100
570
+ step(w, 0.0, 0.0, 0.016)
571
+ assert w.mineList.length() == 5
572
+ assert w.mines.children.length() == 5
573
+ restart(w)
574
+ assert w.mineList.length() == 3
575
+ assert w.mines.children.length() == 3
576
+
577
+ test "the same seed plays out the same way"
578
+ a := newWorld(11, -1)
579
+ b := newWorld(11, -1)
580
+ for i := range 120
581
+ step(a, 1.0, -1.0, 0.016)
582
+ step(b, 1.0, -1.0, 0.016)
583
+ assert a.ship.x == b.ship.x
584
+ assert a.score == b.score
585
+ assert a.mineList.at(0).x == b.mineList.at(0).x
586
+ assert newWorld(12, -1).orbList.at(0).x != a.orbList.at(0).x
587
+
588
+ test "fit centres the arena on the screen"
589
+ w := newWorld(7, -1)
590
+ fit(w, 1600.0, 600.0)
591
+ assert near(w.stage.camScale, 1.0)
592
+ assert near(w.stage.camX, -400.0)
593
+ assert near(w.stage.camY, 0.0)
594
+
595
+ test "animation frames cycle"
596
+ assert frameOf(0.0, 4, 8.0) == 0
597
+ assert frameOf(0.13, 4, 8.0) == 1
598
+ assert frameOf(0.5, 4, 8.0) == 0
599
+ assert axis(true, false) == -1.0
600
+ assert axis(true, true) == 0.0
plum-game/web-test/README.md CHANGED
@@ -25,4 +25,4 @@ Options: `--browser chromium|webkit` (default chromium; WebKit is Playwright's S
25
25
  Exit code is non-zero with a message on failure.
26
26
 
27
27
  `cargo test --manifest-path plum-game/web-test/Cargo.toml` runs the same check on
28
- `plum-examples/game.plum` and `plum-examples/scene.plum`, plus WebKit runs; each skips with a note if its browser is not installed.
28
+ `plum-examples/game.plum`, `plum-examples/game_scene.plum` and `plum-examples/scene.plum`, plus WebKit runs; each skips with a note if its browser is not installed.
plum-game/web-test/tests/web_check.rs CHANGED
@@ -5,6 +5,11 @@ fn orb_run_renders_and_animates() {
5
5
  orb_run("plum-examples/game.plum");
6
6
  }
7
7
 
8
+ #[test]
9
+ fn orb_run_scene_renders_and_animates() {
10
+ orb_run("plum-examples/game_scene.plum");
11
+ }
12
+
8
13
  #[test]
9
14
  fn scene_run_renders_and_animates() {
10
15
  orb_run("plum-examples/scene.plum");