plum

#treesitter#compiler#wasm

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

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


d5a9f81 — Peter John 2026-11-02T14:42:36+05:30
Game host: generic texture/file/circle API, remove KaM decoders and viewer

Generated files not shown: plum-game/Cargo.lock

plum-cli/src/game.rs CHANGED
@@ -6,14 +6,6 @@ use std::process::Command;
6
6
 
7
7
  use anyhow::{bail, Context, Result};
8
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
9
  /// The repository root, fixed when plum-cli is compiled.
18
10
  fn repoRoot() -> PathBuf {
19
11
  Path::new(env!("CARGO_MANIFEST_DIR"))
@@ -26,22 +18,26 @@ fn gameManifest() -> PathBuf {
26
18
  repoRoot().join("plum-game/Cargo.toml")
27
19
  }
28
20
 
21
+ /// The asset root: `--assets`, else `assets` beside the source file.
29
- fn assetRoot(cli: Option<PathBuf>) -> PathBuf {
22
+ fn assetRoot(file: &Path, cli: Option<PathBuf>) -> PathBuf {
23
+ cli.unwrap_or_else(|| {
24
+ file.parent()
30
- cli.or_else(|| std::env::var_os("PLUM_GAME_ASSETS").map(PathBuf::from))
25
+ .filter(|p| !p.as_os_str().is_empty())
31
- .unwrap_or_else(|| repoRoot().join("../kam_macroquad/assets"))
26
+ .unwrap_or(Path::new("."))
27
+ .join("assets")
28
+ })
32
29
  }
33
30
 
34
31
  pub fn cmdGame(
35
32
  file: PathBuf,
36
33
  web: bool,
37
34
  output: Option<PathBuf>,
38
- preload: Vec<String>,
39
35
  assets: Option<PathBuf>,
40
36
  lib_path: Option<PathBuf>,
41
37
  ) -> Result<()> {
42
38
  let lib_path = lib_path.unwrap_or_else(repoRoot);
43
39
  let wasm = crate::compileToWasm(&file, &lib_path)?;
44
- let assets = assetRoot(assets);
40
+ let assets = assetRoot(&file, assets);
45
41
  if web {
46
42
  let dist = output.unwrap_or_else(|| {
47
43
  file.parent()
@@ -49,7 +45,7 @@ pub fn cmdGame(
49
45
  .unwrap_or(Path::new("."))
50
46
  .join("dist")
51
47
  });
52
- return writeWeb(&wasm, &dist, &preload, &assets);
48
+ return writeWeb(&wasm, &dist, &assets);
53
49
  }
54
50
  runNative(&wasm, &assets)
55
51
  }
@@ -127,35 +123,35 @@ fn buildHostWasm() -> Result<PathBuf> {
127
123
  Ok(target.join("wasm32-unknown-unknown/release/plum-game.wasm"))
128
124
  }
129
125
 
126
+ /// Every file under `dir` (dotfiles skipped), as `/`-separated paths relative to it, sorted.
127
+ fn listFiles(dir: &Path) -> Result<Vec<String>> {
128
+ fn walk(dir: &Path, prefix: &str, out: &mut Vec<String>) -> Result<()> {
129
+ for entry in fs::read_dir(dir).with_context(|| format!("failed to read {}", dir.display()))? {
130
+ let entry = entry?;
131
+ let name = format!("{prefix}{}", entry.file_name().to_string_lossy());
132
+ if entry.file_name().to_string_lossy().starts_with('.') {
133
+ continue;
134
+ }
135
+ if entry.file_type()?.is_dir() {
136
+ walk(&entry.path(), &format!("{name}/"), out)?;
137
+ } else {
138
+ out.push(name);
139
+ }
140
+ }
141
+ Ok(())
142
+ }
143
+ let mut out = Vec::new();
144
+ walk(dir, "", &mut out)?;
145
+ out.sort();
146
+ Ok(out)
147
+ }
148
+
130
- fn writeWeb(wasm: &[u8], dist: &Path, preload: &[String], assets: &Path) -> Result<()> {
149
+ fn writeWeb(wasm: &[u8], dist: &Path, assets: &Path) -> Result<()> {
131
150
  let hostWasm = buildHostWasm()?;
132
151
  let bundle = jsBundle()?;
133
152
 
134
- // Defaults that are missing from the asset root are skipped with a
153
+ // The whole asset directory (if there is one) is bundled and preloaded.
135
- // warning; files asked for with --preload must exist.
136
- let mut files: Vec<String> = Vec::new();
154
+ let files = if assets.is_dir() { listFiles(assets)? } else { 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
155
 
160
156
  fs::create_dir_all(dist)?;
161
157
  let web = repoRoot().join("plum-game/web");
@@ -167,7 +163,7 @@ fn writeWeb(wasm: &[u8], dist: &Path, preload: &[String], assets: &Path) -> Resu
167
163
  fs::copy(&hostWasm, dist.join("plum-game.wasm"))
168
164
  .with_context(|| format!("failed to copy {}", hostWasm.display()))?;
169
165
  fs::write(dist.join("plum.wasm"), wasm)?;
170
- fs::write(dist.join("preload.txt"), files.join("\n") + "\n")?;
166
+ fs::write(dist.join("preload.txt"), files.iter().map(|f| format!("{f}\n")).collect::<String>())?;
171
167
  for rel in &files {
172
168
  let to = dist.join("assets").join(rel);
173
169
  fs::create_dir_all(to.parent().expect("asset has a parent"))?;
plum-cli/src/main.rs CHANGED
@@ -86,10 +86,7 @@ enum Command {
86
86
  /// Output directory for --web (default: `dist` beside the source file)
87
87
  #[arg(short, long)]
88
88
  output: Option<std::path::PathBuf>,
89
- /// Asset file (relative to the asset root) to bundle with --web; repeatable
89
+ /// Asset root (default: `assets` beside the source file); --web bundles all of it
90
- #[arg(long)]
91
- preload: Vec<String>,
92
- /// Asset root (default: $PLUM_GAME_ASSETS, else ../kam_macroquad/assets beside the repo)
93
90
  #[arg(long)]
94
91
  assets: Option<std::path::PathBuf>,
95
92
  /// Root directory `import <path>` is resolved against (default: the repository root)
@@ -133,10 +130,9 @@ fn run() -> Result<()> {
133
130
  file,
134
131
  web,
135
132
  output,
136
- preload,
137
133
  assets,
138
134
  lib_path,
139
- } => game::cmdGame(file, web, output, preload, assets, lib_path),
135
+ } => game::cmdGame(file, web, output, assets, lib_path),
140
136
  Command::Editor { editor, insiders } => editor::install(editor, insiders),
141
137
  }
142
138
  }
plum-cli/tests/game_web_test.rs CHANGED
@@ -20,16 +20,9 @@ fn webBuildWritesTheDist() {
20
20
  let assets = scratch.join("assets");
21
21
  let dist = scratch.join("dist");
22
22
  // Stand-in asset files: the build copies them without reading them.
23
- let sprites = [
24
- "GUI_a", "GUIMain", "Houses_a", "Tileset", "Trees_a", "Units_a",
25
- ];
26
- std::fs::create_dir_all(assets.join("sprites")).unwrap();
23
+ std::fs::create_dir_all(assets.join("sub")).unwrap();
27
- std::fs::create_dir_all(assets.join("data/defines")).unwrap();
28
- for s in sprites {
29
- std::fs::write(assets.join(format!("sprites/{s}.rxx")), b"x").unwrap();
30
- }
31
- std::fs::write(assets.join("data/defines/mapelem.dat"), b"x").unwrap();
32
- std::fs::write(assets.join("extra.map"), b"x").unwrap();
24
+ std::fs::write(assets.join("a.png"), b"x").unwrap();
25
+ std::fs::write(assets.join("sub/b.txt"), b"x").unwrap();
33
26
 
34
27
  let out = Command::new(plumBin())
35
28
  .current_dir(root)
@@ -42,7 +35,6 @@ fn webBuildWritesTheDist() {
42
35
  .arg(&dist)
43
36
  .arg("--assets")
44
37
  .arg(&assets)
45
- .args(["--preload", "extra.map"])
46
38
  .output()
47
39
  .expect("failed to run plum");
48
40
  let all = format!(
@@ -64,13 +56,7 @@ fn webBuildWritesTheDist() {
64
56
  }
65
57
  let preload = std::fs::read_to_string(dist.join("preload.txt")).unwrap();
66
58
  let lines: Vec<&str> = preload.lines().collect();
67
- for s in sprites {
68
- assert!(
69
- lines.contains(&format!("sprites/{s}.rxx").as_str()),
70
- "{s} not preloaded"
71
- );
72
- }
73
- assert!(lines.contains(&"extra.map"));
59
+ assert_eq!(lines, ["a.png", "sub/b.txt"]);
74
60
  for l in &lines {
75
61
  assert!(
76
62
  dist.join("assets").join(l).is_file(),
plum-game/Cargo.toml CHANGED
@@ -6,7 +6,6 @@ description = "Native and web host for Plum game programs"
6
6
 
7
7
  [dependencies]
8
8
  macroquad = { version = "0.4", default-features = false }
9
- flate2 = "1"
10
9
 
11
10
  [target.'cfg(not(target_arch = "wasm32"))'.dependencies]
12
11
  wasmtime = "47"
plum-game/kam/Main.plum DELETED
@@ -1,269 +0,0 @@
1
- # A Knights and Merchants map viewer: terrain tiles and map objects drawn through
2
- # the game host, a camera that pans (arrows / WASD) and zooms about the mouse
3
- # (wheel), and a minimap in the bottom-right corner that centres the camera on click.
4
- module main
5
- import std/Str
6
- import std/Int
7
- import std/Float
8
- import std/Bool
9
- import std/Array
10
- import std/Grid
11
- import std/Vec2
12
- import std/Color
13
- import std/Game
14
-
15
- primitive View =
16
- TileSize = 32.0 # world units per tile
17
- MinScale = 0.25
18
- MaxScale = 4.0
19
- PanSpeed = 600.0 # screen pixels per second
20
- MiniSize = 160.0 # minimap's longest side in screen pixels
21
- MiniMargin = 8.0
22
- MapPath = "Maps/A new Beginning/A new Beginning.map"
23
-
24
- # The inclusive tile columns and rows to draw.
25
- struct TileRange(x0: Int, y0: Int, x1: Int, y1: Int)
26
-
27
- # The minimap's placement on screen: top-left corner and pixels per tile.
28
- struct MiniRect(x: Float, y: Float, cell: Float)
29
-
30
- # World point (x, y) sits at the screen's top-left; one world unit is `scale` pixels.
31
- record Camera(x: Float, y: Float, scale: Float) =
32
-
33
- fun screenToWorld(self, p: Vec2) -> Vec2 =
34
- Vec2(x: self.x + p.x / self.scale, y: self.y + p.y / self.scale)
35
-
36
- fun worldToScreen(self, p: Vec2) -> Vec2 =
37
- Vec2(x: {p.x - self.x} * self.scale, y: {p.y - self.y} * self.scale)
38
-
39
- # Keeps the view inside a `w` x `h` world on a `sw` x `sh` screen; an axis the
40
- # world doesn't fill is centred.
41
- fun clampTo(self, w: Float, h: Float, sw: Float, sh: Float) =
42
- vw := sw / self.scale
43
- vh := sh / self.scale
44
- if vw >= w
45
- self.x = {w - vw} / 2.0
46
- else
47
- self.x = self.x.max(0.0).min(w - vw)
48
- if vh >= h
49
- self.y = {h - vh} / 2.0
50
- else
51
- self.y = self.y.max(0.0).min(h - vh)
52
-
53
- # Multiplies the zoom by `factor` (scale stays within MinScale..MaxScale) keeping the
54
- # world point under `screen` fixed.
55
- fun zoomAt(self, screen: Vec2, factor: Float) =
56
- anchor := self.screenToWorld(screen)
57
- self.scale = {self.scale * factor}.max(View.MinScale).min(View.MaxScale)
58
- self.x = anchor.x - screen.x / self.scale
59
- self.y = anchor.y - screen.y / self.scale
60
-
61
- # Moves the view so world point `p` is at the screen's centre.
62
- fun centerOn(self, p: Vec2, sw: Float, sh: Float) =
63
- self.x = p.x - sw / self.scale / 2.0
64
- self.y = p.y - sh / self.scale / 2.0
65
-
66
- # Tiles touching the screen, including partial ones, clamped to a `mapW` x `mapH` map.
67
- fun visibleTiles(self, mapW: Int, mapH: Int, sw: Float, sh: Float) -> TileRange =
68
- left := self.x.max(0.0)
69
- top := self.y.max(0.0)
70
- right := {self.x + sw / self.scale}.max(0.0)
71
- bottom := {self.y + sh / self.scale}.max(0.0)
72
- TileRange(
73
- x0: Int(left / View.TileSize).min(mapW - 1),
74
- y0: Int(top / View.TileSize).min(mapH - 1),
75
- x1: Int(right / View.TileSize).min(mapW - 1),
76
- y1: Int(bottom / View.TileSize).min(mapH - 1))
77
-
78
- fun miniRect(sw: Float, sh: Float, mapW: Int, mapH: Int) -> MiniRect =
79
- longest := Float(mapW.max(mapH))
80
- cell := View.MiniSize / longest
81
- MiniRect(
82
- x: sw - View.MiniMargin - cell * Float(mapW),
83
- y: sh - View.MiniMargin - cell * Float(mapH),
84
- cell: cell)
85
-
86
- # World point under screen point `p` of the minimap, or null when `p` is outside it.
87
- fun miniToWorld(m: MiniRect, mapW: Int, mapH: Int, p: Vec2) -> Vec2? =
88
- tx := {p.x - m.x} / m.cell
89
- ty := {p.y - m.y} / m.cell
90
- if tx < 0.0 || ty < 0.0 || tx >= Float(mapW) || ty >= Float(mapH)
91
- return null
92
- Vec2(x: tx * View.TileSize, y: ty * View.TileSize)
93
-
94
- record Viewer(
95
- cam: Camera,
96
- ok: Bool,
97
- mapW: Int,
98
- mapH: Int,
99
- tileset: Int,
100
- trees: Int,
101
- terrain: Grid[Int],
102
- rotation: Grid[Int],
103
- objects: Grid[Int],
104
- mini: Grid[Color])
105
-
106
- # The minimap's colour storage: one cell per tile, sized up front (Array.push returns a new array).
107
- fun miniColors(w: Int, h: Int) -> Grid[Color] =
108
- Grid.filled(w, h, Color.Black)
109
-
110
- export fun init() -> Viewer =
111
- tileset := atlasLoad("Tileset")
112
- trees := atlasLoad("Trees_a")
113
- m := mapLoad(View.MapPath)
114
- cam := Camera(x: 0.0, y: 0.0, scale: 1.0)
115
- if tileset < 0 || trees < 0 || m < 0
116
- return Viewer(
117
- cam: cam, ok: false, mapW: 1, mapH: 1, tileset: tileset, trees: trees,
118
- terrain: Grid.filled(1, 1, 0), rotation: Grid.filled(1, 1, 0),
119
- objects: Grid.filled(1, 1, 0), mini: miniColors(1, 1))
120
- w := mapWidth(m)
121
- h := mapHeight(m)
122
- terrain := Grid.filled(w, h, 0)
123
- rotation := Grid.filled(w, h, 0)
124
- objects := Grid.filled(w, h, 0)
125
- mini := miniColors(w, h)
126
- for y := range h
127
- for x := range w
128
- t := mapTerrain(m, x, y)
129
- terrain.set(x, y, t)
130
- rotation.set(x, y, mapRotation(m, x, y))
131
- objects.set(x, y, mapObject(m, x, y))
132
- mini.set(x, y, spriteColor(tileset, t + 1))
133
- Viewer(
134
- cam: cam, ok: true, mapW: w, mapH: h, tileset: tileset, trees: trees,
135
- terrain: terrain, rotation: rotation, objects: objects, mini: mini)
136
-
137
- fun worldW(v: Viewer) -> Float =
138
- Float(v.mapW) * View.TileSize
139
-
140
- fun worldH(v: Viewer) -> Float =
141
- Float(v.mapH) * View.TileSize
142
-
143
- export fun update(v: Viewer, dt: Float) =
144
- if !v.ok
145
- return
146
- sw := screenWidth()
147
- sh := screenHeight()
148
- step := View.PanSpeed * dt / v.cam.scale
149
- if isKeyDown(Key.Left) || isKeyDown(Key.KeyA)
150
- v.cam.x = v.cam.x - step
151
- if isKeyDown(Key.Right) || isKeyDown(Key.KeyD)
152
- v.cam.x = v.cam.x + step
153
- if isKeyDown(Key.Up) || isKeyDown(Key.KeyW)
154
- v.cam.y = v.cam.y - step
155
- if isKeyDown(Key.Down) || isKeyDown(Key.KeyS)
156
- v.cam.y = v.cam.y + step
157
- wheel := mouseWheel()
158
- if wheel != 0.0
159
- v.cam.zoomAt(mousePos(), Float(1.1).pow(wheel))
160
- if isMouseDown(Mouse.Left)
161
- target := miniToWorld(miniRect(sw, sh, v.mapW, v.mapH), v.mapW, v.mapH, mousePos())
162
- if t := target
163
- v.cam.centerOn(t, sw, sh)
164
- v.cam.clampTo(worldW(v), worldH(v), sw, sh)
165
-
166
- export fun draw(v: Viewer) =
167
- clear(Color.Black)
168
- sw := screenWidth()
169
- sh := screenHeight()
170
- if !v.ok
171
- drawText("assets not found", 20.0, 40.0, 24.0, Color.White)
172
- return
173
- setCamera(v.cam.x, v.cam.y, v.cam.scale)
174
- r := v.cam.visibleTiles(v.mapW, v.mapH, sw, sh)
175
- for y := range r.y1 - r.y0 + 1
176
- for x := range r.x1 - r.x0 + 1
177
- tx := r.x0 + x
178
- ty := r.y0 + y
179
- drawTile(v.tileset, v.terrain.get(tx, ty) + 1, Float(tx) * View.TileSize,
180
- Float(ty) * View.TileSize, View.TileSize, v.rotation.get(tx, ty), Color.White)
181
- for y := range r.y1 - r.y0 + 1
182
- for x := range r.x1 - r.x0 + 1
183
- tx := r.x0 + x
184
- ty := r.y0 + y
185
- o := v.objects.get(tx, ty)
186
- if o != 0
187
- drawSprite(v.trees, o, Float(tx) * View.TileSize, Float(ty) * View.TileSize,
188
- false, Color.White)
189
- resetCamera()
190
- drawMinimap(v, sw, sh)
191
- drawText("FPS {Int(1.0 / frameTime().max(0.0001))}", 8.0, 20.0, 20.0, Color.Yellow)
192
-
193
- fun drawMinimap(v: Viewer, sw: Float, sh: Float) =
194
- m := miniRect(sw, sh, v.mapW, v.mapH)
195
- for y := range v.mapH
196
- for x := range v.mapW
197
- drawRect(m.x + Float(x) * m.cell, m.y + Float(y) * m.cell, m.cell, m.cell,
198
- v.mini.get(x, y))
199
- vx := m.x + v.cam.x * m.cell / View.TileSize
200
- vy := m.y + v.cam.y * m.cell / View.TileSize
201
- vw := sw / v.cam.scale * m.cell / View.TileSize
202
- vh := sh / v.cam.scale * m.cell / View.TileSize
203
- drawLine(vx, vy, vx + vw, vy, 1.0, Color.White)
204
- drawLine(vx + vw, vy, vx + vw, vy + vh, 1.0, Color.White)
205
- drawLine(vx + vw, vy + vh, vx, vy + vh, 1.0, Color.White)
206
- drawLine(vx, vy + vh, vx, vy, 1.0, Color.White)
207
-
208
- test "screenToWorld and worldToScreen are inverses"
209
- c := Camera(x: 100.0, y: 50.0, scale: 2.0)
210
- assert c.screenToWorld(Vec2(x: 20.0, y: 10.0)) == Vec2(x: 110.0, y: 55.0)
211
- assert c.worldToScreen(Vec2(x: 110.0, y: 55.0)) == Vec2(x: 20.0, y: 10.0)
212
-
213
- test "zoomAt keeps the world point under the mouse fixed and clamps the scale"
214
- c := Camera(x: 100.0, y: 50.0, scale: 2.0)
215
- m := Vec2(x: 300.0, y: 200.0)
216
- before := c.screenToWorld(m)
217
- c.zoomAt(m, 1.5)
218
- assert c.scale == 3.0
219
- assert c.screenToWorld(m).distance(before) < 0.000001
220
- c.zoomAt(m, 100.0)
221
- assert c.scale == 4.0
222
- c.zoomAt(m, 0.0001)
223
- assert c.scale == 0.25
224
-
225
- test "visibleTiles covers the partial tile and clamps to the map"
226
- c := Camera(x: 0.0, y: 0.0, scale: 1.0)
227
- r := c.visibleTiles(64, 64, 800.0, 600.0)
228
- assert r.x0 == 0
229
- assert r.y0 == 0
230
- assert r.x1 == 25
231
- assert r.y1 == 18
232
- small := c.visibleTiles(10, 8, 800.0, 600.0)
233
- assert small.x1 == 9
234
- assert small.y1 == 7
235
- far := Camera(x: 3000.0, y: 3000.0, scale: 1.0).visibleTiles(64, 64, 800.0, 600.0)
236
- assert far.x1 == 63
237
- assert far.y1 == 63
238
-
239
- test "clampTo keeps the view in the map and centres a small map"
240
- c := Camera(x: -50.0, y: 5000.0, scale: 1.0)
241
- c.clampTo(2048.0, 2048.0, 800.0, 600.0)
242
- assert c.x == 0.0
243
- assert c.y == 1448.0
244
- c.x = 5000.0
245
- c.clampTo(2048.0, 2048.0, 800.0, 600.0)
246
- assert c.x == 1248.0
247
- s := Camera(x: 99.0, y: 99.0, scale: 1.0)
248
- s.clampTo(320.0, 240.0, 800.0, 600.0)
249
- assert s.x == -240.0
250
- assert s.y == -180.0
251
-
252
- test "a minimap click centres the camera"
253
- m := miniRect(800.0, 600.0, 64, 64)
254
- assert m.cell == 2.5
255
- assert m.x == 632.0
256
- assert m.y == 432.0
257
- w := miniToWorld(m, 64, 64, Vec2(x: 632.0 + 25.0, y: 432.0 + 50.0)) ?: Vec2.Zero
258
- assert w == Vec2(x: 320.0, y: 640.0)
259
- assert miniToWorld(m, 64, 64, Vec2(x: 10.0, y: 10.0)) == null
260
- c := Camera(x: 0.0, y: 0.0, scale: 2.0)
261
- c.centerOn(w, 800.0, 600.0)
262
- assert c.screenToWorld(Vec2(x: 400.0, y: 300.0)) == w
263
-
264
- test "minimap colours are sized w*h up front"
265
- g := miniColors(4, 3)
266
- assert g.w * g.h == 12
267
- g.set(3, 2, Color.White)
268
- assert g.get(3, 2) == Color.White
269
- assert g.get(0, 0) == Color.Black
plum-game/src/api.rs CHANGED
@@ -3,9 +3,9 @@
3
3
  //! `i32`, `Str` is `&str`, a `Color` is `[f32; 4]`, a `Vec2` is `(f32, f32)`).
4
4
  //! `Int` handles are indexes into host vectors, `-1` on failure.
5
5
 
6
- use crate::assets::{Atlas, AssetRoot, MapData};
6
+ use macroquad::texture::Image;
7
7
  use std::collections::HashMap;
8
- use std::path::PathBuf;
8
+ use std::path::{Component, Path, PathBuf};
9
9
 
10
10
  pub type Color = [f32; 4];
11
11
 
@@ -16,23 +16,35 @@ pub trait Backend: 'static {
16
16
 
17
17
  fn clear(&mut self, c: Color);
18
18
  fn draw_rect(&mut self, x: f32, y: f32, w: f32, h: f32, c: Color);
19
+ fn draw_circle(&mut self, x: f32, y: f32, r: f32, c: Color);
19
20
  fn draw_line(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, thickness: f32, c: Color);
20
21
  fn draw_text(&mut self, s: &str, x: f32, y: f32, size: f32, c: Color);
21
22
 
22
23
  fn set_camera(&mut self, x: f32, y: f32, scale: f32);
23
24
  fn reset_camera(&mut self);
24
25
 
25
- fn atlas_load(&mut self, name: &str) -> i32;
26
+ fn texture_load(&mut self, path: &str) -> i32;
27
+ fn texture_width(&mut self, t: i32) -> i32;
26
- fn sprite_count(&mut self, atlas: i32) -> i32;
28
+ fn texture_height(&mut self, t: i32) -> i32;
27
- fn sprite_w(&mut self, atlas: i32, idx: i32) -> i32;
28
- fn sprite_h(&mut self, atlas: i32, idx: i32) -> i32;
29
- fn sprite_pivot_x(&mut self, atlas: i32, idx: i32) -> i32;
29
+ fn texture_set_smooth(&mut self, t: i32, smooth: bool);
30
- fn sprite_pivot_y(&mut self, atlas: i32, idx: i32) -> i32;
31
- fn sprite_color(&mut self, atlas: i32, idx: i32) -> Color;
32
-
33
- fn draw_sprite(&mut self, atlas: i32, idx: i32, x: f32, y: f32, flip_x: bool, tint: Color);
30
+ fn draw_texture(&mut self, t: i32, x: f32, y: f32, tint: Color);
31
+ /// Draws the source rect `(sx, sy, sw, sh)` (texture pixels) into the dest
32
+ /// rect `(x, y, w, h)`, rotated `rotation` radians clockwise about the dest
33
+ /// centre.
34
34
  #[allow(clippy::too_many_arguments)]
35
+ fn draw_texture_ex(
36
+ &mut self,
37
+ t: i32,
38
+ src: [f32; 4],
39
+ dest: [f32; 4],
40
+ rotation: f32,
41
+ flip_x: bool,
42
+ tint: Color,
43
+ );
44
+
45
+ fn file_exists(&mut self, path: &str) -> bool;
46
+ /// The whole file, empty when it is missing.
35
- fn draw_tile(&mut self, atlas: i32, idx: i32, x: f32, y: f32, size: f32, quarter_turns: i32, tint: Color);
47
+ fn file_load(&mut self, path: &str) -> Vec<u8>;
36
48
 
37
49
  fn is_key_down(&mut self, k: i32) -> bool;
38
50
  fn is_key_pressed(&mut self, k: i32) -> bool;
@@ -40,20 +52,13 @@ pub trait Backend: 'static {
40
52
  fn is_mouse_down(&mut self, b: i32) -> bool;
41
53
  fn is_mouse_pressed(&mut self, b: i32) -> bool;
42
54
  fn mouse_wheel(&mut self) -> f32;
43
-
44
- fn map_load(&mut self, path: &str) -> i32;
45
- fn map_width(&mut self, m: i32) -> i32;
46
- fn map_height(&mut self, m: i32) -> i32;
47
- fn map_terrain(&mut self, m: i32, x: i32, y: i32) -> i32;
48
- fn map_rotation(&mut self, m: i32, x: i32, y: i32) -> i32;
49
- fn map_elevation(&mut self, m: i32, x: i32, y: i32) -> i32;
50
- fn map_object(&mut self, m: i32, x: i32, y: i32) -> i32;
51
55
  }
52
56
 
53
- /// Radians clockwise for `quarter_turns` quarter turns; `drawTile` rotates
57
+ /// Whether `path` stays inside the asset root: relative, no `..`.
54
- /// about the tile's centre by this angle.
55
- pub fn quarter_turns_radians(quarter_turns: i32) -> f32 {
58
+ pub fn path_in_root(path: &str) -> bool {
59
+ !path.is_empty()
60
+ && !path.contains('\\')
56
- quarter_turns.rem_euclid(4) as f32 * std::f32::consts::FRAC_PI_2
61
+ && Path::new(path).components().all(|c| matches!(c, Component::Normal(_) | Component::CurDir))
57
62
  }
58
63
 
59
64
  /// Where an [`AssetStore`] reads its files from.
@@ -65,28 +70,26 @@ enum Source {
65
70
  Files(HashMap<String, Vec<u8>>),
66
71
  }
67
72
 
68
- const MAPELEM: &str = "data/defines/mapelem.dat";
73
+ struct Tex {
74
+ image: Image,
75
+ smooth: bool,
76
+ }
69
77
 
70
- /// Atlases and maps loaded from an asset root, addressed by handle. Shared by
78
+ /// Files and decoded textures from an asset root, addressed by handle. Shared
71
- /// every backend so handle numbering and lookups behave the same everywhere.
79
+ /// by every backend so handle numbering and lookups behave the same everywhere.
72
80
  pub struct AssetStore {
73
81
  source: Source,
74
- /// `mapelem.dat`, read on the first map load so files can be preloaded
75
- /// after construction.
76
- mapelem: Option<Vec<u8>>,
77
- pub atlases: Vec<Atlas>,
82
+ textures: Vec<Tex>,
78
- pub maps: Vec<MapData>,
79
83
  }
80
84
 
81
85
  impl AssetStore {
82
- pub fn new(root: Option<PathBuf>) -> Self {
86
+ pub fn new(root: PathBuf) -> Self {
83
- let root = AssetRoot::resolve(root.as_deref());
84
- AssetStore { source: Source::Dir(root), mapelem: None, atlases: Vec::new(), maps: Vec::new() }
87
+ AssetStore { source: Source::Dir(root), textures: Vec::new() }
85
88
  }
86
89
 
87
90
  /// A store that serves only files added with [`AssetStore::insert`].
88
91
  pub fn preloaded() -> Self {
89
- AssetStore { source: Source::Files(HashMap::new()), mapelem: None, atlases: Vec::new(), maps: Vec::new() }
92
+ AssetStore { source: Source::Files(HashMap::new()), textures: Vec::new() }
90
93
  }
91
94
 
92
95
  /// Registers a file under its asset-root-relative path. No effect on a
@@ -97,89 +100,69 @@ impl AssetStore {
97
100
  }
98
101
  }
99
102
 
100
- fn read(&self, path: &str) -> Option<Vec<u8>> {
103
+ pub fn read(&self, path: &str) -> Option<Vec<u8>> {
104
+ if !path_in_root(path) {
105
+ return None;
106
+ }
101
107
  match &self.source {
102
108
  Source::Dir(root) => std::fs::read(root.join(path)).ok(),
103
- Source::Files(files) => files.get(path).cloned(),
109
+ Source::Files(files) => files.get(path.trim_start_matches("./")).cloned(),
104
110
  }
105
111
  }
106
112
 
107
- pub fn atlas_load(&mut self, name: &str) -> i32 {
113
+ pub fn file_exists(&self, path: &str) -> bool {
108
- let Some(bytes) = self.read(&format!("sprites/{name}.rxx")) else { return -1 };
109
- match Atlas::parse(&bytes) {
110
- Ok(a) => {
111
- self.atlases.push(a);
114
+ if !path_in_root(path) {
112
- self.atlases.len() as i32 - 1
113
- }
114
- Err(_) => -1,
115
+ return false;
115
116
  }
117
+ match &self.source {
118
+ Source::Dir(root) => root.join(path).is_file(),
119
+ Source::Files(files) => files.contains_key(path.trim_start_matches("./")),
120
+ }
121
+ }
122
+
123
+ pub fn file_load(&self, path: &str) -> Vec<u8> {
124
+ self.read(path).unwrap_or_default()
116
125
  }
117
126
 
118
- pub fn map_load(&mut self, path: &str) -> i32 {
127
+ pub fn texture_load(&mut self, path: &str) -> i32 {
119
128
  let Some(bytes) = self.read(path) else { return -1 };
120
- if self.mapelem.is_none() {
121
- self.mapelem = Some(self.read(MAPELEM).unwrap_or_default());
122
- }
123
- match MapData::parse_with(&bytes, self.mapelem.as_deref().unwrap_or_default()) {
129
+ match Image::from_file_with_format(&bytes, None) {
124
- Ok(m) => {
130
+ Ok(image) => {
125
- self.maps.push(m);
131
+ self.textures.push(Tex { image, smooth: false });
126
- self.maps.len() as i32 - 1
132
+ self.textures.len() as i32 - 1
127
133
  }
128
134
  Err(_) => -1,
129
135
  }
130
136
  }
131
137
 
132
- fn sprite(&self, atlas: i32, idx: i32) -> Option<&crate::assets::Sprite> {
138
+ fn tex(&self, t: i32) -> Option<&Tex> {
133
- let a = self.atlases.get(usize::try_from(atlas).ok()?)?;
139
+ self.textures.get(usize::try_from(t).ok()?)
134
- a.sprites.get(usize::try_from(idx).ok()?)?.as_ref()
135
140
  }
136
141
 
137
- pub fn sprite_count(&self, atlas: i32) -> i32 {
142
+ pub fn image(&self, t: i32) -> Option<&Image> {
138
- usize::try_from(atlas).ok().and_then(|a| self.atlases.get(a)).map_or(0, |a| a.sprites.len() as i32 - 1)
143
+ self.tex(t).map(|t| &t.image)
139
144
  }
140
- pub fn sprite_w(&self, atlas: i32, idx: i32) -> i32 {
145
+
141
- self.sprite(atlas, idx).map_or(0, |s| s.w as i32)
142
- }
143
- pub fn sprite_h(&self, atlas: i32, idx: i32) -> i32 {
146
+ pub fn smooth(&self, t: i32) -> bool {
144
- self.sprite(atlas, idx).map_or(0, |s| s.h as i32)
147
+ self.tex(t).is_some_and(|t| t.smooth)
145
148
  }
149
+
146
- pub fn sprite_pivot_x(&self, atlas: i32, idx: i32) -> i32 {
150
+ pub fn texture_count(&self) -> usize {
147
- self.sprite(atlas, idx).map_or(0, |s| s.px)
151
+ self.textures.len()
148
152
  }
153
+
149
- pub fn sprite_pivot_y(&self, atlas: i32, idx: i32) -> i32 {
154
+ pub fn texture_width(&self, t: i32) -> i32 {
150
- self.sprite(atlas, idx).map_or(0, |s| s.py)
155
+ self.tex(t).map_or(0, |t| t.image.width as i32)
151
156
  }
157
+
152
- pub fn sprite_color(&self, atlas: i32, idx: i32) -> Color {
158
+ pub fn texture_height(&self, t: i32) -> i32 {
153
- match (usize::try_from(atlas).ok().and_then(|a| self.atlases.get(a)), usize::try_from(idx).ok()) {
154
- (Some(a), Some(i)) => a.avg_color(i),
159
+ self.tex(t).map_or(0, |t| t.image.height as i32)
155
- _ => [0.0; 4],
156
- }
157
160
  }
158
161
 
162
+ pub fn texture_set_smooth(&mut self, t: i32, smooth: bool) {
159
- fn cell(&self, m: i32, x: i32, y: i32) -> Option<(&MapData, usize)> {
163
+ if let Some(tex) = usize::try_from(t).ok().and_then(|t| self.textures.get_mut(t)) {
160
- let map = self.maps.get(usize::try_from(m).ok()?)?;
161
- if x < 0 || y < 0 || x >= map.w || y >= map.h {
162
- return None;
164
+ tex.smooth = smooth;
163
165
  }
164
- Some((map, (y * map.w + x) as usize))
165
- }
166
- pub fn map_width(&self, m: i32) -> i32 {
167
- usize::try_from(m).ok().and_then(|m| self.maps.get(m)).map_or(0, |m| m.w)
168
- }
169
- pub fn map_height(&self, m: i32) -> i32 {
170
- usize::try_from(m).ok().and_then(|m| self.maps.get(m)).map_or(0, |m| m.h)
171
- }
172
- pub fn map_terrain(&self, m: i32, x: i32, y: i32) -> i32 {
173
- self.cell(m, x, y).map_or(0, |(d, i)| d.terrain[i] as i32)
174
- }
175
- pub fn map_rotation(&self, m: i32, x: i32, y: i32) -> i32 {
176
- self.cell(m, x, y).map_or(0, |(d, i)| d.rotation[i] as i32)
177
- }
178
- pub fn map_elevation(&self, m: i32, x: i32, y: i32) -> i32 {
179
- self.cell(m, x, y).map_or(0, |(d, i)| d.elevation[i] as i32)
180
- }
181
- pub fn map_object(&self, m: i32, x: i32, y: i32) -> i32 {
182
- self.cell(m, x, y).map_or(0, |(d, i)| d.object[i] as i32)
183
166
  }
184
167
  }
185
168
 
@@ -190,11 +173,24 @@ mod tests {
190
173
  #[test]
191
174
  fn preloaded_store_serves_only_inserted_files() {
192
175
  let mut s = AssetStore::preloaded();
193
- assert_eq!(s.atlas_load("missing"), -1);
176
+ assert_eq!(s.texture_load("missing.png"), -1);
194
- assert_eq!(s.map_load("maps/missing.map"), -1);
177
+ assert!(!s.file_exists("a.txt"));
195
- s.insert("sprites/bad.rxx", vec![1, 2, 3]);
178
+ s.insert("bad.png", vec![1, 2, 3]);
179
+ s.insert("a.txt", b"hi".to_vec());
196
- // Present but malformed: still a failure, not a panic.
180
+ // Present but undecodable: a failure, not a panic.
197
- assert_eq!(s.atlas_load("bad"), -1);
181
+ assert_eq!(s.texture_load("bad.png"), -1);
198
- assert_eq!(s.sprite_count(0), 0);
182
+ assert_eq!(s.texture_width(0), 0);
183
+ assert!(s.file_exists("a.txt"));
184
+ assert_eq!(s.file_load("a.txt"), b"hi");
185
+ }
186
+
187
+ #[test]
188
+ fn paths_escaping_the_root_are_rejected() {
189
+ for bad in ["../x", "a/../../x", "/etc/passwd", "a\\b", ""] {
190
+ assert!(!path_in_root(bad), "{bad}");
191
+ }
192
+ for ok in ["a.png", "sub/a.png", "./a.png"] {
193
+ assert!(path_in_root(ok), "{ok}");
194
+ }
199
195
  }
200
196
  }
plum-game/src/assets.rs DELETED
@@ -1,253 +0,0 @@
1
- //! Decoders for KaM Remake assets: `.rxx` sprite atlases and `.map` terrain
2
- //! files. Plain Rust with no graphics dependency. Malformed or truncated
3
- //! input is an `Err`, never a panic.
4
- //!
5
- //! The formats follow kam_macroquad's `rxx.rs`, `rules/map_file.rs` and
6
- //! `kamdat.rs`.
7
-
8
- use flate2::read::ZlibDecoder;
9
- use std::io::{self, Read};
10
- use std::path::{Path, PathBuf};
11
-
12
- fn bad(what: impl Into<String>) -> io::Error {
13
- io::Error::new(io::ErrorKind::InvalidData, what.into())
14
- }
15
-
16
- /// Where the game's asset files live.
17
- pub struct AssetRoot;
18
-
19
- impl AssetRoot {
20
- /// The `--assets` argument, else `$PLUM_GAME_ASSETS`, else
21
- /// `../kam_macroquad/assets` beside the repository.
22
- pub fn resolve(cli: Option<&Path>) -> PathBuf {
23
- if let Some(p) = cli {
24
- return p.to_path_buf();
25
- }
26
- if let Some(p) = std::env::var_os("PLUM_GAME_ASSETS") {
27
- return PathBuf::from(p);
28
- }
29
- Path::new(env!("CARGO_MANIFEST_DIR")).join("../../kam_macroquad/assets")
30
- }
31
- }
32
-
33
- // --- bounds-checked reader ---------------------------------------------------
34
-
35
- struct Reader<'a> {
36
- b: &'a [u8],
37
- pos: usize,
38
- }
39
-
40
- impl<'a> Reader<'a> {
41
- fn take(&mut self, n: usize) -> Result<&'a [u8], String> {
42
- let end = self.pos.checked_add(n).ok_or("length overflow")?;
43
- let s = self.b.get(self.pos..end).ok_or_else(|| format!("data ends early at byte {}", self.pos))?;
44
- self.pos = end;
45
- Ok(s)
46
- }
47
- fn u8(&mut self) -> Result<u8, String> {
48
- Ok(self.take(1)?[0])
49
- }
50
- fn u16(&mut self) -> Result<u16, String> {
51
- let s = self.take(2)?;
52
- Ok(u16::from_le_bytes([s[0], s[1]]))
53
- }
54
- fn i32(&mut self) -> Result<i32, String> {
55
- let s = self.take(4)?;
56
- Ok(i32::from_le_bytes([s[0], s[1], s[2], s[3]]))
57
- }
58
- fn u32(&mut self) -> Result<u32, String> {
59
- Ok(self.i32()? as u32)
60
- }
61
- }
62
-
63
- // --- .rxx --------------------------------------------------------------------
64
-
65
- pub struct Sprite {
66
- pub w: u16,
67
- pub h: u16,
68
- pub px: i32,
69
- pub py: i32,
70
- pub rgba: Vec<u8>,
71
- }
72
-
73
- /// A sprite archive. `sprites` is 1-based: sprite `n` is `sprites[n]`, and
74
- /// index 0 and unused slots are `None`.
75
- pub struct Atlas {
76
- pub sprites: Vec<Option<Sprite>>,
77
- }
78
-
79
- impl Atlas {
80
- /// Layout: optional `RXX1`/`RXX2` header, then a zlib stream holding a
81
- /// u32 count, `count` flag bytes, then per flagged sprite its size
82
- /// (u16 w, u16 h), pivot (i32 x, i32 y), w*h RGBA bytes, a has-mask byte
83
- /// and an optional w*h team-colour mask (skipped).
84
- pub fn parse(bytes: &[u8]) -> io::Result<Atlas> {
85
- let off = if bytes.starts_with(b"RXX1") {
86
- 4 + 32
87
- } else if bytes.starts_with(b"RXX2") {
88
- let l = bytes.get(4..6).ok_or_else(|| bad("rxx header ends early"))?;
89
- 6 + u16::from_le_bytes([l[0], l[1]]) as usize
90
- } else {
91
- 0
92
- };
93
- let body = bytes.get(off..).ok_or_else(|| bad("rxx header ends early"))?;
94
- let mut data = Vec::new();
95
- ZlibDecoder::new(body).read_to_end(&mut data)?;
96
-
97
- let mut r = Reader { b: &data, pos: 0 };
98
- let count = r.u32().map_err(bad)? as usize;
99
- let flags = r.take(count).map_err(bad)?;
100
- let mut sprites: Vec<Option<Sprite>> = Vec::with_capacity(count + 1);
101
- sprites.push(None);
102
- for &flag in flags {
103
- if flag != 1 {
104
- sprites.push(None);
105
- continue;
106
- }
107
- let w = r.u16().map_err(bad)?;
108
- let h = r.u16().map_err(bad)?;
109
- let px = r.i32().map_err(bad)?;
110
- let py = r.i32().map_err(bad)?;
111
- let n = w as usize * h as usize;
112
- let rgba = padded(&mut r, n * 4)?;
113
- let has_mask = padded(&mut r, 1)?[0];
114
- if has_mask != 0 {
115
- padded(&mut r, n)?;
116
- }
117
- sprites.push(Some(Sprite { w, h, px, py, rgba }));
118
- }
119
- Ok(Atlas { sprites })
120
- }
121
-
122
- /// Mean colour of the non-transparent pixels, alpha 1; zeros for a
123
- /// missing sprite or one with no opaque pixel.
124
- pub fn avg_color(&self, idx: usize) -> [f32; 4] {
125
- let Some(Some(s)) = self.sprites.get(idx) else { return [0.0; 4] };
126
- let (mut sum, mut n) = ([0u64; 3], 0u64);
127
- for p in s.rgba.chunks_exact(4).filter(|p| p[3] != 0) {
128
- for c in 0..3 {
129
- sum[c] += p[c] as u64;
130
- }
131
- n += 1;
132
- }
133
- if n == 0 {
134
- return [0.0; 4];
135
- }
136
- [0, 1, 2].map(|c| sum[c] as f32 / n as f32 / 255.0).into_iter().chain([1.0]).collect::<Vec<_>>().try_into().unwrap()
137
- }
138
- }
139
-
140
- /// Read `len` bytes. Some archives end a few bytes early, and what is
141
- /// missing stays zero; a larger shortfall is an error.
142
- fn padded(r: &mut Reader, len: usize) -> io::Result<Vec<u8>> {
143
- let avail = len.min(r.b.len() - r.pos);
144
- if len - avail > 1024 {
145
- return Err(bad("rxx sprite data ends early"));
146
- }
147
- let mut v = r.take(avail).map_err(bad)?.to_vec();
148
- v.resize(len, 0);
149
- Ok(v)
150
- }
151
-
152
- // --- .map --------------------------------------------------------------------
153
-
154
- /// Terrain grids indexed `y * w + x`, row 0 being the first row in the file.
155
- pub struct MapData {
156
- pub w: i32,
157
- pub h: i32,
158
- /// Tileset id (the Tileset sprite is id + 1).
159
- pub terrain: Vec<u16>,
160
- pub rotation: Vec<u8>,
161
- pub elevation: Vec<u8>,
162
- /// Trees_a sprite index, 0 = none.
163
- pub object: Vec<u16>,
164
- }
165
-
166
- const MAX_MAP_SIZE: i32 = 256;
167
- const MAPELEM_RECORD: usize = 99;
168
-
169
- /// First Trees sprite of a map element in `mapelem.dat`, 0 when the element
170
- /// is none/invisible or has no frames.
171
- fn object_sprite(mapelem: &[u8], obj: u16) -> u16 {
172
- if obj >= 254 {
173
- return 0;
174
- }
175
- let o = obj as usize * MAPELEM_RECORD;
176
- let Some(rec) = mapelem.get(o..o + MAPELEM_RECORD) else { return 0 };
177
- let i16_at = |i: usize| i16::from_le_bytes([rec[i], rec[i + 1]]);
178
- let count = i16_at(60).clamp(0, 30) as usize;
179
- (0..count).map(|s| i16_at(s * 2)).find(|&v| v > 0).map_or(0, |v| v as u16 + 1)
180
- }
181
-
182
- impl MapData {
183
- /// Parse a map, converting object ids through `mapelem.dat` read from the
184
- /// default asset root (see [`AssetRoot::resolve`]).
185
- pub fn parse(bytes: &[u8]) -> Result<MapData, String> {
186
- let path = AssetRoot::resolve(None).join("data/defines/mapelem.dat");
187
- let mapelem = std::fs::read(&path).map_err(|e| format!("{}: {e}", path.display()))?;
188
- MapData::parse_with(bytes, &mapelem)
189
- }
190
-
191
- /// Parse a map with the contents of `mapelem.dat` given.
192
- pub fn parse_with(bytes: &[u8], mapelem: &[u8]) -> Result<MapData, String> {
193
- let mut r = Reader { b: bytes, pos: 0 };
194
- let mut w = r.i32()?;
195
- let mut game_rev = 0;
196
- if w == 0 {
197
- // Remake format: UTF-16 revision string like "r12345", data size.
198
- let len = r.u16()? as usize;
199
- let units: Vec<u16> = (0..len).map(|_| r.u16()).collect::<Result<_, _>>()?;
200
- let rev = String::from_utf16_lossy(&units);
201
- game_rev = rev.chars().skip(1).collect::<String>().parse::<i32>().map_err(|_| "unexpected game revision in map header")?;
202
- r.u32()?;
203
- w = r.i32()?;
204
- }
205
- let h = r.i32()?;
206
- if !(1..=MAX_MAP_SIZE).contains(&w) || !(1..=MAX_MAP_SIZE).contains(&h) {
207
- return Err(format!("wrong map dimensions [{w}:{h}]"));
208
- }
209
- let n = (w * h) as usize;
210
- let mut m = MapData {
211
- w,
212
- h,
213
- terrain: Vec::with_capacity(n),
214
- rotation: Vec::with_capacity(n),
215
- elevation: Vec::with_capacity(n),
216
- object: Vec::with_capacity(n),
217
- };
218
- for _ in 0..n {
219
- let (terrain, rotation, height, obj);
220
- if game_rev == 0 {
221
- terrain = r.u8()? as u16;
222
- r.take(1)?;
223
- height = r.u8()?;
224
- rotation = r.u8()?;
225
- r.take(1)?;
226
- obj = r.u8()? as u16;
227
- r.take(17)?;
228
- } else {
229
- terrain = r.u16()?;
230
- rotation = r.u8()?;
231
- height = r.u8()?;
232
- obj = r.u16()?;
233
- r.u8()?; // IsCustom
234
- if game_rev > 10968 {
235
- r.u8()?; // overlay
236
- }
237
- let layers = r.u8()?;
238
- if layers > 0 {
239
- r.u8()?; // corner owners
240
- if game_rev > 10745 {
241
- r.u8()?; // blending level
242
- }
243
- r.take(layers as usize * 3)?;
244
- }
245
- }
246
- m.terrain.push(terrain);
247
- m.rotation.push(rotation % 4);
248
- m.elevation.push(height);
249
- m.object.push(object_sprite(mapelem, obj));
250
- }
251
- Ok(m)
252
- }
253
- }
plum-game/src/lib.rs CHANGED
@@ -1,7 +1,6 @@
1
1
  //! Native and web host for Plum game programs.
2
2
 
3
3
  pub mod api;
4
- pub mod assets;
5
4
  pub mod mq;
6
5
  pub mod recorder;
7
6
 
plum-game/src/main.rs CHANGED
@@ -1,5 +1,6 @@
1
1
  //! `plum-game <module.wasm> [--assets DIR]`: runs a compiled Plum game in a
2
+ //! macroquad window. The asset root defaults to `assets` beside the wasm, or
2
- //! macroquad window. With `PLUM_GAME_CAPTURE=<path.png>` (and optionally
3
+ //! `$PLUM_GAME_ASSETS`. With `PLUM_GAME_CAPTURE=<path.png>` (and optionally
3
4
  //! `PLUM_GAME_FRAMES=<n>`, default 30) it saves the screen after n frames and
4
5
  //! exits 0.
5
6
 
@@ -12,7 +13,7 @@ fn window_conf() -> macroquad::window::Conf {
12
13
  }
13
14
 
14
15
  #[cfg(not(target_arch = "wasm32"))]
15
- fn parse_args() -> Result<(std::path::PathBuf, Option<std::path::PathBuf>), String> {
16
+ fn parse_args() -> Result<(std::path::PathBuf, std::path::PathBuf), String> {
16
17
  let mut wasm = None;
17
18
  let mut assets = None;
18
19
  let mut args = std::env::args().skip(1);
@@ -25,11 +26,15 @@ fn parse_args() -> Result<(std::path::PathBuf, Option<std::path::PathBuf>), Stri
25
26
  return Err(format!("unexpected argument `{a}`"));
26
27
  }
27
28
  }
28
- Ok((wasm.ok_or("usage: plum-game <module.wasm> [--assets DIR]")?, assets))
29
+ let wasm: std::path::PathBuf = wasm.ok_or("usage: plum-game <module.wasm> [--assets DIR]")?;
30
+ let assets = assets
31
+ .or_else(|| std::env::var_os("PLUM_GAME_ASSETS").map(Into::into))
32
+ .unwrap_or_else(|| wasm.parent().unwrap_or(std::path::Path::new(".")).join("assets"));
33
+ Ok((wasm, assets))
29
34
  }
30
35
 
31
36
  #[cfg(not(target_arch = "wasm32"))]
32
- async fn run(wasm: std::path::PathBuf, assets: Option<std::path::PathBuf>) -> Result<(), String> {
37
+ async fn run(wasm: std::path::PathBuf, assets: std::path::PathBuf) -> Result<(), String> {
33
38
  let bytes = std::fs::read(&wasm).map_err(|e| format!("{}: {e}", wasm.display()))?;
34
39
  let mut game = PlumGame::load(&bytes, MqBackend::new(assets)).map_err(|e| format!("{e:#}"))?;
35
40
  game.init().map_err(|e| format!("init: {e:#}"))?;
plum-game/src/mq.rs CHANGED
@@ -2,7 +2,7 @@
2
2
  //! (`screen = (world - camera) * scale`), so every draw works in world units
3
3
  //! and `resetCamera` returns to screen pixels.
4
4
 
5
- use crate::api::{quarter_turns_radians, AssetStore, Backend, Color};
5
+ use crate::api::{AssetStore, Backend, Color};
6
6
  use macroquad::prelude as mq;
7
7
  use std::path::PathBuf;
8
8
 
@@ -47,18 +47,18 @@ fn col(c: Color) -> mq::Color {
47
47
 
48
48
  pub struct MqBackend {
49
49
  assets: AssetStore,
50
- /// Lazily uploaded textures, `[atlas][sprite]`.
50
+ /// Textures uploaded on first use, with the filter currently applied.
51
- textures: Vec<Vec<Option<mq::Texture2D>>>,
51
+ uploaded: Vec<Option<(mq::Texture2D, bool)>>,
52
52
  cam: (f32, f32, f32),
53
53
  }
54
54
 
55
55
  impl MqBackend {
56
- pub fn new(assets: Option<PathBuf>) -> Self {
56
+ pub fn new(root: PathBuf) -> Self {
57
- Self::with_assets(AssetStore::new(assets))
57
+ Self::with_assets(AssetStore::new(root))
58
58
  }
59
59
 
60
60
  pub fn with_assets(assets: AssetStore) -> Self {
61
- MqBackend { assets, textures: Vec::new(), cam: (0.0, 0.0, 1.0) }
61
+ MqBackend { assets, uploaded: Vec::new(), cam: (0.0, 0.0, 1.0) }
62
62
  }
63
63
 
64
64
  pub fn assets_mut(&mut self) -> &mut AssetStore {
@@ -69,22 +69,32 @@ impl MqBackend {
69
69
  ((x - self.cam.0) * self.cam.2, (y - self.cam.1) * self.cam.2)
70
70
  }
71
71
 
72
+ /// The GPU texture for handle `t`, uploaded on first use and refiltered
73
+ /// when `textureSetSmooth` changed.
72
- fn texture(&mut self, atlas: i32, idx: i32) -> Option<mq::Texture2D> {
74
+ fn texture(&mut self, t: i32) -> Option<mq::Texture2D> {
73
- let (a, i) = (usize::try_from(atlas).ok()?, usize::try_from(idx).ok()?);
75
+ let i = usize::try_from(t).ok()?;
76
+ let smooth = self.assets.smooth(t);
77
+ self.uploaded.resize(self.assets.texture_count(), None);
74
- while self.textures.len() <= a {
78
+ if self.uploaded.get(i)?.is_none() {
79
+ let img = self.assets.image(t)?;
75
- let n = self.assets.atlases.get(self.textures.len()).map_or(0, |at| at.sprites.len());
80
+ let tex = mq::Texture2D::from_rgba8(img.width, img.height, &img.bytes);
81
+ tex.set_filter(filter(smooth));
76
- self.textures.push(vec![None; n]);
82
+ self.uploaded[i] = Some((tex, smooth));
77
83
  }
78
- if i >= self.textures[a].len() {
84
+ let (tex, applied) = self.uploaded[i].as_mut()?;
85
+ if *applied != smooth {
86
+ tex.set_filter(filter(smooth));
79
- return None;
87
+ *applied = smooth;
80
88
  }
81
- if self.textures[a][i].is_none() {
89
+ Some(tex.clone())
82
- let s = self.assets.atlases.get(a)?.sprites.get(i)?.as_ref()?;
83
- let t = mq::Texture2D::from_rgba8(s.w, s.h, &s.rgba);
84
- t.set_filter(mq::FilterMode::Nearest);
85
- self.textures[a][i] = Some(t);
86
- }
90
+ }
91
+ }
92
+
87
- self.textures[a].get(i)?.clone()
93
+ fn filter(smooth: bool) -> mq::FilterMode {
94
+ if smooth {
95
+ mq::FilterMode::Linear
96
+ } else {
97
+ mq::FilterMode::Nearest
88
98
  }
89
99
  }
90
100
 
@@ -105,6 +115,10 @@ impl Backend for MqBackend {
105
115
  let (sx, sy) = self.to_screen(x, y);
106
116
  mq::draw_rectangle(sx, sy, w * self.cam.2, h * self.cam.2, col(c));
107
117
  }
118
+ fn draw_circle(&mut self, x: f32, y: f32, r: f32, c: Color) {
119
+ let (sx, sy) = self.to_screen(x, y);
120
+ mq::draw_circle(sx, sy, r * self.cam.2, col(c));
121
+ }
108
122
  fn draw_line(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, t: f32, c: Color) {
109
123
  let (a, b) = (self.to_screen(x1, y1), self.to_screen(x2, y2));
110
124
  mq::draw_line(a.0, a.1, b.0, b.1, t * self.cam.2, col(c));
@@ -122,51 +136,45 @@ impl Backend for MqBackend {
122
136
  fn reset_camera(&mut self) {
123
137
  self.cam = (0.0, 0.0, 1.0);
124
138
  }
125
- fn atlas_load(&mut self, name: &str) -> i32 {
139
+ fn texture_load(&mut self, path: &str) -> i32 {
126
- self.assets.atlas_load(name)
140
+ self.assets.texture_load(path)
127
- }
128
- fn sprite_count(&mut self, a: i32) -> i32 {
129
- self.assets.sprite_count(a)
130
141
  }
131
- fn sprite_w(&mut self, a: i32, i: i32) -> i32 {
142
+ fn texture_width(&mut self, t: i32) -> i32 {
132
- self.assets.sprite_w(a, i)
143
+ self.assets.texture_width(t)
133
144
  }
134
- fn sprite_h(&mut self, a: i32, i: i32) -> i32 {
145
+ fn texture_height(&mut self, t: i32) -> i32 {
135
- self.assets.sprite_h(a, i)
146
+ self.assets.texture_height(t)
136
147
  }
137
- fn sprite_pivot_x(&mut self, a: i32, i: i32) -> i32 {
148
+ fn texture_set_smooth(&mut self, t: i32, smooth: bool) {
138
- self.assets.sprite_pivot_x(a, i)
149
+ self.assets.texture_set_smooth(t, smooth);
139
150
  }
140
- fn sprite_pivot_y(&mut self, a: i32, i: i32) -> i32 {
141
- self.assets.sprite_pivot_y(a, i)
142
- }
143
- fn sprite_color(&mut self, a: i32, i: i32) -> Color {
144
- self.assets.sprite_color(a, i)
145
- }
146
- fn draw_sprite(&mut self, a: i32, i: i32, x: f32, y: f32, flip_x: bool, tint: Color) {
151
+ fn draw_texture(&mut self, t: i32, x: f32, y: f32, tint: Color) {
147
- let Some(tex) = self.texture(a, i) else { return };
152
+ let Some(tex) = self.texture(t) else { return };
148
- let (px, py) = (self.assets.sprite_pivot_x(a, i) as f32, self.assets.sprite_pivot_y(a, i) as f32);
149
- let (sx, sy) = self.to_screen(x + px, y + py);
153
+ let (sx, sy) = self.to_screen(x, y);
150
154
  let s = self.cam.2;
151
- let params = mq::DrawTextureParams {
152
- dest_size: Some(mq::vec2(tex.width() * s, tex.height() * s)),
155
+ let params = mq::DrawTextureParams { dest_size: Some(mq::vec2(tex.width() * s, tex.height() * s)), ..Default::default() };
153
- flip_x,
154
- ..Default::default()
155
- };
156
156
  mq::draw_texture_ex(&tex, sx, sy, col(tint), params);
157
157
  }
158
- fn draw_tile(&mut self, a: i32, i: i32, x: f32, y: f32, size: f32, quarter_turns: i32, tint: Color) {
158
+ fn draw_texture_ex(&mut self, t: i32, src: [f32; 4], dest: [f32; 4], rotation: f32, flip_x: bool, tint: Color) {
159
- let Some(tex) = self.texture(a, i) else { return };
159
+ let Some(tex) = self.texture(t) else { return };
160
- let (sx, sy) = self.to_screen(x, y);
160
+ let (sx, sy) = self.to_screen(dest[0], dest[1]);
161
- let side = size * self.cam.2;
161
+ let (w, h) = (dest[2] * self.cam.2, dest[3] * self.cam.2);
162
162
  let params = mq::DrawTextureParams {
163
- dest_size: Some(mq::vec2(side, side)),
163
+ dest_size: Some(mq::vec2(w, h)),
164
- rotation: quarter_turns_radians(quarter_turns),
164
+ source: Some(mq::Rect::new(src[0], src[1], src[2], src[3])),
165
+ rotation,
166
+ flip_x,
165
- pivot: Some(mq::vec2(sx + side / 2.0, sy + side / 2.0)),
167
+ pivot: Some(mq::vec2(sx + w / 2.0, sy + h / 2.0)),
166
168
  ..Default::default()
167
169
  };
168
170
  mq::draw_texture_ex(&tex, sx, sy, col(tint), params);
169
171
  }
172
+ fn file_exists(&mut self, path: &str) -> bool {
173
+ self.assets.file_exists(path)
174
+ }
175
+ fn file_load(&mut self, path: &str) -> Vec<u8> {
176
+ self.assets.file_load(path)
177
+ }
170
178
  fn is_key_down(&mut self, k: i32) -> bool {
171
179
  key_code(k).is_some_and(mq::is_key_down)
172
180
  }
@@ -191,27 +199,6 @@ impl Backend for MqBackend {
191
199
  dy.signum()
192
200
  }
193
201
  }
194
- fn map_load(&mut self, path: &str) -> i32 {
195
- self.assets.map_load(path)
196
- }
197
- fn map_width(&mut self, m: i32) -> i32 {
198
- self.assets.map_width(m)
199
- }
200
- fn map_height(&mut self, m: i32) -> i32 {
201
- self.assets.map_height(m)
202
- }
203
- fn map_terrain(&mut self, m: i32, x: i32, y: i32) -> i32 {
204
- self.assets.map_terrain(m, x, y)
205
- }
206
- fn map_rotation(&mut self, m: i32, x: i32, y: i32) -> i32 {
207
- self.assets.map_rotation(m, x, y)
208
- }
209
- fn map_elevation(&mut self, m: i32, x: i32, y: i32) -> i32 {
210
- self.assets.map_elevation(m, x, y)
211
- }
212
- fn map_object(&mut self, m: i32, x: i32, y: i32) -> i32 {
213
- self.assets.map_object(m, x, y)
214
- }
215
202
  }
216
203
 
217
204
  #[cfg(test)]
plum-game/src/native.rs CHANGED
@@ -63,6 +63,25 @@ fn str_arg<B: Backend>(caller: &mut Caller<'_, HostState<B>>, p: &[Val], i: usiz
63
63
  Ok(String::from_utf8_lossy(&str_bytes(caller, s)?).into_owned())
64
64
  }
65
65
 
66
+ /// Builds a Plum `Str` holding `bytes` with the module's `strNew` and
67
+ /// `strSetByte` exports.
68
+ fn new_str<B: Backend>(caller: &mut Caller<'_, HostState<B>>, bytes: &[u8]) -> Result<Val> {
69
+ let export = |caller: &mut Caller<'_, HostState<B>>, name: &str| {
70
+ caller
71
+ .get_export(name)
72
+ .and_then(|e| e.into_func())
73
+ .ok_or_else(|| wasmtime::Error::msg(format!("module has no `{name}` export")))
74
+ };
75
+ let (new, set) = (export(caller, "strNew")?, export(caller, "strSetByte")?);
76
+ let mut out = [Val::AnyRef(None)];
77
+ new.call(&mut *caller, &[Val::I32(bytes.len() as i32)], &mut out)?;
78
+ for (i, b) in bytes.iter().enumerate() {
79
+ set.call(&mut *caller, &[out[0].clone(), Val::I32(i as i32), Val::I32(*b as i32)], &mut [])?;
80
+ }
81
+ let [s] = out;
82
+ Ok(s)
83
+ }
84
+
66
85
  /// Calls the backend method behind the `plum` import `name`.
67
86
  fn dispatch<B: Backend>(name: &str, caller: &mut Caller<'_, HostState<B>>, p: &[Val], r: &mut [Val]) -> Result<()> {
68
87
  macro_rules! b {
@@ -78,6 +97,7 @@ fn dispatch<B: Backend>(name: &str, caller: &mut Caller<'_, HostState<B>>, p: &[
78
97
  "frameTime" => r[0] = fv(b!().frame_time()),
79
98
  "clear" => b!().clear(color(p, 0)),
80
99
  "drawRect" => b!().draw_rect(f(p, 0), f(p, 1), f(p, 2), f(p, 3), color(p, 4)),
100
+ "drawCircle" => b!().draw_circle(f(p, 0), f(p, 1), f(p, 2), color(p, 3)),
81
101
  "drawLine" => b!().draw_line(f(p, 0), f(p, 1), f(p, 2), f(p, 3), f(p, 4), color(p, 5)),
82
102
  "drawText" => {
83
103
  let s = str_arg(caller, p, 0)?;
@@ -85,23 +105,31 @@ fn dispatch<B: Backend>(name: &str, caller: &mut Caller<'_, HostState<B>>, p: &[
85
105
  }
86
106
  "setCamera" => b!().set_camera(f(p, 0), f(p, 1), f(p, 2)),
87
107
  "resetCamera" => b!().reset_camera(),
88
- "atlasLoad" => {
108
+ "textureLoad" => {
89
109
  let s = str_arg(caller, p, 0)?;
90
- r[0] = Val::I32(b!().atlas_load(&s));
110
+ r[0] = Val::I32(b!().texture_load(&s));
91
111
  }
112
+ "textureWidth" => r[0] = Val::I32(b!().texture_width(int(p, 0))),
92
- "spriteCount" => r[0] = Val::I32(b!().sprite_count(int(p, 0))),
113
+ "textureHeight" => r[0] = Val::I32(b!().texture_height(int(p, 0))),
93
- "spriteW" => r[0] = Val::I32(b!().sprite_w(int(p, 0), int(p, 1))),
94
- "spriteH" => r[0] = Val::I32(b!().sprite_h(int(p, 0), int(p, 1))),
95
- "spritePivotX" => r[0] = Val::I32(b!().sprite_pivot_x(int(p, 0), int(p, 1))),
114
+ "textureSetSmooth" => b!().texture_set_smooth(int(p, 0), int(p, 1) != 0),
96
- "spritePivotY" => r[0] = Val::I32(b!().sprite_pivot_y(int(p, 0), int(p, 1))),
115
+ "drawTexture" => b!().draw_texture(int(p, 0), f(p, 1), f(p, 2), color(p, 3)),
116
+ "drawTextureEx" => b!().draw_texture_ex(
117
+ int(p, 0),
118
+ [f(p, 1), f(p, 2), f(p, 3), f(p, 4)],
119
+ [f(p, 5), f(p, 6), f(p, 7), f(p, 8)],
120
+ f(p, 9),
121
+ int(p, 10) != 0,
122
+ color(p, 11),
123
+ ),
97
- "spriteColor" => {
124
+ "fileExists" => {
98
- let c = b!().sprite_color(int(p, 0), int(p, 1));
125
+ let s = str_arg(caller, p, 0)?;
99
- for (i, v) in c.into_iter().enumerate() {
126
+ r[0] = Val::I32(b!().file_exists(&s) as i32);
100
- r[i] = fv(v);
101
- }
127
+ }
128
+ "fileLoad" => {
129
+ let s = str_arg(caller, p, 0)?;
130
+ let bytes = b!().file_load(&s);
131
+ r[0] = new_str(caller, &bytes)?;
102
132
  }
103
- "drawSprite" => b!().draw_sprite(int(p, 0), int(p, 1), f(p, 2), f(p, 3), int(p, 4) != 0, color(p, 5)),
104
- "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)),
105
133
  "isKeyDown" => r[0] = Val::I32(b!().is_key_down(int(p, 0)) as i32),
106
134
  "isKeyPressed" => r[0] = Val::I32(b!().is_key_pressed(int(p, 0)) as i32),
107
135
  "mousePos" => {
@@ -112,16 +140,6 @@ fn dispatch<B: Backend>(name: &str, caller: &mut Caller<'_, HostState<B>>, p: &[
112
140
  "isMouseDown" => r[0] = Val::I32(b!().is_mouse_down(int(p, 0)) as i32),
113
141
  "isMousePressed" => r[0] = Val::I32(b!().is_mouse_pressed(int(p, 0)) as i32),
114
142
  "mouseWheel" => r[0] = fv(b!().mouse_wheel()),
115
- "mapLoad" => {
116
- let s = str_arg(caller, p, 0)?;
117
- r[0] = Val::I32(b!().map_load(&s));
118
- }
119
- "mapWidth" => r[0] = Val::I32(b!().map_width(int(p, 0))),
120
- "mapHeight" => r[0] = Val::I32(b!().map_height(int(p, 0))),
121
- "mapTerrain" => r[0] = Val::I32(b!().map_terrain(int(p, 0), int(p, 1), int(p, 2))),
122
- "mapRotation" => r[0] = Val::I32(b!().map_rotation(int(p, 0), int(p, 1), int(p, 2))),
123
- "mapElevation" => r[0] = Val::I32(b!().map_elevation(int(p, 0), int(p, 1), int(p, 2))),
124
- "mapObject" => r[0] = Val::I32(b!().map_object(int(p, 0), int(p, 1), int(p, 2))),
125
143
  other => return Err(wasmtime::Error::msg(format!("host function '{other}' is not available here"))),
126
144
  }
127
145
  Ok(())
plum-game/src/recorder.rs CHANGED
@@ -1,5 +1,5 @@
1
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`.
2
+ //! from scripted values. Texture and file calls go through a real `AssetStore`.
3
3
 
4
4
  use crate::api::{AssetStore, Backend, Color};
5
5
  use std::collections::HashSet;
@@ -23,11 +23,12 @@ fn c4(c: Color) -> String {
23
23
  }
24
24
 
25
25
  impl Recorder {
26
+ /// A recorder whose asset root does not exist.
26
27
  pub fn new() -> Self {
27
- Self::with_assets(None)
28
+ Self::with_assets(PathBuf::from("/nonexistent-plum-assets"))
28
29
  }
29
30
 
30
- pub fn with_assets(root: Option<PathBuf>) -> Self {
31
+ pub fn with_assets(root: PathBuf) -> Self {
31
32
  Recorder {
32
33
  calls: Vec::new(),
33
34
  screen: (800.0, 600.0),
@@ -72,6 +73,9 @@ impl Backend for Recorder {
72
73
  fn draw_rect(&mut self, x: f32, y: f32, w: f32, h: f32, c: Color) {
73
74
  self.rec(format!("drawRect({x},{y},{w},{h},{})", c4(c)));
74
75
  }
76
+ fn draw_circle(&mut self, x: f32, y: f32, r: f32, c: Color) {
77
+ self.rec(format!("drawCircle({x},{y},{r},{})", c4(c)));
78
+ }
75
79
  fn draw_line(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, t: f32, c: Color) {
76
80
  self.rec(format!("drawLine({x1},{y1},{x2},{y2},{t},{})", c4(c)));
77
81
  }
@@ -84,39 +88,40 @@ impl Backend for Recorder {
84
88
  fn reset_camera(&mut self) {
85
89
  self.rec("resetCamera()".into());
86
90
  }
87
- fn atlas_load(&mut self, name: &str) -> i32 {
91
+ fn texture_load(&mut self, path: &str) -> i32 {
92
+ let t = self.assets.texture_load(path);
88
- self.rec(format!("atlasLoad({name:?})"));
93
+ self.rec(format!("textureLoad({path:?}) -> {t}"));
89
- self.assets.atlas_load(name)
90
- }
94
+ t
91
- fn sprite_count(&mut self, a: i32) -> i32 {
92
- self.rec(format!("spriteCount({a})"));
93
- self.assets.sprite_count(a)
94
95
  }
95
- fn sprite_w(&mut self, a: i32, i: i32) -> i32 {
96
+ fn texture_width(&mut self, t: i32) -> i32 {
96
- self.rec(format!("spriteW({a},{i})"));
97
+ self.rec(format!("textureWidth({t})"));
97
- self.assets.sprite_w(a, i)
98
+ self.assets.texture_width(t)
98
99
  }
99
- fn sprite_h(&mut self, a: i32, i: i32) -> i32 {
100
+ fn texture_height(&mut self, t: i32) -> i32 {
100
- self.rec(format!("spriteH({a},{i})"));
101
+ self.rec(format!("textureHeight({t})"));
101
- self.assets.sprite_h(a, i)
102
+ self.assets.texture_height(t)
102
103
  }
103
- fn sprite_pivot_x(&mut self, a: i32, i: i32) -> i32 {
104
+ fn texture_set_smooth(&mut self, t: i32, smooth: bool) {
104
- self.rec(format!("spritePivotX({a},{i})"));
105
+ self.rec(format!("textureSetSmooth({t},{smooth})"));
105
- self.assets.sprite_pivot_x(a, i)
106
+ self.assets.texture_set_smooth(t, smooth);
106
107
  }
107
- fn sprite_pivot_y(&mut self, a: i32, i: i32) -> i32 {
108
+ fn draw_texture(&mut self, t: i32, x: f32, y: f32, tint: Color) {
108
- self.rec(format!("spritePivotY({a},{i})"));
109
+ self.rec(format!("drawTexture({t},{x},{y},{})", c4(tint)));
109
- self.assets.sprite_pivot_y(a, i)
110
110
  }
111
- fn sprite_color(&mut self, a: i32, i: i32) -> Color {
111
+ fn draw_texture_ex(&mut self, t: i32, s: [f32; 4], d: [f32; 4], rotation: f32, flip_x: bool, tint: Color) {
112
- self.rec(format!("spriteColor({a},{i})"));
113
- self.assets.sprite_color(a, i)
112
+ self.rec(format!(
113
+ "drawTextureEx({t},{},{},{},{},{},{},{},{},{rotation},{flip_x},{})",
114
+ s[0], s[1], s[2], s[3], d[0], d[1], d[2], d[3], c4(tint)
115
+ ));
114
116
  }
115
- fn draw_sprite(&mut self, a: i32, i: i32, x: f32, y: f32, flip_x: bool, tint: Color) {
117
+ fn file_exists(&mut self, path: &str) -> bool {
116
- self.rec(format!("drawSprite({a},{i},{x},{y},{flip_x},{})", c4(tint)));
118
+ self.rec(format!("fileExists({path:?})"));
119
+ self.assets.file_exists(path)
117
120
  }
118
- fn draw_tile(&mut self, a: i32, i: i32, x: f32, y: f32, size: f32, qt: i32, tint: Color) {
121
+ fn file_load(&mut self, path: &str) -> Vec<u8> {
122
+ let bytes = self.assets.file_load(path);
119
- self.rec(format!("drawTile({a},{i},{x},{y},{size},{qt},{})", c4(tint)));
123
+ self.rec(format!("fileLoad({path:?}) -> {} bytes", bytes.len()));
124
+ bytes
120
125
  }
121
126
  fn is_key_down(&mut self, k: i32) -> bool {
122
127
  self.rec(format!("isKeyDown({k})"));
@@ -142,32 +147,4 @@ impl Backend for Recorder {
142
147
  self.rec("mouseWheel()".into());
143
148
  self.wheel
144
149
  }
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
150
  }
plum-game/src/web.rs CHANGED
@@ -103,6 +103,10 @@ pub extern "C" fn pg_draw_rect(x: f32, y: f32, w: f32, h: f32, r: f32, g: f32, b
103
103
  with(|s| s.draw_rect(x, y, w, h, [r, g, b, a]))
104
104
  }
105
105
  #[no_mangle]
106
+ pub extern "C" fn pg_draw_circle(x: f32, y: f32, r: f32, cr: f32, cg: f32, cb: f32, ca: f32) {
107
+ with(|s| s.draw_circle(x, y, r, [cr, cg, cb, ca]))
108
+ }
109
+ #[no_mangle]
106
110
  #[allow(clippy::too_many_arguments)]
107
111
  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) {
108
112
  with(|s| s.draw_line(x1, y1, x2, y2, t, [r, g, b, a]))
@@ -122,44 +126,75 @@ pub extern "C" fn pg_reset_camera() {
122
126
  with(|b| b.reset_camera())
123
127
  }
124
128
  #[no_mangle]
125
- pub extern "C" fn pg_atlas_load(p: *const u8, n: usize) -> i32 {
129
+ pub extern "C" fn pg_texture_load(p: *const u8, n: usize) -> i32 {
126
130
  let s = text(p, n);
127
- with(|b| b.atlas_load(&s))
131
+ with(|b| b.texture_load(&s))
128
132
  }
129
133
  #[no_mangle]
130
- pub extern "C" fn pg_sprite_count(a: i32) -> i32 {
134
+ pub extern "C" fn pg_texture_width(t: i32) -> i32 {
131
- with(|b| b.sprite_count(a))
135
+ with(|b| b.texture_width(t))
132
136
  }
133
137
  #[no_mangle]
134
- pub extern "C" fn pg_sprite_w(a: i32, i: i32) -> i32 {
138
+ pub extern "C" fn pg_texture_height(t: i32) -> i32 {
135
- with(|b| b.sprite_w(a, i))
139
+ with(|b| b.texture_height(t))
136
140
  }
137
141
  #[no_mangle]
138
- pub extern "C" fn pg_sprite_h(a: i32, i: i32) -> i32 {
142
+ pub extern "C" fn pg_texture_set_smooth(t: i32, smooth: i32) {
139
- with(|b| b.sprite_h(a, i))
143
+ with(|b| b.texture_set_smooth(t, smooth != 0))
140
144
  }
141
145
  #[no_mangle]
142
- pub extern "C" fn pg_sprite_pivot_x(a: i32, i: i32) -> i32 {
146
+ pub extern "C" fn pg_draw_texture(t: i32, x: f32, y: f32, r: f32, g: f32, b: f32, a: f32) {
143
- with(|b| b.sprite_pivot_x(a, i))
147
+ with(|s| s.draw_texture(t, x, y, [r, g, b, a]))
144
148
  }
145
149
  #[no_mangle]
150
+ #[allow(clippy::too_many_arguments)]
151
+ pub extern "C" fn pg_draw_texture_ex(
152
+ t: i32,
153
+ sx: f32,
154
+ sy: f32,
155
+ sw: f32,
156
+ sh: f32,
157
+ x: f32,
158
+ y: f32,
159
+ w: f32,
160
+ h: f32,
161
+ rotation: f32,
162
+ flip: i32,
163
+ r: f32,
164
+ g: f32,
165
+ b: f32,
166
+ a: f32,
167
+ ) {
168
+ with(|s| s.draw_texture_ex(t, [sx, sy, sw, sh], [x, y, w, h], rotation, flip != 0, [r, g, b, a]))
169
+ }
170
+ #[no_mangle]
146
- pub extern "C" fn pg_sprite_pivot_y(a: i32, i: i32) -> i32 {
171
+ pub extern "C" fn pg_file_exists(p: *const u8, n: usize) -> i32 {
172
+ let s = text(p, n);
147
- with(|b| b.sprite_pivot_y(a, i))
173
+ with(|b| b.file_exists(&s)) as i32
148
174
  }
175
+ thread_local! {
149
- /// Channel `k` (0 = r .. 3 = a) of the sprite's average colour.
176
+ /// The file `pg_file_load` last read, until JS collects it with `pg_file_read`.
150
- #[no_mangle]
151
- pub extern "C" fn pg_sprite_color(a: i32, i: i32, k: i32) -> f32 {
177
+ static LOADED: RefCell<Vec<u8>> = const { RefCell::new(Vec::new()) };
152
- with(|b| b.sprite_color(a, i)[(k & 3) as usize])
153
178
  }
179
+ /// Loads the file and returns its length (0 when missing); `pg_file_read`
180
+ /// then copies the bytes out.
154
181
  #[no_mangle]
155
- #[allow(clippy::too_many_arguments)]
156
- 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) {
182
+ pub extern "C" fn pg_file_load(p: *const u8, n: usize) -> i32 {
183
+ let s = text(p, n);
157
- with(|s| s.draw_sprite(a, i, x, y, flip != 0, [r, g, b, al]))
184
+ let bytes = with(|b| b.file_load(&s));
185
+ let len = bytes.len() as i32;
186
+ LOADED.with(|l| *l.borrow_mut() = bytes);
187
+ len
158
188
  }
189
+ /// Copies the bytes of the last `pg_file_load` into `dst`, a `pg_alloc`
190
+ /// buffer of that length.
159
191
  #[no_mangle]
160
- #[allow(clippy::too_many_arguments)]
161
- 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) {
192
+ pub extern "C" fn pg_file_read(dst: *mut u8) {
193
+ LOADED.with(|l| {
194
+ let bytes = std::mem::take(&mut *l.borrow_mut());
195
+ // SAFETY: JS passes a live `pg_alloc` buffer of `bytes.len()` bytes.
162
- with(|s| s.draw_tile(a, i, x, y, size, qt, [r, g, b, al]))
196
+ unsafe { std::ptr::copy_nonoverlapping(bytes.as_ptr(), dst, bytes.len()) }
197
+ })
163
198
  }
164
199
  #[no_mangle]
165
200
  pub extern "C" fn pg_is_key_down(k: i32) -> i32 {
@@ -189,32 +224,3 @@ pub extern "C" fn pg_is_mouse_pressed(k: i32) -> i32 {
189
224
  pub extern "C" fn pg_mouse_wheel() -> f32 {
190
225
  with(|b| b.mouse_wheel())
191
226
  }
192
- #[no_mangle]
193
- pub extern "C" fn pg_map_load(p: *const u8, n: usize) -> i32 {
194
- let s = text(p, n);
195
- with(|b| b.map_load(&s))
196
- }
197
- #[no_mangle]
198
- pub extern "C" fn pg_map_width(m: i32) -> i32 {
199
- with(|b| b.map_width(m))
200
- }
201
- #[no_mangle]
202
- pub extern "C" fn pg_map_height(m: i32) -> i32 {
203
- with(|b| b.map_height(m))
204
- }
205
- #[no_mangle]
206
- pub extern "C" fn pg_map_terrain(m: i32, x: i32, y: i32) -> i32 {
207
- with(|b| b.map_terrain(m, x, y))
208
- }
209
- #[no_mangle]
210
- pub extern "C" fn pg_map_rotation(m: i32, x: i32, y: i32) -> i32 {
211
- with(|b| b.map_rotation(m, x, y))
212
- }
213
- #[no_mangle]
214
- pub extern "C" fn pg_map_elevation(m: i32, x: i32, y: i32) -> i32 {
215
- with(|b| b.map_elevation(m, x, y))
216
- }
217
- #[no_mangle]
218
- pub extern "C" fn pg_map_object(m: i32, x: i32, y: i32) -> i32 {
219
- with(|b| b.map_object(m, x, y))
220
- }
plum-game/tests/assets_test.rs DELETED
@@ -1,114 +0,0 @@
1
- #![allow(non_snake_case)]
2
- use plum_game::assets::{Atlas, AssetRoot, MapData};
3
- use std::path::PathBuf;
4
-
5
- fn root() -> Option<PathBuf> {
6
- let r = AssetRoot::resolve(None);
7
- if r.join("sprites").is_dir() {
8
- Some(r)
9
- } else {
10
- eprintln!("skipping: asset root {} not found", r.display());
11
- None
12
- }
13
- }
14
-
15
- fn zlib(data: &[u8]) -> Vec<u8> {
16
- use flate2::{write::ZlibEncoder, Compression};
17
- use std::io::Write;
18
- let mut e = ZlibEncoder::new(Vec::new(), Compression::default());
19
- e.write_all(data).unwrap();
20
- e.finish().unwrap()
21
- }
22
-
23
- fn tiny_payload() -> Vec<u8> {
24
- let mut p = Vec::new();
25
- p.extend(2u32.to_le_bytes());
26
- p.extend([0u8, 1]);
27
- p.extend(2u16.to_le_bytes());
28
- p.extend(1u16.to_le_bytes());
29
- p.extend((-3i32).to_le_bytes());
30
- p.extend(4i32.to_le_bytes());
31
- p.extend([10, 20, 30, 255, 0, 0, 0, 0]);
32
- p.push(0);
33
- p
34
- }
35
-
36
- #[test]
37
- fn rxxHeaderVariants() {
38
- let z = zlib(&tiny_payload());
39
- let mut rxx1 = b"RXX1".to_vec();
40
- rxx1.extend([0u8; 32]);
41
- rxx1.extend(&z);
42
- let mut rxx2 = b"RXX2".to_vec();
43
- rxx2.extend(5u16.to_le_bytes());
44
- rxx2.extend([9u8; 5]);
45
- rxx2.extend(&z);
46
- for bytes in [z.clone(), rxx1, rxx2] {
47
- let a = Atlas::parse(&bytes).unwrap();
48
- assert_eq!(a.sprites.len(), 3);
49
- let s = a.sprites[2].as_ref().unwrap();
50
- assert_eq!((s.w, s.h, s.px, s.py), (2, 1, -3, 4));
51
- assert_eq!(a.avg_color(2), [10.0 / 255.0, 20.0 / 255.0, 30.0 / 255.0, 1.0]);
52
- assert!(a.sprites[1].is_none());
53
- }
54
- }
55
-
56
- #[test]
57
- fn tilesetSprite1Is32() {
58
- let Some(r) = root() else { return };
59
- let a = Atlas::parse(&std::fs::read(r.join("sprites/Tileset.rxx")).unwrap()).unwrap();
60
- let s = a.sprites[1].as_ref().unwrap();
61
- assert_eq!((s.w, s.h), (32, 32));
62
- }
63
-
64
- #[test]
65
- fn mapParsesANewBeginning() {
66
- let Some(r) = root() else { return };
67
- let m = MapData::parse(&std::fs::read(r.join("Maps/A new Beginning/A new Beginning.map")).unwrap()).unwrap();
68
- assert!((32..=256).contains(&m.w) && (32..=256).contains(&m.h));
69
- let n = (m.w * m.h) as usize;
70
- assert_eq!(m.terrain.len(), n);
71
- assert_eq!(m.rotation.len(), n);
72
- assert_eq!(m.elevation.len(), n);
73
- assert_eq!(m.object.len(), n);
74
- assert!(m.terrain.iter().all(|&t| t < 512));
75
- assert!(m.rotation.iter().all(|&r| r < 4));
76
- }
77
-
78
- #[test]
79
- fn mapObjectsAreTreesSpriteIndices() {
80
- let Some(r) = root() else { return };
81
- let trees = Atlas::parse(&std::fs::read(r.join("sprites/Trees_a.rxx")).unwrap()).unwrap();
82
- let m = MapData::parse(&std::fs::read(r.join("Maps/A new Beginning/A new Beginning.map")).unwrap()).unwrap();
83
- assert!(m.object.iter().any(|&o| o != 0));
84
- assert!(m.object.iter().all(|&o| o == 0 || trees.sprites.get(o as usize).is_some_and(|s| s.is_some())));
85
- }
86
-
87
- #[test]
88
- fn truncatedRxxIsError() {
89
- let z = zlib(&tiny_payload());
90
- for n in [0, 1, 3, z.len() / 2] {
91
- assert!(Atlas::parse(&z[..n]).is_err());
92
- }
93
- assert!(Atlas::parse(b"RXX1").is_err());
94
- assert!(Atlas::parse(b"RXX2\xff\xff").is_err());
95
- assert!(Atlas::parse(b"RXX2\x05").is_err());
96
- // A count larger than the data must not allocate or panic.
97
- assert!(Atlas::parse(&zlib(&u32::MAX.to_le_bytes())).is_err());
98
- }
99
-
100
- #[test]
101
- fn truncatedMapIsError() {
102
- assert!(MapData::parse(&[]).is_err());
103
- assert!(MapData::parse(&[1, 0]).is_err());
104
- let mut b = Vec::new();
105
- b.extend(40i32.to_le_bytes());
106
- b.extend(40i32.to_le_bytes());
107
- b.extend(vec![0u8; 23 * 10]);
108
- assert!(MapData::parse(&b).is_err());
109
- let Some(r) = root() else { return };
110
- let full = std::fs::read(r.join("Maps/A new Beginning/A new Beginning.map")).unwrap();
111
- for n in [0, 4, 8, 100, full.len() / 2] {
112
- assert!(MapData::parse(&full[..n]).is_err(), "n={n}");
113
- }
114
- }
plum-game/tests/fixtures/assets_fixture.plum ADDED
@@ -0,0 +1,30 @@
1
+ module assets_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
+ export fun init() -> State =
13
+ State(frames: 0)
14
+
15
+ export fun update(g: State, dt: Float) =
16
+ g.frames = g.frames + 1
17
+
18
+ # Loads a texture and files from the asset root and reports what came back as text.
19
+ export fun draw(g: State) =
20
+ t := textureLoad("sprites.png")
21
+ drawText("size {textureWidth(t)}x{textureHeight(t)}", 0.0, 0.0, 8.0, Color.White)
22
+ textureSetSmooth(t, true)
23
+ drawTexture(t, 1.0, 2.0, Color.White)
24
+ drawTextureEx(t, 16.0, 0.0, 16.0, 16.0, 10.0, 20.0, 32.0, 48.0, 0.5, true, Color.Blue)
25
+ drawText("bad {textureWidth(99)} {textureLoad("missing.png")}", 0.0, 0.0, 8.0, Color.White)
26
+ if fileExists("hello.txt")
27
+ drawText("file [{fileLoad("hello.txt")}]", 0.0, 0.0, 8.0, Color.White)
28
+ drawText("missing [{fileLoad("nope.txt")}] {fileExists("nope.txt")}", 0.0, 0.0, 8.0, Color.White)
29
+ drawText("escape {textureLoad("../sprites.png")} {fileExists("../hello.txt")} [{fileLoad("../hello.txt")}]", 0.0, 0.0, 8.0, Color.White)
30
+ drawText("abs {textureLoad("/etc/hosts")} {fileExists("/etc/hosts")}", 0.0, 0.0, 8.0, Color.White)
plum-game/tests/fixtures/host_fixture.plum CHANGED
@@ -19,6 +19,7 @@ export fun update(g: State, dt: Float) =
19
19
  export fun draw(g: State) =
20
20
  clear(Color.Black)
21
21
  drawRect(1.0, 2.0, 3.0, 4.0, Color.Red)
22
+ drawCircle(5.0, 6.0, 7.0, Color.Green)
22
23
  p := mousePos()
23
24
  drawText("hi", p.x, p.y, 16.0, Color.White)
24
25
  if g.frames > 0
plum-game/tests/host_test.rs CHANGED
@@ -1,5 +1,4 @@
1
1
  #![allow(non_snake_case)]
2
- use plum_game::api::quarter_turns_radians;
3
2
  use plum_game::native::PlumGame;
4
3
  use plum_game::recorder::Recorder;
5
4
  use std::path::{Path, PathBuf};
@@ -32,7 +31,7 @@ fn callOrderAndArgs() {
32
31
  g.draw().unwrap();
33
32
  assert_eq!(
34
33
  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)"]
34
+ ["clear(0,0,0,1)", "drawRect(1,2,3,4,1,0,0,1)", "drawCircle(5,6,7,0,1,0,1)", "mousePos()", "drawText(\"hi\",5,6,16,1,1,1,1)"]
36
35
  );
37
36
  }
38
37
 
@@ -101,80 +100,104 @@ fn unknownImportTraps() {
101
100
  }
102
101
 
103
102
  #[test]
104
- fn quarterTurnsRotateClockwise() {
103
+ fn manyFramesDoNotAccumulateRoots() {
105
- use std::f32::consts::FRAC_PI_2;
104
+ let mut g = game(Recorder::new());
105
+ for _ in 0..2000 {
106
+ g.update(0.016).unwrap();
107
+ g.draw().unwrap();
108
+ }
106
- assert_eq!(quarter_turns_radians(0), 0.0);
109
+ g.backend_mut().calls.clear();
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
+ g.draw().unwrap();
110
- assert_eq!(quarter_turns_radians(-1), 3.0 * FRAC_PI_2);
111
+ assert_eq!(g.backend().calls.last().unwrap(), "drawText(\"frames 2000\",0,0,8,1,1,1,1)");
111
112
  }
112
113
 
113
- #[test]
114
- fn intHandlesFailWithMinusOne() {
114
+ fn examples_assets() -> PathBuf {
115
+ root().join("plum-examples/assets")
116
+ }
117
+
118
+ fn assets_fixture() -> Vec<u8> {
119
+ compile(&Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/assets_fixture.plum"))
120
+ }
121
+
115
- use plum_game::api::Backend;
122
+ fn scratch(name: &str) -> PathBuf {
116
- let mut rec = Recorder::with_assets(Some(PathBuf::from("/nonexistent-assets")));
123
+ let dir = std::env::temp_dir().join(format!("plum_game_{name}_{}", std::process::id()));
117
- assert_eq!(rec.atlas_load("Tileset"), -1);
124
+ std::fs::create_dir_all(&dir).unwrap();
118
- assert_eq!(rec.map_load("x.map"), -1);
119
- assert_eq!(rec.sprite_w(5, 5), 0);
125
+ dir
120
- assert_eq!(rec.map_terrain(-1, 0, 0), 0);
121
126
  }
122
127
 
123
128
  #[test]
124
- fn atlasAndMapThroughHost() {
129
+ fn textureLoadFromAssetRoot() {
125
130
  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);
131
+ let mut rec = Recorder::with_assets(examples_assets());
132
- let a = rec.atlas_load("Tileset");
132
+ let t = rec.texture_load("sprites.png");
133
- assert_eq!(a, 0);
133
+ assert_eq!(t, 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));
134
+ assert_eq!((rec.texture_width(t), rec.texture_height(t)), (64, 48));
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);
135
+ assert_eq!(rec.texture_load("missing.png"), -1);
136
+ assert_eq!((rec.texture_width(-1), rec.texture_height(99)), (0, 0));
137
+
138
+ let mut g = PlumGame::load(&assets_fixture(), Recorder::with_assets(examples_assets())).unwrap();
139
+ g.init().unwrap();
140
+ g.draw().unwrap();
141
+ let calls = &g.backend().calls;
142
+ assert!(calls.contains(&"textureLoad(\"sprites.png\") -> 0".to_string()), "{calls:?}");
143
+ assert!(calls.contains(&"drawText(\"size 64x48\",0,0,8,1,1,1,1)".to_string()), "{calls:?}");
144
+ assert!(calls.contains(&"textureSetSmooth(0,true)".to_string()));
145
+ assert!(calls.contains(&"drawTexture(0,1,2,1,1,1,1)".to_string()));
146
+ assert!(calls.contains(&"drawTextureEx(0,16,0,16,16,10,20,32,48,0.5,true,0,0,1,1)".to_string()), "{calls:?}");
147
+ assert!(calls.contains(&"drawText(\"bad 0 -1\",0,0,8,1,1,1,1)".to_string()));
141
148
  }
142
149
 
143
150
  #[test]
144
- fn manyFramesDoNotAccumulateRoots() {
151
+ fn fileLoadReturnsContents() {
145
- let mut g = game(Recorder::new());
152
+ let dir = scratch("fileload");
146
- for _ in 0..2000 {
147
- g.update(0.016).unwrap();
153
+ std::fs::write(dir.join("hello.txt"), "héllo\nworld").unwrap();
154
+ let mut g = PlumGame::load(&assets_fixture(), Recorder::with_assets(dir.clone())).unwrap();
148
- g.draw().unwrap();
155
+ g.init().unwrap();
149
- }
150
- g.backend_mut().calls.clear();
151
156
  g.draw().unwrap();
157
+ let calls = g.backend().calls.clone();
158
+ let _ = std::fs::remove_dir_all(&dir);
159
+ assert!(calls.contains(&"fileLoad(\"hello.txt\") -> 12 bytes".to_string()), "{calls:?}");
160
+ assert!(calls.contains(&"drawText(\"file [héllo\\nworld]\",0,0,8,1,1,1,1)".to_string()), "{calls:?}");
152
- assert_eq!(g.backend().calls.last().unwrap(), "drawText(\"frames 2000\",0,0,8,1,1,1,1)");
161
+ assert!(calls.contains(&"drawText(\"missing [] false\",0,0,8,1,1,1,1)".to_string()), "{calls:?}");
153
162
  }
154
163
 
155
164
  #[test]
156
- fn kamViewerInitAndDrawWithRealAssets() {
165
+ fn assetPathEscapeRejected() {
157
166
  use plum_game::api::Backend;
167
+ // A secret one level above the asset root must stay unreachable.
158
- let assets = plum_game::assets::AssetRoot::resolve(None);
168
+ let dir = scratch("escape");
159
- if !assets.join("sprites").is_dir() {
169
+ let root = dir.join("assets");
170
+ std::fs::create_dir_all(&root).unwrap();
171
+ std::fs::write(dir.join("hello.txt"), "secret").unwrap();
172
+ std::fs::copy(examples_assets().join("sprites.png"), dir.join("sprites.png")).unwrap();
173
+
174
+ let mut rec = Recorder::with_assets(root.clone());
175
+ assert!(!rec.file_exists("../hello.txt"));
160
- eprintln!("skipping: asset root {} not found", assets.display());
176
+ assert!(rec.file_load("../hello.txt").is_empty());
177
+ assert_eq!(rec.texture_load("../sprites.png"), -1);
178
+ assert!(!rec.file_exists("/etc/hosts"));
179
+ assert_eq!(rec.texture_load("sub/../../sprites.png"), -1);
180
+
181
+ let mut g = PlumGame::load(&assets_fixture(), Recorder::with_assets(root)).unwrap();
182
+ g.init().unwrap();
183
+ g.draw().unwrap();
184
+ let calls = g.backend().calls.clone();
185
+ let _ = std::fs::remove_dir_all(&dir);
186
+ assert!(calls.contains(&"drawText(\"escape -1 false []\",0,0,8,1,1,1,1)".to_string()), "{calls:?}");
187
+ assert!(calls.contains(&"drawText(\"abs -1 false\",0,0,8,1,1,1,1)".to_string()), "{calls:?}");
188
+ }
189
+
161
- return;
190
+ #[test]
191
+ fn exampleGameRunsFrames() {
192
+ let wasm = compile(&root().join("plum-examples/game.plum"));
193
+ let mut g = PlumGame::load(&wasm, Recorder::with_assets(examples_assets())).expect("load game");
194
+ g.init().expect("init");
195
+ for _ in 0..60 {
196
+ g.update(1.0 / 60.0).expect("update");
162
197
  }
163
- // Map size, read through a separate backend so the minimap rect count is exact.
164
- let mut probe = Recorder::with_assets(None);
165
- let m = probe.map_load("Maps/A new Beginning/A new Beginning.map");
166
- assert!(m >= 0);
167
- let cells = (probe.map_width(m) * probe.map_height(m)) as usize;
168
-
169
- let wasm = compile(&Path::new(env!("CARGO_MANIFEST_DIR")).join("kam/Main.plum"));
170
- let mut g = PlumGame::load(&wasm, Recorder::with_assets(None)).expect("load viewer");
171
- g.init().expect("init must not trap");
172
- g.update(0.016).expect("update must not trap");
173
- g.draw().expect("first draw must not trap");
198
+ g.draw().expect("draw");
174
- g.draw().expect("second draw must not trap");
175
199
  let calls = &g.backend().calls;
176
- assert!(calls.iter().any(|c| c.starts_with("drawTile(")), "no drawTile recorded");
200
+ assert!(calls.iter().any(|c| c.starts_with("drawTextureEx(0,")), "no drawTextureEx recorded");
177
- let rects = calls.iter().filter(|c| c.starts_with("drawRect(")).count();
201
+ assert!(calls.iter().any(|c| c.starts_with("drawCircle(")));
178
- assert!(rects >= cells, "minimap drew {rects} rects, expected at least {cells}");
179
- assert!(!calls.iter().any(|c| c.contains("assets not found")), "assets-not-found was drawn");
202
+ assert!(calls.iter().any(|c| c.starts_with("drawText(\"Score ")));
180
203
  }
plum-game/web/bridge_check.mjs CHANGED
@@ -44,6 +44,10 @@ function stubHost() {
44
44
  if (name === "pg_free") return () => {};
45
45
  return (...a) => {
46
46
  calls.push([name, ...a]);
47
+ if (name === "pg_file_load") {
48
+ t.loaded.push(new TextDecoder().decode(new Uint8Array(memory.buffer, a[0], a[1])));
49
+ return 0;
50
+ }
47
51
  if (/load$/.test(name)) {
48
52
  // (ptr, len): the Str arrived intact in host memory.
49
53
  t.loaded.push(new TextDecoder().decode(new Uint8Array(memory.buffer, a[0], a[1])));
plum-game/web/plum_bridge.js CHANGED
@@ -21,39 +21,33 @@
21
21
  drawText: "pg_draw_text",
22
22
  setCamera: "pg_set_camera",
23
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",
24
+ drawCircle: "pg_draw_circle",
25
+ textureLoad: "pg_texture_load",
26
+ textureWidth: "pg_texture_width",
27
+ textureHeight: "pg_texture_height",
28
+ textureSetSmooth: "pg_texture_set_smooth",
29
+ drawTexture: "pg_draw_texture",
30
+ drawTextureEx: "pg_draw_texture_ex",
31
+ fileExists: "pg_file_exists",
32
32
  isKeyDown: "pg_is_key_down",
33
33
  isKeyPressed: "pg_is_key_pressed",
34
34
  isMouseDown: "pg_is_mouse_down",
35
35
  isMousePressed: "pg_is_mouse_pressed",
36
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
37
  };
45
38
  // Externs whose first parameter is a Str.
46
- const STR_FIRST = new Set(["drawText", "atlasLoad", "mapLoad"]);
39
+ const STR_FIRST = new Set(["drawText", "textureLoad", "fileExists"]);
47
40
  // Externs with a multi-value result, built from several pg_* calls.
48
41
  const MULTI = {
49
42
  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
43
  };
52
- const MULTI_HOST = ["pg_mouse_x", "pg_mouse_y", "pg_sprite_color"];
44
+ const MULTI_HOST = ["pg_mouse_x", "pg_mouse_y"];
45
+ // fileLoad returns a Str: the host loads the file and JS copies it into a new Str.
46
+ const FILE_HOST = ["pg_file_load", "pg_file_read"];
53
47
  const MEMORY_HOST = ["pg_alloc", "pg_free"];
54
48
 
55
49
  // Every pg_* export the plugin may call.
56
- const hostNames = [...new Set([...Object.values(DIRECT), ...MULTI_HOST, ...MEMORY_HOST])];
50
+ const hostNames = [...new Set([...Object.values(DIRECT), ...MULTI_HOST, ...FILE_HOST, ...MEMORY_HOST])];
57
51
 
58
52
  // Copies a Plum Str into a host buffer, calls f(ptr, len), frees the buffer.
59
53
  function withStr(host, plum, s, f) {
@@ -68,6 +62,23 @@
68
62
  }
69
63
  }
70
64
 
65
+ // Builds a Plum Str from the host's file at the Str path `s` (empty if missing).
66
+ function fileLoad(host, plum, s) {
67
+ const len = withStr(host, plum, s, (ptr, n) => host.pg_file_load(ptr, n));
68
+ const out = plum.strNew(len);
69
+ if (len > 0) {
70
+ const ptr = host.pg_alloc(len);
71
+ try {
72
+ host.pg_file_read(ptr);
73
+ const mem = new Uint8Array(host.memory.buffer, ptr, len);
74
+ for (let i = 0; i < len; i++) plum.strSetByte(out, i, mem[i]);
75
+ } finally {
76
+ host.pg_free(ptr, len);
77
+ }
78
+ }
79
+ return out;
80
+ }
81
+
71
82
  function strToJs(plum, s) {
72
83
  const n = plum.strLen(s);
73
84
  const bytes = new Uint8Array(n);
@@ -88,6 +99,8 @@
88
99
  plum[name] = (s) => {
89
100
  throw new Error("crash: " + strToJs(ctx.plum, s));
90
101
  };
102
+ } else if (name === "fileLoad") {
103
+ plum[name] = (s) => fileLoad(host, ctx.plum, s);
91
104
  } else if (MULTI[name]) {
92
105
  plum[name] = (...args) => MULTI[name](host, ...args);
93
106
  } else if (STR_FIRST.has(name)) {
plum-std/Game.plum CHANGED
@@ -5,9 +5,10 @@ import std/Str
5
5
  import std/Vec2
6
6
  import std/Color
7
7
 
8
- # The host API of a game: drawing, input, sprite atlases and KaM maps, all
8
+ # The host API of a game: drawing, textures, files and input, all provided by
9
- # provided by the game host (`plum game`). Handles (`atlasLoad`, `mapLoad`)
9
+ # the game host (`plum game`). Texture handles are -1 on failure. File and
10
+ # texture paths are relative to the asset root; a path that escapes it fails
10
- # are -1 on failure. Coordinates are world units once `setCamera` is set.
11
+ # like a missing file. Coordinates are world units once `setCamera` is set.
11
12
 
12
13
  extern fun screenWidth() -> Float
13
14
  extern fun screenHeight() -> Float
@@ -15,22 +16,29 @@ extern fun frameTime() -> Float
15
16
 
16
17
  extern fun clear(c: Color)
17
18
  extern fun drawRect(x: Float, y: Float, w: Float, h: Float, c: Color)
19
+ extern fun drawCircle(x: Float, y: Float, r: Float, c: Color)
18
20
  extern fun drawLine(x1: Float, y1: Float, x2: Float, y2: Float, thickness: Float, c: Color)
19
21
  extern fun drawText(s: Str, x: Float, y: Float, size: Float, c: Color)
20
22
 
21
23
  extern fun setCamera(x: Float, y: Float, scale: Float)
22
24
  extern fun resetCamera()
23
25
 
26
+ # Loads any image the host decodes (PNG at least); -1 on failure.
24
- extern fun atlasLoad(name: Str) -> Int
27
+ extern fun textureLoad(path: Str) -> Int
28
+ # Size in pixels; 0 for a bad handle.
25
- extern fun spriteCount(atlas: Int) -> Int
29
+ extern fun textureWidth(t: Int) -> Int
26
- extern fun spriteW(atlas: Int, idx: Int) -> Int
30
+ extern fun textureHeight(t: Int) -> Int
27
- extern fun spriteH(atlas: Int, idx: Int) -> Int
31
+ # Linear (true) or nearest (false, the default) filtering.
28
- extern fun spritePivotX(atlas: Int, idx: Int) -> Int
32
+ extern fun textureSetSmooth(t: Int, smooth: Bool)
29
- extern fun spritePivotY(atlas: Int, idx: Int) -> Int
33
+ # The texture at its natural size, top-left at (x, y).
30
- extern fun spriteColor(atlas: Int, idx: Int) -> Color
34
+ extern fun drawTexture(t: Int, x: Float, y: Float, tint: Color)
35
+ # The source rect (sx, sy, sw, sh) in texture pixels drawn into the dest rect
36
+ # (x, y, w, h), rotated `rotation` radians clockwise about the dest centre.
37
+ extern fun drawTextureEx(t: Int, sx: Float, sy: Float, sw: Float, sh: Float, x: Float, y: Float, w: Float, h: Float, rotation: Float, flipX: Bool, tint: Color)
31
38
 
32
- extern fun drawSprite(atlas: Int, idx: Int, x: Float, y: Float, flipX: Bool, tint: Color)
39
+ extern fun fileExists(path: Str) -> Bool
40
+ # The whole file; "" if it is missing.
33
- extern fun drawTile(atlas: Int, idx: Int, x: Float, y: Float, size: Float, quarterTurns: Int, tint: Color)
41
+ extern fun fileLoad(path: Str) -> Str
34
42
 
35
43
  extern fun isKeyDown(k: Int) -> Bool
36
44
  extern fun isKeyPressed(k: Int) -> Bool
@@ -39,14 +47,6 @@ extern fun isMouseDown(b: Int) -> Bool
39
47
  extern fun isMousePressed(b: Int) -> Bool
40
48
  extern fun mouseWheel() -> Float
41
49
 
42
- extern fun mapLoad(path: Str) -> Int
43
- extern fun mapWidth(m: Int) -> Int
44
- extern fun mapHeight(m: Int) -> Int
45
- extern fun mapTerrain(m: Int, x: Int, y: Int) -> Int
46
- extern fun mapRotation(m: Int, x: Int, y: Int) -> Int
47
- extern fun mapElevation(m: Int, x: Int, y: Int) -> Int
48
- extern fun mapObject(m: Int, x: Int, y: Int) -> Int
49
-
50
50
  # Key codes (GLFW numbering) for `isKeyDown` / `isKeyPressed`.
51
51
  # Letter keys are `KeyA`..`KeyZ` (a constant name needs two characters).
52
52
  primitive Key =