plum
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 +37 -41
- plum-cli/src/main.rs +2 -6
- plum-cli/tests/game_web_test.rs +4 -18
- plum-game/Cargo.toml +0 -1
- plum-game/kam/Main.plum +0 -269
- plum-game/src/api.rs +98 -102
- plum-game/src/assets.rs +0 -253
- plum-game/src/lib.rs +0 -1
- plum-game/src/main.rs +9 -4
- plum-game/src/mq.rs +61 -74
- plum-game/src/native.rs +42 -24
- plum-game/src/recorder.rs +34 -57
- plum-game/src/web.rs +57 -51
- plum-game/tests/assets_test.rs +0 -114
- plum-game/tests/fixtures/assets_fixture.plum +30 -0
- plum-game/tests/fixtures/host_fixture.plum +1 -0
- plum-game/tests/host_test.rs +85 -62
- plum-game/web/bridge_check.mjs +4 -0
- plum-game/web/plum_bridge.js +32 -19
- plum-std/Game.plum +20 -20
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
|
-
|
|
25
|
+
.filter(|p| !p.as_os_str().is_empty())
|
|
31
|
-
|
|
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, &
|
|
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,
|
|
149
|
+
fn writeWeb(wasm: &[u8], dist: &Path, assets: &Path) -> Result<()> {
|
|
131
150
|
let hostWasm = buildHostWasm()?;
|
|
132
151
|
let bundle = jsBundle()?;
|
|
133
152
|
|
|
134
|
-
//
|
|
153
|
+
// The whole asset directory (if there is one) is bundled and preloaded.
|
|
135
|
-
// warning; files asked for with --preload must exist.
|
|
136
|
-
let
|
|
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.
|
|
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
|
|
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,
|
|
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("
|
|
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("
|
|
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
|
-
|
|
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
|
|
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
|
|
26
|
+
fn texture_load(&mut self, path: &str) -> i32;
|
|
27
|
+
fn texture_width(&mut self, t: i32) -> i32;
|
|
26
|
-
fn
|
|
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
|
|
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
|
|
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
|
|
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
|
-
///
|
|
57
|
+
/// Whether `path` stays inside the asset root: relative, no `..`.
|
|
54
|
-
/// about the tile's centre by this angle.
|
|
55
|
-
pub fn
|
|
58
|
+
pub fn path_in_root(path: &str) -> bool {
|
|
59
|
+
!path.is_empty()
|
|
60
|
+
&& !path.contains('\\')
|
|
56
|
-
|
|
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
|
-
|
|
73
|
+
struct Tex {
|
|
74
|
+
image: Image,
|
|
75
|
+
smooth: bool,
|
|
76
|
+
}
|
|
69
77
|
|
|
70
|
-
///
|
|
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
|
-
|
|
82
|
+
textures: Vec<Tex>,
|
|
78
|
-
pub maps: Vec<MapData>,
|
|
79
83
|
}
|
|
80
84
|
|
|
81
85
|
impl AssetStore {
|
|
82
|
-
pub fn new(root:
|
|
86
|
+
pub fn new(root: PathBuf) -> Self {
|
|
83
|
-
let root = AssetRoot::resolve(root.as_deref());
|
|
84
|
-
AssetStore { source: Source::Dir(root),
|
|
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()),
|
|
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
|
|
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
|
-
|
|
114
|
+
if !path_in_root(path) {
|
|
112
|
-
self.atlases.len() as i32 - 1
|
|
113
|
-
}
|
|
114
|
-
|
|
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
|
|
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
|
|
129
|
+
match Image::from_file_with_format(&bytes, None) {
|
|
124
|
-
Ok(
|
|
130
|
+
Ok(image) => {
|
|
125
|
-
self.
|
|
131
|
+
self.textures.push(Tex { image, smooth: false });
|
|
126
|
-
self.
|
|
132
|
+
self.textures.len() as i32 - 1
|
|
127
133
|
}
|
|
128
134
|
Err(_) => -1,
|
|
129
135
|
}
|
|
130
136
|
}
|
|
131
137
|
|
|
132
|
-
fn
|
|
138
|
+
fn tex(&self, t: i32) -> Option<&Tex> {
|
|
133
|
-
|
|
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
|
|
142
|
+
pub fn image(&self, t: i32) -> Option<&Image> {
|
|
138
|
-
|
|
143
|
+
self.tex(t).map(|t| &t.image)
|
|
139
144
|
}
|
|
140
|
-
|
|
145
|
+
|
|
141
|
-
self.sprite(atlas, idx).map_or(0, |s| s.w as i32)
|
|
142
|
-
}
|
|
143
|
-
pub fn
|
|
146
|
+
pub fn smooth(&self, t: i32) -> bool {
|
|
144
|
-
self.
|
|
147
|
+
self.tex(t).is_some_and(|t| t.smooth)
|
|
145
148
|
}
|
|
149
|
+
|
|
146
|
-
pub fn
|
|
150
|
+
pub fn texture_count(&self) -> usize {
|
|
147
|
-
self.
|
|
151
|
+
self.textures.len()
|
|
148
152
|
}
|
|
153
|
+
|
|
149
|
-
pub fn
|
|
154
|
+
pub fn texture_width(&self, t: i32) -> i32 {
|
|
150
|
-
self.
|
|
155
|
+
self.tex(t).map_or(0, |t| t.image.width as i32)
|
|
151
156
|
}
|
|
157
|
+
|
|
152
|
-
pub fn
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
176
|
+
assert_eq!(s.texture_load("missing.png"), -1);
|
|
194
|
-
|
|
177
|
+
assert!(!s.file_exists("a.txt"));
|
|
195
|
-
s.insert("
|
|
178
|
+
s.insert("bad.png", vec![1, 2, 3]);
|
|
179
|
+
s.insert("a.txt", b"hi".to_vec());
|
|
196
|
-
// Present but
|
|
180
|
+
// Present but undecodable: a failure, not a panic.
|
|
197
|
-
assert_eq!(s.
|
|
181
|
+
assert_eq!(s.texture_load("bad.png"), -1);
|
|
198
|
-
assert_eq!(s.
|
|
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
|
-
//!
|
|
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,
|
|
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
|
-
|
|
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:
|
|
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::{
|
|
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
|
-
///
|
|
50
|
+
/// Textures uploaded on first use, with the filter currently applied.
|
|
51
|
-
|
|
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(
|
|
56
|
+
pub fn new(root: PathBuf) -> Self {
|
|
57
|
-
Self::with_assets(AssetStore::new(
|
|
57
|
+
Self::with_assets(AssetStore::new(root))
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
pub fn with_assets(assets: AssetStore) -> Self {
|
|
61
|
-
MqBackend { assets,
|
|
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,
|
|
74
|
+
fn texture(&mut self, t: i32) -> Option<mq::Texture2D> {
|
|
73
|
-
let
|
|
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
|
-
|
|
78
|
+
if self.uploaded.get(i)?.is_none() {
|
|
79
|
+
let img = self.assets.image(t)?;
|
|
75
|
-
let
|
|
80
|
+
let tex = mq::Texture2D::from_rgba8(img.width, img.height, &img.bytes);
|
|
81
|
+
tex.set_filter(filter(smooth));
|
|
76
|
-
self.
|
|
82
|
+
self.uploaded[i] = Some((tex, smooth));
|
|
77
83
|
}
|
|
78
|
-
|
|
84
|
+
let (tex, applied) = self.uploaded[i].as_mut()?;
|
|
85
|
+
if *applied != smooth {
|
|
86
|
+
tex.set_filter(filter(smooth));
|
|
79
|
-
|
|
87
|
+
*applied = smooth;
|
|
80
88
|
}
|
|
81
|
-
|
|
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
|
-
|
|
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
|
|
139
|
+
fn texture_load(&mut self, path: &str) -> i32 {
|
|
126
|
-
self.assets.
|
|
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
|
|
142
|
+
fn texture_width(&mut self, t: i32) -> i32 {
|
|
132
|
-
self.assets.
|
|
143
|
+
self.assets.texture_width(t)
|
|
133
144
|
}
|
|
134
|
-
fn
|
|
145
|
+
fn texture_height(&mut self, t: i32) -> i32 {
|
|
135
|
-
self.assets.
|
|
146
|
+
self.assets.texture_height(t)
|
|
136
147
|
}
|
|
137
|
-
fn
|
|
148
|
+
fn texture_set_smooth(&mut self, t: i32, smooth: bool) {
|
|
138
|
-
self.assets.
|
|
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
|
|
151
|
+
fn draw_texture(&mut self, t: i32, x: f32, y: f32, tint: Color) {
|
|
147
|
-
let Some(tex) = self.texture(
|
|
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
|
|
153
|
+
let (sx, sy) = self.to_screen(x, y);
|
|
150
154
|
let s = self.cam.2;
|
|
151
|
-
let params = mq::DrawTextureParams {
|
|
152
|
-
|
|
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
|
|
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(
|
|
159
|
+
let Some(tex) = self.texture(t) else { return };
|
|
160
|
-
let (sx, sy) = self.to_screen(
|
|
160
|
+
let (sx, sy) = self.to_screen(dest[0], dest[1]);
|
|
161
|
-
let
|
|
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(
|
|
163
|
+
dest_size: Some(mq::vec2(w, h)),
|
|
164
|
-
|
|
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 +
|
|
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
|
-
"
|
|
108
|
+
"textureLoad" => {
|
|
89
109
|
let s = str_arg(caller, p, 0)?;
|
|
90
|
-
r[0] = Val::I32(b!().
|
|
110
|
+
r[0] = Val::I32(b!().texture_load(&s));
|
|
91
111
|
}
|
|
112
|
+
"textureWidth" => r[0] = Val::I32(b!().texture_width(int(p, 0))),
|
|
92
|
-
"
|
|
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
|
-
"
|
|
114
|
+
"textureSetSmooth" => b!().texture_set_smooth(int(p, 0), int(p, 1) != 0),
|
|
96
|
-
"
|
|
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
|
-
"
|
|
124
|
+
"fileExists" => {
|
|
98
|
-
let
|
|
125
|
+
let s = str_arg(caller, p, 0)?;
|
|
99
|
-
|
|
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.
|
|
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(
|
|
28
|
+
Self::with_assets(PathBuf::from("/nonexistent-plum-assets"))
|
|
28
29
|
}
|
|
29
30
|
|
|
30
|
-
pub fn with_assets(root:
|
|
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
|
|
91
|
+
fn texture_load(&mut self, path: &str) -> i32 {
|
|
92
|
+
let t = self.assets.texture_load(path);
|
|
88
|
-
self.rec(format!("
|
|
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
|
|
96
|
+
fn texture_width(&mut self, t: i32) -> i32 {
|
|
96
|
-
self.rec(format!("
|
|
97
|
+
self.rec(format!("textureWidth({t})"));
|
|
97
|
-
self.assets.
|
|
98
|
+
self.assets.texture_width(t)
|
|
98
99
|
}
|
|
99
|
-
fn
|
|
100
|
+
fn texture_height(&mut self, t: i32) -> i32 {
|
|
100
|
-
self.rec(format!("
|
|
101
|
+
self.rec(format!("textureHeight({t})"));
|
|
101
|
-
self.assets.
|
|
102
|
+
self.assets.texture_height(t)
|
|
102
103
|
}
|
|
103
|
-
fn
|
|
104
|
+
fn texture_set_smooth(&mut self, t: i32, smooth: bool) {
|
|
104
|
-
self.rec(format!("
|
|
105
|
+
self.rec(format!("textureSetSmooth({t},{smooth})"));
|
|
105
|
-
self.assets.
|
|
106
|
+
self.assets.texture_set_smooth(t, smooth);
|
|
106
107
|
}
|
|
107
|
-
fn
|
|
108
|
+
fn draw_texture(&mut self, t: i32, x: f32, y: f32, tint: Color) {
|
|
108
|
-
self.rec(format!("
|
|
109
|
+
self.rec(format!("drawTexture({t},{x},{y},{})", c4(tint)));
|
|
109
|
-
self.assets.sprite_pivot_y(a, i)
|
|
110
110
|
}
|
|
111
|
-
fn
|
|
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.
|
|
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
|
|
117
|
+
fn file_exists(&mut self, path: &str) -> bool {
|
|
116
|
-
self.rec(format!("
|
|
118
|
+
self.rec(format!("fileExists({path:?})"));
|
|
119
|
+
self.assets.file_exists(path)
|
|
117
120
|
}
|
|
118
|
-
fn
|
|
121
|
+
fn file_load(&mut self, path: &str) -> Vec<u8> {
|
|
122
|
+
let bytes = self.assets.file_load(path);
|
|
119
|
-
self.rec(format!("
|
|
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
|
|
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.
|
|
131
|
+
with(|b| b.texture_load(&s))
|
|
128
132
|
}
|
|
129
133
|
#[no_mangle]
|
|
130
|
-
pub extern "C" fn
|
|
134
|
+
pub extern "C" fn pg_texture_width(t: i32) -> i32 {
|
|
131
|
-
with(|b| b.
|
|
135
|
+
with(|b| b.texture_width(t))
|
|
132
136
|
}
|
|
133
137
|
#[no_mangle]
|
|
134
|
-
pub extern "C" fn
|
|
138
|
+
pub extern "C" fn pg_texture_height(t: i32) -> i32 {
|
|
135
|
-
with(|b| b.
|
|
139
|
+
with(|b| b.texture_height(t))
|
|
136
140
|
}
|
|
137
141
|
#[no_mangle]
|
|
138
|
-
pub extern "C" fn
|
|
142
|
+
pub extern "C" fn pg_texture_set_smooth(t: i32, smooth: i32) {
|
|
139
|
-
with(|b| b.
|
|
143
|
+
with(|b| b.texture_set_smooth(t, smooth != 0))
|
|
140
144
|
}
|
|
141
145
|
#[no_mangle]
|
|
142
|
-
pub extern "C" fn
|
|
146
|
+
pub extern "C" fn pg_draw_texture(t: i32, x: f32, y: f32, r: f32, g: f32, b: f32, a: f32) {
|
|
143
|
-
with(|
|
|
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
|
|
171
|
+
pub extern "C" fn pg_file_exists(p: *const u8, n: usize) -> i32 {
|
|
172
|
+
let s = text(p, n);
|
|
147
|
-
with(|b| b.
|
|
173
|
+
with(|b| b.file_exists(&s)) as i32
|
|
148
174
|
}
|
|
175
|
+
thread_local! {
|
|
149
|
-
///
|
|
176
|
+
/// The file `pg_file_load` last read, until JS collects it with `pg_file_read`.
|
|
150
|
-
#[no_mangle]
|
|
151
|
-
|
|
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
|
|
182
|
+
pub extern "C" fn pg_file_load(p: *const u8, n: usize) -> i32 {
|
|
183
|
+
let s = text(p, n);
|
|
157
|
-
with(|
|
|
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
|
|
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
|
-
|
|
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
|
|
103
|
+
fn manyFramesDoNotAccumulateRoots() {
|
|
105
|
-
|
|
104
|
+
let mut g = game(Recorder::new());
|
|
105
|
+
for _ in 0..2000 {
|
|
106
|
+
g.update(0.016).unwrap();
|
|
107
|
+
g.draw().unwrap();
|
|
108
|
+
}
|
|
106
|
-
|
|
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
|
-
|
|
110
|
+
g.draw().unwrap();
|
|
110
|
-
assert_eq!(
|
|
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
|
|
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
|
-
|
|
122
|
+
fn scratch(name: &str) -> PathBuf {
|
|
116
|
-
let
|
|
123
|
+
let dir = std::env::temp_dir().join(format!("plum_game_{name}_{}", std::process::id()));
|
|
117
|
-
|
|
124
|
+
std::fs::create_dir_all(&dir).unwrap();
|
|
118
|
-
assert_eq!(rec.map_load("x.map"), -1);
|
|
119
|
-
|
|
125
|
+
dir
|
|
120
|
-
assert_eq!(rec.map_terrain(-1, 0, 0), 0);
|
|
121
126
|
}
|
|
122
127
|
|
|
123
128
|
#[test]
|
|
124
|
-
fn
|
|
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(
|
|
131
|
+
let mut rec = Recorder::with_assets(examples_assets());
|
|
132
|
-
let
|
|
132
|
+
let t = rec.texture_load("sprites.png");
|
|
133
|
-
assert_eq!(
|
|
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
|
-
|
|
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.
|
|
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
|
|
151
|
+
fn fileLoadReturnsContents() {
|
|
145
|
-
let
|
|
152
|
+
let dir = scratch("fileload");
|
|
146
|
-
for _ in 0..2000 {
|
|
147
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
165
|
+
fn assetPathEscapeRejected() {
|
|
157
166
|
use plum_game::api::Backend;
|
|
167
|
+
// A secret one level above the asset root must stay unreachable.
|
|
158
|
-
let
|
|
168
|
+
let dir = scratch("escape");
|
|
159
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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("
|
|
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("
|
|
200
|
+
assert!(calls.iter().any(|c| c.starts_with("drawTextureEx(0,")), "no drawTextureEx recorded");
|
|
177
|
-
|
|
201
|
+
assert!(calls.iter().any(|c| c.starts_with("drawCircle(")));
|
|
178
|
-
assert!(rects >= cells, "minimap drew {rects} rects, expected at least {cells}");
|
|
179
|
-
assert!(
|
|
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
|
-
|
|
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", "
|
|
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"
|
|
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,
|
|
8
|
+
# The host API of a game: drawing, textures, files and input, all provided by
|
|
9
|
-
#
|
|
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
|
-
#
|
|
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
|
|
27
|
+
extern fun textureLoad(path: Str) -> Int
|
|
28
|
+
# Size in pixels; 0 for a bad handle.
|
|
25
|
-
extern fun
|
|
29
|
+
extern fun textureWidth(t: Int) -> Int
|
|
26
|
-
extern fun
|
|
30
|
+
extern fun textureHeight(t: Int) -> Int
|
|
27
|
-
|
|
31
|
+
# Linear (true) or nearest (false, the default) filtering.
|
|
28
|
-
extern fun
|
|
32
|
+
extern fun textureSetSmooth(t: Int, smooth: Bool)
|
|
29
|
-
|
|
33
|
+
# The texture at its natural size, top-left at (x, y).
|
|
30
|
-
extern fun
|
|
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
|
|
39
|
+
extern fun fileExists(path: Str) -> Bool
|
|
40
|
+
# The whole file; "" if it is missing.
|
|
33
|
-
extern fun
|
|
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 =
|