plum
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 +22 -4
- docs/game.png +0 -0
- plum-examples/assets/gameover.wav +0 -0
- plum-examples/assets/hit.wav +0 -0
- plum-examples/assets/music.wav +0 -0
- plum-examples/assets/pickup.wav +0 -0
- plum-examples/game.plum +35 -4
- plum-examples/tools/make_sounds.py +106 -0
- plum-game/Cargo.toml +1 -1
- plum-game/src/api.rs +85 -2
- plum-game/src/main.rs +10 -1
- plum-game/src/mq.rs +52 -1
- plum-game/src/native.rs +8 -0
- plum-game/src/recorder.rs +17 -0
- plum-game/src/web.rs +33 -0
- plum-game/tests/host_test.rs +49 -0
- plum-game/web/plum_bridge.js +6 -1
- plum-game/web/screenshot.mjs +0 -29
- plum-std/Game.plum +17 -4
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
|
+

|
|
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`,
|
|
1033
|
-
|
|
1034
|
-

|
|
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
|
-
**
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|