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
11mod audio;
13
14use dotzuki_ui::FrameBufferPainter;
15use dotzuki_engine::render::Rgba;
16
17fn log_warn(msg: &str) {
20 eprintln!("[dotzuki-web] WARN: {}", msg);
21}
22
23fn 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#[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 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 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 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 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, ®istry, &mut painter) {
109 log_error(&format!("render_gui: render failed: {e:?}"));
110 }
111 }
112 fb.data
113}
114
115fn 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
132const EDITOR_SCENE_PATH: &str = "editor/script.scene";
145
146fn parse_error_location(err: &str) -> (u32, u32, String) {
153 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
164fn dsl_ok_json(field: &str, output: &str) -> String {
166 serde_json::json!({ "ok": true, field: output }).to_string()
167}
168
169fn 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 "raw": err,
177 "line": line,
178 "col": col,
179 })
180 .to_string()
181}
182
183#[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#[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#[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
222fn 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 #[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 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 #[test]
291 fn render_gui_bad_source_is_empty() {
292 assert!(render_gui("not a screen", 100, 100, "", "{}", 0).is_empty());
293 }
294}