plum
git clone https://git.pyrossh.dev/plum
A statically typed, imperative programming language inspired by rust, python
43b65ab
— Peter John
2026-11-02T13:56:10+05:30
T4: native host: Backend, wasmtime binding, macroquad, recorder
- plum-game/src/api.rs +149 -0
- plum-game/src/lib.rs +9 -0
- plum-game/src/main.rs +59 -0
- plum-game/src/mq.rs +240 -0
- plum-game/src/native.rs +195 -0
- plum-game/src/recorder.rs +173 -0
- plum-game/tests/fixtures/host_fixture.plum +25 -0
- plum-game/tests/host_test.rs +141 -0
plum-game/src/api.rs
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
//! The host API a Plum game programs against: one `Backend` method per
|
|
2
|
+
//! extern in `plum-std/Game.plum`, in Rust types (`Float` is `f32`, `Int` is
|
|
3
|
+
//! `i32`, `Str` is `&str`, a `Color` is `[f32; 4]`, a `Vec2` is `(f32, f32)`).
|
|
4
|
+
//! `Int` handles are indexes into host vectors, `-1` on failure.
|
|
5
|
+
|
|
6
|
+
use crate::assets::{Atlas, AssetRoot, MapData};
|
|
7
|
+
use std::path::PathBuf;
|
|
8
|
+
|
|
9
|
+
pub type Color = [f32; 4];
|
|
10
|
+
|
|
11
|
+
pub trait Backend: 'static {
|
|
12
|
+
fn screen_width(&mut self) -> f32;
|
|
13
|
+
fn screen_height(&mut self) -> f32;
|
|
14
|
+
fn frame_time(&mut self) -> f32;
|
|
15
|
+
|
|
16
|
+
fn clear(&mut self, c: Color);
|
|
17
|
+
fn draw_rect(&mut self, x: f32, y: f32, w: f32, h: f32, c: Color);
|
|
18
|
+
fn draw_line(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, thickness: f32, c: Color);
|
|
19
|
+
fn draw_text(&mut self, s: &str, x: f32, y: f32, size: f32, c: Color);
|
|
20
|
+
|
|
21
|
+
fn set_camera(&mut self, x: f32, y: f32, scale: f32);
|
|
22
|
+
fn reset_camera(&mut self);
|
|
23
|
+
|
|
24
|
+
fn atlas_load(&mut self, name: &str) -> i32;
|
|
25
|
+
fn sprite_count(&mut self, atlas: i32) -> i32;
|
|
26
|
+
fn sprite_w(&mut self, atlas: i32, idx: i32) -> i32;
|
|
27
|
+
fn sprite_h(&mut self, atlas: i32, idx: i32) -> i32;
|
|
28
|
+
fn sprite_pivot_x(&mut self, atlas: i32, idx: i32) -> i32;
|
|
29
|
+
fn sprite_pivot_y(&mut self, atlas: i32, idx: i32) -> i32;
|
|
30
|
+
fn sprite_color(&mut self, atlas: i32, idx: i32) -> Color;
|
|
31
|
+
|
|
32
|
+
fn draw_sprite(&mut self, atlas: i32, idx: i32, x: f32, y: f32, flip_x: bool, tint: Color);
|
|
33
|
+
#[allow(clippy::too_many_arguments)]
|
|
34
|
+
fn draw_tile(&mut self, atlas: i32, idx: i32, x: f32, y: f32, size: f32, quarter_turns: i32, tint: Color);
|
|
35
|
+
|
|
36
|
+
fn is_key_down(&mut self, k: i32) -> bool;
|
|
37
|
+
fn is_key_pressed(&mut self, k: i32) -> bool;
|
|
38
|
+
fn mouse_pos(&mut self) -> (f32, f32);
|
|
39
|
+
fn is_mouse_down(&mut self, b: i32) -> bool;
|
|
40
|
+
fn is_mouse_pressed(&mut self, b: i32) -> bool;
|
|
41
|
+
fn mouse_wheel(&mut self) -> f32;
|
|
42
|
+
|
|
43
|
+
fn map_load(&mut self, path: &str) -> i32;
|
|
44
|
+
fn map_width(&mut self, m: i32) -> i32;
|
|
45
|
+
fn map_height(&mut self, m: i32) -> i32;
|
|
46
|
+
fn map_terrain(&mut self, m: i32, x: i32, y: i32) -> i32;
|
|
47
|
+
fn map_rotation(&mut self, m: i32, x: i32, y: i32) -> i32;
|
|
48
|
+
fn map_elevation(&mut self, m: i32, x: i32, y: i32) -> i32;
|
|
49
|
+
fn map_object(&mut self, m: i32, x: i32, y: i32) -> i32;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/// Radians clockwise for `quarter_turns` quarter turns; `drawTile` rotates
|
|
53
|
+
/// about the tile's centre by this angle.
|
|
54
|
+
pub fn quarter_turns_radians(quarter_turns: i32) -> f32 {
|
|
55
|
+
quarter_turns.rem_euclid(4) as f32 * std::f32::consts::FRAC_PI_2
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/// Atlases and maps loaded from an asset root, addressed by handle. Shared by
|
|
59
|
+
/// every backend so handle numbering and lookups behave the same everywhere.
|
|
60
|
+
pub struct AssetStore {
|
|
61
|
+
root: PathBuf,
|
|
62
|
+
mapelem: Vec<u8>,
|
|
63
|
+
pub atlases: Vec<Atlas>,
|
|
64
|
+
pub maps: Vec<MapData>,
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
impl AssetStore {
|
|
68
|
+
pub fn new(root: Option<PathBuf>) -> Self {
|
|
69
|
+
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() }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
pub fn atlas_load(&mut self, name: &str) -> i32 {
|
|
75
|
+
let path = self.root.join("sprites").join(format!("{name}.rxx"));
|
|
76
|
+
let Ok(bytes) = std::fs::read(&path) else { return -1 };
|
|
77
|
+
match Atlas::parse(&bytes) {
|
|
78
|
+
Ok(a) => {
|
|
79
|
+
self.atlases.push(a);
|
|
80
|
+
self.atlases.len() as i32 - 1
|
|
81
|
+
}
|
|
82
|
+
Err(_) => -1,
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
pub fn map_load(&mut self, path: &str) -> i32 {
|
|
87
|
+
let Ok(bytes) = std::fs::read(self.root.join(path)) else { return -1 };
|
|
88
|
+
match MapData::parse_with(&bytes, &self.mapelem) {
|
|
89
|
+
Ok(m) => {
|
|
90
|
+
self.maps.push(m);
|
|
91
|
+
self.maps.len() as i32 - 1
|
|
92
|
+
}
|
|
93
|
+
Err(_) => -1,
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
fn sprite(&self, atlas: i32, idx: i32) -> Option<&crate::assets::Sprite> {
|
|
98
|
+
let a = self.atlases.get(usize::try_from(atlas).ok()?)?;
|
|
99
|
+
a.sprites.get(usize::try_from(idx).ok()?)?.as_ref()
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
pub fn sprite_count(&self, atlas: i32) -> i32 {
|
|
103
|
+
usize::try_from(atlas).ok().and_then(|a| self.atlases.get(a)).map_or(0, |a| a.sprites.len() as i32 - 1)
|
|
104
|
+
}
|
|
105
|
+
pub fn sprite_w(&self, atlas: i32, idx: i32) -> i32 {
|
|
106
|
+
self.sprite(atlas, idx).map_or(0, |s| s.w as i32)
|
|
107
|
+
}
|
|
108
|
+
pub fn sprite_h(&self, atlas: i32, idx: i32) -> i32 {
|
|
109
|
+
self.sprite(atlas, idx).map_or(0, |s| s.h as i32)
|
|
110
|
+
}
|
|
111
|
+
pub fn sprite_pivot_x(&self, atlas: i32, idx: i32) -> i32 {
|
|
112
|
+
self.sprite(atlas, idx).map_or(0, |s| s.px)
|
|
113
|
+
}
|
|
114
|
+
pub fn sprite_pivot_y(&self, atlas: i32, idx: i32) -> i32 {
|
|
115
|
+
self.sprite(atlas, idx).map_or(0, |s| s.py)
|
|
116
|
+
}
|
|
117
|
+
pub fn sprite_color(&self, atlas: i32, idx: i32) -> Color {
|
|
118
|
+
match (usize::try_from(atlas).ok().and_then(|a| self.atlases.get(a)), usize::try_from(idx).ok()) {
|
|
119
|
+
(Some(a), Some(i)) => a.avg_color(i),
|
|
120
|
+
_ => [0.0; 4],
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
fn cell(&self, m: i32, x: i32, y: i32) -> Option<(&MapData, usize)> {
|
|
125
|
+
let map = self.maps.get(usize::try_from(m).ok()?)?;
|
|
126
|
+
if x < 0 || y < 0 || x >= map.w || y >= map.h {
|
|
127
|
+
return None;
|
|
128
|
+
}
|
|
129
|
+
Some((map, (y * map.w + x) as usize))
|
|
130
|
+
}
|
|
131
|
+
pub fn map_width(&self, m: i32) -> i32 {
|
|
132
|
+
usize::try_from(m).ok().and_then(|m| self.maps.get(m)).map_or(0, |m| m.w)
|
|
133
|
+
}
|
|
134
|
+
pub fn map_height(&self, m: i32) -> i32 {
|
|
135
|
+
usize::try_from(m).ok().and_then(|m| self.maps.get(m)).map_or(0, |m| m.h)
|
|
136
|
+
}
|
|
137
|
+
pub fn map_terrain(&self, m: i32, x: i32, y: i32) -> i32 {
|
|
138
|
+
self.cell(m, x, y).map_or(0, |(d, i)| d.terrain[i] as i32)
|
|
139
|
+
}
|
|
140
|
+
pub fn map_rotation(&self, m: i32, x: i32, y: i32) -> i32 {
|
|
141
|
+
self.cell(m, x, y).map_or(0, |(d, i)| d.rotation[i] as i32)
|
|
142
|
+
}
|
|
143
|
+
pub fn map_elevation(&self, m: i32, x: i32, y: i32) -> i32 {
|
|
144
|
+
self.cell(m, x, y).map_or(0, |(d, i)| d.elevation[i] as i32)
|
|
145
|
+
}
|
|
146
|
+
pub fn map_object(&self, m: i32, x: i32, y: i32) -> i32 {
|
|
147
|
+
self.cell(m, x, y).map_or(0, |(d, i)| d.object[i] as i32)
|
|
148
|
+
}
|
|
149
|
+
}
|
plum-game/src/lib.rs
CHANGED
|
@@ -1 +1,10 @@
|
|
|
1
|
+
//! Native and web host for Plum game programs.
|
|
2
|
+
|
|
3
|
+
pub mod api;
|
|
1
4
|
pub mod assets;
|
|
5
|
+
pub mod recorder;
|
|
6
|
+
|
|
7
|
+
#[cfg(not(target_arch = "wasm32"))]
|
|
8
|
+
pub mod mq;
|
|
9
|
+
#[cfg(not(target_arch = "wasm32"))]
|
|
10
|
+
pub mod native;
|
plum-game/src/main.rs
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
//! `plum-game <module.wasm> [--assets DIR]`: runs a compiled Plum game in a
|
|
2
|
+
//! macroquad window.
|
|
3
|
+
|
|
4
|
+
#[cfg(not(target_arch = "wasm32"))]
|
|
5
|
+
use plum_game::{mq::MqBackend, native::PlumGame};
|
|
6
|
+
|
|
7
|
+
#[cfg(not(target_arch = "wasm32"))]
|
|
8
|
+
fn window_conf() -> macroquad::window::Conf {
|
|
9
|
+
macroquad::window::Conf { window_title: "Plum Game".to_string(), window_width: 1280, window_height: 720, ..Default::default() }
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
#[cfg(not(target_arch = "wasm32"))]
|
|
13
|
+
fn parse_args() -> Result<(std::path::PathBuf, Option<std::path::PathBuf>), String> {
|
|
14
|
+
let mut wasm = None;
|
|
15
|
+
let mut assets = None;
|
|
16
|
+
let mut args = std::env::args().skip(1);
|
|
17
|
+
while let Some(a) = args.next() {
|
|
18
|
+
if a == "--assets" {
|
|
19
|
+
assets = Some(args.next().ok_or("--assets needs a directory")?.into());
|
|
20
|
+
} else if wasm.is_none() {
|
|
21
|
+
wasm = Some(a.into());
|
|
22
|
+
} else {
|
|
23
|
+
return Err(format!("unexpected argument `{a}`"));
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
Ok((wasm.ok_or("usage: plum-game <module.wasm> [--assets DIR]")?, assets))
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
#[cfg(not(target_arch = "wasm32"))]
|
|
30
|
+
async fn run(wasm: std::path::PathBuf, assets: Option<std::path::PathBuf>) -> Result<(), String> {
|
|
31
|
+
let bytes = std::fs::read(&wasm).map_err(|e| format!("{}: {e}", wasm.display()))?;
|
|
32
|
+
let mut game = PlumGame::load(&bytes, MqBackend::new(assets)).map_err(|e| format!("{e:#}"))?;
|
|
33
|
+
game.init().map_err(|e| format!("init: {e:#}"))?;
|
|
34
|
+
loop {
|
|
35
|
+
let dt = macroquad::time::get_frame_time();
|
|
36
|
+
game.update(dt).map_err(|e| format!("update: {e:#}"))?;
|
|
37
|
+
game.draw().map_err(|e| format!("draw: {e:#}"))?;
|
|
38
|
+
macroquad::window::next_frame().await;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
#[cfg(not(target_arch = "wasm32"))]
|
|
43
|
+
#[macroquad::main(window_conf)]
|
|
44
|
+
async fn main() {
|
|
45
|
+
let (wasm, assets) = match parse_args() {
|
|
46
|
+
Ok(a) => a,
|
|
47
|
+
Err(e) => {
|
|
48
|
+
eprintln!("{e}");
|
|
49
|
+
std::process::exit(2);
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
if let Err(e) = run(wasm, assets).await {
|
|
53
|
+
eprintln!("{e}");
|
|
54
|
+
std::process::exit(1);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
#[cfg(target_arch = "wasm32")]
|
|
59
|
+
fn main() {}
|
plum-game/src/mq.rs
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
//! The macroquad `Backend`. The camera set by `setCamera` is applied by hand
|
|
2
|
+
//! (`screen = (world - camera) * scale`), so every draw works in world units
|
|
3
|
+
//! and `resetCamera` returns to screen pixels.
|
|
4
|
+
|
|
5
|
+
use crate::api::{quarter_turns_radians, AssetStore, Backend, Color};
|
|
6
|
+
use macroquad::prelude as mq;
|
|
7
|
+
use std::path::PathBuf;
|
|
8
|
+
|
|
9
|
+
/// Maps the Design's GLFW key numbers to macroquad key codes.
|
|
10
|
+
pub fn key_code(k: i32) -> Option<mq::KeyCode> {
|
|
11
|
+
use mq::KeyCode as K;
|
|
12
|
+
const LETTERS: [K; 26] = [
|
|
13
|
+
K::A, K::B, K::C, K::D, K::E, K::F, K::G, K::H, K::I, K::J, K::K, K::L, K::M,
|
|
14
|
+
K::N, K::O, K::P, K::Q, K::R, K::S, K::T, K::U, K::V, K::W, K::X, K::Y, K::Z,
|
|
15
|
+
];
|
|
16
|
+
const DIGITS: [K; 10] = [K::Key0, K::Key1, K::Key2, K::Key3, K::Key4, K::Key5, K::Key6, K::Key7, K::Key8, K::Key9];
|
|
17
|
+
Some(match k {
|
|
18
|
+
32 => K::Space,
|
|
19
|
+
65..=90 => LETTERS[(k - 65) as usize],
|
|
20
|
+
48..=57 => DIGITS[(k - 48) as usize],
|
|
21
|
+
256 => K::Escape,
|
|
22
|
+
257 => K::Enter,
|
|
23
|
+
258 => K::Tab,
|
|
24
|
+
259 => K::Backspace,
|
|
25
|
+
262 => K::Right,
|
|
26
|
+
263 => K::Left,
|
|
27
|
+
264 => K::Down,
|
|
28
|
+
265 => K::Up,
|
|
29
|
+
340 => K::LeftShift,
|
|
30
|
+
341 => K::LeftControl,
|
|
31
|
+
_ => return None,
|
|
32
|
+
})
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
pub fn mouse_button(b: i32) -> Option<mq::MouseButton> {
|
|
36
|
+
match b {
|
|
37
|
+
0 => Some(mq::MouseButton::Left),
|
|
38
|
+
1 => Some(mq::MouseButton::Right),
|
|
39
|
+
2 => Some(mq::MouseButton::Middle),
|
|
40
|
+
_ => None,
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
fn col(c: Color) -> mq::Color {
|
|
45
|
+
mq::Color::new(c[0], c[1], c[2], c[3])
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
pub struct MqBackend {
|
|
49
|
+
assets: AssetStore,
|
|
50
|
+
/// Lazily uploaded textures, `[atlas][sprite]`.
|
|
51
|
+
textures: Vec<Vec<Option<mq::Texture2D>>>,
|
|
52
|
+
cam: (f32, f32, f32),
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
impl MqBackend {
|
|
56
|
+
pub fn new(assets: Option<PathBuf>) -> Self {
|
|
57
|
+
MqBackend { assets: AssetStore::new(assets), textures: Vec::new(), cam: (0.0, 0.0, 1.0) }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
fn to_screen(&self, x: f32, y: f32) -> (f32, f32) {
|
|
61
|
+
((x - self.cam.0) * self.cam.2, (y - self.cam.1) * self.cam.2)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
fn texture(&mut self, atlas: i32, idx: i32) -> Option<mq::Texture2D> {
|
|
65
|
+
let (a, i) = (usize::try_from(atlas).ok()?, usize::try_from(idx).ok()?);
|
|
66
|
+
while self.textures.len() <= a {
|
|
67
|
+
let n = self.assets.atlases.get(self.textures.len()).map_or(0, |at| at.sprites.len());
|
|
68
|
+
self.textures.push(vec![None; n]);
|
|
69
|
+
}
|
|
70
|
+
if i >= self.textures[a].len() {
|
|
71
|
+
return None;
|
|
72
|
+
}
|
|
73
|
+
if self.textures[a][i].is_none() {
|
|
74
|
+
let s = self.assets.atlases.get(a)?.sprites.get(i)?.as_ref()?;
|
|
75
|
+
let t = mq::Texture2D::from_rgba8(s.w, s.h, &s.rgba);
|
|
76
|
+
t.set_filter(mq::FilterMode::Nearest);
|
|
77
|
+
self.textures[a][i] = Some(t);
|
|
78
|
+
}
|
|
79
|
+
self.textures[a].get(i)?.clone()
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
impl Backend for MqBackend {
|
|
84
|
+
fn screen_width(&mut self) -> f32 {
|
|
85
|
+
mq::screen_width()
|
|
86
|
+
}
|
|
87
|
+
fn screen_height(&mut self) -> f32 {
|
|
88
|
+
mq::screen_height()
|
|
89
|
+
}
|
|
90
|
+
fn frame_time(&mut self) -> f32 {
|
|
91
|
+
mq::get_frame_time()
|
|
92
|
+
}
|
|
93
|
+
fn clear(&mut self, c: Color) {
|
|
94
|
+
mq::clear_background(col(c));
|
|
95
|
+
}
|
|
96
|
+
fn draw_rect(&mut self, x: f32, y: f32, w: f32, h: f32, c: Color) {
|
|
97
|
+
let (sx, sy) = self.to_screen(x, y);
|
|
98
|
+
mq::draw_rectangle(sx, sy, w * self.cam.2, h * self.cam.2, col(c));
|
|
99
|
+
}
|
|
100
|
+
fn draw_line(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, t: f32, c: Color) {
|
|
101
|
+
let (a, b) = (self.to_screen(x1, y1), self.to_screen(x2, y2));
|
|
102
|
+
mq::draw_line(a.0, a.1, b.0, b.1, t * self.cam.2, col(c));
|
|
103
|
+
}
|
|
104
|
+
/// `(x, y)` is the text's top-left corner.
|
|
105
|
+
fn draw_text(&mut self, s: &str, x: f32, y: f32, size: f32, c: Color) {
|
|
106
|
+
let (sx, sy) = self.to_screen(x, y);
|
|
107
|
+
let px = size * self.cam.2;
|
|
108
|
+
let dims = mq::measure_text(s, None, px.max(1.0) as u16, 1.0);
|
|
109
|
+
mq::draw_text(s, sx, sy + dims.offset_y, px, col(c));
|
|
110
|
+
}
|
|
111
|
+
fn set_camera(&mut self, x: f32, y: f32, scale: f32) {
|
|
112
|
+
self.cam = (x, y, scale);
|
|
113
|
+
}
|
|
114
|
+
fn reset_camera(&mut self) {
|
|
115
|
+
self.cam = (0.0, 0.0, 1.0);
|
|
116
|
+
}
|
|
117
|
+
fn atlas_load(&mut self, name: &str) -> i32 {
|
|
118
|
+
self.assets.atlas_load(name)
|
|
119
|
+
}
|
|
120
|
+
fn sprite_count(&mut self, a: i32) -> i32 {
|
|
121
|
+
self.assets.sprite_count(a)
|
|
122
|
+
}
|
|
123
|
+
fn sprite_w(&mut self, a: i32, i: i32) -> i32 {
|
|
124
|
+
self.assets.sprite_w(a, i)
|
|
125
|
+
}
|
|
126
|
+
fn sprite_h(&mut self, a: i32, i: i32) -> i32 {
|
|
127
|
+
self.assets.sprite_h(a, i)
|
|
128
|
+
}
|
|
129
|
+
fn sprite_pivot_x(&mut self, a: i32, i: i32) -> i32 {
|
|
130
|
+
self.assets.sprite_pivot_x(a, i)
|
|
131
|
+
}
|
|
132
|
+
fn sprite_pivot_y(&mut self, a: i32, i: i32) -> i32 {
|
|
133
|
+
self.assets.sprite_pivot_y(a, i)
|
|
134
|
+
}
|
|
135
|
+
fn sprite_color(&mut self, a: i32, i: i32) -> Color {
|
|
136
|
+
self.assets.sprite_color(a, i)
|
|
137
|
+
}
|
|
138
|
+
fn draw_sprite(&mut self, a: i32, i: i32, x: f32, y: f32, flip_x: bool, tint: Color) {
|
|
139
|
+
let Some(tex) = self.texture(a, i) else { return };
|
|
140
|
+
let (px, py) = (self.assets.sprite_pivot_x(a, i) as f32, self.assets.sprite_pivot_y(a, i) as f32);
|
|
141
|
+
let (sx, sy) = self.to_screen(x + px, y + py);
|
|
142
|
+
let s = self.cam.2;
|
|
143
|
+
let params = mq::DrawTextureParams {
|
|
144
|
+
dest_size: Some(mq::vec2(tex.width() * s, tex.height() * s)),
|
|
145
|
+
flip_x,
|
|
146
|
+
..Default::default()
|
|
147
|
+
};
|
|
148
|
+
mq::draw_texture_ex(&tex, sx, sy, col(tint), params);
|
|
149
|
+
}
|
|
150
|
+
fn draw_tile(&mut self, a: i32, i: i32, x: f32, y: f32, size: f32, quarter_turns: i32, tint: Color) {
|
|
151
|
+
let Some(tex) = self.texture(a, i) else { return };
|
|
152
|
+
let (sx, sy) = self.to_screen(x, y);
|
|
153
|
+
let side = size * self.cam.2;
|
|
154
|
+
let params = mq::DrawTextureParams {
|
|
155
|
+
dest_size: Some(mq::vec2(side, side)),
|
|
156
|
+
rotation: quarter_turns_radians(quarter_turns),
|
|
157
|
+
pivot: Some(mq::vec2(sx + side / 2.0, sy + side / 2.0)),
|
|
158
|
+
..Default::default()
|
|
159
|
+
};
|
|
160
|
+
mq::draw_texture_ex(&tex, sx, sy, col(tint), params);
|
|
161
|
+
}
|
|
162
|
+
fn is_key_down(&mut self, k: i32) -> bool {
|
|
163
|
+
key_code(k).is_some_and(mq::is_key_down)
|
|
164
|
+
}
|
|
165
|
+
fn is_key_pressed(&mut self, k: i32) -> bool {
|
|
166
|
+
key_code(k).is_some_and(mq::is_key_pressed)
|
|
167
|
+
}
|
|
168
|
+
fn mouse_pos(&mut self) -> (f32, f32) {
|
|
169
|
+
mq::mouse_position()
|
|
170
|
+
}
|
|
171
|
+
fn is_mouse_down(&mut self, b: i32) -> bool {
|
|
172
|
+
mouse_button(b).is_some_and(mq::is_mouse_button_down)
|
|
173
|
+
}
|
|
174
|
+
fn is_mouse_pressed(&mut self, b: i32) -> bool {
|
|
175
|
+
mouse_button(b).is_some_and(mq::is_mouse_button_pressed)
|
|
176
|
+
}
|
|
177
|
+
/// One notch per frame with scrolling, signed: positive is up.
|
|
178
|
+
fn mouse_wheel(&mut self) -> f32 {
|
|
179
|
+
{
|
|
180
|
+
let dy = mq::mouse_wheel().1;
|
|
181
|
+
if dy == 0.0 { 0.0 } else { dy.signum() }
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
fn map_load(&mut self, path: &str) -> i32 {
|
|
185
|
+
self.assets.map_load(path)
|
|
186
|
+
}
|
|
187
|
+
fn map_width(&mut self, m: i32) -> i32 {
|
|
188
|
+
self.assets.map_width(m)
|
|
189
|
+
}
|
|
190
|
+
fn map_height(&mut self, m: i32) -> i32 {
|
|
191
|
+
self.assets.map_height(m)
|
|
192
|
+
}
|
|
193
|
+
fn map_terrain(&mut self, m: i32, x: i32, y: i32) -> i32 {
|
|
194
|
+
self.assets.map_terrain(m, x, y)
|
|
195
|
+
}
|
|
196
|
+
fn map_rotation(&mut self, m: i32, x: i32, y: i32) -> i32 {
|
|
197
|
+
self.assets.map_rotation(m, x, y)
|
|
198
|
+
}
|
|
199
|
+
fn map_elevation(&mut self, m: i32, x: i32, y: i32) -> i32 {
|
|
200
|
+
self.assets.map_elevation(m, x, y)
|
|
201
|
+
}
|
|
202
|
+
fn map_object(&mut self, m: i32, x: i32, y: i32) -> i32 {
|
|
203
|
+
self.assets.map_object(m, x, y)
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
#[cfg(test)]
|
|
208
|
+
mod tests {
|
|
209
|
+
use super::*;
|
|
210
|
+
use macroquad::prelude::KeyCode as K;
|
|
211
|
+
|
|
212
|
+
#[test]
|
|
213
|
+
fn keys_map_to_the_design_numbers() {
|
|
214
|
+
assert_eq!(key_code(32), Some(K::Space));
|
|
215
|
+
assert_eq!(key_code(65), Some(K::A));
|
|
216
|
+
assert_eq!(key_code(87), Some(K::W));
|
|
217
|
+
assert_eq!(key_code(90), Some(K::Z));
|
|
218
|
+
assert_eq!(key_code(48), Some(K::Key0));
|
|
219
|
+
assert_eq!(key_code(57), Some(K::Key9));
|
|
220
|
+
assert_eq!(key_code(256), Some(K::Escape));
|
|
221
|
+
assert_eq!(key_code(257), Some(K::Enter));
|
|
222
|
+
assert_eq!(key_code(258), Some(K::Tab));
|
|
223
|
+
assert_eq!(key_code(259), Some(K::Backspace));
|
|
224
|
+
assert_eq!(key_code(262), Some(K::Right));
|
|
225
|
+
assert_eq!(key_code(263), Some(K::Left));
|
|
226
|
+
assert_eq!(key_code(264), Some(K::Down));
|
|
227
|
+
assert_eq!(key_code(265), Some(K::Up));
|
|
228
|
+
assert_eq!(key_code(340), Some(K::LeftShift));
|
|
229
|
+
assert_eq!(key_code(341), Some(K::LeftControl));
|
|
230
|
+
assert_eq!(key_code(1), None);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
#[test]
|
|
234
|
+
fn mouse_buttons() {
|
|
235
|
+
assert_eq!(mouse_button(0), Some(mq::MouseButton::Left));
|
|
236
|
+
assert_eq!(mouse_button(1), Some(mq::MouseButton::Right));
|
|
237
|
+
assert_eq!(mouse_button(2), Some(mq::MouseButton::Middle));
|
|
238
|
+
assert_eq!(mouse_button(3), None);
|
|
239
|
+
}
|
|
240
|
+
}
|
plum-game/src/native.rs
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
//! Runs a compiled Plum game module under wasmtime, binding every `plum`
|
|
2
|
+
//! import to a [`Backend`].
|
|
3
|
+
|
|
4
|
+
use crate::api::{Backend, Color};
|
|
5
|
+
use wasmtime::{AnyRef, Caller, Engine, Func, Instance, Linker, Module, OwnedRooted, Rooted, Store, Val};
|
|
6
|
+
|
|
7
|
+
pub use wasmtime::Result;
|
|
8
|
+
|
|
9
|
+
struct HostState<B> {
|
|
10
|
+
backend: B,
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
pub struct PlumGame<B: Backend> {
|
|
14
|
+
store: Store<HostState<B>>,
|
|
15
|
+
instance: Instance,
|
|
16
|
+
/// The `init` result, rooted across frames.
|
|
17
|
+
state: Option<OwnedRooted<AnyRef>>,
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
fn f(p: &[Val], i: usize) -> f32 {
|
|
21
|
+
match p[i] {
|
|
22
|
+
Val::F64(bits) => f64::from_bits(bits) as f32,
|
|
23
|
+
_ => 0.0,
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
fn int(p: &[Val], i: usize) -> i32 {
|
|
28
|
+
match p[i] {
|
|
29
|
+
Val::I32(v) => v,
|
|
30
|
+
_ => 0,
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
fn color(p: &[Val], i: usize) -> Color {
|
|
35
|
+
[f(p, i), f(p, i + 1), f(p, i + 2), f(p, i + 3)]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
fn fv(v: f32) -> Val {
|
|
39
|
+
Val::F64((v as f64).to_bits())
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/// Reads the bytes of a Plum `Str` (a struct of `data`, `start`, `len`).
|
|
43
|
+
fn str_bytes(store: &mut impl wasmtime::AsContextMut, s: &Rooted<AnyRef>) -> Result<Vec<u8>> {
|
|
44
|
+
let st = s.unwrap_struct(&*store)?;
|
|
45
|
+
let Val::AnyRef(Some(data)) = st.field(&mut *store, 0)? else {
|
|
46
|
+
return Err(wasmtime::Error::msg("Str.data is not a byte array"));
|
|
47
|
+
};
|
|
48
|
+
let start = st.field(&mut *store, 1)?.unwrap_i64() as usize;
|
|
49
|
+
let len = st.field(&mut *store, 2)?.unwrap_i64() as usize;
|
|
50
|
+
let arr = data.unwrap_array(&*store)?;
|
|
51
|
+
let mut buf = vec![0u8; arr.len(&*store)? as usize];
|
|
52
|
+
arr.copy_to_i8_slice(&mut *store, &mut buf)?;
|
|
53
|
+
buf.get(start..start + len).map(|b| b.to_vec()).ok_or_else(|| wasmtime::Error::msg("Str view out of range"))
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
fn str_arg<B: Backend>(caller: &mut Caller<'_, HostState<B>>, p: &[Val], i: usize) -> Result<String> {
|
|
57
|
+
let Val::AnyRef(Some(s)) = &p[i] else {
|
|
58
|
+
return Err(wasmtime::Error::msg("expected a Str argument"));
|
|
59
|
+
};
|
|
60
|
+
Ok(String::from_utf8_lossy(&str_bytes(caller, s)?).into_owned())
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/// Calls the backend method behind the `plum` import `name`.
|
|
64
|
+
fn dispatch<B: Backend>(name: &str, caller: &mut Caller<'_, HostState<B>>, p: &[Val], r: &mut [Val]) -> Result<()> {
|
|
65
|
+
macro_rules! b {
|
|
66
|
+
() => {
|
|
67
|
+
caller.data_mut().backend
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
match name {
|
|
71
|
+
"printLn" => println!("{}", str_arg(caller, p, 0)?),
|
|
72
|
+
"crash" => return Err(wasmtime::Error::msg(format!("crash: {}", str_arg(caller, p, 0)?))),
|
|
73
|
+
"screenWidth" => r[0] = fv(b!().screen_width()),
|
|
74
|
+
"screenHeight" => r[0] = fv(b!().screen_height()),
|
|
75
|
+
"frameTime" => r[0] = fv(b!().frame_time()),
|
|
76
|
+
"clear" => b!().clear(color(p, 0)),
|
|
77
|
+
"drawRect" => b!().draw_rect(f(p, 0), f(p, 1), f(p, 2), f(p, 3), color(p, 4)),
|
|
78
|
+
"drawLine" => b!().draw_line(f(p, 0), f(p, 1), f(p, 2), f(p, 3), f(p, 4), color(p, 5)),
|
|
79
|
+
"drawText" => {
|
|
80
|
+
let s = str_arg(caller, p, 0)?;
|
|
81
|
+
b!().draw_text(&s, f(p, 1), f(p, 2), f(p, 3), color(p, 4));
|
|
82
|
+
}
|
|
83
|
+
"setCamera" => b!().set_camera(f(p, 0), f(p, 1), f(p, 2)),
|
|
84
|
+
"resetCamera" => b!().reset_camera(),
|
|
85
|
+
"atlasLoad" => {
|
|
86
|
+
let s = str_arg(caller, p, 0)?;
|
|
87
|
+
r[0] = Val::I32(b!().atlas_load(&s));
|
|
88
|
+
}
|
|
89
|
+
"spriteCount" => r[0] = Val::I32(b!().sprite_count(int(p, 0))),
|
|
90
|
+
"spriteW" => r[0] = Val::I32(b!().sprite_w(int(p, 0), int(p, 1))),
|
|
91
|
+
"spriteH" => r[0] = Val::I32(b!().sprite_h(int(p, 0), int(p, 1))),
|
|
92
|
+
"spritePivotX" => r[0] = Val::I32(b!().sprite_pivot_x(int(p, 0), int(p, 1))),
|
|
93
|
+
"spritePivotY" => r[0] = Val::I32(b!().sprite_pivot_y(int(p, 0), int(p, 1))),
|
|
94
|
+
"spriteColor" => {
|
|
95
|
+
let c = b!().sprite_color(int(p, 0), int(p, 1));
|
|
96
|
+
for (i, v) in c.into_iter().enumerate() {
|
|
97
|
+
r[i] = fv(v);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
"drawSprite" => b!().draw_sprite(int(p, 0), int(p, 1), f(p, 2), f(p, 3), int(p, 4) != 0, color(p, 5)),
|
|
101
|
+
"drawTile" => b!().draw_tile(int(p, 0), int(p, 1), f(p, 2), f(p, 3), f(p, 4), int(p, 5), color(p, 6)),
|
|
102
|
+
"isKeyDown" => r[0] = Val::I32(b!().is_key_down(int(p, 0)) as i32),
|
|
103
|
+
"isKeyPressed" => r[0] = Val::I32(b!().is_key_pressed(int(p, 0)) as i32),
|
|
104
|
+
"mousePos" => {
|
|
105
|
+
let (x, y) = b!().mouse_pos();
|
|
106
|
+
r[0] = fv(x);
|
|
107
|
+
r[1] = fv(y);
|
|
108
|
+
}
|
|
109
|
+
"isMouseDown" => r[0] = Val::I32(b!().is_mouse_down(int(p, 0)) as i32),
|
|
110
|
+
"isMousePressed" => r[0] = Val::I32(b!().is_mouse_pressed(int(p, 0)) as i32),
|
|
111
|
+
"mouseWheel" => r[0] = fv(b!().mouse_wheel()),
|
|
112
|
+
"mapLoad" => {
|
|
113
|
+
let s = str_arg(caller, p, 0)?;
|
|
114
|
+
r[0] = Val::I32(b!().map_load(&s));
|
|
115
|
+
}
|
|
116
|
+
"mapWidth" => r[0] = Val::I32(b!().map_width(int(p, 0))),
|
|
117
|
+
"mapHeight" => r[0] = Val::I32(b!().map_height(int(p, 0))),
|
|
118
|
+
"mapTerrain" => r[0] = Val::I32(b!().map_terrain(int(p, 0), int(p, 1), int(p, 2))),
|
|
119
|
+
"mapRotation" => r[0] = Val::I32(b!().map_rotation(int(p, 0), int(p, 1), int(p, 2))),
|
|
120
|
+
"mapElevation" => r[0] = Val::I32(b!().map_elevation(int(p, 0), int(p, 1), int(p, 2))),
|
|
121
|
+
"mapObject" => r[0] = Val::I32(b!().map_object(int(p, 0), int(p, 1), int(p, 2))),
|
|
122
|
+
other => return Err(wasmtime::Error::msg(format!("host function '{other}' is not available here"))),
|
|
123
|
+
}
|
|
124
|
+
Ok(())
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
impl<B: Backend> PlumGame<B> {
|
|
128
|
+
/// Instantiates `wasm`, requiring `init`, `update` and `draw` exports.
|
|
129
|
+
pub fn load(wasm: &[u8], backend: B) -> Result<Self> {
|
|
130
|
+
let mut config = wasmtime::Config::new();
|
|
131
|
+
config.wasm_gc(true);
|
|
132
|
+
config.wasm_function_references(true);
|
|
133
|
+
let engine = Engine::new(&config)?;
|
|
134
|
+
let module = Module::new(&engine, wasm)?;
|
|
135
|
+
for name in ["init", "update", "draw"] {
|
|
136
|
+
if module.get_export(name).is_none() {
|
|
137
|
+
return Err(wasmtime::Error::msg(format!("module has no `{name}` export")));
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
let mut store = Store::new(&engine, HostState { backend });
|
|
141
|
+
let mut linker: Linker<HostState<B>> = Linker::new(&engine);
|
|
142
|
+
for imp in module.imports() {
|
|
143
|
+
let Some(ty) = imp.ty().func().cloned() else { continue };
|
|
144
|
+
let name = imp.name().to_string();
|
|
145
|
+
let func = Func::new(&mut store, ty, {
|
|
146
|
+
let name = name.clone();
|
|
147
|
+
move |mut caller, p, r| dispatch(&name, &mut caller, p, r)
|
|
148
|
+
});
|
|
149
|
+
linker.define(&store, imp.module(), imp.name(), func)?;
|
|
150
|
+
}
|
|
151
|
+
let instance = linker.instantiate(&mut store, &module)?;
|
|
152
|
+
Ok(PlumGame { store, instance, state: None })
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
pub fn backend(&self) -> &B {
|
|
156
|
+
&self.store.data().backend
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
pub fn backend_mut(&mut self) -> &mut B {
|
|
160
|
+
&mut self.store.data_mut().backend
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
fn func(&mut self, name: &str) -> Result<Func> {
|
|
164
|
+
self.instance.get_func(&mut self.store, name).ok_or_else(|| wasmtime::Error::msg(format!("module has no `{name}` export")))
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/// Calls `init` and keeps its result as the game state.
|
|
168
|
+
pub fn init(&mut self) -> Result<()> {
|
|
169
|
+
let init = self.func("init")?;
|
|
170
|
+
let mut out = [Val::AnyRef(None)];
|
|
171
|
+
init.call(&mut self.store, &[], &mut out)?;
|
|
172
|
+
let Val::AnyRef(Some(g)) = &out[0] else {
|
|
173
|
+
return Err(wasmtime::Error::msg("`init` returned no state"));
|
|
174
|
+
};
|
|
175
|
+
self.state = Some(g.to_owned_rooted(&mut self.store)?);
|
|
176
|
+
Ok(())
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
fn state_val(&mut self) -> Result<Val> {
|
|
180
|
+
let s = self.state.as_ref().ok_or_else(|| wasmtime::Error::msg("`init` has not run"))?;
|
|
181
|
+
Ok(Val::AnyRef(Some(s.to_rooted(&mut self.store))))
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
pub fn update(&mut self, dt: f32) -> Result<()> {
|
|
185
|
+
let update = self.func("update")?;
|
|
186
|
+
let g = self.state_val()?;
|
|
187
|
+
update.call(&mut self.store, &[g, fv(dt)], &mut [])
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
pub fn draw(&mut self) -> Result<()> {
|
|
191
|
+
let draw = self.func("draw")?;
|
|
192
|
+
let g = self.state_val()?;
|
|
193
|
+
draw.call(&mut self.store, &[g], &mut [])
|
|
194
|
+
}
|
|
195
|
+
}
|
plum-game/src/recorder.rs
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
//! A `Backend` that records every call as a string and answers input queries
|
|
2
|
+
//! from scripted values. Asset calls go through a real `AssetStore`.
|
|
3
|
+
|
|
4
|
+
use crate::api::{AssetStore, Backend, Color};
|
|
5
|
+
use std::collections::HashSet;
|
|
6
|
+
use std::path::PathBuf;
|
|
7
|
+
|
|
8
|
+
pub struct Recorder {
|
|
9
|
+
pub calls: Vec<String>,
|
|
10
|
+
pub screen: (f32, f32),
|
|
11
|
+
pub frame_time: f32,
|
|
12
|
+
pub mouse: (f32, f32),
|
|
13
|
+
pub wheel: f32,
|
|
14
|
+
pub keys_down: HashSet<i32>,
|
|
15
|
+
pub keys_pressed: HashSet<i32>,
|
|
16
|
+
pub mouse_down: HashSet<i32>,
|
|
17
|
+
pub mouse_pressed: HashSet<i32>,
|
|
18
|
+
pub assets: AssetStore,
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
fn c4(c: Color) -> String {
|
|
22
|
+
format!("{},{},{},{}", c[0], c[1], c[2], c[3])
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
impl Recorder {
|
|
26
|
+
pub fn new() -> Self {
|
|
27
|
+
Self::with_assets(None)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
pub fn with_assets(root: Option<PathBuf>) -> Self {
|
|
31
|
+
Recorder {
|
|
32
|
+
calls: Vec::new(),
|
|
33
|
+
screen: (800.0, 600.0),
|
|
34
|
+
frame_time: 1.0 / 60.0,
|
|
35
|
+
mouse: (0.0, 0.0),
|
|
36
|
+
wheel: 0.0,
|
|
37
|
+
keys_down: HashSet::new(),
|
|
38
|
+
keys_pressed: HashSet::new(),
|
|
39
|
+
mouse_down: HashSet::new(),
|
|
40
|
+
mouse_pressed: HashSet::new(),
|
|
41
|
+
assets: AssetStore::new(root),
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
fn rec(&mut self, s: String) {
|
|
46
|
+
self.calls.push(s);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
impl Default for Recorder {
|
|
51
|
+
fn default() -> Self {
|
|
52
|
+
Self::new()
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
impl Backend for Recorder {
|
|
57
|
+
fn screen_width(&mut self) -> f32 {
|
|
58
|
+
self.rec("screenWidth()".into());
|
|
59
|
+
self.screen.0
|
|
60
|
+
}
|
|
61
|
+
fn screen_height(&mut self) -> f32 {
|
|
62
|
+
self.rec("screenHeight()".into());
|
|
63
|
+
self.screen.1
|
|
64
|
+
}
|
|
65
|
+
fn frame_time(&mut self) -> f32 {
|
|
66
|
+
self.rec("frameTime()".into());
|
|
67
|
+
self.frame_time
|
|
68
|
+
}
|
|
69
|
+
fn clear(&mut self, c: Color) {
|
|
70
|
+
self.rec(format!("clear({})", c4(c)));
|
|
71
|
+
}
|
|
72
|
+
fn draw_rect(&mut self, x: f32, y: f32, w: f32, h: f32, c: Color) {
|
|
73
|
+
self.rec(format!("drawRect({x},{y},{w},{h},{})", c4(c)));
|
|
74
|
+
}
|
|
75
|
+
fn draw_line(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, t: f32, c: Color) {
|
|
76
|
+
self.rec(format!("drawLine({x1},{y1},{x2},{y2},{t},{})", c4(c)));
|
|
77
|
+
}
|
|
78
|
+
fn draw_text(&mut self, s: &str, x: f32, y: f32, size: f32, c: Color) {
|
|
79
|
+
self.rec(format!("drawText({s:?},{x},{y},{size},{})", c4(c)));
|
|
80
|
+
}
|
|
81
|
+
fn set_camera(&mut self, x: f32, y: f32, scale: f32) {
|
|
82
|
+
self.rec(format!("setCamera({x},{y},{scale})"));
|
|
83
|
+
}
|
|
84
|
+
fn reset_camera(&mut self) {
|
|
85
|
+
self.rec("resetCamera()".into());
|
|
86
|
+
}
|
|
87
|
+
fn atlas_load(&mut self, name: &str) -> i32 {
|
|
88
|
+
self.rec(format!("atlasLoad({name:?})"));
|
|
89
|
+
self.assets.atlas_load(name)
|
|
90
|
+
}
|
|
91
|
+
fn sprite_count(&mut self, a: i32) -> i32 {
|
|
92
|
+
self.rec(format!("spriteCount({a})"));
|
|
93
|
+
self.assets.sprite_count(a)
|
|
94
|
+
}
|
|
95
|
+
fn sprite_w(&mut self, a: i32, i: i32) -> i32 {
|
|
96
|
+
self.rec(format!("spriteW({a},{i})"));
|
|
97
|
+
self.assets.sprite_w(a, i)
|
|
98
|
+
}
|
|
99
|
+
fn sprite_h(&mut self, a: i32, i: i32) -> i32 {
|
|
100
|
+
self.rec(format!("spriteH({a},{i})"));
|
|
101
|
+
self.assets.sprite_h(a, i)
|
|
102
|
+
}
|
|
103
|
+
fn sprite_pivot_x(&mut self, a: i32, i: i32) -> i32 {
|
|
104
|
+
self.rec(format!("spritePivotX({a},{i})"));
|
|
105
|
+
self.assets.sprite_pivot_x(a, i)
|
|
106
|
+
}
|
|
107
|
+
fn sprite_pivot_y(&mut self, a: i32, i: i32) -> i32 {
|
|
108
|
+
self.rec(format!("spritePivotY({a},{i})"));
|
|
109
|
+
self.assets.sprite_pivot_y(a, i)
|
|
110
|
+
}
|
|
111
|
+
fn sprite_color(&mut self, a: i32, i: i32) -> Color {
|
|
112
|
+
self.rec(format!("spriteColor({a},{i})"));
|
|
113
|
+
self.assets.sprite_color(a, i)
|
|
114
|
+
}
|
|
115
|
+
fn draw_sprite(&mut self, a: i32, i: i32, x: f32, y: f32, flip_x: bool, tint: Color) {
|
|
116
|
+
self.rec(format!("drawSprite({a},{i},{x},{y},{flip_x},{})", c4(tint)));
|
|
117
|
+
}
|
|
118
|
+
fn draw_tile(&mut self, a: i32, i: i32, x: f32, y: f32, size: f32, qt: i32, tint: Color) {
|
|
119
|
+
self.rec(format!("drawTile({a},{i},{x},{y},{size},{qt},{})", c4(tint)));
|
|
120
|
+
}
|
|
121
|
+
fn is_key_down(&mut self, k: i32) -> bool {
|
|
122
|
+
self.rec(format!("isKeyDown({k})"));
|
|
123
|
+
self.keys_down.contains(&k)
|
|
124
|
+
}
|
|
125
|
+
fn is_key_pressed(&mut self, k: i32) -> bool {
|
|
126
|
+
self.rec(format!("isKeyPressed({k})"));
|
|
127
|
+
self.keys_pressed.contains(&k)
|
|
128
|
+
}
|
|
129
|
+
fn mouse_pos(&mut self) -> (f32, f32) {
|
|
130
|
+
self.rec("mousePos()".into());
|
|
131
|
+
self.mouse
|
|
132
|
+
}
|
|
133
|
+
fn is_mouse_down(&mut self, b: i32) -> bool {
|
|
134
|
+
self.rec(format!("isMouseDown({b})"));
|
|
135
|
+
self.mouse_down.contains(&b)
|
|
136
|
+
}
|
|
137
|
+
fn is_mouse_pressed(&mut self, b: i32) -> bool {
|
|
138
|
+
self.rec(format!("isMousePressed({b})"));
|
|
139
|
+
self.mouse_pressed.contains(&b)
|
|
140
|
+
}
|
|
141
|
+
fn mouse_wheel(&mut self) -> f32 {
|
|
142
|
+
self.rec("mouseWheel()".into());
|
|
143
|
+
self.wheel
|
|
144
|
+
}
|
|
145
|
+
fn map_load(&mut self, path: &str) -> i32 {
|
|
146
|
+
self.rec(format!("mapLoad({path:?})"));
|
|
147
|
+
self.assets.map_load(path)
|
|
148
|
+
}
|
|
149
|
+
fn map_width(&mut self, m: i32) -> i32 {
|
|
150
|
+
self.rec(format!("mapWidth({m})"));
|
|
151
|
+
self.assets.map_width(m)
|
|
152
|
+
}
|
|
153
|
+
fn map_height(&mut self, m: i32) -> i32 {
|
|
154
|
+
self.rec(format!("mapHeight({m})"));
|
|
155
|
+
self.assets.map_height(m)
|
|
156
|
+
}
|
|
157
|
+
fn map_terrain(&mut self, m: i32, x: i32, y: i32) -> i32 {
|
|
158
|
+
self.rec(format!("mapTerrain({m},{x},{y})"));
|
|
159
|
+
self.assets.map_terrain(m, x, y)
|
|
160
|
+
}
|
|
161
|
+
fn map_rotation(&mut self, m: i32, x: i32, y: i32) -> i32 {
|
|
162
|
+
self.rec(format!("mapRotation({m},{x},{y})"));
|
|
163
|
+
self.assets.map_rotation(m, x, y)
|
|
164
|
+
}
|
|
165
|
+
fn map_elevation(&mut self, m: i32, x: i32, y: i32) -> i32 {
|
|
166
|
+
self.rec(format!("mapElevation({m},{x},{y})"));
|
|
167
|
+
self.assets.map_elevation(m, x, y)
|
|
168
|
+
}
|
|
169
|
+
fn map_object(&mut self, m: i32, x: i32, y: i32) -> i32 {
|
|
170
|
+
self.rec(format!("mapObject({m},{x},{y})"));
|
|
171
|
+
self.assets.map_object(m, x, y)
|
|
172
|
+
}
|
|
173
|
+
}
|
plum-game/tests/fixtures/host_fixture.plum
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
module host_fixture
|
|
2
|
+
import std/Int
|
|
3
|
+
import std/Float
|
|
4
|
+
import std/Str
|
|
5
|
+
import std/Os
|
|
6
|
+
import std/Vec2
|
|
7
|
+
import std/Color
|
|
8
|
+
import std/Game
|
|
9
|
+
|
|
10
|
+
record State(frames: Int)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
export fun init() -> State =
|
|
14
|
+
State(frames: 0)
|
|
15
|
+
|
|
16
|
+
export fun update(g: State, dt: Float) =
|
|
17
|
+
g.frames = g.frames + 1
|
|
18
|
+
|
|
19
|
+
export fun draw(g: State) =
|
|
20
|
+
clear(Color.Black)
|
|
21
|
+
drawRect(1.0, 2.0, 3.0, 4.0, Color.Red)
|
|
22
|
+
p := mousePos()
|
|
23
|
+
drawText("hi", p.x, p.y, 16.0, Color.White)
|
|
24
|
+
if g.frames > 0
|
|
25
|
+
drawText("frames {g.frames}", 0.0, 0.0, 8.0, Color.White)
|
plum-game/tests/host_test.rs
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
#![allow(non_snake_case)]
|
|
2
|
+
use plum_game::api::quarter_turns_radians;
|
|
3
|
+
use plum_game::native::PlumGame;
|
|
4
|
+
use plum_game::recorder::Recorder;
|
|
5
|
+
use std::path::{Path, PathBuf};
|
|
6
|
+
|
|
7
|
+
fn root() -> PathBuf {
|
|
8
|
+
Path::new(env!("CARGO_MANIFEST_DIR")).join("..")
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
fn compile(src_path: &Path) -> Vec<u8> {
|
|
12
|
+
let source = plum_core::loadAndMerge(src_path, &root()).expect("load");
|
|
13
|
+
let typed = plum_wasm_codegen::typeCheckIn(&source, &src_path.display().to_string()).expect("typecheck");
|
|
14
|
+
plum_wasm_codegen::compileTyped(&source, typed).expect("compile")
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
fn fixture() -> Vec<u8> {
|
|
18
|
+
compile(&Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/host_fixture.plum"))
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
fn game(rec: Recorder) -> PlumGame<Recorder> {
|
|
22
|
+
let mut g = PlumGame::load(&fixture(), rec).expect("load game");
|
|
23
|
+
g.init().expect("init");
|
|
24
|
+
g
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
#[test]
|
|
28
|
+
fn callOrderAndArgs() {
|
|
29
|
+
let mut rec = Recorder::new();
|
|
30
|
+
rec.mouse = (5.0, 6.0);
|
|
31
|
+
let mut g = game(rec);
|
|
32
|
+
g.draw().unwrap();
|
|
33
|
+
assert_eq!(
|
|
34
|
+
g.backend().calls,
|
|
35
|
+
["clear(0,0,0,1)", "drawRect(1,2,3,4,1,0,0,1)", "mousePos()", "drawText(\"hi\",5,6,16,1,1,1,1)"]
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
#[test]
|
|
40
|
+
fn colorFlattened() {
|
|
41
|
+
let mut g = game(Recorder::new());
|
|
42
|
+
g.draw().unwrap();
|
|
43
|
+
assert!(g.backend().calls.contains(&"drawRect(1,2,3,4,1,0,0,1)".to_string()));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
#[test]
|
|
47
|
+
fn mousePosMultiValue() {
|
|
48
|
+
let mut rec = Recorder::new();
|
|
49
|
+
rec.mouse = (12.5, -3.0);
|
|
50
|
+
let mut g = game(rec);
|
|
51
|
+
g.draw().unwrap();
|
|
52
|
+
assert_eq!(g.backend().calls.last().unwrap(), "drawText(\"hi\",12.5,-3,16,1,1,1,1)");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
#[test]
|
|
56
|
+
fn stateSurvivesFrames() {
|
|
57
|
+
let mut g = game(Recorder::new());
|
|
58
|
+
for _ in 0..3 {
|
|
59
|
+
g.update(0.016).unwrap();
|
|
60
|
+
}
|
|
61
|
+
g.draw().unwrap();
|
|
62
|
+
assert_eq!(g.backend().calls.last().unwrap(), "drawText(\"frames 3\",0,0,8,1,1,1,1)");
|
|
63
|
+
g.update(0.016).unwrap();
|
|
64
|
+
g.backend_mut().calls.clear();
|
|
65
|
+
g.draw().unwrap();
|
|
66
|
+
assert_eq!(g.backend().calls.last().unwrap(), "drawText(\"frames 4\",0,0,8,1,1,1,1)");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
#[test]
|
|
70
|
+
fn missingExportErrors() {
|
|
71
|
+
let dir = std::env::temp_dir().join(format!("plum_game_missing_{}", std::process::id()));
|
|
72
|
+
std::fs::create_dir_all(&dir).unwrap();
|
|
73
|
+
let path = dir.join("m.plum");
|
|
74
|
+
std::fs::write(
|
|
75
|
+
&path,
|
|
76
|
+
"import std/Int\nimport std/Float\nrecord State(n: Int)\n\nexport fun init() -> State =\n State(n: 0)\nexport fun update(g: State, dt: Float) =\n g.n = 1\n",
|
|
77
|
+
)
|
|
78
|
+
.unwrap();
|
|
79
|
+
let wasm = compile(&path);
|
|
80
|
+
let _ = std::fs::remove_dir_all(&dir);
|
|
81
|
+
let err = PlumGame::load(&wasm, Recorder::new()).err().expect("should fail");
|
|
82
|
+
assert!(err.to_string().contains("`draw`"), "{err}");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
#[test]
|
|
86
|
+
fn unknownImportTraps() {
|
|
87
|
+
let dir = std::env::temp_dir().join(format!("plum_game_unknown_{}", std::process::id()));
|
|
88
|
+
std::fs::create_dir_all(&dir).unwrap();
|
|
89
|
+
let path = dir.join("m.plum");
|
|
90
|
+
std::fs::write(
|
|
91
|
+
&path,
|
|
92
|
+
"import std/Int\nimport std/Float\nimport std/Str\nimport std/Os\nrecord State(n: Int)\n\nexport fun init() -> State =\n State(n: 0)\nexport fun update(g: State, dt: Float) =\n g.n = 1\nexport fun draw(g: State) =\n x := rawExists(\"a\")\n",
|
|
93
|
+
)
|
|
94
|
+
.unwrap();
|
|
95
|
+
let wasm = compile(&path);
|
|
96
|
+
let _ = std::fs::remove_dir_all(&dir);
|
|
97
|
+
let mut g = PlumGame::load(&wasm, Recorder::new()).unwrap();
|
|
98
|
+
g.init().unwrap();
|
|
99
|
+
let err = g.draw().unwrap_err();
|
|
100
|
+
assert!(format!("{err:?}").contains("is not available here"), "{err:?}");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
#[test]
|
|
104
|
+
fn quarterTurnsRotateClockwise() {
|
|
105
|
+
use std::f32::consts::FRAC_PI_2;
|
|
106
|
+
assert_eq!(quarter_turns_radians(0), 0.0);
|
|
107
|
+
assert_eq!(quarter_turns_radians(1), FRAC_PI_2);
|
|
108
|
+
assert_eq!(quarter_turns_radians(3), 3.0 * FRAC_PI_2);
|
|
109
|
+
assert_eq!(quarter_turns_radians(4), 0.0);
|
|
110
|
+
assert_eq!(quarter_turns_radians(-1), 3.0 * FRAC_PI_2);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
#[test]
|
|
114
|
+
fn intHandlesFailWithMinusOne() {
|
|
115
|
+
use plum_game::api::Backend;
|
|
116
|
+
let mut rec = Recorder::with_assets(Some(PathBuf::from("/nonexistent-assets")));
|
|
117
|
+
assert_eq!(rec.atlas_load("Tileset"), -1);
|
|
118
|
+
assert_eq!(rec.map_load("x.map"), -1);
|
|
119
|
+
assert_eq!(rec.sprite_w(5, 5), 0);
|
|
120
|
+
assert_eq!(rec.map_terrain(-1, 0, 0), 0);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
#[test]
|
|
124
|
+
fn atlasAndMapThroughHost() {
|
|
125
|
+
use plum_game::api::Backend;
|
|
126
|
+
let assets = plum_game::assets::AssetRoot::resolve(None);
|
|
127
|
+
if !assets.join("sprites").is_dir() {
|
|
128
|
+
eprintln!("skipping: asset root {} not found", assets.display());
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
let mut rec = Recorder::with_assets(None);
|
|
132
|
+
let a = rec.atlas_load("Tileset");
|
|
133
|
+
assert_eq!(a, 0);
|
|
134
|
+
assert_eq!(rec.sprite_w(a, 1), 32);
|
|
135
|
+
let m = rec.map_load("Maps/A new Beginning/A new Beginning.map");
|
|
136
|
+
assert_eq!(m, 0);
|
|
137
|
+
let (w, h) = (rec.map_width(m), rec.map_height(m));
|
|
138
|
+
assert!((32..=256).contains(&w) && (32..=256).contains(&h));
|
|
139
|
+
assert!(rec.map_terrain(m, 0, 0) < 512);
|
|
140
|
+
assert_eq!(rec.map_terrain(m, w, 0), 0);
|
|
141
|
+
}
|