plum
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 +2 -0
- plum-examples/game_scene.plum +600 -0
- plum-game/web-test/README.md +1 -1
- plum-game/web-test/tests/web_check.rs +5 -0
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");
|