use std::collections::HashMap;
use wasm_bindgen::prelude::*;
use dotzuki_renderer::{FrameBuffer, RenderConfig};
use dotzuki_renderer::layout_engine::deserialize::parse_layout;
use dotzuki_renderer::layout_engine::registry::ElementRegistry;
use dotzuki_renderer::layout_engine::types::{DataContext, DataValue, RenderContext, Theme};
use dotzuki_renderer::layout_engine::renderer::render_layout as render_screen;
mod audio;
use dotzuki_ui::FrameBufferPainter;
use dotzuki_engine::render::Rgba;
fn log_warn(msg: &str) {
eprintln!("[dotzuki-web] WARN: {}", msg);
}
fn log_error(msg: &str) {
eprintln!("[dotzuki-web] ERROR: {}", msg);
}
#[cfg(feature = "debug-panic-hook")]
#[wasm_bindgen]
pub fn install_panic_hook() {
console_error_panic_hook::set_once();
}
#[wasm_bindgen]
pub fn render_gui(
source: &str,
width: u32,
height: u32,
theme_json: &str,
data_json: &str,
lang: u32,
) -> Vec<u8> {
let json = match compile_gui_inner(source) {
Ok(j) => j,
Err(e) => {
log_error(&format!("render_gui: compile failed: {e}"));
return Vec::new();
}
};
let mut layout = match parse_layout(&json) {
Ok(l) => l,
Err(e) => {
log_error(&format!("render_gui: parse failed: {e:?}"));
return Vec::new();
}
};
if !theme_json.is_empty() {
match serde_json::from_str::<Theme>(theme_json) {
Ok(t) => layout.theme = t,
Err(e) => log_warn(&format!("render_gui: theme parse failed: {e}")),
}
}
let mut ctx = DataContext::new();
ctx.set("__lang", if lang == 1 { "zh" } else { "en" });
if !data_json.is_empty() {
match serde_json::from_str::<serde_json::Map<String, serde_json::Value>>(data_json) {
Ok(map) => {
for (key, value) in map {
ctx.set(&key, json_to_data_value(&value));
}
}
Err(e) => log_warn(&format!("render_gui: data parse failed: {e}")),
}
}
let fonts: HashMap<String, ()> = HashMap::new();
let tilesets: HashMap<String, ()> = HashMap::new();
let render_ctx = RenderContext::new(&layout.screen, &layout.theme, &fonts, &tilesets);
let mut fb = FrameBuffer::new(RenderConfig::new(width, height), Rgba::WHITE);
{
let mut painter = FrameBufferPainter::new(&mut fb);
let registry = ElementRegistry::new();
if let Err(e) = render_screen(&layout, &ctx, &render_ctx, ®istry, &mut painter) {
log_error(&format!("render_gui: render failed: {e:?}"));
}
}
fb.data
}
fn json_to_data_value(v: &serde_json::Value) -> DataValue {
match v {
serde_json::Value::String(s) => DataValue::Str(s.clone()),
serde_json::Value::Bool(b) => DataValue::Bool(*b),
serde_json::Value::Number(n) => n
.as_i64()
.map(DataValue::Int)
.unwrap_or_else(|| DataValue::Float(n.as_f64().unwrap_or(0.0))),
serde_json::Value::Array(a) => {
DataValue::List(a.iter().map(json_to_data_value).collect())
}
serde_json::Value::Object(_) => DataValue::Str(v.to_string()),
serde_json::Value::Null => DataValue::Str(String::new()),
}
}
const EDITOR_SCENE_PATH: &str = "editor/script.scene";
fn parse_error_location(err: &str) -> (u32, u32, String) {
let first = err.split("; ").next().unwrap_or(err);
let mut parts = first.splitn(3, ':');
if let (Some(l), Some(c), Some(rest)) = (parts.next(), parts.next(), parts.next()) {
if let (Ok(line), Ok(col)) = (l.trim().parse::<u32>(), c.trim().parse::<u32>()) {
return (line, col, rest.trim().to_string());
}
}
(1, 1, err.to_string())
}
fn dsl_ok_json(field: &str, output: &str) -> String {
serde_json::json!({ "ok": true, field: output }).to_string()
}
fn dsl_err_json(err: &str) -> String {
let (line, col, message) = parse_error_location(err);
serde_json::json!({
"ok": false,
"error": message,
"raw": err,
"line": line,
"col": col,
})
.to_string()
}
#[wasm_bindgen]
pub fn compile_scene(source: &str) -> String {
match dotzuki_engine_dsl::compiler::compile_scene_to_js(source, EDITOR_SCENE_PATH) {
Ok(js) => dsl_ok_json("js", &js),
Err(e) => dsl_err_json(&e),
}
}
#[wasm_bindgen]
pub fn compile_scene_config(source: &str) -> String {
match dotzuki_engine_dsl::config_gen::compile_scene_to_config(source, EDITOR_SCENE_PATH) {
Ok(config) => dsl_ok_json("config", &config),
Err(e) => dsl_err_json(&e),
}
}
#[wasm_bindgen]
pub fn compile_screen_source(source: &str) -> String {
match compile_gui_inner(source) {
Ok(json) => dsl_ok_json("js", &json),
Err(e) => dsl_err_json(&e),
}
}
fn compile_gui_inner(source: &str) -> Result<String, String> {
let tokens = dotzuki_engine_dsl::lexer::Lexer::new(source, "editor/screen.gui")
.tokenize()
.map_err(|errors| {
errors
.iter()
.map(|e| format!("{}:{}: {}", e.line, e.col, e.message))
.collect::<Vec<_>>()
.join("; ")
})?;
let (doc, parse_errors) = dotzuki_engine_dsl::parser::Parser::new(tokens, source).parse();
if !parse_errors.is_empty() {
return Err(parse_errors
.iter()
.map(|e| e.to_string())
.collect::<Vec<_>>()
.join("; "));
}
match doc.ok_or_else(|| "parser returned no document".to_string())? {
dotzuki_engine_dsl::ast::Document::Screen(screen) => {
dotzuki_engine_dsl::codegen::json_ui::compile_screen(&screen).map_err(|e| e.to_string())
}
_ => Err("expected a screen layout (screen { ... })".to_string()),
}
}
#[cfg(test)]
mod render_gui_tests {
use super::*;
#[test]
fn render_gui_draws_themed_proportional_layout() {
let src = r##"screen Party {
text("队伍") { rect = {tx: 2, ty: 1, tw: 20, th: 2} color = "#F0D070" }
flex_list("{members}") {
rect = {tx: 2, ty: 4, tw: 50, th: 22}
item_layout = [
{field: "name", width: 26, align: "left"},
{field: "hp", width: 13, align: "right"}
]
padding = {top: 0, left: 0}
gap = 1
selected = "{cursor}"
cursor = {tile: 223, position: "left"}
}
}"##;
let theme = r##"{"bg_color":"#18140F","default_font":"default","text_mode":"proportional","ink":"#F4ECD8","cursor_color":"#F0D070"}"##;
let data = r##"{"members":[["陈墨 [土]主角","气血120"],["吕醉仙 [火]侠客","气血130"]],"cursor":0}"##;
let bytes = render_gui(src, 426, 240, theme, data, 1);
assert_eq!(bytes.len(), 426 * 240 * 4, "RGBA buffer size");
let non_bg = bytes
.chunks_exact(4)
.filter(|p| p[0..3] != [0x18, 0x14, 0x0F])
.count();
assert!(non_bg > 200, "expected drawn glyph pixels, got {non_bg}");
}
#[test]
fn render_gui_bad_source_is_empty() {
assert!(render_gui("not a screen", 100, 100, "", "{}", 0).is_empty());
}
}