1use dotzuki_engine::render::painter::Painter;
14use dotzuki_engine::render::{Rgba, TilePos};
15
16use crate::layout_engine::types::{DataContext, LayoutElement, RenderContext, RenderError, TileParams};
17
18pub fn render_tile(
33 element: &LayoutElement,
34 params: &TileParams,
35 ctx: &DataContext,
36 _render_ctx: &RenderContext,
37 painter: &mut dyn Painter,
38) -> Result<(), RenderError> {
39 let tile_id = resolve_tile_id(params, ctx)?;
40 let color = params
41 .palette
42 .as_deref()
43 .map(parse_color)
44 .unwrap_or(Rgba::INK_BLACK);
45 let fallback = format!("[{}]", tile_id);
46
47 let rect = &element.rect;
48
49 let fill = params.repeat.is_some();
50 let tw = rect.tw.unwrap_or(1);
51 let th = rect.th.unwrap_or(1);
52
53 let base_tx = rect.tx.resolve(ctx);
54 let base_ty = rect.ty.resolve(ctx);
55
56 let cols = if fill { tw } else { 1 };
57 let rows = if fill { th } else { 1 };
58
59 for row in 0..rows {
60 for col in 0..cols {
61 painter.draw_gb_tile(
62 TilePos::new(base_tx + col, base_ty + row),
63 tile_id,
64 &fallback,
65 color,
66 );
67 }
68 }
69
70 Ok(())
71}
72
73pub fn render_tile_with_tiles(
87 element: &LayoutElement,
88 params: &TileParams,
89 ctx: &DataContext,
90 tileset: &crate::tile::TileSet,
91 palette: &crate::palette::Palette,
92 fb: &mut dotzuki_engine::render::FrameBuffer,
93) -> Result<(), RenderError> {
94 use crate::tile::TILE_PIXELS;
95
96 let tile_id = resolve_tile_id(params, ctx)?;
97 let tile = tileset.get(tile_id as usize);
98
99 let mut tile_data = tile.clone();
100 if params.flip_x {
101 tile_data = tile_data.flip_x();
102 }
103 if params.flip_y {
104 tile_data = tile_data.flip_y();
105 }
106
107 let rect = &element.rect;
108 let fill = params.repeat.is_some();
109 let tw = rect.tw.unwrap_or(1);
110 let th = rect.th.unwrap_or(1);
111
112 let base_tx = rect.tx.resolve(ctx);
113 let base_ty = rect.ty.resolve(ctx);
114
115 let cols = if fill { tw } else { 1 };
116 let rows = if fill { th } else { 1 };
117
118 for row in 0..rows {
119 for col in 0..cols {
120 let px = (base_tx + col) * TILE_PIXELS as u32;
121 let py = (base_ty + row) * TILE_PIXELS as u32;
122
123 for ty in 0..TILE_PIXELS {
124 let rgba_row = tile_data.render_row(ty, palette);
125 for tx in 0..TILE_PIXELS {
126 let rgba = rgba_row[tx];
127 if rgba != dotzuki_engine::render::Rgba::TRANSPARENT {
128 fb.set_pixel(px + tx as u32, py + ty as u32, rgba);
129 }
130 }
131 }
132 }
133 }
134
135 Ok(())
136}
137
138pub fn resolve_tile_id(params: &TileParams, ctx: &DataContext) -> Result<u8, RenderError> {
143 match ¶ms.tile_id {
144 serde_json::Value::Number(n) => n
145 .as_u64()
146 .and_then(|v| u8::try_from(v).ok())
147 .ok_or(RenderError::MissingVariable),
148 serde_json::Value::String(s) => {
149 let resolved = ctx.resolve(s);
150 resolved
151 .trim()
152 .parse::<u8>()
153 .map_err(|_| RenderError::MissingVariable)
154 }
155 _ => Err(RenderError::MissingVariable),
156 }
157}
158
159pub use crate::layout_engine::elements::text::parse_color;
160
161#[cfg(test)]
164mod tests {
165 use super::*;
166 use crate::layout_engine::types::{Coord, ElementParams, ElementRect};
167 use dotzuki_engine::render::Rgba as EngineRgba;
168
169 #[derive(Debug, Default)]
172 struct RecordingPainter {
173 tile_calls: Vec<(TilePos, u8, String, EngineRgba)>,
174 }
175
176 impl RecordingPainter {
177 fn new() -> Self {
178 Self::default()
179 }
180 }
181
182 impl Painter for RecordingPainter {
183 fn clear(&mut self, _color: EngineRgba) {}
184
185 fn draw_text_box(
186 &mut self,
187 _rect: dotzuki_engine::render::TileRect,
188 _color: EngineRgba,
189 ) {
190 }
191
192 fn draw_text(&mut self, _pos: TilePos, _text: &str, _color: EngineRgba) {}
193
194 fn draw_glyph(&mut self, _pos: TilePos, _glyph: char, _color: EngineRgba) {}
195
196 fn draw_pixel_rect(
197 &mut self,
198 _px: u32,
199 _py: u32,
200 _pw: u32,
201 _ph: u32,
202 _color: EngineRgba,
203 ) {
204 }
205
206 fn draw_gb_tile(
207 &mut self,
208 pos: TilePos,
209 tile_id: u8,
210 fallback: &str,
211 color: EngineRgba,
212 ) {
213 self.tile_calls
214 .push((pos, tile_id, fallback.to_string(), color));
215 }
216 }
217
218 fn make_element(tile_id: serde_json::Value, tx: u32, ty: u32) -> LayoutElement {
221 LayoutElement {
222 id: String::new(),
223 element_type: "tile".to_string(),
224 rect: ElementRect {
225 tx: Coord::Literal(tx),
226 ty: Coord::Literal(ty),
227 tw: Some(1),
228 th: Some(1),
229 },
230 visible: crate::layout_engine::types::Visibility::Static(true),
231 z_index: 0,
232 params: ElementParams::Tile(TileParams {
233 tile_id,
234 flip_x: false,
235 flip_y: false,
236 palette: None,
237 repeat: None,
238 }),
239 }
240 }
241
242 fn make_theme() -> crate::layout_engine::types::Theme {
243 Default::default()
244 }
245
246 #[test]
249 fn resolve_from_json_number() {
250 let params = TileParams {
251 tile_id: serde_json::Value::Number(serde_json::Number::from(42)),
252 flip_x: false,
253 flip_y: false,
254 palette: None,
255 repeat: None,
256 };
257 assert_eq!(resolve_tile_id(¶ms, &DataContext::new()).unwrap(), 42);
258 }
259
260 #[test]
261 fn resolve_from_json_string() {
262 let params = TileParams {
263 tile_id: serde_json::Value::String("99".to_string()),
264 flip_x: false,
265 flip_y: false,
266 palette: None,
267 repeat: None,
268 };
269 assert_eq!(resolve_tile_id(¶ms, &DataContext::new()).unwrap(), 99);
270 }
271
272 #[test]
273 fn resolve_from_template() {
274 let params = TileParams {
275 tile_id: serde_json::Value::String("{t}".to_string()),
276 flip_x: false,
277 flip_y: false,
278 palette: None,
279 repeat: None,
280 };
281 let mut ctx = DataContext::new();
282 ctx.set("t", 77i64);
283 assert_eq!(resolve_tile_id(¶ms, &ctx).unwrap(), 77);
284 }
285
286 #[test]
287 fn resolve_invalid_number_errors() {
288 let params = TileParams {
289 tile_id: serde_json::Value::Number(serde_json::Number::from(99999)),
290 flip_x: false,
291 flip_y: false,
292 palette: None,
293 repeat: None,
294 };
295 assert!(resolve_tile_id(¶ms, &DataContext::new()).is_err());
296 }
297
298 #[test]
299 fn resolve_invalid_string_errors() {
300 let params = TileParams {
301 tile_id: serde_json::Value::String("abc".to_string()),
302 flip_x: false,
303 flip_y: false,
304 palette: None,
305 repeat: None,
306 };
307 assert!(resolve_tile_id(¶ms, &DataContext::new()).is_err());
308 }
309
310 #[test]
311 fn resolve_missing_template_errors() {
312 let params = TileParams {
313 tile_id: serde_json::Value::String("{missing}".to_string()),
314 flip_x: false,
315 flip_y: false,
316 palette: None,
317 repeat: None,
318 };
319 let ctx = DataContext::new();
320 assert!(resolve_tile_id(¶ms, &ctx).is_err());
321 }
322
323 #[test]
326 fn parse_black() {
327 assert_eq!(parse_color("black"), Rgba::INK_BLACK);
328 }
329
330 #[test]
331 fn parse_darkgray() {
332 assert_eq!(parse_color("darkgray"), Rgba::INK_DARK_GRAY);
333 }
334
335 #[test]
336 fn parse_unknown_returns_black() {
337 assert_eq!(parse_color("green"), Rgba::INK_BLACK);
338 }
339
340 #[test]
343 fn render_single_tile() {
344 let elem = make_element(serde_json::Value::Number(serde_json::Number::from(10)), 3, 5);
345 let params = match &elem.params {
346 ElementParams::Tile(p) => p,
347 _ => unreachable!(),
348 };
349 let ctx = DataContext::new();
350 let theme = make_theme();
351 let fonts: std::collections::HashMap<String, ()> = std::collections::HashMap::new();
352 let tilesets: std::collections::HashMap<String, ()> = std::collections::HashMap::new();
353 let rc = RenderContext {
354 screen: "test",
355 theme: &theme,
356 fonts: &fonts,
357 tilesets: &tilesets,
358 images: crate::layout_engine::types::empty_image_registry(),
359 };
360 let mut p = RecordingPainter::new();
361
362 render_tile(&elem, params, &ctx, &rc, &mut p).unwrap();
363
364 assert_eq!(p.tile_calls.len(), 1);
365 assert_eq!(p.tile_calls[0].0, TilePos::new(3, 5));
366 assert_eq!(p.tile_calls[0].1, 10);
367 }
368
369 #[test]
370 fn render_repeat_fills_rect() {
371 let mut elem = make_element(serde_json::Value::Number(serde_json::Number::from(5)), 0, 0);
372 elem.rect.tw = Some(3);
373 elem.rect.th = Some(2);
374 if let ElementParams::Tile(ref mut tp) = elem.params {
375 tp.repeat = Some(1);
376 }
377 let params = match &elem.params {
378 ElementParams::Tile(p) => p,
379 _ => unreachable!(),
380 };
381 let ctx = DataContext::new();
382 let theme = make_theme();
383 let fonts: std::collections::HashMap<String, ()> = std::collections::HashMap::new();
384 let tilesets: std::collections::HashMap<String, ()> = std::collections::HashMap::new();
385 let rc = RenderContext {
386 screen: "test",
387 theme: &theme,
388 fonts: &fonts,
389 tilesets: &tilesets,
390 images: crate::layout_engine::types::empty_image_registry(),
391 };
392 let mut p = RecordingPainter::new();
393
394 render_tile(&elem, params, &ctx, &rc, &mut p).unwrap();
395
396 assert_eq!(p.tile_calls.len(), 6); for call in &p.tile_calls {
398 assert_eq!(call.1, 5);
399 }
400 assert_eq!(p.tile_calls[0].0, TilePos::new(0, 0));
401 assert_eq!(p.tile_calls[5].0, TilePos::new(2, 1));
402 }
403
404 #[test]
405 fn render_repeat_none_draws_once() {
406 let elem = make_element(serde_json::Value::Number(serde_json::Number::from(7)), 0, 0);
407 let params = match &elem.params {
408 ElementParams::Tile(p) => p,
409 _ => unreachable!(),
410 };
411 let ctx = DataContext::new();
412 let theme = make_theme();
413 let fonts: std::collections::HashMap<String, ()> = std::collections::HashMap::new();
414 let tilesets: std::collections::HashMap<String, ()> = std::collections::HashMap::new();
415 let rc = RenderContext {
416 screen: "test",
417 theme: &theme,
418 fonts: &fonts,
419 tilesets: &tilesets,
420 images: crate::layout_engine::types::empty_image_registry(),
421 };
422 let mut p = RecordingPainter::new();
423
424 render_tile(&elem, params, &ctx, &rc, &mut p).unwrap();
425
426 assert_eq!(p.tile_calls.len(), 1);
427 }
428
429 #[test]
430 fn render_from_template() {
431 let elem = make_element(
432 serde_json::Value::String("{id}".to_string()),
433 0,
434 0,
435 );
436 let params = match &elem.params {
437 ElementParams::Tile(p) => p,
438 _ => unreachable!(),
439 };
440 let mut ctx = DataContext::new();
441 ctx.set("id", 77i64);
442 let theme = make_theme();
443 let fonts: std::collections::HashMap<String, ()> = std::collections::HashMap::new();
444 let tilesets: std::collections::HashMap<String, ()> = std::collections::HashMap::new();
445 let rc = RenderContext {
446 screen: "test",
447 theme: &theme,
448 fonts: &fonts,
449 tilesets: &tilesets,
450 images: crate::layout_engine::types::empty_image_registry(),
451 };
452 let mut p = RecordingPainter::new();
453
454 render_tile(&elem, params, &ctx, &rc, &mut p).unwrap();
455
456 assert_eq!(p.tile_calls[0].1, 77);
457 }
458
459 #[test]
460 fn render_with_palette() {
461 let mut elem = make_element(serde_json::Value::Number(serde_json::Number::from(1)), 0, 0);
462 if let ElementParams::Tile(ref mut tp) = elem.params {
463 tp.palette = Some("darkgray".to_string());
464 }
465 let params = match &elem.params {
466 ElementParams::Tile(p) => p,
467 _ => unreachable!(),
468 };
469 let ctx = DataContext::new();
470 let theme = make_theme();
471 let fonts: std::collections::HashMap<String, ()> = std::collections::HashMap::new();
472 let tilesets: std::collections::HashMap<String, ()> = std::collections::HashMap::new();
473 let rc = RenderContext {
474 screen: "test",
475 theme: &theme,
476 fonts: &fonts,
477 tilesets: &tilesets,
478 images: crate::layout_engine::types::empty_image_registry(),
479 };
480 let mut p = RecordingPainter::new();
481
482 render_tile(&elem, params, &ctx, &rc, &mut p).unwrap();
483
484 assert_eq!(p.tile_calls[0].3, Rgba::INK_DARK_GRAY);
485 }
486
487 #[test]
490 fn render_tile_with_tiles_no_flip() {
491 let elem = make_element(serde_json::Value::Number(serde_json::Number::from(0)), 0, 0);
492 let params = match &elem.params {
493 ElementParams::Tile(p) => p,
494 _ => unreachable!(),
495 };
496 let ctx = DataContext::new();
497
498 let data = [0u8; 16]; let tileset = crate::tile::TileSet::from_2bpp(&data);
500 let palette = crate::palette::GRAYSCALE_PALETTE;
501 let mut fb = dotzuki_engine::render::FrameBuffer::new(dotzuki_engine::render_config::RenderConfig::new(160, 144), dotzuki_engine::render::Rgba::WHITE);
502
503 let result = render_tile_with_tiles(&elem, params, &ctx, &tileset, &palette, &mut fb);
504 assert!(result.is_ok());
505 }
506
507 #[test]
508 fn render_tile_with_tiles_flip_x() {
509 let elem = make_element(serde_json::Value::Number(serde_json::Number::from(0)), 0, 0);
510 let params = TileParams {
511 tile_id: serde_json::Value::Number(serde_json::Number::from(0)),
512 flip_x: true,
513 flip_y: false,
514 palette: None,
515 repeat: None,
516 };
517 let ctx = DataContext::new();
518
519 let data = [0xFFu8; 16]; let tileset = crate::tile::TileSet::from_2bpp(&data);
521 let palette = crate::palette::GRAYSCALE_PALETTE;
522 let mut fb = dotzuki_engine::render::FrameBuffer::new(dotzuki_engine::render_config::RenderConfig::new(160, 144), dotzuki_engine::render::Rgba::WHITE);
523
524 let result = render_tile_with_tiles(&elem, ¶ms, &ctx, &tileset, &palette, &mut fb);
525 assert!(result.is_ok());
526 }
527
528 #[test]
529 fn render_tile_with_tiles_repeat() {
530 let mut elem = make_element(serde_json::Value::Number(serde_json::Number::from(0)), 2, 3);
531 elem.rect.tw = Some(2);
532 elem.rect.th = Some(2);
533 let params = TileParams {
534 tile_id: serde_json::Value::Number(serde_json::Number::from(0)),
535 flip_x: false,
536 flip_y: false,
537 palette: None,
538 repeat: Some(1),
539 };
540 let ctx = DataContext::new();
541
542 let data = [0xFFu8; 16];
543 let tileset = crate::tile::TileSet::from_2bpp(&data);
544 let palette = crate::palette::GRAYSCALE_PALETTE;
545 let mut fb = dotzuki_engine::render::FrameBuffer::new(dotzuki_engine::render_config::RenderConfig::new(160, 144), dotzuki_engine::render::Rgba::WHITE);
546
547 let result = render_tile_with_tiles(&elem, ¶ms, &ctx, &tileset, &palette, &mut fb);
548 assert!(result.is_ok());
549
550 let tile_pixel_count = 8 * 8;
551 let filled_pixels: usize = (0..tile_pixel_count * 4)
552 .filter(|&i| {
553 let _x = (i % 4) as u32;
554 fb.data.get(i).map_or(false, |&b| b != 0)
555 })
556 .count();
557 assert!(filled_pixels > 0, "should have drawn some pixels");
558 }
559
560 #[test]
561 fn render_tile_with_tiles_out_of_bounds_tile_does_not_panic() {
562 let elem = make_element(
563 serde_json::Value::Number(serde_json::Number::from(255)),
564 0,
565 0,
566 );
567 let params = match &elem.params {
568 ElementParams::Tile(p) => p,
569 _ => unreachable!(),
570 };
571 let ctx = DataContext::new();
572
573 let data = [0u8; 16]; let tileset = crate::tile::TileSet::from_2bpp(&data);
575 let palette = crate::palette::GRAYSCALE_PALETTE;
576 let mut fb = dotzuki_engine::render::FrameBuffer::new(dotzuki_engine::render_config::RenderConfig::new(160, 144), dotzuki_engine::render::Rgba::WHITE);
577
578 let result = render_tile_with_tiles(&elem, params, &ctx, &tileset, &palette, &mut fb);
579 assert!(result.is_ok());
580 }
581}