Skip to main content

dotzuki_web/
lib.rs

1use wasm_bindgen::prelude::*;
2
3use dotzuki_renderer::layout_engine::deserialize::parse_layout;
4use dotzuki_renderer::layout_engine::registry::ElementRegistry;
5use dotzuki_renderer::layout_engine::renderer::render_layout as render_screen;
6use dotzuki_renderer::layout_engine::types::{
7    DataContext, DataValue, FontRegistry, RenderContext, Theme, TilesetRegistry,
8};
9use dotzuki_renderer::{FrameBuffer, RenderConfig};
10
11/// Real-engine audio playback (`render_audio_pcm`, `audio_sample_rate`).
12mod audio;
13
14/// pixels + winit game shell (feature `game-shell`): browser canvas + native
15/// fallback window around a [`GameLoop`].
16#[cfg(feature = "game-shell")]
17pub mod game_shell;
18
19/// BroadcastChannel link transport (feature `link`): serverless tab-to-tab
20/// link play over the game's own message type.
21#[cfg(feature = "link")]
22pub mod link;
23
24#[cfg(feature = "game-shell")]
25pub use game_shell::{GameLoop, GameShellConfig, GameShellError, run_game};
26
27use dotzuki_engine::render::Rgba;
28use dotzuki_ui::FrameBufferPainter;
29
30/// Log a warning message (goes to stderr; in WASM this reaches the browser
31/// console when using `wasm-bindgen` test runner or `console_log`).
32fn log_warn(msg: &str) {
33    eprintln!("[dotzuki-web] WARN: {}", msg);
34}
35
36/// Log an error message.
37fn log_error(msg: &str) {
38    eprintln!("[dotzuki-web] ERROR: {}", msg);
39}
40
41#[cfg(feature = "debug-panic-hook")]
42#[wasm_bindgen]
43pub fn install_panic_hook() {
44    console_error_panic_hook::set_once();
45}
46
47/// Generic, **game-agnostic** layout preview for editors.
48///
49/// Compiles raw `.gui` source, renders at an arbitrary `width`×`height`, lets
50/// the editor inject the screen `theme` (so high-resolution / proportional /
51/// themed layouts preview exactly as in-game) and supplies the data bindings
52/// itself as JSON. Game-specific `custom:*` elements are NOT registered here
53/// (the engine cannot know them); games that have any ship their own preview
54/// WASM built on `dotzuki-renderer`'s layout engine.
55///
56/// * `source`     — raw `.gui` DSL text.
57/// * `width/height` — framebuffer size in pixels (e.g. 426×240 for wuxia).
58/// * `theme_json` — a `Theme` object (`{bg_color, text_mode, ink, …}`); empty
59///   string keeps the layout's own theme (default GB white/tile).
60/// * `data_json`  — an object of template bindings; values may be nested arrays
61///   (→ `DataValue::List`) to feed `list`/`flex_list` rows.
62/// * `lang`       — 0=en, 1=zh for `@t(...)` text.
63///
64/// Returns a `width*height*4` RGBA buffer, or empty on compile/parse error.
65#[wasm_bindgen]
66pub fn render_gui(
67    source: &str,
68    width: u32,
69    height: u32,
70    theme_json: &str,
71    data_json: &str,
72    lang: u32,
73) -> Vec<u8> {
74    // 1. Compile the `.gui` source → schema-v2 JSON → ScreenLayout.
75    //    A declarations-only component prelude (`component Foo { ... }`) is a
76    //    valid `.gui` file but has no screen to render — return an empty buffer
77    //    silently instead of logging a compile error.
78    let json = match parse_gui_doc(source) {
79        Ok(dotzuki_engine_dsl::ast::Document::Screen(screen)) => {
80            match dotzuki_engine_dsl::codegen::json_ui::compile_screen(&screen) {
81                Ok(j) => j,
82                Err(e) => {
83                    log_error(&format!("render_gui: compile failed: {e}"));
84                    return Vec::new();
85                }
86            }
87        }
88        Ok(dotzuki_engine_dsl::ast::Document::Components(_)) => return Vec::new(),
89        Ok(_) => {
90            log_error("render_gui: expected a screen layout (screen { ... })");
91            return Vec::new();
92        }
93        Err(e) => {
94            log_error(&format!("render_gui: compile failed: {e}"));
95            return Vec::new();
96        }
97    };
98    let mut layout = match parse_layout(&json) {
99        Ok(l) => l,
100        Err(e) => {
101            log_error(&format!("render_gui: parse failed: {e:?}"));
102            return Vec::new();
103        }
104    };
105
106    // 2. Editor-supplied theme override (the DSL emits no theme block).
107    if !theme_json.is_empty() {
108        match serde_json::from_str::<Theme>(theme_json) {
109            Ok(t) => layout.theme = t,
110            Err(e) => log_warn(&format!("render_gui: theme parse failed: {e}")),
111        }
112    }
113
114    // 3. Data context from the editor's mock bindings (recursive: nested arrays
115    //    become DataValue::List for flex_list/list rows).
116    let mut ctx = DataContext::new();
117    ctx.set("__lang", if lang == 1 { "zh" } else { "en" });
118    if !data_json.is_empty() {
119        match serde_json::from_str::<serde_json::Map<String, serde_json::Value>>(data_json) {
120            Ok(map) => {
121                for (key, value) in map {
122                    ctx.set(&key, json_to_data_value(&value));
123                }
124            }
125            Err(e) => log_warn(&format!("render_gui: data parse failed: {e}")),
126        }
127    }
128
129    // 4. Render at the requested size (render_layout clears to the theme bg).
130    let fonts = FontRegistry::default();
131    let tilesets = TilesetRegistry::default();
132    let render_ctx = RenderContext::new(&layout.screen, &layout.theme, &fonts, &tilesets);
133    let mut fb = FrameBuffer::new(RenderConfig::new(width, height), Rgba::WHITE);
134    {
135        let mut painter = FrameBufferPainter::new(&mut fb);
136        let registry = ElementRegistry::new();
137        if let Err(e) = render_screen(&layout, &ctx, &render_ctx, &registry, &mut painter) {
138            log_error(&format!("render_gui: render failed: {e:?}"));
139        }
140    }
141    fb.data
142}
143
144/// Recursively convert an editor JSON binding value to a [`DataValue`].
145fn json_to_data_value(v: &serde_json::Value) -> DataValue {
146    match v {
147        serde_json::Value::String(s) => DataValue::Str(s.clone()),
148        serde_json::Value::Bool(b) => DataValue::Bool(*b),
149        serde_json::Value::Number(n) => n
150            .as_i64()
151            .map(DataValue::Int)
152            .unwrap_or_else(|| DataValue::Float(n.as_f64().unwrap_or(0.0))),
153        serde_json::Value::Array(a) => DataValue::List(a.iter().map(json_to_data_value).collect()),
154        serde_json::Value::Object(_) => DataValue::Str(v.to_string()),
155        serde_json::Value::Null => DataValue::Str(String::new()),
156    }
157}
158
159// ── DSL (.scene) compile bridge ───────────────────────────────────────────
160//
161// These exports wrap the `dotzuki-engine-dsl` compiler so the game-editor can
162// validate `.scene` files inline (CM6 linter) and show a compiled-JS preview.
163//
164// They return a **JSON string** rather than a structured `JsValue` so we avoid
165// pulling in `serde-wasm-bindgen` (not currently a workspace dependency). The
166// TS side calls `JSON.parse` on the result. Shapes:
167//   success: { "ok": true,  "js": "<compiled JS or JSON config>" }
168//   failure: { "ok": false, "error": "<message>", "line": <n>, "col": <n> }
169
170/// Fixed placeholder path used when compiling from the editor (no real file).
171const EDITOR_SCENE_PATH: &str = "editor/script.scene";
172
173/// Parse the leading `line:col:` prefix out of a compiler error string.
174///
175/// The DSL compiler formats lexer errors as `"<line>:<col>: <message>; ..."`
176/// but parser/semantic errors carry no positional prefix. When no prefix is
177/// present we fall back to line 1, col 1 so the diagnostic still anchors
178/// somewhere sensible.
179fn parse_error_location(err: &str) -> (u32, u32, String) {
180    // Only consider the first error (segments are joined with "; ").
181    let first = err.split("; ").next().unwrap_or(err);
182    let mut parts = first.splitn(3, ':');
183    if let (Some(l), Some(c), Some(rest)) = (parts.next(), parts.next(), parts.next()) {
184        if let (Ok(line), Ok(col)) = (l.trim().parse::<u32>(), c.trim().parse::<u32>()) {
185            return (line, col, rest.trim().to_string());
186        }
187    }
188    (1, 1, err.to_string())
189}
190
191/// Build the JSON success payload `{ "ok": true, "<field>": "<output>" }`.
192fn dsl_ok_json(field: &str, output: &str) -> String {
193    serde_json::json!({ "ok": true, field: output }).to_string()
194}
195
196/// Build the JSON failure payload `{ "ok": false, "error", "line", "col" }`.
197fn dsl_err_json(err: &str) -> String {
198    let (line, col, message) = parse_error_location(err);
199    serde_json::json!({
200        "ok": false,
201        "error": message,
202        // Keep the full (possibly multi-error) message available too.
203        "raw": err,
204        "line": line,
205        "col": col,
206    })
207    .to_string()
208}
209
210/// Compile `.scene` DSL source to JavaScript.
211///
212/// Returns a JSON string (parse with `JSON.parse`):
213///   `{ ok: true, js: "<compiled JS>" }` on success
214///   `{ ok: false, error, raw, line, col }` on failure
215#[wasm_bindgen]
216pub fn compile_scene(source: &str) -> String {
217    match dotzuki_engine_dsl::compiler::compile_scene_to_js(source, EDITOR_SCENE_PATH) {
218        Ok(js) => dsl_ok_json("js", &js),
219        Err(e) => dsl_err_json(&e),
220    }
221}
222
223/// Compile `.scene` DSL source to its `script_config.json` representation.
224///
225/// Returns a JSON string (parse with `JSON.parse`):
226///   `{ ok: true, config: "<compiled JSON config>" }` on success
227///   `{ ok: false, error, raw, line, col }` on failure
228#[wasm_bindgen]
229pub fn compile_scene_config(source: &str) -> String {
230    match dotzuki_engine_dsl::config_gen::compile_scene_to_config(source, EDITOR_SCENE_PATH) {
231        Ok(config) => dsl_ok_json("config", &config),
232        Err(e) => dsl_err_json(&e),
233    }
234}
235
236/// Compile `.gui` DSL source (screen layout) to v2 ScreenLayout JSON.
237///
238/// Returns a JSON string (parse with `JSON.parse`):
239///   `{ ok: true, kind: "screen", js: "<compiled JSON>" }` for a screen layout
240///   `{ ok: true, kind: "components", names: ["Foo", ...] }` for a
241///     declarations-only component prelude (a valid `.gui` file with no screen
242///     to preview — the editor shows an informational state, not an error)
243///   `{ ok: false, error, raw, line, col }` on failure
244#[wasm_bindgen]
245pub fn compile_screen_source(source: &str) -> String {
246    match parse_gui_doc(source) {
247        Ok(dotzuki_engine_dsl::ast::Document::Screen(screen)) => {
248            match dotzuki_engine_dsl::codegen::json_ui::compile_screen(&screen) {
249                Ok(json) => serde_json::json!({ "ok": true, "kind": "screen", "js": json }).to_string(),
250                Err(e) => dsl_err_json(&e.to_string()),
251            }
252        }
253        Ok(dotzuki_engine_dsl::ast::Document::Components(decls)) => {
254            let names: Vec<&str> = decls.iter().map(|d| d.name.as_str()).collect();
255            serde_json::json!({ "ok": true, "kind": "components", "names": names }).to_string()
256        }
257        Ok(_) => dsl_err_json("expected a screen layout (screen { ... })"),
258        Err(e) => dsl_err_json(&e),
259    }
260}
261
262/// Parse `.gui` source into a DSL [`Document`](dotzuki_engine_dsl::ast::Document),
263/// or a `line:col: msg` error string. Shared by [`compile_screen_source`] and
264/// [`render_gui`].
265fn parse_gui_doc(source: &str) -> Result<dotzuki_engine_dsl::ast::Document, String> {
266    let tokens = dotzuki_engine_dsl::lexer::Lexer::new(source, "editor/screen.gui")
267        .tokenize()
268        .map_err(|errors| {
269            errors
270                .iter()
271                .map(|e| format!("{}:{}: {}", e.line, e.col, e.message))
272                .collect::<Vec<_>>()
273                .join("; ")
274        })?;
275
276    let (doc, parse_errors) = dotzuki_engine_dsl::parser::Parser::new(tokens, source).parse();
277    if !parse_errors.is_empty() {
278        return Err(parse_errors
279            .iter()
280            .map(|e| e.to_string())
281            .collect::<Vec<_>>()
282            .join("; "));
283    }
284
285    doc.ok_or_else(|| "parser returned no document".to_string())
286}
287
288#[cfg(test)]
289mod render_gui_tests {
290    use super::*;
291
292    /// End-to-end smoke test of the generic editor preview path: compile a wuxia
293    /// `.gui`, inject the parchment theme, bind list rows, render at 426×240, and
294    /// assert real (non-background) pixels were drawn.
295    #[test]
296    fn render_gui_draws_themed_proportional_layout() {
297        let src = r##"screen Party {
298  text("队伍") { rect = {tx: 2, ty: 1, tw: 20, th: 2} color = "#F0D070" }
299  flex_list("{members}") {
300    rect = {tx: 2, ty: 4, tw: 50, th: 22}
301    item_layout = [
302      {field: "name", width: 26, align: "left"},
303      {field: "hp", width: 13, align: "right"}
304    ]
305    padding = {top: 0, left: 0}
306    gap = 1
307    selected = "{cursor}"
308    cursor = {tile: 223, position: "left"}
309  }
310}"##;
311        let theme = r##"{"bg_color":"#18140F","default_font":"default","text_mode":"proportional","ink":"#F4ECD8","cursor_color":"#F0D070"}"##;
312        let data = r##"{"members":[["陈墨  [土]主角","气血120"],["吕醉仙  [火]侠客","气血130"]],"cursor":0}"##;
313
314        let bytes = render_gui(src, 426, 240, theme, data, 1);
315        assert_eq!(bytes.len(), 426 * 240 * 4, "RGBA buffer size");
316
317        // Background is #18140F; require a meaningful number of ink/cursor pixels.
318        let non_bg = bytes
319            .chunks_exact(4)
320            .filter(|p| p[0..3] != [0x18, 0x14, 0x0F])
321            .count();
322        assert!(non_bg > 200, "expected drawn glyph pixels, got {non_bg}");
323    }
324
325    /// Bad source compiles to nothing → empty buffer (editor shows blank, no panic).
326    #[test]
327    fn render_gui_bad_source_is_empty() {
328        assert!(render_gui("not a screen", 100, 100, "", "{}", 0).is_empty());
329    }
330
331    /// A declarations-only component prelude is a valid `.gui` file:
332    /// `compile_screen_source` reports it as `kind: "components"` (not an error)
333    /// so the editor can show an informational state instead of
334    /// "expected a screen layout (screen { ... })".
335    #[test]
336    fn compile_screen_source_accepts_component_prelude() {
337        let src = r##"component HpGauge {
338  current: int required
339  max: int required
340  color: color
341}
342
343component NamePlate {
344  title: string required
345}"##;
346        let raw = compile_screen_source(src);
347        let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap();
348        assert_eq!(parsed["ok"], true, "component prelude must not be an error: {raw}");
349        assert_eq!(parsed["kind"], "components");
350        assert_eq!(parsed["names"], serde_json::json!(["HpGauge", "NamePlate"]));
351        // Nothing to render — empty buffer, and no error logged.
352        assert!(render_gui(src, 100, 100, "", "{}", 0).is_empty());
353    }
354
355    /// Screen sources keep the original success shape, plus `kind: "screen"`.
356    #[test]
357    fn compile_screen_source_marks_screens() {
358        let raw = compile_screen_source(r##"screen Main { text("hi") { rect = {tx: 1, ty: 1, tw: 4, th: 1} } }"##);
359        let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap();
360        assert_eq!(parsed["ok"], true, "screen must compile: {raw}");
361        assert_eq!(parsed["kind"], "screen");
362        assert!(parsed["js"].as_str().unwrap().contains("\"screen\""));
363    }
364}