plum

#treesitter#compiler#wasm

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

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


8ffa113 — Peter John 2026-11-02T14:02:19+05:30
T5: web bridge + `plum game` + README
.gitignore CHANGED
@@ -18,6 +18,7 @@ codegen
18
18
 
19
19
  # `cargo make dist-macos`/`dist-linux-docker` output — uploaded to R2, not committed
20
20
  /dist
21
+ dist/
21
22
 
22
23
  # superlite: dependency, build and cache folders
23
24
  .superlite/
README.md CHANGED
@@ -92,6 +92,7 @@ test "glance"
92
92
  | a namespace of constants and functions | `primitive Math =` |
93
93
  | a test | `test "name"` with `assert` |
94
94
  | host function | `extern fun printLn(s: Str)` |
95
+ | a function the host calls | `export fun draw(g: Game)` |
95
96
 
96
97
  ## Installing
97
98
 
@@ -133,6 +134,7 @@ plum test file.plum dir/ # run the `test` blocks of files and directories (
133
134
  plum compile file.plum # write file.wasm
134
135
  plum build file.plum # embed the module into a native executable
135
136
  plum format file.plum # rewrite in place (--check, --stdin)
137
+ plum game file.plum # run a game (see Games); --web writes a static web build
136
138
  plum editor helix|vscode # install highlighting and the language server
137
139
  ```
138
140
 
@@ -1000,6 +1002,54 @@ hello from plum
1000
1002
  hello, world!
1001
1003
  ```
1002
1004
 
1005
+ ## `export fun`
1006
+
1007
+ ```plum
1008
+ export fun step(n: Int, v: Vec2) -> Vec2 =
1009
+ Vec2(x: v.x + 1.0, y: v.y * 2.0)
1010
+ ```
1011
+
1012
+ `export fun` (top level only: not a method, not generic, no error-union result) is an ordinary Plum function that is also exported from the wasm module under its own name. Its boundary uses the `extern fun` ABI: `Int` is narrowed to `i32`, `Float` is `f64`, `Bool` is `i32`, a struct is flattened into its fields' values, and `Str` and records are GC references. Plum code can still call it directly with the normal internal ABI. A function can't be both `extern` and `export`.
1013
+
1014
+ Every module that imports `std/Str` also exports four helpers for hosts that can't read GC structs: `strLen(s) -> i32`, `strByte(s, i) -> i32`, `strNew(n) -> Str` (n zero bytes) and `strSetByte(s, i, b)` (for a fresh `strNew` result only).
1015
+
1016
+ ## Games
1017
+
1018
+ `plum game file.plum` compiles a game and runs it in a native window (macroquad, with the module under wasmtime). `plum game --web file.plum [-o dist] [--preload path]...` writes a static web build instead: `index.html`, `mq_js_bundle.js`, `plum_bridge.js`, `plum-game.wasm`, `plum.wasm`, `preload.txt` and an `assets/` copy of the preloaded files (the six `sprites/*.rxx` and `data/defines/mapelem.dat` are always preloaded; `dist` defaults to the source file's directory). Serve it from any static host; the browser needs wasm GC support. The web build needs `rustup target add wasm32-unknown-unknown`.
1019
+
1020
+ A game declares three exports. `G` is any record and holds all game state (there are no globals):
1021
+
1022
+ ```plum
1023
+ export fun init() -> G
1024
+ export fun update(g: G, dt: Float)
1025
+ export fun draw(g: G)
1026
+ ```
1027
+
1028
+ The host calls `init` once after the window exists, then `update` and `draw` every frame. A game imports `std/Game`, `std/Vec2` and `std/Color`; pure logic can import `std/Vec2` and `std/Color` alone. Under `plum test` and `plum run` the host functions are stubs that trap with `host function 'X' is not available here`, so `test` blocks work as long as they don't call the host.
1029
+
1030
+ **Assets** are never part of the repository. The asset root is `--assets DIR`, else `$PLUM_GAME_ASSETS`, else `../kam_macroquad/assets` beside the repository. `--web` copies only the preloaded files into `dist/assets`.
1031
+
1032
+ **Host API** (`std/Game`; `Int` handles are `-1` on failure; coordinates are world units once a camera is set):
1033
+
1034
+ | function | meaning |
1035
+ |---|---|
1036
+ | `screenWidth()`, `screenHeight()`, `frameTime()` | window size in pixels, last frame time in seconds |
1037
+ | `clear(c)` | fill the screen |
1038
+ | `drawRect(x, y, w, h, c)`, `drawLine(x1, y1, x2, y2, thickness, c)`, `drawText(s, x, y, size, c)` | shapes and text (`x, y` is the text's top-left) |
1039
+ | `setCamera(x, y, scale)`, `resetCamera()` | world point `(x, y)` maps to the screen's top-left, one world unit is `scale` pixels |
1040
+ | `atlasLoad(name)` | load `<assets>/sprites/<name>.rxx` |
1041
+ | `spriteCount(a)`, `spriteW(a, i)`, `spriteH`, `spritePivotX`, `spritePivotY`, `spriteColor(a, i)` | sprite info (0 for a missing sprite); `spriteColor` is the average opaque colour |
1042
+ | `drawSprite(a, i, x, y, flipX, tint)` | sprite at its natural size, top-left at `(x + pivotX, y + pivotY)` |
1043
+ | `drawTile(a, i, x, y, size, quarterTurns, tint)` | sprite stretched to a square, rotated clockwise about its centre |
1044
+ | `isKeyDown(k)`, `isKeyPressed(k)`, `isMouseDown(b)`, `isMousePressed(b)` | input; `k` is a `Key` constant, `b` a `Mouse` constant |
1045
+ | `mousePos()`, `mouseWheel()` | pointer in screen pixels; wheel direction this frame (+ is up) |
1046
+ | `mapLoad(path)`, `mapWidth(m)`, `mapHeight(m)` | load a KaM `.map` relative to the asset root |
1047
+ | `mapTerrain(m, x, y)`, `mapRotation`, `mapElevation`, `mapObject` | per-tile data; row 0 is the map's first row. The Tileset sprite for a terrain id is `id + 1`; `mapObject` is a `Trees_a` sprite index (0 for none) |
1048
+
1049
+ `Key` constants use GLFW numbering: `Key.Space`, `Key.KeyA` to `Key.KeyZ`, `Key.Num0` to `Key.Num9`, `Key.Escape`, `Key.Enter`, `Key.Tab`, `Key.Backspace`, `Key.Right`, `Key.Left`, `Key.Down`, `Key.Up`, `Key.LeftShift`, `Key.LeftControl`. `Mouse` has `Mouse.Left`, `Mouse.Right` and `Mouse.Middle`. `Color` holds RGBA channels in 0..1 with the constants `White`, `Black`, `Red`, `Green`, `Blue`, `Yellow` and `Transparent`, plus `Color.rgb(r, g, b)` and `withAlpha(a)`.
1050
+
1051
+ `plum-game/kam/Main.plum` is a Knights and Merchants map viewer built on this API: `plum game plum-game/kam/Main.plum`.
1052
+
1003
1053
  ## Standard library highlights
1004
1054
 
1005
1055
  - **`Bool`** — `true`/`false`, `if`, `&&`/`||`/`!` and `==` are built in (no import); `toStr` (also used by `"{b}"` interpolation) and `Bool.parse("true")` need `import std/Bool`
plum-cli/src/game.rs ADDED
@@ -0,0 +1,178 @@
1
+ //! `plum game`: compile a game and run it natively, or write a static web build.
2
+
3
+ use std::fs;
4
+ use std::path::{Path, PathBuf};
5
+ use std::process::Command;
6
+
7
+ use anyhow::{bail, Context, Result};
8
+
9
+ /// The six KaM sprite archives every web build bundles.
10
+ const SPRITES: [&str; 6] = [
11
+ "GUI_a", "GUIMain", "Houses_a", "Tileset", "Trees_a", "Units_a",
12
+ ];
13
+
14
+ /// Needed to turn map object ids into sprite indices.
15
+ const MAPELEM: &str = "data/defines/mapelem.dat";
16
+
17
+ /// The repository root, fixed when plum-cli is compiled.
18
+ fn repoRoot() -> PathBuf {
19
+ Path::new(env!("CARGO_MANIFEST_DIR"))
20
+ .parent()
21
+ .expect("plum-cli lives inside the repository")
22
+ .to_path_buf()
23
+ }
24
+
25
+ fn gameManifest() -> PathBuf {
26
+ repoRoot().join("plum-game/Cargo.toml")
27
+ }
28
+
29
+ fn assetRoot(cli: Option<PathBuf>) -> PathBuf {
30
+ cli.or_else(|| std::env::var_os("PLUM_GAME_ASSETS").map(PathBuf::from))
31
+ .unwrap_or_else(|| repoRoot().join("../kam_macroquad/assets"))
32
+ }
33
+
34
+ pub fn cmdGame(
35
+ file: PathBuf,
36
+ web: bool,
37
+ output: Option<PathBuf>,
38
+ preload: Vec<String>,
39
+ assets: Option<PathBuf>,
40
+ lib_path: Option<PathBuf>,
41
+ ) -> Result<()> {
42
+ let lib_path = lib_path.unwrap_or_else(repoRoot);
43
+ let wasm = crate::compileToWasm(&file, &lib_path)?;
44
+ let assets = assetRoot(assets);
45
+ if web {
46
+ let dist = output.unwrap_or_else(|| {
47
+ file.parent()
48
+ .filter(|p| !p.as_os_str().is_empty())
49
+ .unwrap_or(Path::new("."))
50
+ .join("dist")
51
+ });
52
+ return writeWeb(&wasm, &dist, &preload, &assets);
53
+ }
54
+ runNative(&wasm, &assets)
55
+ }
56
+
57
+ fn runNative(wasm: &[u8], assets: &Path) -> Result<()> {
58
+ let dir = std::env::temp_dir().join(format!("plum_game_{}", std::process::id()));
59
+ fs::create_dir_all(&dir)?;
60
+ let path = dir.join("game.wasm");
61
+ fs::write(&path, wasm).with_context(|| format!("failed to write {}", path.display()))?;
62
+ let status = Command::new("cargo")
63
+ .args(["run", "--release", "--manifest-path"])
64
+ .arg(gameManifest())
65
+ .arg("--")
66
+ .arg(&path)
67
+ .arg("--assets")
68
+ .arg(assets)
69
+ .status()
70
+ .context("failed to run cargo")?;
71
+ let _ = fs::remove_dir_all(&dir);
72
+ if !status.success() {
73
+ bail!("the game host exited with {status}");
74
+ }
75
+ Ok(())
76
+ }
77
+
78
+ /// Path of macroquad's `mq_js_bundle.js`, found through the host crate's
79
+ /// locked dependency (the bundle must match the crate version).
80
+ fn jsBundle() -> Result<PathBuf> {
81
+ let out = Command::new("cargo")
82
+ .args(["metadata", "--format-version", "1", "--manifest-path"])
83
+ .arg(gameManifest())
84
+ .output()
85
+ .context("failed to run cargo metadata")?;
86
+ if !out.status.success() {
87
+ bail!(
88
+ "cargo metadata failed: {}",
89
+ String::from_utf8_lossy(&out.stderr)
90
+ );
91
+ }
92
+ let json = String::from_utf8_lossy(&out.stdout);
93
+ // `"manifest_path":".../macroquad-0.4.x/Cargo.toml"` of the macroquad package.
94
+ let mut rest = json.as_ref();
95
+ while let Some(i) = rest.find("\"manifest_path\":\"") {
96
+ rest = &rest[i + 17..];
97
+ let end = rest.find('"').unwrap_or(rest.len());
98
+ let manifest = &rest[..end];
99
+ if Path::new(manifest)
100
+ .parent()
101
+ .and_then(|d| d.file_name())
102
+ .is_some_and(|n| n.to_string_lossy().starts_with("macroquad-"))
103
+ {
104
+ let bundle = Path::new(manifest).with_file_name("js/mq_js_bundle.js");
105
+ if bundle.is_file() {
106
+ return Ok(bundle);
107
+ }
108
+ }
109
+ }
110
+ bail!("macroquad's mq_js_bundle.js not found; run `cargo fetch --manifest-path plum-game/Cargo.toml`")
111
+ }
112
+
113
+ /// Builds the host for the browser and returns the path of its wasm.
114
+ fn buildHostWasm() -> Result<PathBuf> {
115
+ let status = Command::new("cargo")
116
+ .args(["build", "--release", "--target", "wasm32-unknown-unknown"])
117
+ .arg("--manifest-path")
118
+ .arg(gameManifest())
119
+ .status()
120
+ .context("failed to run cargo")?;
121
+ if !status.success() {
122
+ bail!("building the web host failed (is the wasm32-unknown-unknown target installed?)");
123
+ }
124
+ let target = std::env::var_os("CARGO_TARGET_DIR")
125
+ .map(PathBuf::from)
126
+ .unwrap_or_else(|| repoRoot().join("plum-game/target"));
127
+ Ok(target.join("wasm32-unknown-unknown/release/plum-game.wasm"))
128
+ }
129
+
130
+ fn writeWeb(wasm: &[u8], dist: &Path, preload: &[String], assets: &Path) -> Result<()> {
131
+ let hostWasm = buildHostWasm()?;
132
+ let bundle = jsBundle()?;
133
+
134
+ // Defaults that are missing from the asset root are skipped with a
135
+ // warning; files asked for with --preload must exist.
136
+ let mut files: Vec<String> = Vec::new();
137
+ for rel in SPRITES
138
+ .iter()
139
+ .map(|s| format!("sprites/{s}.rxx"))
140
+ .chain([MAPELEM.to_string()])
141
+ {
142
+ if assets.join(&rel).is_file() {
143
+ files.push(rel);
144
+ } else {
145
+ eprintln!(
146
+ "warning: {} not found, not bundled",
147
+ assets.join(&rel).display()
148
+ );
149
+ }
150
+ }
151
+ for rel in preload {
152
+ if !assets.join(rel).is_file() {
153
+ bail!("--preload {rel}: {} not found", assets.join(rel).display());
154
+ }
155
+ if !files.contains(rel) {
156
+ files.push(rel.clone());
157
+ }
158
+ }
159
+
160
+ fs::create_dir_all(dist)?;
161
+ let web = repoRoot().join("plum-game/web");
162
+ for name in ["index.html", "plum_bridge.js"] {
163
+ fs::copy(web.join(name), dist.join(name))
164
+ .with_context(|| format!("failed to copy {name}"))?;
165
+ }
166
+ fs::copy(&bundle, dist.join("mq_js_bundle.js"))?;
167
+ fs::copy(&hostWasm, dist.join("plum-game.wasm"))
168
+ .with_context(|| format!("failed to copy {}", hostWasm.display()))?;
169
+ fs::write(dist.join("plum.wasm"), wasm)?;
170
+ fs::write(dist.join("preload.txt"), files.join("\n") + "\n")?;
171
+ for rel in &files {
172
+ let to = dist.join("assets").join(rel);
173
+ fs::create_dir_all(to.parent().expect("asset has a parent"))?;
174
+ fs::copy(assets.join(rel), &to)?;
175
+ }
176
+ eprintln!("wrote web build to {}", dist.display());
177
+ Ok(())
178
+ }
plum-cli/src/main.rs CHANGED
@@ -12,6 +12,7 @@ use clap::{Parser, Subcommand};
12
12
  use plum_core::formatSource;
13
13
 
14
14
  mod editor;
15
+ mod game;
15
16
  use editor::EditorType;
16
17
 
17
18
  #[derive(Parser)]
@@ -75,6 +76,26 @@ enum Command {
75
76
  #[arg(long, default_value = ".")]
76
77
  lib_path: std::path::PathBuf,
77
78
  },
79
+ /// Compile a Plum game and run it natively, or package it for the browser with --web
80
+ Game {
81
+ /// Source file with `export fun init/update/draw`
82
+ file: std::path::PathBuf,
83
+ /// Write a static web build instead of opening a window
84
+ #[arg(long)]
85
+ web: bool,
86
+ /// Output directory for --web (default: `dist` beside the source file)
87
+ #[arg(short, long)]
88
+ output: Option<std::path::PathBuf>,
89
+ /// Asset file (relative to the asset root) to bundle with --web; repeatable
90
+ #[arg(long)]
91
+ preload: Vec<String>,
92
+ /// Asset root (default: $PLUM_GAME_ASSETS, else ../kam_macroquad/assets beside the repo)
93
+ #[arg(long)]
94
+ assets: Option<std::path::PathBuf>,
95
+ /// Root directory `import <path>` is resolved against (default: the repository root)
96
+ #[arg(long)]
97
+ lib_path: Option<std::path::PathBuf>,
98
+ },
78
99
  /// Install Plum syntax highlighting into an editor's configuration
79
100
  Editor {
80
101
  /// Editor to install support for
@@ -108,6 +129,14 @@ fn run() -> Result<()> {
108
129
  lib_path,
109
130
  } => cmdBuild(file, output, lib_path),
110
131
  Command::Test { paths, lib_path } => cmdTest(paths, lib_path),
132
+ Command::Game {
133
+ file,
134
+ web,
135
+ output,
136
+ preload,
137
+ assets,
138
+ lib_path,
139
+ } => game::cmdGame(file, web, output, preload, assets, lib_path),
111
140
  Command::Editor { editor, insiders } => editor::install(editor, insiders),
112
141
  }
113
142
  }
@@ -580,9 +609,10 @@ fn hostImports(
580
609
  // is stubbed with a function that traps when called, so a program
581
610
  // that imports it can still run or test code that never calls it.
582
611
  ("plum", n) => {
612
+ let func_ty =
583
- let func_ty = imp.ty().func().cloned().ok_or_else(|| {
613
+ imp.ty().func().cloned().ok_or_else(|| {
584
- anyhow::anyhow!("plum::{n} import must be a function")
614
+ anyhow::anyhow!("plum::{n} import must be a function")
585
- })?;
615
+ })?;
586
616
  let name = n.to_string();
587
617
  Ok(wasmtime::Extern::Func(wasmtime::Func::new(
588
618
  &mut *store,
plum-game/src/lib.rs CHANGED
@@ -2,9 +2,10 @@
2
2
 
3
3
  pub mod api;
4
4
  pub mod assets;
5
+ pub mod mq;
5
6
  pub mod recorder;
6
7
 
7
- #[cfg(not(target_arch = "wasm32"))]
8
- pub mod mq;
9
8
  #[cfg(not(target_arch = "wasm32"))]
10
9
  pub mod native;
10
+ #[cfg(target_arch = "wasm32")]
11
+ pub mod web;
plum-game/src/main.rs CHANGED
@@ -55,5 +55,37 @@ async fn main() {
55
55
  }
56
56
  }
57
57
 
58
+ /// Fetches every file in `preload.txt`, waits for the JS plugin to have
59
+ /// instantiated `plum.wasm`, then runs the frame loop.
58
60
  #[cfg(target_arch = "wasm32")]
61
+ async fn run_web() -> Result<(), String> {
62
+ use macroquad::file::{load_file, load_string};
63
+ use macroquad::window::next_frame;
64
+ let list = load_string("preload.txt").await.map_err(|e| format!("preload.txt: {e}"))?;
65
+ for path in list.lines().map(str::trim).filter(|l| !l.is_empty()) {
66
+ let bytes = load_file(&format!("assets/{path}")).await.map_err(|e| format!("{path}: {e}"))?;
67
+ plum_game::web::preload(path, bytes);
68
+ }
69
+ while !plum_game::web::bridge_ready() {
70
+ next_frame().await;
71
+ }
72
+ plum_game::web::bridge_init();
73
+ loop {
74
+ plum_game::web::bridge_update(macroquad::time::get_frame_time());
75
+ plum_game::web::bridge_draw();
76
+ next_frame().await;
77
+ }
78
+ }
79
+
80
+ #[cfg(target_arch = "wasm32")]
81
+ #[macroquad::main("Plum Game")]
59
- fn main() {}
82
+ async fn main() {
83
+ if let Err(e) = run_web().await {
84
+ use macroquad::prelude::*;
85
+ loop {
86
+ clear_background(BLACK);
87
+ draw_text(&e, 20.0, 40.0, 24.0, RED);
88
+ next_frame().await;
89
+ }
90
+ }
91
+ }
plum-game/src/web.rs ADDED
@@ -0,0 +1,403 @@
1
+ //! The browser side of the host. The page's `plum_bridge.js` plugin runs
2
+ //! `plum.wasm` and implements its `plum` imports by calling the `pg_*`
3
+ //! exports below; this module draws with macroquad and answers input
4
+ //! queries. Every `pg_*` call borrows the backend only for its own body, so a
5
+ //! frame can re-enter Rust from JS (Rust -> `plum_bridge_draw` -> Plum -> `pg_*`)
6
+ //! without a borrow held across the JS call.
7
+ //!
8
+ //! Strings cross as UTF-8 bytes in host memory: JS asks `pg_alloc` for a
9
+ //! buffer, copies the Plum `Str` into it with `strLen`/`strByte`, passes
10
+ //! `(ptr, len)` to the `pg_*` function and releases the buffer with `pg_free`.
11
+
12
+ use crate::api::{quarter_turns_radians, AssetStore, Backend, Color};
13
+ use crate::assets::{Atlas, MapData};
14
+ use crate::mq::{key_code, mouse_button};
15
+ use macroquad::prelude as mq;
16
+ use std::cell::RefCell;
17
+ use std::collections::HashMap;
18
+
19
+ extern "C" {
20
+ /// 1 once `plum.wasm` is instantiated and its exports are callable.
21
+ fn plum_bridge_ready() -> i32;
22
+ fn plum_bridge_init();
23
+ fn plum_bridge_update(dt: f32);
24
+ fn plum_bridge_draw();
25
+ }
26
+
27
+ /// Version the JS plugin checks against its own.
28
+ #[no_mangle]
29
+ pub extern "C" fn plum_bridge_crate_version() -> u32 {
30
+ 1
31
+ }
32
+
33
+ pub fn bridge_ready() -> bool {
34
+ unsafe { plum_bridge_ready() != 0 }
35
+ }
36
+
37
+ pub fn bridge_init() {
38
+ unsafe { plum_bridge_init() }
39
+ }
40
+
41
+ pub fn bridge_update(dt: f32) {
42
+ unsafe { plum_bridge_update(dt) }
43
+ }
44
+
45
+ pub fn bridge_draw() {
46
+ unsafe { plum_bridge_draw() }
47
+ }
48
+
49
+ const MAPELEM: &str = "data/defines/mapelem.dat";
50
+
51
+ /// The files fetched before the first frame, keyed by their path relative to
52
+ /// the asset root. Atlases and maps are served from here.
53
+ pub struct WebBackend {
54
+ files: HashMap<String, Vec<u8>>,
55
+ assets: AssetStore,
56
+ textures: Vec<Vec<Option<mq::Texture2D>>>,
57
+ cam: (f32, f32, f32),
58
+ }
59
+
60
+ thread_local! {
61
+ static BACKEND: RefCell<WebBackend> = RefCell::new(WebBackend::new());
62
+ }
63
+
64
+ /// Runs `f` on the backend. `f` never calls back into JS.
65
+ fn with<R>(f: impl FnOnce(&mut WebBackend) -> R) -> R {
66
+ BACKEND.with(|b| f(&mut b.borrow_mut()))
67
+ }
68
+
69
+ /// Registers a fetched file under its asset-root-relative path.
70
+ pub fn preload(path: &str, bytes: Vec<u8>) {
71
+ with(|b| {
72
+ b.files.insert(path.to_string(), bytes);
73
+ });
74
+ }
75
+
76
+ fn col(c: Color) -> mq::Color {
77
+ mq::Color::new(c[0], c[1], c[2], c[3])
78
+ }
79
+
80
+ impl WebBackend {
81
+ fn new() -> Self {
82
+ WebBackend { files: HashMap::new(), assets: AssetStore::new(None), textures: Vec::new(), cam: (0.0, 0.0, 1.0) }
83
+ }
84
+
85
+ fn to_screen(&self, x: f32, y: f32) -> (f32, f32) {
86
+ ((x - self.cam.0) * self.cam.2, (y - self.cam.1) * self.cam.2)
87
+ }
88
+
89
+ fn texture(&mut self, atlas: i32, idx: i32) -> Option<mq::Texture2D> {
90
+ let (a, i) = (usize::try_from(atlas).ok()?, usize::try_from(idx).ok()?);
91
+ while self.textures.len() <= a {
92
+ let n = self.assets.atlases.get(self.textures.len()).map_or(0, |at| at.sprites.len());
93
+ self.textures.push(vec![None; n]);
94
+ }
95
+ if i >= self.textures[a].len() {
96
+ return None;
97
+ }
98
+ if self.textures[a][i].is_none() {
99
+ let s = self.assets.atlases.get(a)?.sprites.get(i)?.as_ref()?;
100
+ let t = mq::Texture2D::from_rgba8(s.w, s.h, &s.rgba);
101
+ t.set_filter(mq::FilterMode::Nearest);
102
+ self.textures[a][i] = Some(t);
103
+ }
104
+ self.textures[a][i].clone()
105
+ }
106
+ }
107
+
108
+ impl Backend for WebBackend {
109
+ fn screen_width(&mut self) -> f32 {
110
+ mq::screen_width()
111
+ }
112
+ fn screen_height(&mut self) -> f32 {
113
+ mq::screen_height()
114
+ }
115
+ fn frame_time(&mut self) -> f32 {
116
+ mq::get_frame_time()
117
+ }
118
+ fn clear(&mut self, c: Color) {
119
+ mq::clear_background(col(c));
120
+ }
121
+ fn draw_rect(&mut self, x: f32, y: f32, w: f32, h: f32, c: Color) {
122
+ let (sx, sy) = self.to_screen(x, y);
123
+ mq::draw_rectangle(sx, sy, w * self.cam.2, h * self.cam.2, col(c));
124
+ }
125
+ fn draw_line(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, t: f32, c: Color) {
126
+ let (a, b) = (self.to_screen(x1, y1), self.to_screen(x2, y2));
127
+ mq::draw_line(a.0, a.1, b.0, b.1, t * self.cam.2, col(c));
128
+ }
129
+ fn draw_text(&mut self, s: &str, x: f32, y: f32, size: f32, c: Color) {
130
+ let (sx, sy) = self.to_screen(x, y);
131
+ let px = size * self.cam.2;
132
+ let dims = mq::measure_text(s, None, px.max(1.0) as u16, 1.0);
133
+ mq::draw_text(s, sx, sy + dims.offset_y, px, col(c));
134
+ }
135
+ fn set_camera(&mut self, x: f32, y: f32, scale: f32) {
136
+ self.cam = (x, y, scale);
137
+ }
138
+ fn reset_camera(&mut self) {
139
+ self.cam = (0.0, 0.0, 1.0);
140
+ }
141
+ fn atlas_load(&mut self, name: &str) -> i32 {
142
+ let Some(bytes) = self.files.get(&format!("sprites/{name}.rxx")) else { return -1 };
143
+ match Atlas::parse(bytes) {
144
+ Ok(a) => {
145
+ self.assets.atlases.push(a);
146
+ self.assets.atlases.len() as i32 - 1
147
+ }
148
+ Err(_) => -1,
149
+ }
150
+ }
151
+ fn sprite_count(&mut self, a: i32) -> i32 {
152
+ self.assets.sprite_count(a)
153
+ }
154
+ fn sprite_w(&mut self, a: i32, i: i32) -> i32 {
155
+ self.assets.sprite_w(a, i)
156
+ }
157
+ fn sprite_h(&mut self, a: i32, i: i32) -> i32 {
158
+ self.assets.sprite_h(a, i)
159
+ }
160
+ fn sprite_pivot_x(&mut self, a: i32, i: i32) -> i32 {
161
+ self.assets.sprite_pivot_x(a, i)
162
+ }
163
+ fn sprite_pivot_y(&mut self, a: i32, i: i32) -> i32 {
164
+ self.assets.sprite_pivot_y(a, i)
165
+ }
166
+ fn sprite_color(&mut self, a: i32, i: i32) -> Color {
167
+ self.assets.sprite_color(a, i)
168
+ }
169
+ fn draw_sprite(&mut self, a: i32, i: i32, x: f32, y: f32, flip_x: bool, tint: Color) {
170
+ let Some(tex) = self.texture(a, i) else { return };
171
+ let (px, py) = (self.assets.sprite_pivot_x(a, i) as f32, self.assets.sprite_pivot_y(a, i) as f32);
172
+ let (sx, sy) = self.to_screen(x + px, y + py);
173
+ let s = self.cam.2;
174
+ let params = mq::DrawTextureParams { dest_size: Some(mq::vec2(tex.width() * s, tex.height() * s)), flip_x, ..Default::default() };
175
+ mq::draw_texture_ex(&tex, sx, sy, col(tint), params);
176
+ }
177
+ fn draw_tile(&mut self, a: i32, i: i32, x: f32, y: f32, size: f32, quarter_turns: i32, tint: Color) {
178
+ let Some(tex) = self.texture(a, i) else { return };
179
+ let (sx, sy) = self.to_screen(x, y);
180
+ let side = size * self.cam.2;
181
+ let params = mq::DrawTextureParams {
182
+ dest_size: Some(mq::vec2(side, side)),
183
+ rotation: quarter_turns_radians(quarter_turns),
184
+ pivot: Some(mq::vec2(sx + side / 2.0, sy + side / 2.0)),
185
+ ..Default::default()
186
+ };
187
+ mq::draw_texture_ex(&tex, sx, sy, col(tint), params);
188
+ }
189
+ fn is_key_down(&mut self, k: i32) -> bool {
190
+ key_code(k).is_some_and(mq::is_key_down)
191
+ }
192
+ fn is_key_pressed(&mut self, k: i32) -> bool {
193
+ key_code(k).is_some_and(mq::is_key_pressed)
194
+ }
195
+ fn mouse_pos(&mut self) -> (f32, f32) {
196
+ mq::mouse_position()
197
+ }
198
+ fn is_mouse_down(&mut self, b: i32) -> bool {
199
+ mouse_button(b).is_some_and(mq::is_mouse_button_down)
200
+ }
201
+ fn is_mouse_pressed(&mut self, b: i32) -> bool {
202
+ mouse_button(b).is_some_and(mq::is_mouse_button_pressed)
203
+ }
204
+ fn mouse_wheel(&mut self) -> f32 {
205
+ let dy = mq::mouse_wheel().1;
206
+ if dy == 0.0 {
207
+ 0.0
208
+ } else {
209
+ dy.signum()
210
+ }
211
+ }
212
+ fn map_load(&mut self, path: &str) -> i32 {
213
+ let Some(bytes) = self.files.get(path) else { return -1 };
214
+ let mapelem = self.files.get(MAPELEM).map_or(&[][..], |v| v.as_slice());
215
+ match MapData::parse_with(bytes, mapelem) {
216
+ Ok(m) => {
217
+ self.assets.maps.push(m);
218
+ self.assets.maps.len() as i32 - 1
219
+ }
220
+ Err(_) => -1,
221
+ }
222
+ }
223
+ fn map_width(&mut self, m: i32) -> i32 {
224
+ self.assets.map_width(m)
225
+ }
226
+ fn map_height(&mut self, m: i32) -> i32 {
227
+ self.assets.map_height(m)
228
+ }
229
+ fn map_terrain(&mut self, m: i32, x: i32, y: i32) -> i32 {
230
+ self.assets.map_terrain(m, x, y)
231
+ }
232
+ fn map_rotation(&mut self, m: i32, x: i32, y: i32) -> i32 {
233
+ self.assets.map_rotation(m, x, y)
234
+ }
235
+ fn map_elevation(&mut self, m: i32, x: i32, y: i32) -> i32 {
236
+ self.assets.map_elevation(m, x, y)
237
+ }
238
+ fn map_object(&mut self, m: i32, x: i32, y: i32) -> i32 {
239
+ self.assets.map_object(m, x, y)
240
+ }
241
+ }
242
+
243
+ // --- exports called by plum_bridge.js ----------------------------------------
244
+
245
+ /// A buffer of `n` bytes in host memory, for JS to fill.
246
+ #[no_mangle]
247
+ pub extern "C" fn pg_alloc(n: usize) -> *mut u8 {
248
+ let mut v = vec![0u8; n.max(1)];
249
+ let p = v.as_mut_ptr();
250
+ std::mem::forget(v);
251
+ p
252
+ }
253
+
254
+ /// Releases a buffer from [`pg_alloc`] of the same `n`.
255
+ ///
256
+ /// # Safety
257
+ /// `p` must come from `pg_alloc(n)` and not have been freed.
258
+ #[no_mangle]
259
+ pub unsafe extern "C" fn pg_free(p: *mut u8, n: usize) {
260
+ drop(Vec::from_raw_parts(p, n.max(1), n.max(1)));
261
+ }
262
+
263
+ fn text(p: *const u8, n: usize) -> String {
264
+ // SAFETY: JS passes a live `pg_alloc` buffer of `n` bytes.
265
+ String::from_utf8_lossy(unsafe { std::slice::from_raw_parts(p, n) }).into_owned()
266
+ }
267
+
268
+ #[no_mangle]
269
+ pub extern "C" fn pg_screen_width() -> f32 {
270
+ with(|b| b.screen_width())
271
+ }
272
+ #[no_mangle]
273
+ pub extern "C" fn pg_screen_height() -> f32 {
274
+ with(|b| b.screen_height())
275
+ }
276
+ #[no_mangle]
277
+ pub extern "C" fn pg_frame_time() -> f32 {
278
+ with(|b| b.frame_time())
279
+ }
280
+ #[no_mangle]
281
+ pub extern "C" fn pg_clear(r: f32, g: f32, b: f32, a: f32) {
282
+ with(|s| s.clear([r, g, b, a]))
283
+ }
284
+ #[no_mangle]
285
+ pub extern "C" fn pg_draw_rect(x: f32, y: f32, w: f32, h: f32, r: f32, g: f32, b: f32, a: f32) {
286
+ with(|s| s.draw_rect(x, y, w, h, [r, g, b, a]))
287
+ }
288
+ #[no_mangle]
289
+ #[allow(clippy::too_many_arguments)]
290
+ pub extern "C" fn pg_draw_line(x1: f32, y1: f32, x2: f32, y2: f32, t: f32, r: f32, g: f32, b: f32, a: f32) {
291
+ with(|s| s.draw_line(x1, y1, x2, y2, t, [r, g, b, a]))
292
+ }
293
+ #[no_mangle]
294
+ #[allow(clippy::too_many_arguments)]
295
+ pub extern "C" fn pg_draw_text(p: *const u8, n: usize, x: f32, y: f32, size: f32, r: f32, g: f32, b: f32, a: f32) {
296
+ let s = text(p, n);
297
+ with(|st| st.draw_text(&s, x, y, size, [r, g, b, a]))
298
+ }
299
+ #[no_mangle]
300
+ pub extern "C" fn pg_set_camera(x: f32, y: f32, scale: f32) {
301
+ with(|b| b.set_camera(x, y, scale))
302
+ }
303
+ #[no_mangle]
304
+ pub extern "C" fn pg_reset_camera() {
305
+ with(|b| b.reset_camera())
306
+ }
307
+ #[no_mangle]
308
+ pub extern "C" fn pg_atlas_load(p: *const u8, n: usize) -> i32 {
309
+ let s = text(p, n);
310
+ with(|b| b.atlas_load(&s))
311
+ }
312
+ #[no_mangle]
313
+ pub extern "C" fn pg_sprite_count(a: i32) -> i32 {
314
+ with(|b| b.sprite_count(a))
315
+ }
316
+ #[no_mangle]
317
+ pub extern "C" fn pg_sprite_w(a: i32, i: i32) -> i32 {
318
+ with(|b| b.sprite_w(a, i))
319
+ }
320
+ #[no_mangle]
321
+ pub extern "C" fn pg_sprite_h(a: i32, i: i32) -> i32 {
322
+ with(|b| b.sprite_h(a, i))
323
+ }
324
+ #[no_mangle]
325
+ pub extern "C" fn pg_sprite_pivot_x(a: i32, i: i32) -> i32 {
326
+ with(|b| b.sprite_pivot_x(a, i))
327
+ }
328
+ #[no_mangle]
329
+ pub extern "C" fn pg_sprite_pivot_y(a: i32, i: i32) -> i32 {
330
+ with(|b| b.sprite_pivot_y(a, i))
331
+ }
332
+ /// Channel `k` (0 = r .. 3 = a) of the sprite's average colour.
333
+ #[no_mangle]
334
+ pub extern "C" fn pg_sprite_color(a: i32, i: i32, k: i32) -> f32 {
335
+ with(|b| b.sprite_color(a, i)[(k & 3) as usize])
336
+ }
337
+ #[no_mangle]
338
+ #[allow(clippy::too_many_arguments)]
339
+ pub extern "C" fn pg_draw_sprite(a: i32, i: i32, x: f32, y: f32, flip: i32, r: f32, g: f32, b: f32, al: f32) {
340
+ with(|s| s.draw_sprite(a, i, x, y, flip != 0, [r, g, b, al]))
341
+ }
342
+ #[no_mangle]
343
+ #[allow(clippy::too_many_arguments)]
344
+ pub extern "C" fn pg_draw_tile(a: i32, i: i32, x: f32, y: f32, size: f32, qt: i32, r: f32, g: f32, b: f32, al: f32) {
345
+ with(|s| s.draw_tile(a, i, x, y, size, qt, [r, g, b, al]))
346
+ }
347
+ #[no_mangle]
348
+ pub extern "C" fn pg_is_key_down(k: i32) -> i32 {
349
+ with(|b| b.is_key_down(k)) as i32
350
+ }
351
+ #[no_mangle]
352
+ pub extern "C" fn pg_is_key_pressed(k: i32) -> i32 {
353
+ with(|b| b.is_key_pressed(k)) as i32
354
+ }
355
+ #[no_mangle]
356
+ pub extern "C" fn pg_mouse_x() -> f32 {
357
+ with(|b| b.mouse_pos().0)
358
+ }
359
+ #[no_mangle]
360
+ pub extern "C" fn pg_mouse_y() -> f32 {
361
+ with(|b| b.mouse_pos().1)
362
+ }
363
+ #[no_mangle]
364
+ pub extern "C" fn pg_is_mouse_down(k: i32) -> i32 {
365
+ with(|b| b.is_mouse_down(k)) as i32
366
+ }
367
+ #[no_mangle]
368
+ pub extern "C" fn pg_is_mouse_pressed(k: i32) -> i32 {
369
+ with(|b| b.is_mouse_pressed(k)) as i32
370
+ }
371
+ #[no_mangle]
372
+ pub extern "C" fn pg_mouse_wheel() -> f32 {
373
+ with(|b| b.mouse_wheel())
374
+ }
375
+ #[no_mangle]
376
+ pub extern "C" fn pg_map_load(p: *const u8, n: usize) -> i32 {
377
+ let s = text(p, n);
378
+ with(|b| b.map_load(&s))
379
+ }
380
+ #[no_mangle]
381
+ pub extern "C" fn pg_map_width(m: i32) -> i32 {
382
+ with(|b| b.map_width(m))
383
+ }
384
+ #[no_mangle]
385
+ pub extern "C" fn pg_map_height(m: i32) -> i32 {
386
+ with(|b| b.map_height(m))
387
+ }
388
+ #[no_mangle]
389
+ pub extern "C" fn pg_map_terrain(m: i32, x: i32, y: i32) -> i32 {
390
+ with(|b| b.map_terrain(m, x, y))
391
+ }
392
+ #[no_mangle]
393
+ pub extern "C" fn pg_map_rotation(m: i32, x: i32, y: i32) -> i32 {
394
+ with(|b| b.map_rotation(m, x, y))
395
+ }
396
+ #[no_mangle]
397
+ pub extern "C" fn pg_map_elevation(m: i32, x: i32, y: i32) -> i32 {
398
+ with(|b| b.map_elevation(m, x, y))
399
+ }
400
+ #[no_mangle]
401
+ pub extern "C" fn pg_map_object(m: i32, x: i32, y: i32) -> i32 {
402
+ with(|b| b.map_object(m, x, y))
403
+ }
plum-game/web/index.html ADDED
@@ -0,0 +1,23 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Plum Game</title>
7
+ <link rel="icon" href="data:,">
8
+ <style>
9
+ html, body, canvas { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #000; }
10
+ canvas:focus { outline: none; }
11
+ </style>
12
+ </head>
13
+ <body>
14
+ <canvas id="glcanvas" tabindex="1"></canvas>
15
+ <script src="mq_js_bundle.js"></script>
16
+ <script src="plum_bridge.js"></script>
17
+ <script>
18
+ // Right-click is a game input; keep the browser's context menu out of it.
19
+ document.addEventListener("contextmenu", (e) => e.preventDefault());
20
+ load("plum-game.wasm");
21
+ </script>
22
+ </body>
23
+ </html>
plum-game/web/plum_bridge.js ADDED
@@ -0,0 +1,140 @@
1
+ // miniquad plugin that runs plum.wasm beside the macroquad host wasm.
2
+ //
3
+ // The host wasm (plum-game.wasm) calls plum_bridge_ready/init/update/draw; this
4
+ // plugin forwards them to plum.wasm's exports. plum.wasm's `plum` imports are
5
+ // implemented here by calling the host's `pg_*` exports. Plum's `Str` is a GC
6
+ // struct JS can't read, so it is copied byte by byte through the module's
7
+ // `strLen`/`strByte` exports into a host buffer from `pg_alloc`.
8
+ (function () {
9
+ "use strict";
10
+
11
+ // Each Game.plum extern -> the pg_* export that implements it. Plum's flattened
12
+ // parameters (Int/Bool as i32, Float as f64, Color as four f64) are exactly the
13
+ // host's parameters; a leading Str is passed as (ptr, len).
14
+ const DIRECT = {
15
+ screenWidth: "pg_screen_width",
16
+ screenHeight: "pg_screen_height",
17
+ frameTime: "pg_frame_time",
18
+ clear: "pg_clear",
19
+ drawRect: "pg_draw_rect",
20
+ drawLine: "pg_draw_line",
21
+ drawText: "pg_draw_text",
22
+ setCamera: "pg_set_camera",
23
+ resetCamera: "pg_reset_camera",
24
+ atlasLoad: "pg_atlas_load",
25
+ spriteCount: "pg_sprite_count",
26
+ spriteW: "pg_sprite_w",
27
+ spriteH: "pg_sprite_h",
28
+ spritePivotX: "pg_sprite_pivot_x",
29
+ spritePivotY: "pg_sprite_pivot_y",
30
+ drawSprite: "pg_draw_sprite",
31
+ drawTile: "pg_draw_tile",
32
+ isKeyDown: "pg_is_key_down",
33
+ isKeyPressed: "pg_is_key_pressed",
34
+ isMouseDown: "pg_is_mouse_down",
35
+ isMousePressed: "pg_is_mouse_pressed",
36
+ mouseWheel: "pg_mouse_wheel",
37
+ mapLoad: "pg_map_load",
38
+ mapWidth: "pg_map_width",
39
+ mapHeight: "pg_map_height",
40
+ mapTerrain: "pg_map_terrain",
41
+ mapRotation: "pg_map_rotation",
42
+ mapElevation: "pg_map_elevation",
43
+ mapObject: "pg_map_object",
44
+ };
45
+ // Externs whose first parameter is a Str.
46
+ const STR_FIRST = new Set(["drawText", "atlasLoad", "mapLoad"]);
47
+ // Externs with a multi-value result, built from several pg_* calls.
48
+ const MULTI = {
49
+ mousePos: (host) => [host.pg_mouse_x(), host.pg_mouse_y()],
50
+ spriteColor: (host, a, i) => [0, 1, 2, 3].map((k) => host.pg_sprite_color(a, i, k)),
51
+ };
52
+ const MULTI_HOST = ["pg_mouse_x", "pg_mouse_y", "pg_sprite_color"];
53
+ const MEMORY_HOST = ["pg_alloc", "pg_free"];
54
+
55
+ // Every pg_* export the plugin may call.
56
+ const hostNames = [...new Set([...Object.values(DIRECT), ...MULTI_HOST, ...MEMORY_HOST])];
57
+
58
+ // Copies a Plum Str into a host buffer, calls f(ptr, len), frees the buffer.
59
+ function withStr(host, plum, s, f) {
60
+ const n = plum.strLen(s);
61
+ const ptr = host.pg_alloc(n);
62
+ const mem = new Uint8Array(host.memory.buffer, ptr, n);
63
+ for (let i = 0; i < n; i++) mem[i] = plum.strByte(s, i);
64
+ try {
65
+ return f(ptr, n);
66
+ } finally {
67
+ host.pg_free(ptr, n);
68
+ }
69
+ }
70
+
71
+ function strToJs(plum, s) {
72
+ const n = plum.strLen(s);
73
+ const bytes = new Uint8Array(n);
74
+ for (let i = 0; i < n; i++) bytes[i] = plum.strByte(s, i);
75
+ return new TextDecoder().decode(bytes);
76
+ }
77
+
78
+ // The `plum` import object for `module`. `ctx.plum` must hold the instance's
79
+ // exports before any import runs.
80
+ function makeImports(module, host, ctx) {
81
+ const plum = {};
82
+ for (const imp of WebAssembly.Module.imports(module)) {
83
+ if (imp.module !== "plum" || imp.kind !== "function") continue;
84
+ const name = imp.name;
85
+ if (name === "printLn") {
86
+ plum[name] = (s) => console.log(strToJs(ctx.plum, s));
87
+ } else if (name === "crash") {
88
+ plum[name] = (s) => {
89
+ throw new Error("crash: " + strToJs(ctx.plum, s));
90
+ };
91
+ } else if (MULTI[name]) {
92
+ plum[name] = (...args) => MULTI[name](host, ...args);
93
+ } else if (STR_FIRST.has(name)) {
94
+ const pg = DIRECT[name];
95
+ plum[name] = (s, ...rest) => withStr(host, ctx.plum, s, (ptr, n) => host[pg](ptr, n, ...rest));
96
+ } else if (DIRECT[name]) {
97
+ const pg = DIRECT[name];
98
+ plum[name] = (...args) => host[pg](...args);
99
+ } else {
100
+ plum[name] = () => {
101
+ throw new Error("host function '" + name + "' is not available here");
102
+ };
103
+ }
104
+ }
105
+ return { plum };
106
+ }
107
+
108
+ const bridge = { DIRECT, MULTI, STR_FIRST, hostNames, makeImports, withStr };
109
+ globalThis.PlumBridge = bridge;
110
+
111
+ let ctx = { plum: null };
112
+ let state = null;
113
+ let ready = false;
114
+
115
+ function register_plugin(importObject) {
116
+ const env = importObject.env;
117
+ env.plum_bridge_ready = () => (ready ? 1 : 0);
118
+ env.plum_bridge_init = () => {
119
+ state = ctx.plum.init();
120
+ };
121
+ env.plum_bridge_update = (dt) => ctx.plum.update(state, dt);
122
+ env.plum_bridge_draw = () => ctx.plum.draw(state);
123
+ }
124
+
125
+ // Runs after plum-game.wasm is instantiated and before its main().
126
+ function on_init() {
127
+ const host = wasm_exports;
128
+ WebAssembly.compileStreaming(fetch("plum.wasm"))
129
+ .then((module) => WebAssembly.instantiate(module, makeImports(module, host, ctx)))
130
+ .then((instance) => {
131
+ ctx.plum = instance.exports;
132
+ ready = true;
133
+ })
134
+ .catch((e) => console.error("plum.wasm failed to load:", e));
135
+ }
136
+
137
+ if (typeof miniquad_add_plugin === "function") {
138
+ miniquad_add_plugin({ register_plugin, on_init, name: "plum_bridge", version: 1 });
139
+ }
140
+ })();