plum
git clone https://git.pyrossh.dev/plum
A statically typed, imperative programming language inspired by rust, python
f77dbea
— Peter John
2026-11-02T14:04:34+05:30
T4: native host: Backend, wasmtime binding, macroquad, recorder
- plum-game/src/api.rs +59 -8
- plum-game/src/main.rs +21 -5
- plum-game/src/mq.rs +14 -4
- plum-game/src/native.rs +44 -0
plum-game/src/api.rs
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
//! `Int` handles are indexes into host vectors, `-1` on failure.
|
|
5
5
|
|
|
6
6
|
use crate::assets::{Atlas, AssetRoot, MapData};
|
|
7
|
+
use std::collections::HashMap;
|
|
7
8
|
use std::path::PathBuf;
|
|
8
9
|
|
|
9
10
|
pub type Color = [f32; 4];
|
|
@@ -55,11 +56,24 @@ pub fn quarter_turns_radians(quarter_turns: i32) -> f32 {
|
|
|
55
56
|
quarter_turns.rem_euclid(4) as f32 * std::f32::consts::FRAC_PI_2
|
|
56
57
|
}
|
|
57
58
|
|
|
59
|
+
/// Where an [`AssetStore`] reads its files from.
|
|
60
|
+
enum Source {
|
|
61
|
+
/// Files under a directory (native).
|
|
62
|
+
Dir(PathBuf),
|
|
63
|
+
/// Preloaded bytes keyed by asset-root-relative path (the browser fetches
|
|
64
|
+
/// everything before the first frame).
|
|
65
|
+
Files(HashMap<String, Vec<u8>>),
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const MAPELEM: &str = "data/defines/mapelem.dat";
|
|
69
|
+
|
|
58
70
|
/// Atlases and maps loaded from an asset root, addressed by handle. Shared by
|
|
59
71
|
/// every backend so handle numbering and lookups behave the same everywhere.
|
|
60
72
|
pub struct AssetStore {
|
|
61
|
-
|
|
73
|
+
source: Source,
|
|
74
|
+
/// `mapelem.dat`, read on the first map load so files can be preloaded
|
|
75
|
+
/// after construction.
|
|
62
|
-
mapelem: Vec<u8>,
|
|
76
|
+
mapelem: Option<Vec<u8>>,
|
|
63
77
|
pub atlases: Vec<Atlas>,
|
|
64
78
|
pub maps: Vec<MapData>,
|
|
65
79
|
}
|
|
@@ -67,13 +81,31 @@ pub struct AssetStore {
|
|
|
67
81
|
impl AssetStore {
|
|
68
82
|
pub fn new(root: Option<PathBuf>) -> Self {
|
|
69
83
|
let root = AssetRoot::resolve(root.as_deref());
|
|
70
|
-
let mapelem = std::fs::read(root.join("data/defines/mapelem.dat")).unwrap_or_default();
|
|
71
|
-
AssetStore { root, mapelem, atlases: Vec::new(), maps: Vec::new() }
|
|
84
|
+
AssetStore { source: Source::Dir(root), mapelem: None, atlases: Vec::new(), maps: Vec::new() }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/// A store that serves only files added with [`AssetStore::insert`].
|
|
88
|
+
pub fn preloaded() -> Self {
|
|
89
|
+
AssetStore { source: Source::Files(HashMap::new()), mapelem: None, atlases: Vec::new(), maps: Vec::new() }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/// Registers a file under its asset-root-relative path. No effect on a
|
|
93
|
+
/// directory-backed store.
|
|
94
|
+
pub fn insert(&mut self, path: &str, bytes: Vec<u8>) {
|
|
95
|
+
if let Source::Files(files) = &mut self.source {
|
|
96
|
+
files.insert(path.to_string(), bytes);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
fn read(&self, path: &str) -> Option<Vec<u8>> {
|
|
101
|
+
match &self.source {
|
|
102
|
+
Source::Dir(root) => std::fs::read(root.join(path)).ok(),
|
|
103
|
+
Source::Files(files) => files.get(path).cloned(),
|
|
104
|
+
}
|
|
72
105
|
}
|
|
73
106
|
|
|
74
107
|
pub fn atlas_load(&mut self, name: &str) -> i32 {
|
|
75
|
-
let path = self.root.join("sprites").join(format!("{name}.rxx"));
|
|
76
|
-
let
|
|
108
|
+
let Some(bytes) = self.read(&format!("sprites/{name}.rxx")) else { return -1 };
|
|
77
109
|
match Atlas::parse(&bytes) {
|
|
78
110
|
Ok(a) => {
|
|
79
111
|
self.atlases.push(a);
|
|
@@ -84,8 +116,11 @@ impl AssetStore {
|
|
|
84
116
|
}
|
|
85
117
|
|
|
86
118
|
pub fn map_load(&mut self, path: &str) -> i32 {
|
|
87
|
-
let
|
|
119
|
+
let Some(bytes) = self.read(path) else { return -1 };
|
|
120
|
+
if self.mapelem.is_none() {
|
|
121
|
+
self.mapelem = Some(self.read(MAPELEM).unwrap_or_default());
|
|
122
|
+
}
|
|
88
|
-
match MapData::parse_with(&bytes,
|
|
123
|
+
match MapData::parse_with(&bytes, self.mapelem.as_deref().unwrap_or_default()) {
|
|
89
124
|
Ok(m) => {
|
|
90
125
|
self.maps.push(m);
|
|
91
126
|
self.maps.len() as i32 - 1
|
|
@@ -147,3 +182,19 @@ impl AssetStore {
|
|
|
147
182
|
self.cell(m, x, y).map_or(0, |(d, i)| d.object[i] as i32)
|
|
148
183
|
}
|
|
149
184
|
}
|
|
185
|
+
|
|
186
|
+
#[cfg(test)]
|
|
187
|
+
mod tests {
|
|
188
|
+
use super::*;
|
|
189
|
+
|
|
190
|
+
#[test]
|
|
191
|
+
fn preloaded_store_serves_only_inserted_files() {
|
|
192
|
+
let mut s = AssetStore::preloaded();
|
|
193
|
+
assert_eq!(s.atlas_load("missing"), -1);
|
|
194
|
+
assert_eq!(s.map_load("maps/missing.map"), -1);
|
|
195
|
+
s.insert("sprites/bad.rxx", vec![1, 2, 3]);
|
|
196
|
+
// Present but malformed: still a failure, not a panic.
|
|
197
|
+
assert_eq!(s.atlas_load("bad"), -1);
|
|
198
|
+
assert_eq!(s.sprite_count(0), 0);
|
|
199
|
+
}
|
|
200
|
+
}
|
plum-game/src/main.rs
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
//! `plum-game <module.wasm> [--assets DIR]`: runs a compiled Plum game in a
|
|
2
|
+
//! macroquad window. With `PLUM_GAME_CAPTURE=<path.png>` (and optionally
|
|
3
|
+
//! `PLUM_GAME_FRAMES=<n>`, default 30) it saves the screen after n frames and
|
|
2
|
-
//!
|
|
4
|
+
//! exits 0.
|
|
3
5
|
|
|
4
6
|
#[cfg(not(target_arch = "wasm32"))]
|
|
5
|
-
use plum_game::{mq::MqBackend, native::PlumGame};
|
|
7
|
+
use plum_game::{mq::MqBackend, native::{Capture, PlumGame}};
|
|
6
8
|
|
|
7
9
|
#[cfg(not(target_arch = "wasm32"))]
|
|
8
10
|
fn window_conf() -> macroquad::window::Conf {
|
|
@@ -31,10 +33,21 @@ async fn run(wasm: std::path::PathBuf, assets: Option<std::path::PathBuf>) -> Re
|
|
|
31
33
|
let bytes = std::fs::read(&wasm).map_err(|e| format!("{}: {e}", wasm.display()))?;
|
|
32
34
|
let mut game = PlumGame::load(&bytes, MqBackend::new(assets)).map_err(|e| format!("{e:#}"))?;
|
|
33
35
|
game.init().map_err(|e| format!("init: {e:#}"))?;
|
|
36
|
+
let capture = Capture::from_env()?;
|
|
37
|
+
let mut frame = 0u32;
|
|
34
38
|
loop {
|
|
35
39
|
let dt = macroquad::time::get_frame_time();
|
|
36
40
|
game.update(dt).map_err(|e| format!("update: {e:#}"))?;
|
|
37
41
|
game.draw().map_err(|e| format!("draw: {e:#}"))?;
|
|
42
|
+
frame += 1;
|
|
43
|
+
if let Some(c) = &capture {
|
|
44
|
+
if frame >= c.frames {
|
|
45
|
+
// `export_png` flips the bottom-up readback so the PNG is upright.
|
|
46
|
+
let path = c.path.to_str().ok_or("PLUM_GAME_CAPTURE is not valid UTF-8")?;
|
|
47
|
+
macroquad::texture::get_screen_data().export_png(path);
|
|
48
|
+
return Ok(());
|
|
49
|
+
}
|
|
50
|
+
}
|
|
38
51
|
macroquad::window::next_frame().await;
|
|
39
52
|
}
|
|
40
53
|
}
|
|
@@ -49,9 +62,12 @@ async fn main() {
|
|
|
49
62
|
std::process::exit(2);
|
|
50
63
|
}
|
|
51
64
|
};
|
|
52
|
-
|
|
65
|
+
match run(wasm, assets).await {
|
|
66
|
+
Ok(()) => std::process::exit(0),
|
|
67
|
+
Err(e) => {
|
|
53
|
-
|
|
68
|
+
eprintln!("{e}");
|
|
54
|
-
|
|
69
|
+
std::process::exit(1);
|
|
70
|
+
}
|
|
55
71
|
}
|
|
56
72
|
}
|
|
57
73
|
|
plum-game/src/mq.rs
CHANGED
|
@@ -54,7 +54,15 @@ pub struct MqBackend {
|
|
|
54
54
|
|
|
55
55
|
impl MqBackend {
|
|
56
56
|
pub fn new(assets: Option<PathBuf>) -> Self {
|
|
57
|
+
Self::with_assets(AssetStore::new(assets))
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
pub fn with_assets(assets: AssetStore) -> Self {
|
|
57
|
-
MqBackend { assets
|
|
61
|
+
MqBackend { assets, textures: Vec::new(), cam: (0.0, 0.0, 1.0) }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
pub fn assets_mut(&mut self) -> &mut AssetStore {
|
|
65
|
+
&mut self.assets
|
|
58
66
|
}
|
|
59
67
|
|
|
60
68
|
fn to_screen(&self, x: f32, y: f32) -> (f32, f32) {
|
|
@@ -176,10 +184,12 @@ impl Backend for MqBackend {
|
|
|
176
184
|
}
|
|
177
185
|
/// One notch per frame with scrolling, signed: positive is up.
|
|
178
186
|
fn mouse_wheel(&mut self) -> f32 {
|
|
179
|
-
{
|
|
180
187
|
let dy = mq::mouse_wheel().1;
|
|
181
|
-
if dy == 0.0 {
|
|
188
|
+
if dy == 0.0 {
|
|
189
|
+
0.0
|
|
190
|
+
} else {
|
|
191
|
+
dy.signum()
|
|
182
|
-
|
|
192
|
+
}
|
|
183
193
|
}
|
|
184
194
|
fn map_load(&mut self, path: &str) -> i32 {
|
|
185
195
|
self.assets.map_load(path)
|
plum-game/src/native.rs
CHANGED
|
@@ -198,3 +198,47 @@ impl<B: Backend> PlumGame<B> {
|
|
|
198
198
|
self.call_with_state("draw", &[])
|
|
199
199
|
}
|
|
200
200
|
}
|
|
201
|
+
|
|
202
|
+
/// Headless frame capture for visual checks: after `frames` frames the host
|
|
203
|
+
/// saves the framebuffer to `path` and exits 0.
|
|
204
|
+
#[derive(Debug, PartialEq)]
|
|
205
|
+
pub struct Capture {
|
|
206
|
+
pub path: std::path::PathBuf,
|
|
207
|
+
pub frames: u32,
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
impl Capture {
|
|
211
|
+
const DEFAULT_FRAMES: u32 = 30;
|
|
212
|
+
|
|
213
|
+
/// From `PLUM_GAME_CAPTURE` and `PLUM_GAME_FRAMES`. `None` when no path
|
|
214
|
+
/// is set; a frame count that is not a positive integer is an error.
|
|
215
|
+
pub fn parse(path: Option<String>, frames: Option<String>) -> std::result::Result<Option<Capture>, String> {
|
|
216
|
+
let Some(path) = path.filter(|p| !p.is_empty()) else { return Ok(None) };
|
|
217
|
+
let frames = match frames {
|
|
218
|
+
None => Self::DEFAULT_FRAMES,
|
|
219
|
+
Some(f) => f.trim().parse::<u32>().ok().filter(|n| *n > 0).ok_or_else(|| format!("PLUM_GAME_FRAMES must be a positive integer, got `{f}`"))?,
|
|
220
|
+
};
|
|
221
|
+
Ok(Some(Capture { path: path.into(), frames }))
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
pub fn from_env() -> std::result::Result<Option<Capture>, String> {
|
|
225
|
+
Self::parse(std::env::var("PLUM_GAME_CAPTURE").ok(), std::env::var("PLUM_GAME_FRAMES").ok())
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
#[cfg(test)]
|
|
230
|
+
mod tests {
|
|
231
|
+
use super::Capture;
|
|
232
|
+
|
|
233
|
+
#[test]
|
|
234
|
+
fn capture_env_parsing() {
|
|
235
|
+
assert_eq!(Capture::parse(None, Some("5".into())), Ok(None));
|
|
236
|
+
assert_eq!(Capture::parse(Some(String::new()), None), Ok(None));
|
|
237
|
+
let c = Capture::parse(Some("a.png".into()), None).unwrap().unwrap();
|
|
238
|
+
assert_eq!((c.path.to_str(), c.frames), (Some("a.png"), 30));
|
|
239
|
+
let c = Capture::parse(Some("a.png".into()), Some("7".into())).unwrap().unwrap();
|
|
240
|
+
assert_eq!(c.frames, 7);
|
|
241
|
+
assert!(Capture::parse(Some("a.png".into()), Some("0".into())).is_err());
|
|
242
|
+
assert!(Capture::parse(Some("a.png".into()), Some("x".into())).is_err());
|
|
243
|
+
}
|
|
244
|
+
}
|