plum
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 +1 -0
- README.md +50 -0
- plum-cli/src/game.rs +178 -0
- plum-cli/src/main.rs +33 -3
- plum-game/src/lib.rs +3 -2
- plum-game/src/main.rs +33 -1
- plum-game/src/web.rs +403 -0
- plum-game/web/index.html +23 -0
- plum-game/web/plum_bridge.js +140 -0
.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
|
-
|
|
613
|
+
imp.ty().func().cloned().ok_or_else(|| {
|
|
584
|
-
|
|
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
|
+
})();
|