dotzuki_runner/
headless.rs1use std::path::Path;
10
11use anyhow::{Context, Result};
12use dotzuki_engine::render::{FrameBuffer, Rgba};
13use dotzuki_engine::render_config::RenderConfig;
14use dotzuki_renderer::input::{GbButton, InputState};
15
16use crate::game::{RunnerGame, SCREEN_H, SCREEN_W};
17
18#[derive(Debug, Clone)]
20pub struct HeadlessOptions {
21 pub frames: u32,
23 pub press_a_every: u32,
26 pub input_script: Vec<(u32, GbButton)>,
30 pub screenshot: Option<std::path::PathBuf>,
32}
33
34impl Default for HeadlessOptions {
35 fn default() -> Self {
36 Self {
37 frames: 120,
38 press_a_every: 30,
39 input_script: Vec::new(),
40 screenshot: None,
41 }
42 }
43}
44
45pub fn run_headless(game: &mut RunnerGame, opts: &HeadlessOptions) -> Result<FrameBuffer> {
52 let mut input = InputState::new();
53 for frame in 0..opts.frames {
54 let mask = if !opts.input_script.is_empty() {
55 opts.input_script
56 .iter()
57 .filter(|(f, _)| *f == frame)
58 .fold(0, |mask, (_, b)| mask | b.bit_mask())
59 } else if opts.press_a_every > 0 && frame % opts.press_a_every == 0 {
60 GbButton::A.bit_mask()
61 } else {
62 0
63 };
64 input.set_from_bitmask(mask);
65 game.update(&input);
66 input.begin_frame();
67 }
68
69 let mut fb = FrameBuffer::new(
70 RenderConfig::new(SCREEN_W as u32, SCREEN_H as u32),
71 Rgba::BLACK,
72 );
73 game.draw(&mut fb);
74
75 if let Some(path) = &opts.screenshot {
76 save_png(&fb, path)?;
77 }
78 Ok(fb)
79}
80
81pub fn save_png(fb: &FrameBuffer, path: &Path) -> Result<()> {
83 if let Some(parent) = path.parent() {
84 if !parent.as_os_str().is_empty() {
85 std::fs::create_dir_all(parent)
86 .with_context(|| format!("failed to create {}", parent.display()))?;
87 }
88 }
89 image::save_buffer(
90 path,
91 &fb.data,
92 fb.width(),
93 fb.height(),
94 image::ColorType::Rgba8,
95 )
96 .with_context(|| format!("failed to write screenshot {}", path.display()))
97}