Skip to main content

dotzuki_web/
lib.rs

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