plum

#treesitter#compiler#wasm

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

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


3ac7d50 — Peter John 2026-11-02T14:42:36+05:30
Add Orb Run example game with generated sprite sheet; generic Games docs
README.md CHANGED
@@ -1027,7 +1027,11 @@ Every module that imports `std/Str` also exports four helpers for hosts that can
1027
1027
 
1028
1028
  ## Games
1029
1029
 
1030
- `plum game file.plum` compiles a game and runs it in a native window (macroquad, with the module under wasmtime). `plum game --web file.plum [-o dist] [--preload path]...` writes a static web build instead: `index.html`, `mq_js_bundle.js`, `plum_bridge.js`, `plum-game.wasm`, `plum.wasm`, `preload.txt` and an `assets/` copy of the preloaded files (the six `sprites/*.rxx` and `data/defines/mapelem.dat` are always preloaded; `dist` defaults to the source file's directory). Serve it from any static host; the browser needs wasm GC support. The web build needs `rustup target add wasm32-unknown-unknown`.
1030
+ `plum game file.plum` compiles a game and runs it in a native window (macroquad, with the module under wasmtime). `plum game --web file.plum [-o dist] [--assets DIR]` writes a static web build instead: `index.html`, `mq_js_bundle.js`, `plum_bridge.js`, `plum-game.wasm`, `plum.wasm`, `preload.txt` and an `assets/` copy of the whole asset directory (every file in it is listed in `preload.txt` and fetched before the first frame; `dist` defaults to `dist` beside the source file). Serve it from any static host; the browser needs wasm GC support. The web build needs `rustup target add wasm32-unknown-unknown`.
1031
+
1032
+ `plum game plum-examples/game.plum` runs the example, a small arcade game: steer a ship with the arrow keys or WASD, collect orbs, dodge mines (Enter restarts). It draws shapes, text and an animated sprite sheet (`plum-examples/assets/sprites.png`, generated by `make_sprites.py` beside it). Its rules are plain functions with `test` blocks, so `plum test plum-examples` covers them without a window.
1033
+
1034
+ ![the example game](docs/game.png)
1031
1035
 
1032
1036
  A game declares three exports. `G` is any record and holds all game state (there are no globals):
1033
1037
 
@@ -1041,29 +1045,27 @@ The host calls `init` once after the window exists, then `update` and `draw` eve
1041
1045
 
1042
1046
  **Headless check:** `PLUM_GAME_CAPTURE=out.png plum-game game.wasm` runs `PLUM_GAME_FRAMES` frames (default 30), saves the framebuffer as an upright PNG and exits 0.
1043
1047
 
1044
- **Assets** are never part of the repository. The asset root is `--assets DIR`, else `$PLUM_GAME_ASSETS`, else `../kam_macroquad/assets` beside the repository. `--web` copies only the preloaded files into `dist/assets`.
1048
+ **Asset root:** `plum-game <wasm> [--assets DIR]` reads textures and files relative to `DIR`, default `assets` beside the wasm (`$PLUM_GAME_ASSETS` overrides that default). `plum game file.plum` passes `assets` beside the source file. A path that escapes the root (`..` or absolute) fails like a missing file: `-1`, `""` or `false`.
1045
1049
 
1046
- **Host API** (`std/Game`; `Int` handles are `-1` on failure; coordinates are world units once a camera is set):
1050
+ **Host API** (`std/Game`; texture handles are `-1` on failure; coordinates are world units once a camera is set):
1047
1051
 
1048
1052
  | function | meaning |
1049
1053
  |---|---|
1050
1054
  | `screenWidth()`, `screenHeight()`, `frameTime()` | window size in pixels, last frame time in seconds |
1051
1055
  | `clear(c)` | fill the screen |
1052
- | `drawRect(x, y, w, h, c)`, `drawLine(x1, y1, x2, y2, thickness, c)`, `drawText(s, x, y, size, c)` | shapes and text (`x, y` is the text's top-left) |
1056
+ | `drawRect(x, y, w, h, c)`, `drawCircle(x, y, r, c)`, `drawLine(x1, y1, x2, y2, thickness, c)`, `drawText(s, x, y, size, c)` | shapes and text (`x, y` is the text's top-left, a circle's centre) |
1053
1057
  | `setCamera(x, y, scale)`, `resetCamera()` | world point `(x, y)` maps to the screen's top-left, one world unit is `scale` pixels |
1054
- | `atlasLoad(name)` | load `<assets>/sprites/<name>.rxx` |
1055
- | `spriteCount(a)`, `spriteW(a, i)`, `spriteH`, `spritePivotX`, `spritePivotY`, `spriteColor(a, i)` | sprite info (0 for a missing sprite); `spriteColor` is the average opaque colour |
1056
- | `drawSprite(a, i, x, y, flipX, tint)` | sprite at its natural size, top-left at `(x + pivotX, y + pivotY)` |
1057
- | `drawTile(a, i, x, y, size, quarterTurns, tint)` | sprite stretched to a square, rotated clockwise about its centre |
1058
+ | `textureLoad(path)` | load an image (PNG at least) relative to the asset root |
1059
+ | `textureWidth(t)`, `textureHeight(t)` | size in pixels (0 for a bad handle) |
1060
+ | `textureSetSmooth(t, smooth)` | linear (`true`) or nearest (`false`, the default) filtering |
1061
+ | `drawTexture(t, x, y, tint)` | the texture at its natural size, top-left at `(x, y)` |
1062
+ | `drawTextureEx(t, sx, sy, sw, sh, x, y, w, h, rotation, flipX, tint)` | source rect `(sx, sy, sw, sh)` in texture pixels drawn into dest rect `(x, y, w, h)`, rotated `rotation` radians clockwise about the dest centre |
1063
+ | `fileExists(path)`, `fileLoad(path)` | whole file as a `Str` (`""` if missing), relative to the asset root |
1058
1064
  | `isKeyDown(k)`, `isKeyPressed(k)`, `isMouseDown(b)`, `isMousePressed(b)` | input; `k` is a `Key` constant, `b` a `Mouse` constant |
1059
1065
  | `mousePos()`, `mouseWheel()` | pointer in screen pixels; wheel direction this frame (+ is up) |
1060
- | `mapLoad(path)`, `mapWidth(m)`, `mapHeight(m)` | load a KaM `.map` relative to the asset root |
1061
- | `mapTerrain(m, x, y)`, `mapRotation`, `mapElevation`, `mapObject` | per-tile data; row 0 is the map's first row. The Tileset sprite for a terrain id is `id + 1`; `mapObject` is a `Trees_a` sprite index (0 for none) |
1062
1066
 
1063
1067
  `Key` constants use GLFW numbering: `Key.Space`, `Key.KeyA` to `Key.KeyZ`, `Key.Num0` to `Key.Num9`, `Key.Escape`, `Key.Enter`, `Key.Tab`, `Key.Backspace`, `Key.Right`, `Key.Left`, `Key.Down`, `Key.Up`, `Key.LeftShift`, `Key.LeftControl`. `Mouse` has `Mouse.Left`, `Mouse.Right` and `Mouse.Middle`. `Color` holds RGBA channels in 0..1 with the constants `White`, `Black`, `Red`, `Green`, `Blue`, `Yellow` and `Transparent`, plus `Color.rgb(r, g, b)` and `withAlpha(a)`.
1064
1068
 
1065
- `plum-game/kam/Main.plum` is a Knights and Merchants map viewer built on this API: `plum game plum-game/kam/Main.plum`.
1066
-
1067
1069
  ## Standard library highlights
1068
1070
 
1069
1071
  - **`Bool`** — `true`/`false`, `if`, `&&`/`||`/`!` and `==` are built in (no import); `toStr` (also used by `"{b}"` interpolation) and `Bool.parse("true")` need `import std/Bool`
docs/game.png ADDED
Binary file
docs/plans/2026-10-26-game-engine.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # Plum for games: browser-first macroquad host, port of a simplified KaM
2
2
 
3
+ > The engine is generic (textures, files, shapes, input); the KaM port lives in `../kam_plum`, not in this repository.
4
+
3
5
  **Status:** draft. Planned only and not run; the user has more ideas to fold in first.
4
6
  **Depends on:** `2026-10-26-zig-generics-and-numbers.md`, which supplies duck-typed per-type-copy generics, no traits (enums plus duck-typed operator methods), native typed arrays, and `Int`/`Float` kept as i64/f64 with `Int` narrowed to wasm i32 at `extern fun` boundaries (no JS BigInt in the browser bridge). That plan runs first.
5
7
  **Goal:** Port `../kam_macroquad`, a 98k-line Rust/macroquad remake of Knights and Merchants that already runs in the browser via wasm, to Plum as a **simplified** game. There is no mission scripting (rhai) and the rules are simpler, but animations and sounds stay identical. It runs in the browser first.
plum-examples/assets/make_sprites.py ADDED
@@ -0,0 +1,85 @@
1
+ #!/usr/bin/env python3
2
+ """Writes sprites.png beside this script: a 64x48 sheet of 16x16 frames.
3
+
4
+ Row 0: the ship with four engine-flame frames (pointing up).
5
+ Row 1: an orb spinning (four frames, squeezed and widened).
6
+ Row 2: a mine whose light blinks (four frames).
7
+ Standard library only (zlib + struct).
8
+ """
9
+ import math
10
+ import os
11
+ import struct
12
+ import zlib
13
+
14
+ CELL, COLS, ROWS = 16, 4, 3
15
+ W, H = CELL * COLS, CELL * ROWS
16
+ pix = [[(0, 0, 0, 0)] * W for _ in range(H)]
17
+
18
+
19
+ def put(col, row, x, y, rgba):
20
+ if 0 <= x < CELL and 0 <= y < CELL:
21
+ pix[row * CELL + y][col * CELL + x] = rgba
22
+
23
+
24
+ def ship(col):
25
+ hull, edge, glass = (120, 200, 255, 255), (30, 90, 160, 255), (255, 255, 255, 255)
26
+ for y in range(2, 13):
27
+ half = (y - 2) * 5 // 10 + 1
28
+ for x in range(8 - half, 8 + half):
29
+ put(col, 0, x, y, edge if x in (8 - half, 7 + half) or y == 12 else hull)
30
+ for y in (5, 6):
31
+ put(col, 0, 7, y, glass)
32
+ put(col, 0, 8, y, glass)
33
+ flame = (255, 190, 60, 255) if col % 2 == 0 else (255, 90, 40, 255)
34
+ for y in range(13, 13 + 1 + (col % 4)):
35
+ put(col, 0, 7, y, flame)
36
+ put(col, 0, 8, y, flame)
37
+
38
+
39
+ def orb(col):
40
+ half_w = (7, 5, 2, 5)[col]
41
+ for y in range(16):
42
+ for x in range(16):
43
+ dx, dy = (x - 7.5) / max(half_w, 1), (y - 7.5) / 7
44
+ d = dx * dx + dy * dy
45
+ if d <= 1.0:
46
+ shade = (255, 215, 70, 255) if d < 0.55 else (230, 150, 20, 255)
47
+ if dx < -0.2 and dy < -0.2 and d < 0.5:
48
+ shade = (255, 250, 200, 255)
49
+ put(col, 1, x, y, shade)
50
+
51
+
52
+ def mine(col):
53
+ body, spike = (190, 60, 70, 255), (110, 25, 40, 255)
54
+ for y in range(16):
55
+ for x in range(16):
56
+ d = math.hypot(x - 7.5, y - 7.5)
57
+ if d <= 5.2:
58
+ put(col, 2, x, y, body)
59
+ for k in range(8):
60
+ a = k * math.pi / 4
61
+ for r in (6, 7):
62
+ put(col, 2, int(round(7.5 + math.cos(a) * r)), int(round(7.5 + math.sin(a) * r)), spike)
63
+ light = (255, 255, 255, 255) if col % 2 == 0 else (90, 20, 30, 255)
64
+ for x, y in ((7, 7), (8, 7), (7, 8), (8, 8)):
65
+ put(col, 2, x, y, light)
66
+
67
+
68
+ for c in range(COLS):
69
+ ship(c)
70
+ orb(c)
71
+ mine(c)
72
+
73
+
74
+ def chunk(tag, data):
75
+ body = tag + data
76
+ return struct.pack(">I", len(data)) + body + struct.pack(">I", zlib.crc32(body) & 0xFFFFFFFF)
77
+
78
+
79
+ raw = b"".join(b"\x00" + b"".join(bytes(p) for p in row) for row in pix)
80
+ png = b"\x89PNG\r\n\x1a\n"
81
+ png += chunk(b"IHDR", struct.pack(">IIBBBBB", W, H, 8, 6, 0, 0, 0))
82
+ png += chunk(b"IDAT", zlib.compress(raw, 9))
83
+ png += chunk(b"IEND", b"")
84
+ with open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "sprites.png"), "wb") as f:
85
+ f.write(png)
plum-examples/assets/sprites.png ADDED
Binary file
plum-examples/game.plum ADDED
@@ -0,0 +1,373 @@
1
+ # Orb Run: a small arcade game for the game host (`plum game plum-examples/game.plum`).
2
+ # Steer the ship with the arrow keys or WASD, collect the drifting orbs and avoid
3
+ # the mines. Three lives; Enter or R restarts after the game is over.
4
+ #
5
+ # All rules are plain functions over `World` that never call the host, so the
6
+ # `test` blocks below run under `plum test`; only `init`, `update` and `draw`
7
+ # talk to the host.
8
+ module game
9
+ import std/Str
10
+ import std/Int
11
+ import std/Float
12
+ import std/List
13
+ import std/Math
14
+ import std/Random
15
+ import std/Vec2
16
+ import std/Color
17
+ import std/Game
18
+
19
+ primitive Rules =
20
+ ArenaW = 800.0
21
+ ArenaH = 600.0
22
+ ShipRadius = 10.0
23
+ OrbRadius = 10.0
24
+ MineRadius = 11.0
25
+ Thrust = 900.0
26
+ Drag = 2.2
27
+ OrbCount = 3
28
+ StartLives = 3
29
+ HurtTime = 1.5
30
+ OrbPoints = 10
31
+ Cell = 16.0
32
+ Zoom = 3.0
33
+
34
+ # A moving circle. `angle` is the ship's heading or a mine's spin, in radians clockwise.
35
+ record Body(x: Float, y: Float, vx: Float, vy: Float, angle: Float)
36
+
37
+ record World(
38
+ ship: Body,
39
+ orbs: List[Body],
40
+ mines: List[Body],
41
+ rng: Random,
42
+ score: Int,
43
+ best: Int,
44
+ lives: Int,
45
+ hurt: Float,
46
+ clock: Float,
47
+ over: Bool,
48
+ sheet: Int)
49
+
50
+ fun distance(a: Body, b: Body) -> Float =
51
+ Math.hypot(a.x - b.x, a.y - b.y)
52
+
53
+ fun overlaps(a: Body, ra: Float, b: Body, rb: Float) -> Bool =
54
+ distance(a, b) < ra + rb
55
+
56
+ # Which of `count` animation frames is showing after `clock` seconds at `fps` frames per second.
57
+ fun frameOf(clock: Float, count: Int, fps: Float) -> Int =
58
+ Int(clock * fps) % count
59
+
60
+ # A body at a random spot at least `gap` away from `avoid`, drifting at up to `speed`.
61
+ fun spawn(rng: Random, avoid: Body, gap: Float, speed: Float) -> Body =
62
+ margin := 30.0
63
+ b := Body(x: 0.0, y: 0.0, vx: 0.0, vy: 0.0, angle: 0.0)
64
+ tries := 0
65
+ while tries < 20
66
+ b.x = margin + rng.nextFloat() * {Rules.ArenaW - 2.0 * margin}
67
+ b.y = margin + rng.nextFloat() * {Rules.ArenaH - 2.0 * margin}
68
+ if distance(b, avoid) >= gap
69
+ break
70
+ tries = tries + 1
71
+ heading := rng.nextFloat() * Float.Tau
72
+ pace := speed * {0.4 + 0.6 * rng.nextFloat()}
73
+ b.vx = Math.cos(heading) * pace
74
+ b.vy = Math.sin(heading) * pace
75
+ b
76
+
77
+ # How many mines are out: more as the score grows.
78
+ fun mineCount(score: Int) -> Int =
79
+ {3 + score / 50}.min(8)
80
+
81
+ fun newWorld(seed: Int, sheet: Int) -> World =
82
+ rng := Random(seed: seed)
83
+ ship := Body(x: Rules.ArenaW / 2.0, y: Rules.ArenaH / 2.0, vx: 0.0, vy: 0.0, angle: 0.0)
84
+ w := World(
85
+ ship: ship, orbs: List[Body](), mines: List[Body](), rng: rng, score: 0, best: 0,
86
+ lives: Rules.StartLives, hurt: 0.0, clock: 0.0, over: false, sheet: sheet)
87
+ for i := range Rules.OrbCount
88
+ w.orbs.push(spawn(rng, ship, 120.0, 40.0))
89
+ for i := range mineCount(0)
90
+ w.mines.push(spawn(rng, ship, 220.0, 90.0))
91
+ w
92
+
93
+ # Starts a fresh game, keeping the best score and the random stream.
94
+ fun restart(w: World) =
95
+ w.best = w.best.max(w.score)
96
+ w.score = 0
97
+ w.lives = Rules.StartLives
98
+ w.hurt = 0.0
99
+ w.over = false
100
+ w.ship.x = Rules.ArenaW / 2.0
101
+ w.ship.y = Rules.ArenaH / 2.0
102
+ w.ship.vx = 0.0
103
+ w.ship.vy = 0.0
104
+ for i := range w.orbs.length()
105
+ moved := spawn(w.rng, w.ship, 120.0, 40.0)
106
+ w.orbs.at(i).x = moved.x
107
+ w.orbs.at(i).y = moved.y
108
+ w.orbs.at(i).vx = moved.vx
109
+ w.orbs.at(i).vy = moved.vy
110
+ while w.mines.length() > mineCount(0)
111
+ _ := w.mines.pop()!
112
+ for i := range w.mines.length()
113
+ moved := spawn(w.rng, w.ship, 220.0, 90.0)
114
+ w.mines.at(i).x = moved.x
115
+ w.mines.at(i).y = moved.y
116
+ w.mines.at(i).vx = moved.vx
117
+ w.mines.at(i).vy = moved.vy
118
+
119
+ # Accelerates the ship by input axes (-1..1), applies drag and keeps it in the arena.
120
+ fun steer(ship: Body, ax: Float, ay: Float, dt: Float) =
121
+ ship.vx = ship.vx + ax * Rules.Thrust * dt
122
+ ship.vy = ship.vy + ay * Rules.Thrust * dt
123
+ keep := {1.0 - Rules.Drag * dt}.max(0.0)
124
+ ship.vx = ship.vx * keep
125
+ ship.vy = ship.vy * keep
126
+ ship.x = ship.x + ship.vx * dt
127
+ ship.y = ship.y + ship.vy * dt
128
+ r := Rules.ShipRadius
129
+ if ship.x < r
130
+ ship.x = r
131
+ ship.vx = 0.0
132
+ if ship.x > Rules.ArenaW - r
133
+ ship.x = Rules.ArenaW - r
134
+ ship.vx = 0.0
135
+ if ship.y < r
136
+ ship.y = r
137
+ ship.vy = 0.0
138
+ if ship.y > Rules.ArenaH - r
139
+ ship.y = Rules.ArenaH - r
140
+ ship.vy = 0.0
141
+ if ship.vx * ship.vx + ship.vy * ship.vy > 400.0
142
+ ship.angle = Math.atan2(ship.vx, 0.0 - ship.vy)
143
+
144
+ # Moves a drifter and bounces it off the arena walls.
145
+ fun drift(b: Body, r: Float, dt: Float) =
146
+ b.x = b.x + b.vx * dt
147
+ b.y = b.y + b.vy * dt
148
+ if b.x < r || b.x > Rules.ArenaW - r
149
+ b.vx = 0.0 - b.vx
150
+ b.x = b.x.max(r).min(Rules.ArenaW - r)
151
+ if b.y < r || b.y > Rules.ArenaH - r
152
+ b.vy = 0.0 - b.vy
153
+ b.y = b.y.max(r).min(Rules.ArenaH - r)
154
+
155
+ # One simulation step. Does nothing once the game is over.
156
+ fun step(w: World, ax: Float, ay: Float, dt: Float) =
157
+ w.clock = w.clock + dt
158
+ if w.over
159
+ return
160
+ w.hurt = {w.hurt - dt}.max(0.0)
161
+ steer(w.ship, ax, ay, dt)
162
+ for i := range w.orbs.length()
163
+ orb := w.orbs.at(i)
164
+ drift(orb, Rules.OrbRadius, dt)
165
+ if overlaps(w.ship, Rules.ShipRadius, orb, Rules.OrbRadius)
166
+ w.score = w.score + Rules.OrbPoints
167
+ moved := spawn(w.rng, w.ship, 120.0, 40.0)
168
+ orb.x = moved.x
169
+ orb.y = moved.y
170
+ orb.vx = moved.vx
171
+ orb.vy = moved.vy
172
+ while w.mines.length() < mineCount(w.score)
173
+ w.mines.push(spawn(w.rng, w.ship, 220.0, 90.0))
174
+ for i := range w.mines.length()
175
+ mine := w.mines.at(i)
176
+ drift(mine, Rules.MineRadius, dt)
177
+ mine.angle = mine.angle + 2.0 * dt
178
+ if w.hurt <= 0.0 && overlaps(w.ship, Rules.ShipRadius, mine, Rules.MineRadius)
179
+ w.lives = w.lives - 1
180
+ w.hurt = Rules.HurtTime
181
+ if w.lives <= 0
182
+ w.over = true
183
+ w.best = w.best.max(w.score)
184
+
185
+ # --- host glue: input, drawing ---
186
+
187
+ export fun init() -> World =
188
+ newWorld(2024, textureLoad("sprites.png"))
189
+
190
+ fun axis(negative: Bool, positive: Bool) -> Float =
191
+ if negative && !positive
192
+ -1.0
193
+ else if positive && !negative
194
+ 1.0
195
+ else
196
+ 0.0
197
+
198
+ export fun update(w: World, dt: Float) =
199
+ if w.over && {isKeyPressed(Key.Enter) || isKeyPressed(Key.KeyR)}
200
+ restart(w)
201
+ left := isKeyDown(Key.Left) || isKeyDown(Key.KeyA)
202
+ right := isKeyDown(Key.Right) || isKeyDown(Key.KeyD)
203
+ up := isKeyDown(Key.Up) || isKeyDown(Key.KeyW)
204
+ down := isKeyDown(Key.Down) || isKeyDown(Key.KeyS)
205
+ step(w, axis(left, right), axis(up, down), dt)
206
+
207
+ # Draws frame `frame` of sheet row `row`, centred on (x, y), at `Rules.Zoom` times its size.
208
+ fun sprite(w: World, row: Int, frame: Int, x: Float, y: Float, angle: Float, tint: Color) =
209
+ size := Rules.Cell * Rules.Zoom
210
+ drawTextureEx(
211
+ w.sheet, Float(frame) * Rules.Cell, Float(row) * Rules.Cell, Rules.Cell, Rules.Cell,
212
+ x - size / 2.0, y - size / 2.0, size, size, angle, false, tint)
213
+
214
+ fun drawStars(w: World) =
215
+ for i := range 40
216
+ sx := Float({i * 7919} % 800)
217
+ sy := Float({i * 104729} % 600)
218
+ twinkle := 0.25 + 0.2 * Math.sin(w.clock * 2.0 + Float(i))
219
+ drawCircle(sx, sy, 1.0 + Float(i % 3) * 0.5, Color(r: 0.7, g: 0.8, b: 1.0, a: twinkle))
220
+
221
+ export fun draw(w: World) =
222
+ sw := screenWidth()
223
+ sh := screenHeight()
224
+ zoom := {sw / Rules.ArenaW}.min(sh / Rules.ArenaH)
225
+ clear(Color.rgb(0.04, 0.05, 0.1))
226
+ setCamera(0.0 - {sw / zoom - Rules.ArenaW} / 2.0, 0.0 - {sh / zoom - Rules.ArenaH} / 2.0, zoom)
227
+ drawRect(0.0, 0.0, Rules.ArenaW, Rules.ArenaH, Color.rgb(0.07, 0.08, 0.16))
228
+ drawStars(w)
229
+ for i := range w.orbs.length()
230
+ orb := w.orbs.at(i)
231
+ drawCircle(orb.x, orb.y, 22.0, Color(r: 1.0, g: 0.8, b: 0.2, a: 0.18))
232
+ sprite(w, 1, frameOf(w.clock + Float(i) * 0.1, 4, 8.0), orb.x, orb.y, 0.0, Color.White)
233
+ for i := range w.mines.length()
234
+ mine := w.mines.at(i)
235
+ sprite(w, 2, frameOf(w.clock, 4, 4.0), mine.x, mine.y, mine.angle, Color.White)
236
+ blink := w.hurt > 0.0 && frameOf(w.clock, 2, 10.0) == 0
237
+ if !w.over && !blink
238
+ flame := frameOf(w.clock, 4, 14.0)
239
+ drawCircle(w.ship.x, w.ship.y, 17.0, Color(r: 0.4, g: 0.8, b: 1.0, a: 0.12))
240
+ sprite(w, 0, flame, w.ship.x, w.ship.y, w.ship.angle, Color.White)
241
+ drawText("Score {w.score}", 16.0, 12.0, 28.0, Color.White)
242
+ drawText("Best {w.best.max(w.score)}", 16.0, 44.0, 20.0, Color.Yellow)
243
+ for i := range w.lives
244
+ drawTextureEx(
245
+ w.sheet, 0.0, 0.0, Rules.Cell, Rules.Cell,
246
+ Rules.ArenaW - 40.0 - Float(i) * 36.0, 12.0, 32.0, 32.0, 0.0, false, Color.White)
247
+ if w.over
248
+ drawRect(0.0, 240.0, Rules.ArenaW, 120.0, Color(r: 0.0, g: 0.0, b: 0.0, a: 0.6))
249
+ drawText("Game over", 300.0, 258.0, 48.0, Color.Red)
250
+ drawText("Press Enter to play again", 270.0, 316.0, 24.0, Color.White)
251
+ resetCamera()
252
+
253
+ # --- tests ---
254
+
255
+ fun still(x: Float, y: Float) -> Body =
256
+ Body(x: x, y: y, vx: 0.0, vy: 0.0, angle: 0.0)
257
+
258
+ # A world with the orbs and mines moved out of the way.
259
+ fun emptyWorld() -> World =
260
+ w := newWorld(7, -1)
261
+ for i := range w.orbs.length()
262
+ w.orbs.at(i).x = 5000.0
263
+ w.orbs.at(i).vx = 0.0
264
+ w.orbs.at(i).vy = 0.0
265
+ for i := range w.mines.length()
266
+ w.mines.at(i).x = 5000.0
267
+ w.mines.at(i).vx = 0.0
268
+ w.mines.at(i).vy = 0.0
269
+ w
270
+
271
+ test "steering accelerates and drag slows the ship"
272
+ ship := still(400.0, 300.0)
273
+ steer(ship, 1.0, 0.0, 0.1)
274
+ assert ship.vx > 0.0
275
+ assert ship.x > 400.0
276
+ fast := ship.vx
277
+ for i := range 30
278
+ steer(ship, 0.0, 0.0, 0.1)
279
+ assert ship.vx < fast / 4.0
280
+
281
+ test "the ship stops at the arena walls"
282
+ ship := still(20.0, 20.0)
283
+ for i := range 20
284
+ steer(ship, -1.0, -1.0, 0.1)
285
+ assert ship.x == Rules.ShipRadius
286
+ assert ship.y == Rules.ShipRadius
287
+ assert ship.vx == 0.0
288
+
289
+ test "drifters bounce off the walls"
290
+ b := Body(x: 795.0, y: 300.0, vx: 100.0, vy: 0.0, angle: 0.0)
291
+ drift(b, 10.0, 0.1)
292
+ assert b.vx == -100.0
293
+ assert b.x == Rules.ArenaW - 10.0
294
+
295
+ test "collecting an orb scores and respawns it elsewhere"
296
+ w := emptyWorld()
297
+ orb := w.orbs.at(0)
298
+ orb.x = w.ship.x
299
+ orb.y = w.ship.y
300
+ step(w, 0.0, 0.0, 0.016)
301
+ assert w.score == Rules.OrbPoints
302
+ assert distance(orb, w.ship) >= 120.0
303
+ assert orb.x < 5000.0
304
+
305
+ test "a mine costs one life, then the ship is briefly safe"
306
+ w := emptyWorld()
307
+ mine := w.mines.at(0)
308
+ mine.x = w.ship.x
309
+ mine.y = w.ship.y
310
+ step(w, 0.0, 0.0, 0.016)
311
+ assert w.lives == Rules.StartLives - 1
312
+ assert w.hurt > 0.0
313
+ step(w, 0.0, 0.0, 0.016)
314
+ assert w.lives == Rules.StartLives - 1
315
+ mine.x = 5000.0
316
+ for i := range 100
317
+ step(w, 0.0, 0.0, 0.016)
318
+ assert w.hurt == 0.0
319
+
320
+ test "the game ends at zero lives and then ignores steps"
321
+ w := emptyWorld()
322
+ w.lives = 1
323
+ w.score = 40
324
+ mine := w.mines.at(0)
325
+ mine.x = w.ship.x
326
+ mine.y = w.ship.y
327
+ step(w, 0.0, 0.0, 0.016)
328
+ assert w.over
329
+ assert w.lives == 0
330
+ assert w.best == 40
331
+ x := w.ship.x
332
+ step(w, 1.0, 0.0, 0.5)
333
+ assert w.ship.x == x
334
+
335
+ test "restart resets the run and keeps the best score"
336
+ w := emptyWorld()
337
+ w.score = 90
338
+ w.lives = 0
339
+ w.over = true
340
+ restart(w)
341
+ assert !w.over
342
+ assert w.score == 0
343
+ assert w.best == 90
344
+ assert w.lives == Rules.StartLives
345
+ assert w.ship.x == Rules.ArenaW / 2.0
346
+ assert w.mines.length() == mineCount(0)
347
+
348
+ test "more mines appear as the score grows"
349
+ assert mineCount(0) == 3
350
+ assert mineCount(100) == 5
351
+ assert mineCount(10000) == 8
352
+ w := emptyWorld()
353
+ w.score = 100
354
+ step(w, 0.0, 0.0, 0.016)
355
+ assert w.mines.length() == 5
356
+
357
+ test "the same seed plays out the same way"
358
+ a := newWorld(11, -1)
359
+ b := newWorld(11, -1)
360
+ for i := range 120
361
+ step(a, 1.0, -1.0, 0.016)
362
+ step(b, 1.0, -1.0, 0.016)
363
+ assert a.ship.x == b.ship.x
364
+ assert a.score == b.score
365
+ assert a.mines.at(0).x == b.mines.at(0).x
366
+ assert newWorld(12, -1).orbs.at(0).x != a.orbs.at(0).x
367
+
368
+ test "animation frames cycle"
369
+ assert frameOf(0.0, 4, 8.0) == 0
370
+ assert frameOf(0.13, 4, 8.0) == 1
371
+ assert frameOf(0.5, 4, 8.0) == 0
372
+ assert axis(true, false) == -1.0
373
+ assert axis(true, true) == 0.0