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
11mod audio;
13
14#[cfg(feature = "game-shell")]
17pub mod game_shell;
18
19#[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
30fn log_warn(msg: &str) {
33 eprintln!("[dotzuki-web] WARN: {}", msg);
34}
35
36fn 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#[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 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 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 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 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, ®istry, &mut painter) {
138 log_error(&format!("render_gui: render failed: {e:?}"));
139 }
140 }
141 fb.data
142}
143
144fn 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
159const EDITOR_SCENE_PATH: &str = "editor/script.scene";
172
173fn parse_error_location(err: &str) -> (u32, u32, String) {
180 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
191fn dsl_ok_json(field: &str, output: &str) -> String {
193 serde_json::json!({ "ok": true, field: output }).to_string()
194}
195
196fn 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 "raw": err,
204 "line": line,
205 "col": col,
206 })
207 .to_string()
208}
209
210#[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#[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#[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
262fn 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 #[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 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 #[test]
327 fn render_gui_bad_source_is_empty() {
328 assert!(render_gui("not a screen", 100, 100, "", "{}", 0).is_empty());
329 }
330
331 #[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 assert!(render_gui(src, 100, 100, "", "{}", 0).is_empty());
353 }
354
355 #[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}