use std::collections::{HashMap, VecDeque};
#[cfg(all(feature = "watch", not(target_arch = "wasm32")))]
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use dotzuki_engine::camera::{Camera, Rect, Vec2};
use dotzuki_engine::menu::MenuConfig;
use dotzuki_engine::overworld::actor::{frame_col, OverworldActor, OverworldCollision};
use dotzuki_engine::overworld::types::Direction;
use dotzuki_engine::render::{FrameBuffer, Rgba, TileRect, Ui};
use dotzuki_engine_script::command::{CommandResult, ScriptCommand};
use dotzuki_engine_script::engine::ScriptEngine;
use dotzuki_renderer::embedded_font;
use dotzuki_renderer::input::{GbButton, InputState};
use dotzuki_renderer::walk_sprite::WalkSprite;
use dotzuki_ui::widgets::dialog::{draw_dialog, wrap_lines};
use dotzuki_ui::widgets::flex_menu::{draw_flex_menu, FlexMenuState};
use dotzuki_ui::FrameBufferPainter;
use crate::audio::RunnerAudio;
use crate::battle::{
Battle, BattleOutcome, BattleRng, BattleSetup, PartyMemberState, ScriptedRng, XorshiftRng,
};
use crate::manifest::DEFAULT_START_MONEY;
use crate::map::RuntimeMap;
use crate::project::LoadedProject;
use crate::save::{GameSave, PartyMemberSave, PlayerSave, DEFAULT_SAVE_FILE, SAVE_VERSION};
use crate::vfs::join_path;
#[cfg(all(feature = "watch", not(target_arch = "wasm32")))]
use crate::watch::ProjectWatcher;
mod menu;
use menu::{MenuState, ShopState, WhiteoutState};
pub const SCREEN_W: i32 = 320;
pub const SCREEN_H: i32 = 240;
const FADE_FRAMES: u32 = 10;
const FLASH_FRAMES: u32 = 10;
#[cfg(all(feature = "watch", not(target_arch = "wasm32")))]
const WATCH_DEBOUNCE_FRAMES: u32 = 10;
pub(crate) const DIALOG_AREA: TileRect = TileRect::new(0, 24, 40, 6);
const DIALOG_LINES_PER_PAGE: usize = 2;
const DIALOG_WIDTH_PX: usize = (DIALOG_AREA.tw as usize - 2) * 8;
#[derive(Debug, Clone, Default)]
pub struct RunnerOptions {
pub map: Option<String>,
pub lang: String,
pub watch: bool,
pub headless: bool,
pub pcm_audio: bool,
pub fresh: bool,
pub save_file: Option<PathBuf>,
pub rng_script: Option<Vec<u8>>,
pub write_saves: bool,
}
struct TextState {
engine: Option<ScriptEngine>,
pages: VecDeque<String>,
}
struct ChoiceState {
engine: ScriptEngine,
options: Vec<String>,
cursor: usize,
context_text: String,
}
struct DelayState {
engine: ScriptEngine,
frames_left: u16,
}
struct BattleState {
engine: Option<ScriptEngine>,
battle: Battle,
}
enum Mode {
Overworld,
Text(TextState),
Choice(ChoiceState),
Delay(DelayState),
Battle(Box<BattleState>),
Menu(MenuState),
Shop(Box<ShopState>),
Whiteout(WhiteoutState),
Idle,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum FadePhase {
Out,
In,
}
struct WarpTransition {
dest_map: String,
dest_x: i32,
dest_y: i32,
phase: FadePhase,
frames: u32,
}
impl WarpTransition {
fn new(dest_map: String, dest_x: i32, dest_y: i32) -> Self {
Self {
dest_map,
dest_x,
dest_y,
phase: FadePhase::Out,
frames: FADE_FRAMES,
}
}
}
struct CollisionView<'a> {
map: &'a RuntimeMap,
}
impl CollisionView<'_> {
fn npc_blocks(&self, x: i32, y: i32) -> bool {
self.map.objects().npcs.iter().any(|n| (n.x, n.y) == (x, y))
}
}
impl OverworldCollision for CollisionView<'_> {
fn is_blocked(&self, x: i32, y: i32) -> bool {
self.map.is_blocked(x, y) || self.npc_blocks(x, y)
}
fn is_blocked_at(&self, level: u8, x: i32, y: i32) -> bool {
self.map.is_blocked_at(level, x, y) || self.npc_blocks(x, y)
}
}
pub struct RunnerGame {
project: LoadedProject,
map: Option<RuntimeMap>,
camera: Camera,
actor: OverworldActor,
player_sprite: Option<WalkSprite>,
flags: HashMap<String, bool>,
lang: String,
mode: Mode,
transition: Option<WarpTransition>,
pending_scenes: VecDeque<(String, String)>,
opening_dispatch_pending: bool,
active_scene: Option<String>,
last_text: String,
flash: u32,
frame_count: u64,
#[cfg(all(feature = "watch", not(target_arch = "wasm32")))]
watcher: Option<ProjectWatcher>,
#[cfg(all(feature = "watch", not(target_arch = "wasm32")))]
watch_pending: HashSet<PathBuf>,
#[cfg(all(feature = "watch", not(target_arch = "wasm32")))]
watch_cooldown: u32,
audio: RunnerAudio,
save_path: PathBuf,
write_saves: bool,
rng_script: Option<Vec<u8>>,
party_state: Option<Vec<PartyMemberState>>,
inventory: Option<HashMap<String, u32>>,
money: u32,
pending_whiteout: bool,
pending_weather: Option<String>,
overworld_rng: Option<Box<dyn BattleRng>>,
steps_taken: u64,
}
impl RunnerGame {
pub fn new(project: LoadedProject, opts: RunnerOptions) -> Result<Self> {
let lang = if opts.lang.is_empty() {
"en".to_string()
} else {
opts.lang
};
let gfx_rel = project.gfx_root_rel();
let player_sprite = {
let rel = join_path(&gfx_rel, "overworld/player/sheet.png");
match project.files().read(&rel) {
Ok(bytes) => decode_walk_sheet(&bytes, &rel, 24, 32)
.map_err(|e| log::warn!("player sprite {rel}: {e}"))
.ok(),
Err(_) => None,
}
};
let mut camera = Camera::new(SCREEN_W as f32, SCREEN_H as f32);
camera.smooth_factor = 0.0;
#[cfg(all(feature = "watch", not(target_arch = "wasm32")))]
let watcher = if opts.watch {
ProjectWatcher::new(&watch_dirs(&project))
.map_err(|e| log::warn!("hot-reload disabled: {e}"))
.ok()
} else {
None
};
let mut audio = RunnerAudio::from_files(project.files().as_ref(), project.data_root_rel(), !opts.headless);
if opts.pcm_audio {
audio.set_pcm_render(true);
}
let save_path = opts
.save_file
.clone()
.unwrap_or_else(|| project.root().join(DEFAULT_SAVE_FILE));
let start_money = project
.manifest()
.shop
.as_ref()
.map(|s| s.start_money)
.unwrap_or(DEFAULT_START_MONEY);
let mut game = Self {
project,
map: None,
camera,
actor: OverworldActor::new(0, 0, 16),
player_sprite,
flags: HashMap::new(),
lang,
mode: Mode::Idle,
transition: None,
pending_scenes: VecDeque::new(),
opening_dispatch_pending: false,
active_scene: None,
last_text: String::new(),
flash: 0,
frame_count: 0,
#[cfg(all(feature = "watch", not(target_arch = "wasm32")))]
watcher,
#[cfg(all(feature = "watch", not(target_arch = "wasm32")))]
watch_pending: HashSet::new(),
#[cfg(all(feature = "watch", not(target_arch = "wasm32")))]
watch_cooldown: 0,
audio,
save_path,
write_saves: opts.write_saves,
rng_script: opts.rng_script.clone(),
party_state: None,
inventory: None,
money: start_money,
pending_whiteout: false,
pending_weather: None,
overworld_rng: None,
steps_taken: 0,
};
#[cfg(not(target_arch = "wasm32"))]
if !opts.fresh && opts.map.is_none() {
if let Some(save) = GameSave::load(&game.save_path) {
if game.resume_from(save) {
return Ok(game);
}
}
}
let map_ids = game.project.map_ids();
if map_ids.is_empty() {
let scene = game.project.entry_scene_name()?.to_string();
log::info!("dotzuki-runner: no maps; booting dialogue-only scene '{scene}'");
if !game.activate(&scene, "main") {
log::warn!("entry scene '{scene}' has no playable 'main' storyline");
}
return Ok(game);
}
let map_id = match &opts.map {
Some(id) => id.clone(),
None => game.project.entry_map()?,
};
game.boot_map(&map_id)
.with_context(|| format!("failed to load entry map '{map_id}'"))?;
game.dispatch_opening();
Ok(game)
}
fn boot_map(&mut self, map_id: &str) -> Result<()> {
let map = self.project.load_map(map_id)?;
let spawn = find_spawn(&map);
let tile = map.tile_size().0 as i32;
self.camera.clamp_to_bounds(Rect::new(
0.0,
0.0,
map.pixel_width() as f32,
map.pixel_height() as f32,
));
self.actor = OverworldActor::new(spawn.0, spawn.1, tile);
self.map = Some(map);
self.center_camera();
self.camera.update(0.0);
log::info!("loaded map {map_id} @ ({},{})", spawn.0, spawn.1);
Ok(())
}
#[must_use]
fn enter_map(&mut self, map_id: &str, spawn: (i32, i32), facing: Direction) -> bool {
let map = match self.project.load_map(map_id) {
Ok(m) => m,
Err(e) => {
log::error!("warp to {map_id} aborted: {e:#}");
return false;
}
};
let spawn = if map.is_blocked(spawn.0, spawn.1)
|| map.objects().npcs.iter().any(|n| (n.x, n.y) == spawn)
{
let fallback = find_spawn(&map);
log::warn!(
"spawn ({},{}) on {map_id} is occupied; using ({},{})",
spawn.0,
spawn.1,
fallback.0,
fallback.1
);
fallback
} else {
spawn
};
self.camera.clamp_to_bounds(Rect::new(
0.0,
0.0,
map.pixel_width() as f32,
map.pixel_height() as f32,
));
self.map = Some(map);
self.actor.place(spawn.0, spawn.1, facing);
self.actor.set_elevation(0);
self.center_camera();
self.camera.update(0.0);
log::info!("loaded map {map_id} @ ({},{})", spawn.0, spawn.1);
true
}
fn resume_from(&mut self, save: GameSave) -> bool {
self.flags.extend(save.flags);
if let Some(party) = save.party {
self.party_state = Some(
party
.into_iter()
.map(|m| PartyMemberState {
id: m.id,
hp: m.hp,
mp: m.mp,
status: m.status,
level: m.level,
exp: m.exp,
})
.collect(),
);
}
if save.inventory.is_some() {
self.inventory = save.inventory;
}
if let Some(money) = save.money {
self.money = money;
}
let Some(map_id) = &save.map else {
log::info!("save: restored flags (dialogue-only project)");
return false;
};
let map = match self.project.load_map(map_id) {
Ok(m) => m,
Err(e) => {
log::warn!("save: saved map '{map_id}' failed to load ({e:#}) — starting fresh");
return false;
}
};
let (x, y) = (save.player.x, save.player.y);
let facing = parse_facing(&save.player.facing);
let spawn = if map.is_blocked(x, y) || map.objects().npcs.iter().any(|n| (n.x, n.y) == (x, y))
{
let fallback = find_spawn(&map);
log::warn!(
"save: position ({x},{y}) on {map_id} is occupied; using ({},{})",
fallback.0,
fallback.1
);
fallback
} else {
(x, y)
};
self.camera.clamp_to_bounds(Rect::new(
0.0,
0.0,
map.pixel_width() as f32,
map.pixel_height() as f32,
));
self.map = Some(map);
self.actor.place(spawn.0, spawn.1, facing);
let max_level = self
.map
.as_ref()
.map(|m| m.level_count() - 1)
.unwrap_or(0) as u8;
self.actor.set_elevation(save.player.level.min(max_level));
self.center_camera();
self.camera.update(0.0);
self.mode = Mode::Overworld;
log::info!("save: resumed {map_id} @ ({},{})", spawn.0, spawn.1);
true
}
fn write_save(&self) {
if !self.write_saves {
return;
}
self.write_save_now();
}
pub(crate) fn write_save_now(&self) {
#[cfg(not(target_arch = "wasm32"))]
{
let save = self.current_save();
match save.write(&self.save_path) {
Ok(()) => log::info!("save: wrote {}", self.save_path.display()),
Err(e) => {
log::warn!("save: write to {} failed: {e:#}", self.save_path.display())
}
}
}
#[cfg(target_arch = "wasm32")]
{
let _ = &self.save_path; }
}
fn current_save(&self) -> GameSave {
let (x, y) = self.actor.tile();
GameSave {
version: SAVE_VERSION,
map: self.current_map_id().map(str::to_string),
player: PlayerSave {
x,
y,
facing: facing_name(self.actor.facing()).to_string(),
level: self.actor.elevation(),
},
flags: self.flags.clone(),
lang: Some(self.lang.clone()),
party: self.party_state.as_ref().map(|party| {
party
.iter()
.map(|m| PartyMemberSave {
id: m.id.clone(),
hp: m.hp,
mp: m.mp,
status: m.status.clone(),
level: m.level,
exp: m.exp,
})
.collect()
}),
inventory: self.inventory.clone(),
money: Some(self.money),
}
}
pub fn export_save(&self) -> Option<String> {
if self.transition.is_some()
|| matches!(
self.mode,
Mode::Text(_) | Mode::Choice(_) | Mode::Delay(_) | Mode::Battle(_) | Mode::Shop(_)
)
{
return None;
}
Some(self.current_save().to_json())
}
pub fn import_save(&mut self, json: &str) -> bool {
let save = match GameSave::from_json(json) {
Ok(save) => save,
Err(e) => {
log::warn!("save: import failed ({e:#}) — keeping current state");
return false;
}
};
if save.version > SAVE_VERSION {
log::warn!(
"save: imported save is version {} (newer than {SAVE_VERSION}) — keeping current state",
save.version
);
return false;
}
self.resume_from(save)
}
fn dispatch_opening(&mut self) {
let Some(map) = &self.map else {
return;
};
let map_id = map.id().to_string();
let on_enters: Vec<(String, String)> = self
.project
.routes()
.iter()
.filter(|r| r.map == map_id && r.on_enter)
.filter_map(|r| {
self.scene_with_storyline(&map_id, &r.storyline)
.map(|scene| (scene, r.storyline.clone()))
})
.collect();
if !on_enters.is_empty() {
self.pending_scenes.extend(on_enters);
self.pop_pending_scene();
return;
}
let Some(scene) = self.scene_for_map(&map_id) else {
return;
};
let onload = format!("{scene}OnLoad");
if scene_has_fn(&self.project, &scene, &onload) {
self.activate(&scene, &onload);
return;
}
let played_flag = format!("__played_main_{map_id}");
if !self.flags.get(&played_flag).copied().unwrap_or(false)
&& scene_has_fn(&self.project, &scene, "main")
{
self.flags.insert(played_flag, true);
self.activate(&scene, "main");
}
}
fn scene_for_map(&self, map_id: &str) -> Option<String> {
for (name, _js, source_path) in &self.project.report().scenes {
let path = Path::new(source_path);
if path.file_stem().is_some_and(|s| s == "script")
&& path
.parent()
.and_then(Path::file_name)
.is_some_and(|d| d == map_id)
{
return Some(name.clone());
}
}
if self.project.scripts().has_script(map_id) {
return Some(map_id.to_string());
}
None
}
fn scene_with_storyline(&self, map_id: &str, storyline: &str) -> Option<String> {
if let Some(scene) = self.scene_for_map(map_id) {
if scene_has_fn(&self.project, &scene, storyline) {
return Some(scene);
}
}
self.project
.report()
.scenes
.iter()
.map(|(name, _, _)| name)
.find(|name| scene_has_fn(&self.project, name, storyline))
.cloned()
}
fn activate(&mut self, scene: &str, storyline: &str) -> bool {
let Some(js) = self.project.scripts().get_script(scene) else {
log::warn!("scene '{scene}' not registered");
return false;
};
let js = js.to_string();
let mut engine = ScriptEngine::new();
engine.register_async_fn("startBattle", |args, ctx| {
let trainer_id = match args.first() {
Some(v) => v.to_string(ctx)?.to_std_string_lossy(),
None => String::new(),
};
Ok(ScriptCommand::StartBattle { trainer_id })
});
engine.register_async_fn("startWildBattle", |args, ctx| {
let species = match args.first() {
Some(v) => v.to_string(ctx)?.to_std_string_lossy(),
None => String::new(),
};
let level = match args.get(1) {
Some(v) => v.to_number(ctx)? as u8,
None => 1,
};
Ok(ScriptCommand::StartWildBattle { species, level })
});
engine.register_async_fn("setWeather", |args, ctx| {
let weather = match args.first() {
Some(v) => v.to_string(ctx)?.to_std_string_lossy(),
None => String::new(),
};
Ok(ScriptCommand::SetWeather {
weather: Some(weather),
})
});
engine.register_async_fn("clearWeather", |_args, _ctx| {
Ok(ScriptCommand::SetWeather { weather: None })
});
engine.seed_flags(&self.flags);
engine.set_lang(&self.lang);
let (px, py) = self.actor.tile();
engine.set_player_position(px.clamp(0, 255) as u8, py.clamp(0, 255) as u8);
if let Err(e) = engine.load_script(&js) {
log::warn!("load scene '{scene}': {e}");
return false;
}
if !engine.has_function(storyline) {
log::warn!("scene '{scene}' has no function '{storyline}'");
return false;
}
log::info!("activate {scene}::{storyline}");
match engine.call_function_no_args(storyline) {
Ok(cmd) => {
self.active_scene = Some(scene.to_string());
self.pump(engine, cmd, true);
true
}
Err(e) => {
log::warn!("run {scene}::{storyline}: {e}");
false
}
}
}
fn pop_pending_scene(&mut self) -> bool {
while let Some((scene, storyline)) = self.pending_scenes.pop_front() {
if self.activate(&scene, &storyline) {
return true;
}
}
false
}
fn pump(&mut self, mut engine: ScriptEngine, mut cmd: Option<ScriptCommand>, first: bool) {
let scene = self.active_scene.clone().unwrap_or_default();
let mut first = first;
loop {
match cmd {
Some(ScriptCommand::ShowText { text }) => {
self.last_text = text.clone();
self.mode = Mode::Text(TextState {
engine: Some(engine),
pages: paginate(&text),
});
return;
}
Some(ScriptCommand::ShowChoice { options }) => {
self.mode = Mode::Choice(ChoiceState {
engine,
options,
cursor: 0,
context_text: self.last_text.clone(),
});
return;
}
Some(ScriptCommand::Delay { frames }) => {
self.mode = Mode::Delay(DelayState {
engine,
frames_left: frames,
});
return;
}
Some(ScriptCommand::WarpTo { map, x, y }) => {
log::info!("scene warp → {map} ({x},{y})");
if self.enter_map(&map, (x as i32, y as i32), Direction::Down) {
self.opening_dispatch_pending = true;
}
cmd = self.signal(&mut engine, CommandResult::Void);
}
Some(ScriptCommand::FadeScreen { fade_type }) => {
log::info!("fadeScreen({fade_type}) (cosmetic in dotzuki run v1)");
self.flash = FLASH_FRAMES;
cmd = self.signal(&mut engine, CommandResult::Void);
}
Some(ScriptCommand::SetFlag { flag }) => {
engine.set_flag(&flag, true);
self.flags.insert(flag, true);
cmd = self.signal(&mut engine, CommandResult::Void);
}
Some(ScriptCommand::ResetFlag { flag }) => {
engine.set_flag(&flag, false);
self.flags.insert(flag, false);
cmd = self.signal(&mut engine, CommandResult::Void);
}
Some(ScriptCommand::CheckFlag { flag }) => {
let value = self.flags.get(&flag).copied().unwrap_or(false);
cmd = self.signal(&mut engine, CommandResult::Bool(value));
}
Some(ScriptCommand::PlayMusic { music_id }) => {
self.audio.play_music(&music_id);
cmd = self.signal(&mut engine, CommandResult::Void);
}
Some(ScriptCommand::PlaySound { sound_id }) => {
self.audio.play_sound(&sound_id);
cmd = self.signal(&mut engine, CommandResult::Void);
}
Some(ScriptCommand::StopMusic) => {
self.audio.stop_music();
cmd = self.signal(&mut engine, CommandResult::Void);
}
Some(ScriptCommand::FadeOutMusic) => {
self.audio.fade_out_music();
cmd = self.signal(&mut engine, CommandResult::Void);
}
Some(ScriptCommand::StartBattle { trainer_id }) => {
log::info!("scene → battle: startBattle({trainer_id})");
match self.build_battle(&trainer_id) {
Some(battle) => {
self.mode = Mode::Battle(Box::new(BattleState { engine: Some(engine), battle }));
return;
}
None => {
cmd = self.signal(&mut engine, CommandResult::Text("win".to_string()));
}
}
}
Some(ScriptCommand::StartWildBattle { species, level }) => {
log::info!("scene → battle: startWildBattle({species}, lv{level}) — level ignored in v1");
match self.build_battle(&species) {
Some(battle) => {
self.mode = Mode::Battle(Box::new(BattleState { engine: Some(engine), battle }));
return;
}
None => {
cmd = self.signal(&mut engine, CommandResult::Text("win".to_string()));
}
}
}
Some(ScriptCommand::SetWeather { weather }) => {
log::info!("scene → weather: {weather:?} (applies to the next battle)");
self.pending_weather = weather;
cmd = self.signal(&mut engine, CommandResult::Void);
}
Some(ScriptCommand::OpenShop { items }) => {
log::info!("scene → shop: openShop({items:?})");
let items = self.build_shop_items(&items);
self.mode = Mode::Shop(Box::new(ShopState::new(engine, items)));
return;
}
Some(other) => {
log::warn!(
"unhandled scene command {other:?} in scene '{scene}' — \
auto-completing with Void"
);
cmd = self.signal(&mut engine, CommandResult::Void);
}
None => {
if first {
log::warn!(
"scene '{scene}' finished without producing any command — \
did it call an unregistered game.* function?"
);
}
self.finish_scene(engine);
return;
}
}
first = false;
}
}
fn signal(&mut self, engine: &mut ScriptEngine, result: CommandResult) -> Option<ScriptCommand> {
match engine.signal_done(result) {
Ok(cmd) => cmd,
Err(e) => {
let scene = self.active_scene.clone().unwrap_or_default();
log::warn!("scene '{scene}' signal failed: {e}");
None
}
}
}
fn build_battle(&mut self, enemy_id: &str) -> Option<Battle> {
if self.project.manifest().battle.is_none() {
log::warn!(
"startBattle({enemy_id}): project has no battle section — \
auto-completing with \"win\""
);
return None;
}
let rng: Box<dyn BattleRng> = match &self.rng_script {
Some(bytes) => Box::new(ScriptedRng::new(bytes.clone())),
None => {
#[cfg(not(target_arch = "wasm32"))]
let seed = {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.subsec_nanos() as u64)
.unwrap_or(0);
nanos ^ self.frame_count
};
#[cfg(target_arch = "wasm32")]
let seed = self
.frame_count
.wrapping_mul(0x9E37_79B9_7F4A_7C15)
^ 0xA076_1D64_78BD_642F;
Box::new(XorshiftRng::seed(seed))
}
};
match BattleSetup::from_project(&self.project).and_then(|setup| {
setup.start_with(
enemy_id,
rng,
self.party_state.as_deref(),
self.inventory.as_ref(),
)
}) {
Ok(mut battle) => {
battle.set_lang(&self.lang);
battle.set_currency(self.currency());
battle.set_weather(self.pending_weather.clone());
battle.begin();
Some(battle)
}
Err(e) => {
log::error!("startBattle({enemy_id}): battle setup failed: {e:#}");
None
}
}
}
fn finish_scene(&mut self, engine: ScriptEngine) {
self.flags.extend(engine.get_all_flags());
drop(engine);
self.active_scene = None;
self.mode = if self.map.is_some() {
Mode::Overworld
} else {
Mode::Idle
};
if self.pending_whiteout {
self.pending_whiteout = false;
self.pending_scenes.clear();
self.opening_dispatch_pending = false;
self.start_whiteout();
return;
}
if self.pop_pending_scene() {
return;
}
if self.opening_dispatch_pending {
self.opening_dispatch_pending = false;
self.dispatch_opening();
}
if matches!(self.mode, Mode::Overworld | Mode::Idle) {
self.write_save();
}
}
#[cfg(all(feature = "watch", not(target_arch = "wasm32")))]
pub fn poll_watch(&mut self) {
let Some(watcher) = &mut self.watcher else {
return;
};
self.watch_pending.extend(watcher.poll_events());
if self.watch_cooldown > 0 {
self.watch_cooldown -= 1;
return;
}
if self.watch_pending.is_empty() {
return;
}
let paths: Vec<PathBuf> = self.watch_pending.drain().collect();
self.watch_cooldown = WATCH_DEBOUNCE_FRAMES;
self.apply_watch_batch(paths);
}
#[cfg(any(not(feature = "watch"), target_arch = "wasm32"))]
pub fn poll_watch(&mut self) {}
#[cfg(all(feature = "watch", not(target_arch = "wasm32")))]
fn apply_watch_batch(&mut self, paths: Vec<PathBuf>) {
let map_dir = self
.map
.as_ref()
.map(|m| crate::map::map_dir(&self.project.maps_dir(), m.id()));
let mut scenes_changed = false;
let mut map_changed = false;
for path in &paths {
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
if ext == "scene" {
scenes_changed = true;
} else if map_dir
.as_ref()
.is_some_and(|d| path.parent() == Some(d.as_path()))
{
map_changed = true;
}
}
if scenes_changed {
self.reload_scenes();
}
if map_changed {
self.reload_current_map();
}
}
pub fn reload_scenes(&mut self) -> bool {
match self.project.recompile_scripts() {
Ok(()) => {
log::info!("hot-reload: scenes recompiled");
true
}
Err(e) => {
log::warn!("hot-reload: scene reload failed, keeping old scenes: {e:#}");
false
}
}
}
pub fn reload_current_map(&mut self) -> bool {
let Some(map) = &self.map else {
return false;
};
let map_id = map.id().to_string();
match self.project.load_map(&map_id) {
Ok(new_map) => {
self.camera.clamp_to_bounds(Rect::new(
0.0,
0.0,
new_map.pixel_width() as f32,
new_map.pixel_height() as f32,
));
self.map = Some(new_map);
self.center_camera();
self.camera.update(0.0);
log::info!("hot-reload: reloaded map '{map_id}'");
true
}
Err(e) => {
log::warn!("hot-reload: map '{map_id}' reload failed, keeping old map: {e:#}");
false
}
}
}
pub fn update(&mut self, input: &InputState) {
self.frame_count += 1;
self.poll_watch();
self.audio.update_frame();
if self.flash > 0 {
self.flash -= 1;
}
if self.transition.is_some() {
self.update_transition();
return;
}
match std::mem::replace(&mut self.mode, Mode::Overworld) {
Mode::Text(mut state) => {
if input.is_just_pressed(GbButton::A) {
state.pages.pop_front();
if state.pages.is_empty() {
match state.engine {
Some(mut engine) => {
let cmd = self.signal(&mut engine, CommandResult::Void);
self.pump(engine, cmd, false);
}
None => {
self.mode = if self.map.is_some() {
Mode::Overworld
} else {
Mode::Idle
};
}
}
} else {
self.mode = Mode::Text(state);
}
} else {
self.mode = Mode::Text(state);
}
}
Mode::Choice(mut state) => {
let n = state.options.len().max(1);
if input.is_just_pressed(GbButton::Up) {
state.cursor = (state.cursor + n - 1) % n;
} else if input.is_just_pressed(GbButton::Down) {
state.cursor = (state.cursor + 1) % n;
}
if input.is_just_pressed(GbButton::A) {
let cursor = state.cursor;
let mut engine = state.engine;
log::info!("choice picked: {cursor}");
let cmd = self.signal(&mut engine, CommandResult::Number(cursor as f64));
self.pump(engine, cmd, false);
} else {
self.mode = Mode::Choice(state);
}
}
Mode::Delay(mut state) => {
if state.frames_left > 0 {
state.frames_left -= 1;
}
if state.frames_left == 0 {
let mut engine = state.engine;
let cmd = self.signal(&mut engine, CommandResult::Void);
self.pump(engine, cmd, false);
} else {
self.mode = Mode::Delay(state);
}
}
Mode::Battle(mut state) => {
state.battle.update(input);
if let Some(outcome) = state.battle.outcome() {
self.party_state = Some(state.battle.party_state());
self.inventory = Some(state.battle.inventory().clone());
let result = match outcome {
BattleOutcome::Win => "win",
BattleOutcome::Lose => "lose",
BattleOutcome::Run => "run",
};
if outcome == BattleOutcome::Win {
self.money = self.money.saturating_add(state.battle.trainer_money());
}
self.pending_whiteout = outcome == BattleOutcome::Lose;
self.pending_weather = None;
log::info!("battle ended: {result}");
match state.engine {
Some(mut engine) => {
let cmd =
self.signal(&mut engine, CommandResult::Text(result.to_string()));
self.pump(engine, cmd, false);
}
None => {
if outcome == BattleOutcome::Lose {
self.pending_whiteout = false;
self.start_whiteout();
} else {
self.mode = Mode::Overworld;
}
}
}
} else {
self.mode = Mode::Battle(state);
}
}
Mode::Overworld => self.update_overworld(input),
Mode::Menu(state) => self.update_menu(state, input),
Mode::Shop(state) => self.update_shop(*state, input),
Mode::Whiteout(state) => self.update_whiteout(state, input),
Mode::Idle => {
self.mode = Mode::Idle;
}
}
self.center_camera();
self.camera.update(0.0);
}
fn update_transition(&mut self) {
let Some(t) = &mut self.transition else {
return;
};
if t.frames > 0 {
t.frames -= 1;
}
if t.frames > 0 {
return;
}
match t.phase {
FadePhase::Out => {
let (dest_map, dest) = (t.dest_map.clone(), (t.dest_x, t.dest_y));
let facing = self.actor.facing();
if self.enter_map(&dest_map, dest, facing) {
if let Some(t) = &mut self.transition {
t.phase = FadePhase::In;
t.frames = FADE_FRAMES;
}
} else {
self.transition = None;
}
}
FadePhase::In => {
self.transition = None;
self.write_save();
self.dispatch_opening();
}
}
}
fn update_overworld(&mut self, input: &InputState) {
if self.map.is_none() {
return;
}
if input.is_just_pressed(GbButton::Start) {
self.mode = Mode::Menu(MenuState::new());
return;
}
if input.is_just_pressed(GbButton::A) {
if let Some(npc_index) = self.faced_npc_index() {
self.talk_to(npc_index);
return;
}
if let Some(sign_index) = self.faced_sign_index() {
self.read_sign(sign_index);
return;
}
}
let held = held_direction(input);
self.actor.set_running(input.is_held(GbButton::B));
let step = {
let map = self.map.as_ref().expect("checked above");
let view = CollisionView { map };
self.actor.update(held, &view)
};
if let Some((tx, ty)) = step {
self.steps_taken += 1;
self.apply_stairs(tx, ty);
let warp = {
let map = self.map.as_ref().expect("checked above");
map.objects()
.warps
.iter()
.find(|w| (w.x, w.y) == (tx, ty) && !w.dest_map.is_empty())
.map(|w| (w.dest_map.clone(), w.dest_x, w.dest_y))
};
if let Some((dest_map, dest_x, dest_y)) = warp {
self.transition = Some(WarpTransition::new(dest_map, dest_x, dest_y));
} else {
self.roll_encounter(tx, ty);
}
}
}
fn roll_encounter(&mut self, tx: i32, ty: i32) {
let (rate, table) = {
let map = self.map.as_ref().expect("roll_encounter requires a map");
let Some(encounters) = &map.objects().encounters else {
return;
};
match encounters.zones.iter().find(|z| z.contains(tx, ty)) {
Some(zone) => (encounters.rate, zone.table.clone()),
None => return,
}
};
if rate == 0 || table.is_empty() {
return;
}
if self.overworld_rng_byte() >= rate {
return;
}
let total: u32 = table.iter().map(|e| e.weight).sum();
if total == 0 {
return;
}
let mut pick = u32::from(self.overworld_rng_byte()) % total;
let id = table
.iter()
.find(|e| {
if pick < e.weight {
true
} else {
pick -= e.weight;
false
}
})
.map(|e| e.id.clone());
if let Some(id) = id {
self.start_overworld_battle(&id);
}
}
fn overworld_rng_byte(&mut self) -> u8 {
if self.overworld_rng.is_none() {
let rng: Box<dyn BattleRng> = match &self.rng_script {
Some(bytes) => Box::new(ScriptedRng::new(bytes.clone())),
None => {
#[cfg(not(target_arch = "wasm32"))]
let seed = {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.subsec_nanos() as u64)
.unwrap_or(0);
nanos ^ self.frame_count
};
#[cfg(target_arch = "wasm32")]
let seed = self
.frame_count
.wrapping_add(self.steps_taken)
.wrapping_mul(0x9E37_79B9_7F4A_7C15)
^ 0xA076_1D64_78BD_642F;
Box::new(XorshiftRng::seed(seed))
}
};
self.overworld_rng = Some(rng);
}
self.overworld_rng
.as_deref_mut()
.expect("seeded above")
.byte()
}
fn start_overworld_battle(&mut self, id: &str) {
log::info!("random encounter: {id}");
if let Some(battle) = self.build_battle(id) {
self.mode = Mode::Battle(Box::new(BattleState { engine: None, battle }));
}
}
fn apply_stairs(&mut self, x: i32, y: i32) {
let Some(map) = &self.map else {
return;
};
let level = self.actor.elevation();
let next = match map.stair_at(x, y) {
Some(1) => (level as usize + 1).min(map.level_count() - 1) as u8,
Some(2) => level.saturating_sub(1),
_ => return,
};
if next != level {
self.actor.set_elevation(next);
}
}
fn faced_npc_index(&self) -> Option<usize> {
let map = self.map.as_ref()?;
let (dx, dy) = direction_delta(self.actor.facing());
let (tx, ty) = self.actor.tile();
let faced = (tx + dx, ty + dy);
map.objects()
.npcs
.iter()
.position(|n| (n.x, n.y) == faced)
}
fn faced_sign_index(&self) -> Option<usize> {
let map = self.map.as_ref()?;
let (dx, dy) = direction_delta(self.actor.facing());
let (tx, ty) = self.actor.tile();
let faced = (tx + dx, ty + dy);
map.objects()
.signs
.iter()
.position(|s| (s.x, s.y) == faced)
}
fn read_sign(&mut self, index: usize) {
let map = self.map.as_ref().expect("read_sign requires a map");
let text = map.objects().signs[index].text.clone();
if text.is_empty() {
return;
}
self.last_text = text.clone();
self.mode = Mode::Text(TextState {
engine: None,
pages: paginate(&text),
});
}
fn talk_to(&mut self, index: usize) {
let (map_id, npc_name, npc_id, talk) = {
let map = self.map.as_ref().expect("talk requires a map");
let npc = &map.objects().npcs[index];
(
map.id().to_string(),
npc.name.clone(),
npc.id,
npc.talk.clone(),
)
};
if !talk.is_empty() {
if let Some(scene) = self.scene_with_storyline(&map_id, &talk) {
if self.activate(&scene, &talk) {
return;
}
}
}
let route = self
.project
.routes()
.iter()
.filter(|r| r.map == map_id && !r.on_enter)
.find(|r| {
r.npc.as_deref().is_some_and(|n| {
(!npc_name.is_empty() && n == npc_name) || n == npc_id.to_string()
})
})
.map(|r| r.storyline.clone());
if let Some(storyline) = route {
if let Some(scene) = self.scene_with_storyline(&map_id, &storyline) {
if self.activate(&scene, &storyline) {
return;
}
}
}
if let Some(scene) = self.scene_for_map(&map_id) {
if scene_has_fn(&self.project, &scene, "main") && self.activate(&scene, "main") {
return;
}
}
if !talk.is_empty() {
self.last_text = talk.clone();
self.mode = Mode::Text(TextState {
engine: None,
pages: paginate(&talk),
});
}
}
pub fn draw(&mut self, fb: &mut FrameBuffer) {
if let Some(map) = &self.map {
fb.clear(Rgba::BLACK);
let cam_x = self.camera.position.x.round() as i32;
let cam_y = self.camera.position.y.round() as i32;
let level = self.actor.elevation() as i32;
if let Err(e) = map.render_below(fb, cam_x, cam_y, SCREEN_W as u32, SCREEN_H as u32, level)
{
log::warn!("map render: {e:#}");
}
self.draw_npcs(fb, cam_x, cam_y);
self.draw_player(fb, cam_x, cam_y);
if let Err(e) = map.render_above(fb, cam_x, cam_y, SCREEN_W as u32, SCREEN_H as u32, level)
{
log::warn!("map render: {e:#}");
}
} else {
fb.clear(Rgba::rgb(0x10, 0x10, 0x18));
}
match &self.mode {
Mode::Text(state) => {
if let Some(page) = state.pages.front() {
draw_textbox(fb, page);
}
}
Mode::Choice(state) => {
if !state.context_text.is_empty() {
draw_textbox(fb, &state.context_text);
}
draw_choice_menu(fb, &state.options, state.cursor);
}
Mode::Battle(state) => state.battle.draw(fb),
Mode::Menu(state) => self.draw_menu(fb, state),
Mode::Shop(state) => self.draw_shop(fb, state),
Mode::Whiteout(state) => {
if let Some(page) = state.pages.front() {
draw_textbox(fb, page);
}
}
Mode::Idle if self.map.is_none() => {
draw_end_card(fb, &self.project.manifest().name, &self.lang);
}
_ => {}
}
let darkness = match &self.transition {
Some(t) => match t.phase {
FadePhase::Out => 1.0 - t.frames as f32 / FADE_FRAMES as f32,
FadePhase::In => t.frames as f32 / FADE_FRAMES as f32,
},
None if self.flash > 0 => 0.5 * self.flash as f32 / FLASH_FRAMES as f32,
None => 0.0,
};
if darkness > 0.0 {
darken(fb, 1.0 - darkness.clamp(0.0, 1.0));
}
if let Mode::Whiteout(state) = &self.mode {
if state.blackout > 0 {
fb.fill_rect(0, 0, SCREEN_W as u32, SCREEN_H as u32, Rgba::BLACK);
}
}
}
fn draw_npcs(&self, fb: &mut FrameBuffer, cam_x: i32, cam_y: i32) {
let Some(map) = &self.map else {
return;
};
let tile = map.tile_size().0 as i32;
for npc in &map.objects().npcs {
let facing = parse_facing(&npc.facing);
let colors = npc_palette(npc.id);
draw_person(
fb,
npc.x * tile - cam_x,
npc.y * tile - cam_y,
tile,
facing,
&colors,
);
}
}
fn draw_player(&self, fb: &mut FrameBuffer, cam_x: i32, cam_y: i32) {
let tile = self.map.as_ref().map(|m| m.tile_size().0 as i32).unwrap_or(16);
let foot_x = self.actor.px().round() as i32 - cam_x;
let foot_y = self.actor.py().round() as i32 - cam_y;
if let Some(sprite) = &self.player_sprite {
let col = frame_col(
self.actor.locomotion(),
self.actor.step_phase(),
sprite.cols,
);
sprite.draw_on_tile(fb, self.actor.facing_row(), col, foot_x, foot_y, tile);
return;
}
draw_person(fb, foot_x, foot_y, tile, self.actor.facing(), &PLAYER_COLORS);
}
fn center_camera(&mut self) {
let tile = self.map.as_ref().map(|m| m.tile_size().0 as i32).unwrap_or(16);
let cx = self.actor.px() + (tile / 2) as f32;
let cy = self.actor.py() + (tile / 2) as f32;
self.camera.follow_target(Vec2::new(
cx - SCREEN_W as f32 / 2.0,
cy - SCREEN_H as f32 / 2.0,
));
}
pub fn current_map_id(&self) -> Option<&str> {
self.map.as_ref().map(RuntimeMap::id)
}
pub fn flag(&self, name: &str) -> bool {
self.flags.get(name).copied().unwrap_or(false)
}
pub fn dialogue_text(&self) -> Option<&str> {
match &self.mode {
Mode::Text(state) => state.pages.front().map(String::as_str),
Mode::Whiteout(state) if state.blackout == 0 => {
state.pages.front().map(String::as_str)
}
_ => None,
}
}
pub fn choice_options(&self) -> Option<&[String]> {
match &self.mode {
Mode::Choice(state) => Some(&state.options),
_ => None,
}
}
pub fn battle(&self) -> Option<&Battle> {
match &self.mode {
Mode::Battle(state) => Some(&state.battle),
_ => None,
}
}
pub fn party_state(&self) -> Option<&[PartyMemberState]> {
self.party_state.as_deref()
}
pub fn inventory(&self) -> Option<&HashMap<String, u32>> {
self.inventory.as_ref()
}
pub fn money(&self) -> u32 {
self.money
}
pub fn menu_lines(&self) -> Option<Vec<String>> {
match &self.mode {
Mode::Menu(state) => Some(self.menu_lines_for(state)),
_ => None,
}
}
pub fn shop_lines(&self) -> Option<Vec<String>> {
match &self.mode {
Mode::Shop(state) => Some(self.shop_lines_for(state)),
_ => None,
}
}
pub fn whiteout_active(&self) -> bool {
matches!(self.mode, Mode::Whiteout(_))
}
pub fn player_tile(&self) -> (i32, i32) {
self.actor.tile()
}
pub fn player_elevation(&self) -> u8 {
self.actor.elevation()
}
pub fn is_blocked(&self, x: i32, y: i32) -> bool {
match &self.map {
Some(map) => map.is_blocked(x, y),
None => true,
}
}
pub fn audio(&self) -> &RunnerAudio {
&self.audio
}
pub fn render_audio(&mut self, frames: usize) -> Vec<f32> {
self.audio.render_samples(frames)
}
pub fn debug_place(&mut self, x: i32, y: i32, facing: Direction) {
self.actor.place(x, y, facing);
self.center_camera();
self.camera.update(0.0);
}
pub fn frame_count(&self) -> u64 {
self.frame_count
}
}
#[cfg(not(target_arch = "wasm32"))]
impl dotzuki_app::GameLoop for RunnerGame {
type Fb = FrameBuffer;
fn update(&mut self, input: &InputState) {
RunnerGame::update(self, input);
}
fn draw(&mut self, frame_buffer: &mut FrameBuffer) {
RunnerGame::draw(self, frame_buffer);
}
}
fn decode_walk_sheet(
bytes: &[u8],
path: &str,
frame_w: u32,
frame_h: u32,
) -> Result<WalkSprite, String> {
let img = image::load_from_memory(bytes)
.map_err(|e| format!("decode {path}: {e}"))?
.to_rgba8();
let (w, h) = img.dimensions();
let pixels = img
.pixels()
.map(|p| Rgba::new(p.0[0], p.0[1], p.0[2], p.0[3]))
.collect();
WalkSprite::from_rgba(pixels, w, h, frame_w, frame_h)
}
fn scene_has_fn(project: &LoadedProject, scene: &str, fn_name: &str) -> bool {
let Some(js) = project.scripts().get_script(scene) else {
return false;
};
js.contains(&format!("storyline_{fn_name}")) || js.contains(&format!("function {fn_name}"))
}
#[cfg(all(feature = "watch", not(target_arch = "wasm32")))]
fn watch_dirs(project: &LoadedProject) -> Vec<PathBuf> {
let mut dirs = project.manifest().dsl_dirs(project.root());
dirs.push(project.data_root().to_path_buf());
if let Some(gfx) = project.gfx_root() {
dirs.push(gfx.to_path_buf());
}
let mut seen = HashSet::new();
dirs.retain(|d| seen.insert(d.clone()));
dirs
}
fn find_spawn(map: &RuntimeMap) -> (i32, i32) {
let (cx, cy) = (map.width() as i32 / 2, map.height() as i32 / 2);
let free = |x: i32, y: i32| {
!map.is_blocked(x, y) && !map.objects().npcs.iter().any(|n| (n.x, n.y) == (x, y))
};
let max_r = (map.width().max(map.height()) as i32) + 1;
for r in 0..max_r {
for dy in -r..=r {
for dx in -r..=r {
if dx.abs().max(dy.abs()) != r {
continue; }
let (x, y) = (cx + dx, cy + dy);
if free(x, y) {
return (x, y);
}
}
}
}
(cx, cy)
}
fn held_direction(input: &InputState) -> Option<Direction> {
if input.is_held(GbButton::Up) {
Some(Direction::Up)
} else if input.is_held(GbButton::Down) {
Some(Direction::Down)
} else if input.is_held(GbButton::Left) {
Some(Direction::Left)
} else if input.is_held(GbButton::Right) {
Some(Direction::Right)
} else {
None
}
}
fn direction_delta(dir: Direction) -> (i32, i32) {
match dir {
Direction::Down => (0, 1),
Direction::Up => (0, -1),
Direction::Left => (-1, 0),
Direction::Right => (1, 0),
}
}
fn parse_facing(facing: &str) -> Direction {
match facing {
"up" => Direction::Up,
"left" => Direction::Left,
"right" => Direction::Right,
_ => Direction::Down,
}
}
fn facing_name(dir: Direction) -> &'static str {
match dir {
Direction::Down => "down",
Direction::Up => "up",
Direction::Left => "left",
Direction::Right => "right",
}
}
fn paginate(text: &str) -> VecDeque<String> {
let lines = wrap_lines(text, DIALOG_WIDTH_PX, 4096);
let mut pages: VecDeque<String> = lines
.chunks(DIALOG_LINES_PER_PAGE)
.map(|chunk| chunk.join("\n"))
.collect();
if pages.is_empty() {
pages.push_back(String::new());
}
pages
}
fn dialog_config() -> MenuConfig {
MenuConfig::new(
DIALOG_AREA,
None,
TileRect::new(
DIALOG_AREA.tx + 1,
DIALOG_AREA.ty + 1,
DIALOG_AREA.tw - 2,
DIALOG_AREA.th - 2,
),
Default::default(),
)
}
pub(crate) fn draw_textbox(fb: &mut FrameBuffer, text: &str) {
let mut painter = FrameBufferPainter::new(fb);
draw_dialog(text, &[dialog_config()], &mut painter);
}
fn draw_end_card(fb: &mut FrameBuffer, game_name: &str, lang: &str) {
let fin = if lang == "zh" { "完" } else { "fin." };
let cx = SCREEN_W as u32 / 2;
let name_w = embedded_font::measure_text(game_name);
embedded_font::draw_text(
game_name,
cx.saturating_sub(name_w / 2),
100,
Rgba::rgb(0xf0, 0xf0, 0xf0),
fb,
);
let fin_w = embedded_font::measure_text(fin);
embedded_font::draw_text(fin, cx.saturating_sub(fin_w / 2), 120, Rgba::rgb(0x90, 0x90, 0xa8), fb);
}
fn draw_choice_menu(fb: &mut FrameBuffer, options: &[String], cursor: usize) {
let n = options.len() as u32;
if n == 0 {
return;
}
let max_len = options
.iter()
.map(|o| o.chars().count())
.max()
.unwrap_or(1) as u32;
let w = (max_len + 4).clamp(8, 20);
let h = n + 2;
let tx = (40 - w) as i32;
let ty = DIALOG_AREA.ty as i32 - h as i32;
let config = MenuConfig::new(
TileRect::new(tx.max(0) as u32, ty.max(0) as u32, w, h),
None,
TileRect::new(tx.max(0) as u32 + 1, ty.max(0) as u32 + 1, w - 2, n),
Default::default(),
);
let state = FlexMenuState {
cursor,
scroll_offset: 0,
};
let mut painter = FrameBufferPainter::new(fb);
let mut ui = Ui::new(&mut painter);
draw_flex_menu(options, &[config], &state, options.len(), &mut ui);
}
fn darken(fb: &mut FrameBuffer, factor: f32) {
for px in fb.data.chunks_exact_mut(4) {
px[0] = (px[0] as f32 * factor) as u8;
px[1] = (px[1] as f32 * factor) as u8;
px[2] = (px[2] as f32 * factor) as u8;
}
}
struct PersonColors {
outline: Rgba,
skin: Rgba,
body: Rgba,
}
const PLAYER_COLORS: PersonColors = PersonColors {
outline: Rgba::rgb(0x30, 0x18, 0x18),
skin: Rgba::rgb(0xF0, 0xC8, 0xA0),
body: Rgba::rgb(0xC8, 0x30, 0x30),
};
const NPC_BODIES: [(u8, u8, u8); 6] = [
(0x30, 0x58, 0xC8), (0x38, 0x90, 0x40), (0x88, 0x48, 0xA8), (0xC8, 0x78, 0x28), (0x28, 0x98, 0x98), (0x80, 0x58, 0x38), ];
fn npc_palette(id: u32) -> PersonColors {
let (r, g, b) = NPC_BODIES[(id.wrapping_mul(2_654_435_761) >> 16) as usize % NPC_BODIES.len()];
PersonColors {
outline: Rgba::rgb(r / 3, g / 3, b / 3),
skin: Rgba::rgb(0xF0, 0xC8, 0xA0),
body: Rgba::rgb(r, g, b),
}
}
fn draw_person(
fb: &mut FrameBuffer,
sx: i32,
sy: i32,
tile: i32,
facing: Direction,
colors: &PersonColors,
) {
const W: i32 = 12;
const H: i32 = 14;
let ox = sx + (tile - W) / 2;
let oy = sy + tile - H;
let (w, h) = (fb.width() as i32, fb.height() as i32);
let mut put = |x: i32, y: i32, c: Rgba| {
let (px, py) = (ox + x, oy + y);
if px >= 0 && py >= 0 && px < w && py < h {
fb.set_pixel(px as u32, py as u32, c);
}
};
let fill = |x: i32, y: i32, fw: i32, fh: i32, c: Rgba, put: &mut dyn FnMut(i32, i32, Rgba)| {
for dy in 0..fh {
for dx in 0..fw {
put(x + dx, y + dy, c);
}
}
};
fill(3, 0, 6, 5, colors.outline, &mut put);
fill(4, 1, 4, 3, colors.skin, &mut put);
fill(2, 5, 8, 7, colors.outline, &mut put);
fill(3, 6, 6, 5, colors.body, &mut put);
fill(3, 12, 2, 2, colors.outline, &mut put);
fill(7, 12, 2, 2, colors.outline, &mut put);
match facing {
Direction::Down => {
put(4, 2, colors.outline);
put(7, 2, colors.outline);
}
Direction::Left => put(4, 2, colors.outline),
Direction::Right => put(7, 2, colors.outline),
Direction::Up => fill(4, 1, 4, 3, colors.outline, &mut put), }
}