plum

#treesitter#compiler#wasm

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

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


14c8c85 — Peter John 2026-11-02T15:01:04+05:30
Game host: sound (load/play/loop/stop/volume), example music and effects, quick start docs

Generated files not shown: plum-game/Cargo.lock

README.md CHANGED
@@ -152,6 +152,21 @@ plum editor helix|vscode # install highlighting and the language server
152
152
 
153
153
  `import std/Name` resolves to `<lib-path>/plum-std/Name.plum`, and `--lib-path` defaults to the current directory, so run the commands from a checkout of this repository (or pass `--lib-path`).
154
154
 
155
+ ### Run the example game
156
+
157
+ ```sh
158
+ cargo run -q -p plum-cli -- game plum-examples/game.plum # or, after `cargo install --path plum-cli`: plum game plum-examples/game.plum
159
+ ```
160
+
161
+ Arrow keys or WASD steer the ship, collect the orbs, dodge the mines; Enter or R restarts after the game is over. The first run builds the `plum-game` host in release mode, which takes a while. To play in a browser instead (needs `rustup target add wasm32-unknown-unknown` once):
162
+
163
+ ```sh
164
+ plum game --web plum-examples/game.plum -o /tmp/orb-run
165
+ python3 -m http.server -d /tmp/orb-run 8080 # then open http://localhost:8080
166
+ ```
167
+
168
+ ![the example game](docs/game.png)
169
+
155
170
  ## Layout and comments
156
171
 
157
172
  Blocks are indentation-sensitive (2 spaces), like Python — there's no `{ }` for grouping statements. `#` starts a line comment.
@@ -1029,9 +1044,7 @@ Every module that imports `std/Str` also exports four helpers for hosts that can
1029
1044
 
1030
1045
  `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
1046
 
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 `plum-examples/tools/make_sprites.py`, kept outside the asset dir so it is not shipped). 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)
1047
+ `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 `plum-examples/tools/make_sprites.py`) and plays music and sound effects (`plum-examples/assets/*.wav`, generated by `plum-examples/tools/make_sounds.py`); both generators live outside the asset dir so they are not shipped. For a bigger game built on this engine see the Knights and Merchants port in the sibling repo `../kam_plum` (`./run.sh` there). Its rules are plain functions with `test` blocks, so `plum test plum-examples` covers them without a window.
1035
1048
 
1036
1049
  A game declares three exports. `G` is any record and holds all game state (there are no globals):
1037
1050
 
@@ -1047,7 +1060,9 @@ The host calls `init` once after the window exists, then `update` and `draw` eve
1047
1060
 
1048
1061
  **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`.
1049
1062
 
1050
- **Host API** (`std/Game`; texture handles are `-1` on failure; coordinates are world units once a camera is set):
1063
+ **Sounds:** macroquad decodes sounds asynchronously (always on the web), so the host decodes every `.wav` and `.ogg` file in the asset root before it calls `init` (natively it walks the asset directory, in the browser it uses the preloaded files); `soundLoad` then looks the decoded sound up by path with the same path-escape rules as `textureLoad`. Browsers only start audio after a user gesture (a key press or click); sounds played before that are silent, and macroquad's audio plugin resumes playback on the first gesture, so the example's music begins once you press a key.
1064
+
1065
+ **Host API** (`std/Game`; texture and sound handles are `-1` on failure; coordinates are world units once a camera is set):
1051
1066
 
1052
1067
  | function | meaning |
1053
1068
  |---|---|
@@ -1060,6 +1075,9 @@ The host calls `init` once after the window exists, then `update` and `draw` eve
1060
1075
  | `textureSetSmooth(t, smooth)` | linear (`true`) or nearest (`false`, the default) filtering |
1061
1076
  | `drawTexture(t, x, y, tint)` | the texture at its natural size, top-left at `(x, y)` |
1062
1077
  | `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 |
1078
+ | `soundLoad(path)` | a handle for a WAV or OGG file relative to the asset root (`-1` on failure) |
1079
+ | `soundPlay(s, volume)`, `soundLoop(s, volume)` | one-shot play (plays may overlap) and start looping; `volume` is 0..1 |
1080
+ | `soundStop(s)`, `soundSetVolume(s, volume)` | stop every play of the sound; change its volume. A bad handle is a no-op |
1063
1081
  | `fileExists(path)`, `fileLoad(path)` | whole file as a `Str` (`""` if missing), relative to the asset root |
1064
1082
  | `isKeyDown(k)`, `isKeyPressed(k)`, `isMouseDown(b)`, `isMousePressed(b)` | input; `k` is a `Key` constant, `b` a `Mouse` constant |
1065
1083
  | `mousePos()`, `mouseWheel()` | pointer in screen pixels; wheel direction this frame (+ is up) |
docs/game.png CHANGED
Binary file
plum-examples/assets/gameover.wav ADDED
Binary file
plum-examples/assets/hit.wav ADDED
Binary file
plum-examples/assets/music.wav ADDED
Binary file
plum-examples/assets/pickup.wav ADDED
Binary file
plum-examples/game.plum CHANGED
@@ -1,6 +1,7 @@
1
1
  # Orb Run: a small arcade game for the game host (`plum game plum-examples/game.plum`).
2
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.
3
+ # the mines. Three lives; Enter or R restarts after the game is over. The music
4
+ # loops from `init`; orbs, mines and the end of a run each play a sound.
4
5
  #
5
6
  # All rules are plain functions over `World` that never call the host, so the
6
7
  # `test` blocks below run under `plum test`; only `init`, `update` and `draw`
@@ -45,7 +46,11 @@ record World(
45
46
  hurt: Float,
46
47
  clock: Float,
47
48
  over: Bool,
48
- sheet: Int)
49
+ sheet: Int,
50
+ pickupSound: Int,
51
+ hitSound: Int,
52
+ overSound: Int,
53
+ musicSound: Int)
49
54
 
50
55
  fun distance(a: Body, b: Body) -> Float =
51
56
  Math.hypot(a.x - b.x, a.y - b.y)
@@ -83,7 +88,8 @@ fun newWorld(seed: Int, sheet: Int) -> World =
83
88
  ship := Body(x: Rules.ArenaW / 2.0, y: Rules.ArenaH / 2.0, vx: 0.0, vy: 0.0, angle: 0.0)
84
89
  w := World(
85
90
  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)
91
+ lives: Rules.StartLives, hurt: 0.0, clock: 0.0, over: false, sheet: sheet,
92
+ pickupSound: -1, hitSound: -1, overSound: -1, musicSound: -1)
87
93
  for i := range Rules.OrbCount
88
94
  w.orbs.push(spawn(rng, ship, 120.0, 40.0))
89
95
  for i := range mineCount(0)
@@ -184,8 +190,22 @@ fun step(w: World, ax: Float, ay: Float, dt: Float) =
184
190
 
185
191
  # --- host glue: input, drawing ---
186
192
 
193
+ fun musicVolume() -> Float =
194
+ 0.3
195
+
196
+ # Restarts the music loop from the top.
197
+ fun startMusic(w: World) =
198
+ soundStop(w.musicSound)
199
+ soundLoop(w.musicSound, musicVolume())
200
+
187
201
  export fun init() -> World =
188
- newWorld(2024, textureLoad("sprites.png"))
202
+ w := newWorld(2024, textureLoad("sprites.png"))
203
+ w.pickupSound = soundLoad("pickup.wav")
204
+ w.hitSound = soundLoad("hit.wav")
205
+ w.overSound = soundLoad("gameover.wav")
206
+ w.musicSound = soundLoad("music.wav")
207
+ startMusic(w)
208
+ w
189
209
 
190
210
  fun axis(negative: Bool, positive: Bool) -> Float =
191
211
  if negative && !positive
@@ -198,11 +218,22 @@ fun axis(negative: Bool, positive: Bool) -> Float =
198
218
  export fun update(w: World, dt: Float) =
199
219
  if w.over && {isKeyPressed(Key.Enter) || isKeyPressed(Key.KeyR)}
200
220
  restart(w)
221
+ startMusic(w)
201
222
  left := isKeyDown(Key.Left) || isKeyDown(Key.KeyA)
202
223
  right := isKeyDown(Key.Right) || isKeyDown(Key.KeyD)
203
224
  up := isKeyDown(Key.Up) || isKeyDown(Key.KeyW)
204
225
  down := isKeyDown(Key.Down) || isKeyDown(Key.KeyS)
226
+ score := w.score
227
+ lives := w.lives
205
228
  step(w, axis(left, right), axis(up, down), dt)
229
+ if w.score > score
230
+ soundPlay(w.pickupSound, 0.8)
231
+ if w.lives < lives
232
+ if w.over
233
+ soundStop(w.musicSound)
234
+ soundPlay(w.overSound, 1.0)
235
+ else
236
+ soundPlay(w.hitSound, 1.0)
206
237
 
207
238
  # Draws frame `frame` of sheet row `row`, centred on (x, y), at `Rules.Zoom` times its size.
208
239
  fun sprite(w: World, row: Int, frame: Int, x: Float, y: Float, angle: Float, tint: Color) =
plum-examples/tools/make_sounds.py ADDED
@@ -0,0 +1,106 @@
1
+ #!/usr/bin/env python3
2
+ """Generates the Orb Run sound effects and music loop into ../assets.
3
+
4
+ Standard library only: python3 plum-examples/tools/make_sounds.py
5
+ Everything is synthesized here, so the WAVs are original and reproducible.
6
+ All files are mono, 16-bit, 22050 Hz.
7
+ """
8
+ import math
9
+ import os
10
+ import random
11
+ import struct
12
+ import wave
13
+
14
+ RATE = 22050
15
+ OUT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "assets")
16
+
17
+
18
+ def write(name, samples, fade=True):
19
+ if fade:
20
+ # A short fade-out so a clip never ends on a click.
21
+ n = int(0.01 * RATE)
22
+ samples = samples[:-n] + [x * (1 - i / n) for i, x in enumerate(samples[-n:])]
23
+ peak = max(1e-9, max(abs(s) for s in samples))
24
+ gain = min(1.0, 0.9 / peak)
25
+ path = os.path.join(OUT, name)
26
+ with wave.open(path, "wb") as w:
27
+ w.setnchannels(1)
28
+ w.setsampwidth(2)
29
+ w.setframerate(RATE)
30
+ w.writeframes(b"".join(struct.pack("<h", int(s * gain * 32767)) for s in samples))
31
+ print(f"{name}: {len(samples)} samples, {os.path.getsize(path)} bytes")
32
+
33
+
34
+ def tone(freq, seconds, decay=6.0, harmonic=0.0):
35
+ n = int(seconds * RATE)
36
+ out = []
37
+ for i in range(n):
38
+ t = i / RATE
39
+ env = math.exp(-decay * t) * min(1.0, i / 80)
40
+ v = math.sin(2 * math.pi * freq * t) + harmonic * math.sin(4 * math.pi * freq * t)
41
+ out.append(v * env)
42
+ return out
43
+
44
+
45
+ def pickup():
46
+ # Two quick rising notes, a fifth apart.
47
+ a = tone(784.0, 0.07, 14.0, 0.3)
48
+ b = tone(1175.0, 0.12, 12.0, 0.3)
49
+ return [0.6 * s for s in a + b]
50
+
51
+
52
+ def hit():
53
+ # A low thump with a burst of noise.
54
+ rng = random.Random(3)
55
+ n = int(0.25 * RATE)
56
+ out = []
57
+ phase = 0.0
58
+ for i in range(n):
59
+ t = i / RATE
60
+ freq = 140.0 * math.exp(-9.0 * t) + 45.0
61
+ phase += 2 * math.pi * freq / RATE
62
+ env = math.exp(-11.0 * t)
63
+ noise = (rng.random() * 2 - 1) * math.exp(-30.0 * t)
64
+ out.append(0.8 * math.sin(phase) * env + 0.4 * noise)
65
+ return out
66
+
67
+
68
+ def gameover():
69
+ # Three falling notes with a slight wobble.
70
+ out = []
71
+ for f in (440.0, 349.0, 262.0):
72
+ n = int(0.133 * RATE)
73
+ for i in range(n):
74
+ t = i / RATE
75
+ wob = 1 + 0.01 * math.sin(2 * math.pi * 7 * t)
76
+ env = math.exp(-4.0 * t) * min(1.0, i / 80) * min(1.0, (n - i) / 200)
77
+ out.append(0.55 * (math.sin(2 * math.pi * f * wob * t) + 0.4 * math.sin(2 * math.pi * f * 0.5 * t)) * env)
78
+ return out
79
+
80
+
81
+ def music():
82
+ # Two beats of arpeggio over Am then F, 96 bpm, eight eighth notes. Notes
83
+ # ring past their slot and wrap around the end, so the loop has no seam.
84
+ bpm = 96
85
+ step = 60.0 / bpm / 2
86
+ n = int(8 * step * RATE)
87
+ buf = [0.0] * n
88
+ am = [220.0, 261.63, 329.63, 261.63]
89
+ fm = [174.61, 261.63, 349.23, 261.63]
90
+ for k, f in enumerate(am + fm):
91
+ start = int(k * step * RATE)
92
+ for i, s in enumerate(tone(f, 0.9, 4.5, 0.25)):
93
+ buf[(start + i) % n] += 0.28 * s
94
+ for start_beat, f in ((0, 110.0), (2, 87.31)):
95
+ start = int(start_beat * 2 * step * RATE)
96
+ for i, s in enumerate(tone(f, 1.2, 2.2, 0.15)):
97
+ buf[(start + i) % n] += 0.32 * s
98
+ return buf
99
+
100
+
101
+ if __name__ == "__main__":
102
+ os.makedirs(OUT, exist_ok=True)
103
+ write("pickup.wav", pickup())
104
+ write("hit.wav", hit())
105
+ write("gameover.wav", gameover())
106
+ write("music.wav", music(), fade=False)
plum-game/Cargo.toml CHANGED
@@ -5,7 +5,7 @@ edition = "2021"
5
5
  description = "Native and web host for Plum game programs"
6
6
 
7
7
  [dependencies]
8
- macroquad = { version = "0.4", default-features = false }
8
+ macroquad = { version = "0.4", default-features = false, features = ["audio"] }
9
9
 
10
10
  [target.'cfg(not(target_arch = "wasm32"))'.dependencies]
11
11
  wasmtime = "47"
plum-game/src/api.rs CHANGED
@@ -46,6 +46,17 @@ pub trait Backend: 'static {
46
46
  /// The whole file, empty when it is missing.
47
47
  fn file_load(&mut self, path: &str) -> Vec<u8>;
48
48
 
49
+ /// A handle for the WAV or OGG file at `path`, `-1` when it is missing,
50
+ /// escapes the asset root or was not decoded.
51
+ fn sound_load(&mut self, path: &str) -> i32;
52
+ /// A one-shot play; plays of the same sound may overlap.
53
+ fn sound_play(&mut self, s: i32, volume: f32);
54
+ /// Starts the sound looping.
55
+ fn sound_loop(&mut self, s: i32, volume: f32);
56
+ /// Stops every play and loop of the sound.
57
+ fn sound_stop(&mut self, s: i32);
58
+ fn sound_set_volume(&mut self, s: i32, volume: f32);
59
+
49
60
  fn is_key_down(&mut self, k: i32) -> bool;
50
61
  fn is_key_pressed(&mut self, k: i32) -> bool;
51
62
  fn mouse_pos(&mut self) -> (f32, f32);
@@ -86,16 +97,39 @@ struct Tex {
86
97
  pub struct AssetStore {
87
98
  source: Source,
88
99
  textures: Vec<Tex>,
100
+ /// Asset-root-relative paths of the sounds handed out by `sound_load`.
101
+ sounds: Vec<String>,
102
+ }
103
+
104
+ /// Whether `path` names a sound file the host decodes.
105
+ pub fn is_sound_path(path: &str) -> bool {
106
+ let lower = path.to_ascii_lowercase();
107
+ lower.ends_with(".wav") || lower.ends_with(".ogg")
108
+ }
109
+
110
+ /// Appends the relative paths of the regular files under `dir` to `out`.
111
+ /// Symlinked directories are not followed.
112
+ fn walk(dir: &Path, rel: &str, out: &mut Vec<String>) {
113
+ let Ok(entries) = std::fs::read_dir(dir) else { return };
114
+ for e in entries.flatten() {
115
+ let Some(name) = e.file_name().to_str().map(str::to_string) else { continue };
116
+ let path = if rel.is_empty() { name } else { format!("{rel}/{name}") };
117
+ match e.file_type() {
118
+ Ok(t) if t.is_dir() => walk(&e.path(), &path, out),
119
+ Ok(_) => out.push(path),
120
+ Err(_) => {}
121
+ }
122
+ }
89
123
  }
90
124
 
91
125
  impl AssetStore {
92
126
  pub fn new(root: PathBuf) -> Self {
93
- AssetStore { source: Source::Dir(root), textures: Vec::new() }
127
+ AssetStore { source: Source::Dir(root), textures: Vec::new(), sounds: Vec::new() }
94
128
  }
95
129
 
96
130
  /// A store that serves only files added with [`AssetStore::insert`].
97
131
  pub fn preloaded() -> Self {
98
- AssetStore { source: Source::Files(HashMap::new()), textures: Vec::new() }
132
+ AssetStore { source: Source::Files(HashMap::new()), textures: Vec::new(), sounds: Vec::new() }
99
133
  }
100
134
 
101
135
  /// Registers a file under its asset-root-relative path. No effect on a
@@ -141,6 +175,37 @@ impl AssetStore {
141
175
  }
142
176
  }
143
177
 
178
+ /// Every readable `.wav` or `.ogg` file in the asset root, sorted.
179
+ pub fn sound_paths(&self) -> Vec<String> {
180
+ let mut all = Vec::new();
181
+ match &self.source {
182
+ Source::Dir(root) => walk(root, "", &mut all),
183
+ Source::Files(files) => all.extend(files.keys().cloned()),
184
+ }
185
+ all.retain(|p| is_sound_path(p) && self.file_exists(p));
186
+ all.sort();
187
+ all
188
+ }
189
+
190
+ /// The handle for the sound file at `path` (the same one on every call),
191
+ /// `-1` when it is not a readable `.wav` or `.ogg` inside the root.
192
+ pub fn sound_load(&mut self, path: &str) -> i32 {
193
+ if !is_sound_path(path) || !self.file_exists(path) {
194
+ return -1;
195
+ }
196
+ let path = path.trim_start_matches("./");
197
+ let i = self.sounds.iter().position(|p| p == path).unwrap_or_else(|| {
198
+ self.sounds.push(path.to_string());
199
+ self.sounds.len() - 1
200
+ });
201
+ i as i32
202
+ }
203
+
204
+ /// The path a sound handle was loaded from.
205
+ pub fn sound_path(&self, s: i32) -> Option<&str> {
206
+ self.sounds.get(usize::try_from(s).ok()?).map(String::as_str)
207
+ }
208
+
144
209
  fn tex(&self, t: i32) -> Option<&Tex> {
145
210
  self.textures.get(usize::try_from(t).ok()?)
146
211
  }
@@ -206,6 +271,24 @@ mod tests {
206
271
  let _ = std::fs::remove_dir_all(&base);
207
272
  }
208
273
 
274
+ #[test]
275
+ fn sound_handles_need_a_sound_file_in_the_root() {
276
+ let mut s = AssetStore::preloaded();
277
+ s.insert("a.wav", vec![1]);
278
+ s.insert("sub/b.OGG", vec![1]);
279
+ s.insert("c.png", vec![1]);
280
+ assert_eq!(s.sound_paths(), ["a.wav", "sub/b.OGG"]);
281
+ assert_eq!(s.sound_load("sub/b.OGG"), 0);
282
+ assert_eq!(s.sound_load("./a.wav"), 1);
283
+ assert_eq!(s.sound_load("a.wav"), 1);
284
+ assert_eq!(s.sound_load("c.png"), -1);
285
+ assert_eq!(s.sound_load("missing.wav"), -1);
286
+ assert_eq!(s.sound_load("../a.wav"), -1);
287
+ assert_eq!(s.sound_path(1), Some("a.wav"));
288
+ assert_eq!(s.sound_path(-1), None);
289
+ assert_eq!(s.sound_path(2), None);
290
+ }
291
+
209
292
  #[test]
210
293
  fn paths_escaping_the_root_are_rejected() {
211
294
  for bad in ["../x", "a/../../x", "/etc/passwd", "a\\b", ""] {
plum-game/src/main.rs CHANGED
@@ -36,7 +36,15 @@ fn parse_args() -> Result<(std::path::PathBuf, std::path::PathBuf), String> {
36
36
  #[cfg(not(target_arch = "wasm32"))]
37
37
  async fn run(wasm: std::path::PathBuf, assets: std::path::PathBuf) -> Result<(), String> {
38
38
  let bytes = std::fs::read(&wasm).map_err(|e| format!("{}: {e}", wasm.display()))?;
39
+ let mut backend = MqBackend::new(assets);
40
+ // Sounds are decoded before `init`, so `soundLoad` can hand out handles.
41
+ for (path, data) in backend.sound_files() {
42
+ match plum_game::mq::decode_sound(&data).await {
43
+ Some(sound) => backend.add_sound(&path, sound),
44
+ None => eprintln!("{path}: could not decode sound"),
45
+ }
46
+ }
39
- let mut game = PlumGame::load(&bytes, MqBackend::new(assets)).map_err(|e| format!("{e:#}"))?;
47
+ let mut game = PlumGame::load(&bytes, backend).map_err(|e| format!("{e:#}"))?;
40
48
  game.init().map_err(|e| format!("init: {e:#}"))?;
41
49
  let capture = Capture::from_env()?;
42
50
  let mut frame = 0u32;
@@ -92,6 +100,7 @@ async fn run_web() -> Result<(), String> {
92
100
  while !plum_game::web::bridge_ready() {
93
101
  next_frame().await;
94
102
  }
103
+ plum_game::web::decode_sounds().await;
95
104
  plum_game::web::bridge_init();
96
105
  loop {
97
106
  plum_game::web::bridge_update(macroquad::time::get_frame_time());
plum-game/src/mq.rs CHANGED
@@ -3,7 +3,9 @@
3
3
  //! and `resetCamera` returns to screen pixels.
4
4
 
5
5
  use crate::api::{AssetStore, Backend, Color};
6
+ use macroquad::audio;
6
7
  use macroquad::prelude as mq;
8
+ use std::collections::HashMap;
7
9
  use std::path::PathBuf;
8
10
 
9
11
  /// Maps the Design's GLFW key numbers to macroquad key codes.
@@ -50,6 +52,14 @@ pub struct MqBackend {
50
52
  /// Textures uploaded on first use, with the filter currently applied.
51
53
  uploaded: Vec<Option<(mq::Texture2D, bool)>>,
52
54
  cam: (f32, f32, f32),
55
+ /// Sounds decoded before `init`, by asset-root-relative path.
56
+ sounds: HashMap<String, audio::Sound>,
57
+ }
58
+
59
+ /// Decodes WAV or OGG bytes. macroquad decodes in the background on the web,
60
+ /// so this resolves a few frames later there.
61
+ pub async fn decode_sound(bytes: &[u8]) -> Option<audio::Sound> {
62
+ audio::load_sound_from_bytes(bytes).await.ok()
53
63
  }
54
64
 
55
65
  impl MqBackend {
@@ -58,13 +68,28 @@ impl MqBackend {
58
68
  }
59
69
 
60
70
  pub fn with_assets(assets: AssetStore) -> Self {
61
- MqBackend { assets, uploaded: Vec::new(), cam: (0.0, 0.0, 1.0) }
71
+ MqBackend { assets, uploaded: Vec::new(), cam: (0.0, 0.0, 1.0), sounds: HashMap::new() }
62
72
  }
63
73
 
64
74
  pub fn assets_mut(&mut self) -> &mut AssetStore {
65
75
  &mut self.assets
66
76
  }
67
77
 
78
+ /// The `(path, bytes)` of every sound file in the asset root, for the host
79
+ /// to decode (with [`decode_sound`]) and hand back through
80
+ /// [`MqBackend::add_sound`] before the game's `init`.
81
+ pub fn sound_files(&self) -> Vec<(String, Vec<u8>)> {
82
+ self.assets.sound_paths().into_iter().filter_map(|p| Some((p.clone(), self.assets.read(&p)?))).collect()
83
+ }
84
+
85
+ pub fn add_sound(&mut self, path: &str, sound: audio::Sound) {
86
+ self.sounds.insert(path.to_string(), sound);
87
+ }
88
+
89
+ fn sound(&self, s: i32) -> Option<&audio::Sound> {
90
+ self.sounds.get(self.assets.sound_path(s)?)
91
+ }
92
+
68
93
  fn to_screen(&self, x: f32, y: f32) -> (f32, f32) {
69
94
  ((x - self.cam.0) * self.cam.2, (y - self.cam.1) * self.cam.2)
70
95
  }
@@ -175,6 +200,32 @@ impl Backend for MqBackend {
175
200
  fn file_load(&mut self, path: &str) -> Vec<u8> {
176
201
  self.assets.file_load(path)
177
202
  }
203
+ fn sound_load(&mut self, path: &str) -> i32 {
204
+ if !self.sounds.contains_key(path.trim_start_matches("./")) {
205
+ return -1;
206
+ }
207
+ self.assets.sound_load(path)
208
+ }
209
+ fn sound_play(&mut self, s: i32, volume: f32) {
210
+ if let Some(snd) = self.sound(s) {
211
+ audio::play_sound(snd, audio::PlaySoundParams { looped: false, volume });
212
+ }
213
+ }
214
+ fn sound_loop(&mut self, s: i32, volume: f32) {
215
+ if let Some(snd) = self.sound(s) {
216
+ audio::play_sound(snd, audio::PlaySoundParams { looped: true, volume });
217
+ }
218
+ }
219
+ fn sound_stop(&mut self, s: i32) {
220
+ if let Some(snd) = self.sound(s) {
221
+ audio::stop_sound(snd);
222
+ }
223
+ }
224
+ fn sound_set_volume(&mut self, s: i32, volume: f32) {
225
+ if let Some(snd) = self.sound(s) {
226
+ audio::set_sound_volume(snd, volume);
227
+ }
228
+ }
178
229
  fn is_key_down(&mut self, k: i32) -> bool {
179
230
  key_code(k).is_some_and(mq::is_key_down)
180
231
  }
plum-game/src/native.rs CHANGED
@@ -130,6 +130,14 @@ fn dispatch<B: Backend>(name: &str, caller: &mut Caller<'_, HostState<B>>, p: &[
130
130
  let bytes = b!().file_load(&s);
131
131
  r[0] = new_str(caller, &bytes)?;
132
132
  }
133
+ "soundLoad" => {
134
+ let s = str_arg(caller, p, 0)?;
135
+ r[0] = Val::I32(b!().sound_load(&s));
136
+ }
137
+ "soundPlay" => b!().sound_play(int(p, 0), f(p, 1)),
138
+ "soundLoop" => b!().sound_loop(int(p, 0), f(p, 1)),
139
+ "soundStop" => b!().sound_stop(int(p, 0)),
140
+ "soundSetVolume" => b!().sound_set_volume(int(p, 0), f(p, 1)),
133
141
  "isKeyDown" => r[0] = Val::I32(b!().is_key_down(int(p, 0)) as i32),
134
142
  "isKeyPressed" => r[0] = Val::I32(b!().is_key_pressed(int(p, 0)) as i32),
135
143
  "mousePos" => {
plum-game/src/recorder.rs CHANGED
@@ -123,6 +123,23 @@ impl Backend for Recorder {
123
123
  self.rec(format!("fileLoad({path:?}) -> {} bytes", bytes.len()));
124
124
  bytes
125
125
  }
126
+ fn sound_load(&mut self, path: &str) -> i32 {
127
+ let s = self.assets.sound_load(path);
128
+ self.rec(format!("soundLoad({path:?}) -> {s}"));
129
+ s
130
+ }
131
+ fn sound_play(&mut self, s: i32, volume: f32) {
132
+ self.rec(format!("soundPlay({s},{volume})"));
133
+ }
134
+ fn sound_loop(&mut self, s: i32, volume: f32) {
135
+ self.rec(format!("soundLoop({s},{volume})"));
136
+ }
137
+ fn sound_stop(&mut self, s: i32) {
138
+ self.rec(format!("soundStop({s})"));
139
+ }
140
+ fn sound_set_volume(&mut self, s: i32, volume: f32) {
141
+ self.rec(format!("soundSetVolume({s},{volume})"));
142
+ }
126
143
  fn is_key_down(&mut self, k: i32) -> bool {
127
144
  self.rec(format!("isKeyDown({k})"));
128
145
  self.keys_down.contains(&k)
plum-game/src/web.rs CHANGED
@@ -52,6 +52,18 @@ fn with<R>(f: impl FnOnce(&mut MqBackend) -> R) -> R {
52
52
  BACKEND.with(|b| f(&mut b.borrow_mut()))
53
53
  }
54
54
 
55
+ /// Decodes every preloaded `.wav` and `.ogg` file. Run it once, after all
56
+ /// `preload` calls and before the game's `init`.
57
+ pub async fn decode_sounds() {
58
+ let files = with(|b| b.sound_files());
59
+ for (path, bytes) in files {
60
+ // No borrow is held across the await: decoding takes frames on the web.
61
+ if let Some(sound) = crate::mq::decode_sound(&bytes).await {
62
+ with(|b| b.add_sound(&path, sound));
63
+ }
64
+ }
65
+ }
66
+
55
67
  /// Registers a fetched file under its asset-root-relative path.
56
68
  pub fn preload(path: &str, bytes: Vec<u8>) {
57
69
  with(|b| b.assets_mut().insert(path, bytes));
@@ -197,6 +209,27 @@ pub extern "C" fn pg_file_read(dst: *mut u8) {
197
209
  })
198
210
  }
199
211
  #[no_mangle]
212
+ pub extern "C" fn pg_sound_load(p: *const u8, n: usize) -> i32 {
213
+ let s = text(p, n);
214
+ with(|b| b.sound_load(&s))
215
+ }
216
+ #[no_mangle]
217
+ pub extern "C" fn pg_sound_play(s: i32, volume: f32) {
218
+ with(|b| b.sound_play(s, volume))
219
+ }
220
+ #[no_mangle]
221
+ pub extern "C" fn pg_sound_loop(s: i32, volume: f32) {
222
+ with(|b| b.sound_loop(s, volume))
223
+ }
224
+ #[no_mangle]
225
+ pub extern "C" fn pg_sound_stop(s: i32) {
226
+ with(|b| b.sound_stop(s))
227
+ }
228
+ #[no_mangle]
229
+ pub extern "C" fn pg_sound_set_volume(s: i32, volume: f32) {
230
+ with(|b| b.sound_set_volume(s, volume))
231
+ }
232
+ #[no_mangle]
200
233
  pub extern "C" fn pg_is_key_down(k: i32) -> i32 {
201
234
  with(|b| b.is_key_down(k)) as i32
202
235
  }
plum-game/tests/host_test.rs CHANGED
@@ -200,4 +200,53 @@ fn exampleGameRunsFrames() {
200
200
  assert!(calls.iter().any(|c| c.starts_with("drawTextureEx(0,")), "no drawTextureEx recorded");
201
201
  assert!(calls.iter().any(|c| c.starts_with("drawCircle(")));
202
202
  assert!(calls.iter().any(|c| c.starts_with("drawText(\"Score ")));
203
+ for sound in ["pickup", "hit", "gameover", "music"] {
204
+ assert!(calls.iter().any(|c| c.starts_with(&format!("soundLoad(\"{sound}.wav\") -> "))), "{sound}: {calls:?}");
205
+ }
206
+ assert!(calls.contains(&"soundLoad(\"music.wav\") -> 3".to_string()), "{calls:?}");
207
+ assert!(calls.contains(&"soundLoop(3,0.3)".to_string()), "music loop not started: {calls:?}");
208
+ }
209
+
210
+ #[test]
211
+ fn soundLoadAndPlayRecorded() {
212
+ use plum_game::api::Backend;
213
+ let mut rec = Recorder::with_assets(examples_assets());
214
+ let s = rec.sound_load("pickup.wav");
215
+ assert_eq!(s, 0);
216
+ assert_eq!(rec.sound_load("./pickup.wav"), 0);
217
+ assert_eq!(rec.sound_load("sprites.png"), -1);
218
+ assert_eq!(rec.sound_load("missing.wav"), -1);
219
+ rec.sound_play(s, 0.5);
220
+ rec.sound_loop(s, 0.25);
221
+ rec.sound_set_volume(s, 0.75);
222
+ rec.sound_stop(s);
223
+ assert_eq!(
224
+ rec.calls,
225
+ [
226
+ "soundLoad(\"pickup.wav\") -> 0",
227
+ "soundLoad(\"./pickup.wav\") -> 0",
228
+ "soundLoad(\"sprites.png\") -> -1",
229
+ "soundLoad(\"missing.wav\") -> -1",
230
+ "soundPlay(0,0.5)",
231
+ "soundLoop(0,0.25)",
232
+ "soundSetVolume(0,0.75)",
233
+ "soundStop(0)",
234
+ ]
235
+ );
236
+ }
237
+
238
+ #[test]
239
+ fn soundPathEscapeRejected() {
240
+ use plum_game::api::Backend;
241
+ let dir = scratch("sound_escape");
242
+ let root = dir.join("assets");
243
+ std::fs::create_dir_all(&root).unwrap();
244
+ std::fs::copy(examples_assets().join("pickup.wav"), dir.join("pickup.wav")).unwrap();
245
+ std::fs::copy(examples_assets().join("pickup.wav"), root.join("ok.wav")).unwrap();
246
+ let mut rec = Recorder::with_assets(root);
247
+ let escaped = [rec.sound_load("../pickup.wav"), rec.sound_load("sub/../../pickup.wav"), rec.sound_load("/etc/pickup.wav")];
248
+ let ok = rec.sound_load("ok.wav");
249
+ let _ = std::fs::remove_dir_all(&dir);
250
+ assert_eq!(escaped, [-1, -1, -1]);
251
+ assert_eq!(ok, 0);
203
252
  }
plum-game/web/plum_bridge.js CHANGED
@@ -29,6 +29,11 @@
29
29
  drawTexture: "pg_draw_texture",
30
30
  drawTextureEx: "pg_draw_texture_ex",
31
31
  fileExists: "pg_file_exists",
32
+ soundLoad: "pg_sound_load",
33
+ soundPlay: "pg_sound_play",
34
+ soundLoop: "pg_sound_loop",
35
+ soundStop: "pg_sound_stop",
36
+ soundSetVolume: "pg_sound_set_volume",
32
37
  isKeyDown: "pg_is_key_down",
33
38
  isKeyPressed: "pg_is_key_pressed",
34
39
  isMouseDown: "pg_is_mouse_down",
@@ -36,7 +41,7 @@
36
41
  mouseWheel: "pg_mouse_wheel",
37
42
  };
38
43
  // Externs whose first parameter is a Str.
39
- const STR_FIRST = new Set(["drawText", "textureLoad", "fileExists"]);
44
+ const STR_FIRST = new Set(["drawText", "textureLoad", "soundLoad", "fileExists"]);
40
45
  // Externs with a multi-value result, built from several pg_* calls.
41
46
  const MULTI = {
42
47
  mousePos: (host) => [host.pg_mouse_x(), host.pg_mouse_y()],
plum-game/web/screenshot.mjs DELETED
@@ -1,29 +0,0 @@
1
- import { spawn } from "node:child_process";
2
- import fs from "node:fs";
3
- // Headless Chrome screenshot after a real-time wait (Chrome's --screenshot fires before the frame loop has drawn).
4
- // usage: node screenshot.mjs <url> <out.png> <seconds> <chrome-profile-dir>
5
- const [url, out, secs = "12", prof] = process.argv.slice(2);
6
- const chrome = spawn("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", [
7
- "--headless=new", "--use-angle=swiftshader", "--enable-unsafe-swiftshader", "--window-size=1024,768",
8
- "--remote-debugging-port=9333", `--user-data-dir=${prof}`, "about:blank"], { stdio: "ignore" });
9
- const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
10
- let tabs;
11
- for (let i = 0; i < 50; i++) { try { tabs = await (await fetch("http://127.0.0.1:9333/json")).json(); if (tabs.length) break; } catch {} await sleep(200); }
12
- const ws = new WebSocket(tabs.find((t) => t.type === "page").webSocketDebuggerUrl);
13
- await new Promise((r) => (ws.onopen = r));
14
- let id = 0; const pend = new Map();
15
- const send = (method, params = {}) => new Promise((r) => { pend.set(++id, r); ws.send(JSON.stringify({ id, method, params })); });
16
- ws.onmessage = (m) => {
17
- const d = JSON.parse(m.data);
18
- if (d.id && pend.has(d.id)) return pend.get(d.id)(d.result);
19
- if (d.method === "Runtime.consoleAPICalled") console.log("CONSOLE", d.params.type, d.params.args.map((a) => a.value ?? a.description).join(" ").slice(0, 200));
20
- if (d.method === "Runtime.exceptionThrown") console.log("EXC", JSON.stringify(d.params.exceptionDetails.exception?.description ?? d.params.exceptionDetails.text).slice(0, 300));
21
- };
22
- await send("Runtime.enable");
23
- await send("Page.enable");
24
- await send("Page.navigate", { url });
25
- await sleep(Number(secs) * 1000);
26
- const shot = await send("Page.captureScreenshot", { format: "png" });
27
- fs.writeFileSync(out, Buffer.from(shot.data, "base64"));
28
- chrome.kill();
29
- process.exit(0);
plum-std/Game.plum CHANGED
@@ -5,10 +5,11 @@ import std/Str
5
5
  import std/Vec2
6
6
  import std/Color
7
7
 
8
- # The host API of a game: drawing, textures, files and input, all provided by
8
+ # The host API of a game: drawing, textures, sounds, files and input, all
9
- # the game host (`plum game`). Texture handles are -1 on failure. File and
9
+ # provided by the game host (`plum game`). Texture and sound handles are -1 on
10
- # texture paths are relative to the asset root; a path that escapes it fails
10
+ # failure. File, texture and sound paths are relative to the asset root; a path
11
- # like a missing file. Coordinates are world units once `setCamera` is set.
11
+ # that escapes it fails like a missing file. Coordinates are world units once
12
+ # `setCamera` is set.
12
13
 
13
14
  extern fun screenWidth() -> Float
14
15
  extern fun screenHeight() -> Float
@@ -40,6 +41,18 @@ extern fun fileExists(path: Str) -> Bool
40
41
  # The whole file; "" if it is missing.
41
42
  extern fun fileLoad(path: Str) -> Str
42
43
 
44
+ # Loads a WAV or OGG file relative to the asset root; -1 on failure. The host
45
+ # decodes every sound file in the asset root before `init` runs.
46
+ extern fun soundLoad(path: Str) -> Int
47
+ # A one-shot play; plays of the same sound may overlap. Volume is 0..1.
48
+ extern fun soundPlay(s: Int, volume: Float)
49
+ # Starts the sound looping. Call `soundStop` first to restart it cleanly.
50
+ extern fun soundLoop(s: Int, volume: Float)
51
+ # Stops every play and loop of the sound.
52
+ extern fun soundStop(s: Int)
53
+ # Changes the volume of a sound that is playing or looping.
54
+ extern fun soundSetVolume(s: Int, volume: Float)
55
+
43
56
  extern fun isKeyDown(k: Int) -> Bool
44
57
  extern fun isKeyPressed(k: Int) -> Bool
45
58
  extern fun mousePos() -> Vec2