pub(crate) mod pixelhelper; use std::collections::{HashMap, VecDeque}; use crate::assets::{Image, Tile}; use crate::font::Font; use crate::ui; use crate::input::{GameAction, InputState}; use sim::entity::{Brain, Entity, EntityKind, Prey}; use sim::map::TileMap; use sim::{delta_action, load_world, step_allowed, Event, Sim, TICK_HZ, TICKS_PER_MOVE}; pub enum GameSignal { Quit, } /// Length of one base tick. The sim advances in whole ticks; rendering runs at frame /// rate on top and accumulates frame time into ticks. const TICK_MS: f32 = 1000.0 / TICK_HZ as f32; /// The movement cadence (movement on every 4th tick → 6 Hz ≈ 167 ms/tile). Entity /// interpolation lerps over this interval. const MOVE_INTERVAL_MS: usize = (TICK_MS * TICKS_PER_MOVE as f32) as usize; /// Longest frame the sim will catch up on in one go. Anything slower (debugger, window /// drag) is dropped instead of spiralling into a burst of ticks. const MAX_FRAME_MS: f32 = 250.0; /// Color of the planned route: dim blue. const ROUTE_COLOR: u16 = pbio::rgb565(0, 0, 170); /// Fill for tiles outside the tileset / world. const VOID_COLOR: u16 = pbio::rgb565(0, 0, 0); /// Viewport geometry: 30×30 tiles of 8 px, top-left of the framebuffer. const VIEW_TILES: i32 = 30; const TILE_PX: i32 = 8; const VIEW_PX: i32 = VIEW_TILES * TILE_PX; /// Camera smoothing time constant: each frame the camera closes this fraction /// `1 - exp(-dt/tau)` of the remaining distance to its target, so it eases in and /// out instead of chasing at constant speed. Smaller = snappier, larger = floatier. const CAM_SMOOTH_TAU_MS: f32 = 120.0; /// A position correction farther than this is a teleport — snap instead of easing. const CAM_SNAP_PX: f32 = 96.0; /// Animals scattered over the map at start, one per free tile picked at random. const HEN_COUNT: usize = 150; const FOX_COUNT: usize = 6; /// Sprite indices in `entities.png`. const SPRITE_PLAYER: u16 = 0; const SPRITE_HEN: u16 = 1; const SPRITE_FOX: u16 = 2; /// HUD panel: the 80 px strip right of the viewport. const HUD_X: i32 = VIEW_PX; const HUD_W: i32 = 320 - VIEW_PX; const HUD_PAD: i32 = 3; const LINE_H: i32 = 8; const HUD_BG: u16 = pbio::rgb565(16, 12, 24); const HUD_FRAME: u16 = pbio::rgb565(120, 100, 140); const HUD_TEXT: u16 = pbio::rgb565(235, 235, 255); const HUD_DIM: u16 = pbio::rgb565(140, 140, 160); const HP_FG: u16 = pbio::rgb565(200, 40, 40); const HP_BG: u16 = pbio::rgb565(60, 20, 20); /// Lines kept in the message log; only as many as fit the panel are shown. const LOG_LINES: usize = 40; /// Render-side smoothing state for one entity: the previous tile and how long ago the /// current one was taken. Positions are tiles; rendering lerps between them in pixels /// over one movement interval. struct EntityLerp { prev: (i32, i32), cur: (i32, i32), t_ms: usize, } pub struct Game { tileset: Vec, entity_tileset: Vec, font: Font, sim: Sim, player_id: u32, /// Frame time not yet consumed by whole ticks. tick_accum_ms: f32, /// Planned route from click-to-move: tiles still to visit, fed into the sim one /// step per movement window. Keyboard input cancels it. route: VecDeque<(i32, i32)>, /// The persistent movement goal. Outlives the route: a blocked step triggers a /// replan toward it, and while the mouse is held it tracks the tile under the /// cursor. Cleared on arrival, unreachability, keyboard override, or a click on an /// unreachable tile. goal: Option<(i32, i32)>, /// Viewport top-left in world pixels. Follows the player linearly instead of /// snapping tile-to-tile; only rendering rounds it to whole pixels. cam: (f32, f32), /// Per-entity interpolation state, keyed by entity id. Purely cosmetic — all game /// logic keeps using the sim's tile positions. lerp: HashMap, /// Message log, newest last. log: VecDeque, } impl Game { pub fn start(map_path: &str) -> Self { let tileset = Image::from_png("assets/tilesets/overworld.png").to_tileset(); let entity_tileset = Image::from_png("assets/tilesets/entities.png").to_tileset(); let font = Font::from_png("assets/fonts/micro-chat-8x9.png", 8, 9); let t0 = std::time::Instant::now(); let map = TileMap::from_csv(map_path); let world = load_world(&map); println!("loaded {map_path}: {}×{} tiles in {:.1} ms", map.width, map.height, t0.elapsed().as_secs_f64() * 1e3); let mut sim = Sim::new(world); let player_id = sim.world.spawn_entity( EntityKind::Player, SPRITE_PLAYER, Brain::Idle, (0, 0), 100, 3); // Scatter animals over free tiles. A cheap LCG is plenty for placement. let mut seed: u32 = 12345; let mut scatter = |sim: &mut Sim, count: usize, sprite: u16, brain: Brain, hp: u16, attack: u16| { let mut placed = 0; while placed < count { seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); let x = ((seed >> 8) % map.width as u32) as i32; let y = ((seed >> 20) % map.height as u32) as i32; if !sim.world.blocked(x, y) { sim.world.spawn_entity(EntityKind::Npc, sprite, brain, (x as i16, y as i16), hp, attack); placed += 1; } } }; scatter(&mut sim, HEN_COUNT, SPRITE_HEN, Brain::Wander, 5, 1); scatter(&mut sim, FOX_COUNT, SPRITE_FOX, Brain::Hunt(Prey::Kind(SPRITE_HEN)), 8, 2); let mut game = Game { tileset, entity_tileset, font, sim, player_id, tick_accum_ms: 0.0, route: VecDeque::new(), goal: None, cam: (0.0, 0.0), lerp: HashMap::new(), log: VecDeque::new(), }; game.cam = game.cam_target(); game.track_lerp(); game } fn player_pos(&self) -> (i32, i32) { let p = self.sim.world.entities[&self.player_id].pos; (p.0 as i32, p.1 as i32) } pub fn update(&mut self, render_frame: &mut [u16], dt: usize, input: &InputState) -> Option { if input.mouse_clicked() || input.mouse_held() { self.handle_click(input.mouse_pos(), !input.mouse_clicked()); } self.step_movement(input); // Advance the world in whole ticks. After every movement window the route is // synced against the new position and the next step is scheduled right away, // so a slow frame that spans several windows still walks every one of them. self.tick_accum_ms += (dt as f32).min(MAX_FRAME_MS); while self.tick_accum_ms >= TICK_MS { self.tick_accum_ms -= TICK_MS; if self.sim.step() { if self.handle_events() { return Some(GameSignal::Quit); } self.track_lerp(); self.sync_route(); self.step_movement(input); } } if input.button_pressed(GameAction::Cancel) { println!("Goodbye!"); return Some(GameSignal::Quit); } self.step_lerp(dt); self.step_camera(dt); self.render_viewport(render_frame); self.render_hud(render_frame); None } fn count(&self, sprite: u16) -> usize { self.sim.world.entities.values().filter(|e| e.type_id == sprite).count() } fn push_log(&mut self, line: String) { if self.log.len() == LOG_LINES { self.log.pop_front(); } self.log.push_back(line); } /// How an entity is referred to in the log. Ids only carry meaning for debugging. fn name(&self, id: u32, type_id: u16) -> &'static str { if id == self.player_id { "you" } else { species(type_id) } } /// React to what the sim reported for the last window. Returns `true` once the /// player is dead — the one event the game cannot continue past. fn handle_events(&mut self) -> bool { let mut dead = false; // Only fights involving the player make the log; the hens' own drama is // visible on the map. for ev in self.sim.take_events() { match ev { Event::Attacked { by, target, target_type, damage } if by == self.player_id || target == self.player_id => { // The attacker may itself have died later in the same window. let by_type = self.sim.world.entities.get(&by).map_or(u16::MAX, |e| e.type_id); let verb = if by == self.player_id { "hit" } else { "hits" }; let line = format!("{} {verb} {} ({damage})", self.name(by, by_type), self.name(target, target_type)); self.push_log(line); } Event::Died { id, .. } if id == self.player_id => dead = true, Event::Died { type_id, by, .. } if by == self.player_id => { self.push_log(format!("you kill the {}", species(type_id))); } Event::Attacked { .. } | Event::Died { .. } => {} } } if dead { self.push_log("You died.".into()); println!("You died."); } dead } /// Where the camera wants to be: the viewport centered on the player's rendered /// (interpolated) position, not the coarser sim tile — otherwise the target itself /// jumps in discrete per-tile steps and the easing below has nothing smooth to chase. fn cam_target(&self) -> (f32, f32) { let player = &self.sim.world.entities[&self.player_id]; let (px, py) = self.entity_px(player); ( (px - VIEW_PX / 2) as f32, (py - VIEW_PX / 2) as f32, ) } /// The camera rounded to the pixel grid — the actual top-left of the rendered view. fn cam_px(&self) -> (i32, i32) { (self.cam.0.round() as i32, self.cam.1.round() as i32) } /// Ease toward the target: each frame closes `1 - exp(-dt/tau)` of the remaining /// gap, so the camera eases out of a stop and eases into a catch-up rather than /// tracking at constant speed. Corrections beyond `CAM_SNAP_PX` (teleports, /// respawns) snap outright instead of easing across. fn step_camera(&mut self, dt: usize) { let (tx, ty) = self.cam_target(); if (tx - self.cam.0).abs().max((ty - self.cam.1).abs()) > CAM_SNAP_PX { self.cam = (tx, ty); return; } let f = 1.0 - (-(dt as f32) / CAM_SMOOTH_TAU_MS).exp(); self.cam = (self.cam.0 + (tx - self.cam.0) * f, self.cam.1 + (ty - self.cam.1) * f); } /// Click-to-move: translate a framebuffer click into a world tile and adopt it as /// the movement goal. With `hold` (button held after the initial click) this runs /// every frame and keeps steering toward the tile under the cursor, replanning only /// when that tile changes — camera movement alone shifts it too, not just moving /// the mouse. fn handle_click(&mut self, (mx, my): (i32, i32), hold: bool) { if mx < 0 || my < 0 || mx >= VIEW_PX || my >= VIEW_PX { return; // outside the world viewport } let (cx, cy) = self.cam_px(); let goal = ((cx + mx).div_euclid(TILE_PX), (cy + my).div_euclid(TILE_PX)); // While steering, an unchanged goal needs no replan — unless the route was voided // (blocked step) before reaching it; an empty route with the goal still ahead // means exactly that, so plan again. if hold && self.goal == Some(goal) && (!self.route.is_empty() || self.player_pos() == goal) { return; } match self.plan(goal) { Some(steps) => { self.route = steps.into(); self.goal = Some(goal); } // A discrete click on an unreachable tile cancels the plan; while steering, // sweeping the cursor across a blocked tile keeps the current plan alive. None if !hold => { self.route.clear(); self.goal = None; self.sim.clear_action(self.player_id); } None => {} } } /// Schedule the player's next step: a held direction key (which cancels any goal) /// or the head of the planned route becomes the intent for the next movement /// window. The sim executes at most one intent per window, so holding a key walks /// at the movement cadence by construction. Steps are validated with the shared /// movement rule; a blocked route step triggers a replan toward the goal. fn step_movement(&mut self, input: &InputState) { // Both axes combine, so two held keys walk diagonally (a king move). A tap // shorter than a frame shows up as pressed only. let axis = |neg: GameAction, pos: GameAction| { (input.button_held(pos) || input.button_pressed(pos)) as i32 - (input.button_held(neg) || input.button_pressed(neg)) as i32 }; let kx = axis(GameAction::Left, GameAction::Right); let ky = axis(GameAction::Up, GameAction::Down); let dir = (kx != 0 || ky != 0).then_some((kx, ky)); if dir.is_some() { // Manual input overrides click-to-move entirely, goal included. self.route.clear(); self.goal = None; } let from = self.player_pos(); let step = match dir { Some((dx, dy)) => Some((from.0 + dx, from.1 + dy)), None => self.route.front().copied(), }; let Some((nx, ny)) = step else { return }; // A keyboard step or the route's final step may bump into an entity (the sim // turns that into an attack); an entity blocking the route midway is walked // around instead. let may_bump = dir.is_some() || self.goal == Some((nx, ny)); let legal = step_allowed(from, (nx, ny), |x, y| { if may_bump && (x, y) == (nx, ny) { self.sim.world.solid(x, y) } else { self.sim.world.blocked(x, y) } }); if legal { self.sim.set_action(self.player_id, delta_action(nx - from.0, ny - from.1)); } else if dir.is_none() { self.replan_route(); } } /// After a movement window: drop the route head once the player stands on it, and /// release the goal on arrival. fn sync_route(&mut self) { let pos = self.player_pos(); if self.route.front() == Some(&pos) { self.route.pop_front(); } if self.route.is_empty() && self.goal == Some(pos) { self.goal = None; } } /// Replan the route from the current position toward the persistent goal — the /// reaction to a blocked step. Gives the goal up only when it is reached or has /// become unreachable. fn replan_route(&mut self) { self.route.clear(); let Some(goal) = self.goal else { return }; let pos = self.player_pos(); if pos == goal { self.goal = None; return; } match self.plan(goal) { Some(steps) => self.route = steps.into(), None => self.goal = None, } } /// Route from the player to `goal`. An entity standing on the goal does not make /// it unreachable — the route ends with a bump into it — but entities along the /// way are walked around. fn plan(&self, goal: (i32, i32)) -> Option> { if self.sim.world.solid(goal.0, goal.1) { return None; } sim::pathfind::find_path(self.player_pos(), goal, |x, y| { (x, y) != goal && self.sim.world.blocked(x, y) }) } /// Fold the sim's entity positions into the interpolation table: a changed position /// starts a new lerp from the old one, a jump of more than one tile (Chebyshev — a /// teleport) snaps, and vanished entities are dropped. fn track_lerp(&mut self) { let entities = &self.sim.world.entities; for e in entities.values() { let cur = (e.pos.0 as i32, e.pos.1 as i32); self.lerp.entry(e.id) .and_modify(|l| { if l.cur != cur { let far = (cur.0 - l.cur.0).abs().max((cur.1 - l.cur.1).abs()) > 1; l.prev = if far { cur } else { l.cur }; l.cur = cur; l.t_ms = 0; } }) .or_insert(EntityLerp { prev: cur, cur, t_ms: 0 }); } self.lerp.retain(|id, _| entities.contains_key(id)); } /// Advance all interpolation clocks; each lerp completes after one movement interval. fn step_lerp(&mut self, dt: usize) { for l in self.lerp.values_mut() { l.t_ms = (l.t_ms + dt).min(MOVE_INTERVAL_MS); } } /// An entity's render position in world pixels: between its previous and current /// tile, proportional to the time since the current one was taken. fn entity_px(&self, e: &Entity) -> (i32, i32) { let cur = (e.pos.0 as i32 * TILE_PX, e.pos.1 as i32 * TILE_PX); match self.lerp.get(&e.id) { Some(l) => { let f = l.t_ms as f32 / MOVE_INTERVAL_MS as f32; let mix = |a: i32, b: i32| a + ((b - a) as f32 * f).round() as i32; (mix(l.prev.0 * TILE_PX, cur.0), mix(l.prev.1 * TILE_PX, cur.1)) } None => cur, } } fn render_viewport(&self, frame: &mut [u16]) { const W: usize = 320; frame.fill(0); let (cx, cy) = self.cam_px(); let tx0 = cx.div_euclid(TILE_PX); let ty0 = cy.div_euclid(TILE_PX); let sx = -cx.rem_euclid(TILE_PX); let sy = -cy.rem_euclid(TILE_PX); // One extra row and column: with a sub-tile camera offset the viewport spans // partial tiles on both edges. for vy in 0..=VIEW_TILES { for vx in 0..=VIEW_TILES { let wx = tx0 + vx; let wy = ty0 + vy; let (ccx, ccy) = sim::world::tile_to_chunk(wx as i16, wy as i16); let (lx, ly) = sim::world::tile_to_local(wx as i16, wy as i16); let tile_id = self.sim.world.chunk(ccx, ccy) .map(|c| c.get_tile(lx, ly)) .unwrap_or(0); let px = sx + vx * TILE_PX; let py = sy + vy * TILE_PX; if let Some(tile) = self.tileset.get(tile_id as usize) { pixelhelper::blit_tile(frame, W, px, py, tile); } else { for dy in 0..8 { for dx in 0..8 { pixelhelper::set_pixel(frame, W, px + dx, py + dy, VOID_COLOR); } } } } } // Route pass — over the world, under entities. for &(wx, wy) in &self.route { let px = wx * TILE_PX - cx; let py = wy * TILE_PX - cy; if px <= -TILE_PX || px >= VIEW_PX || py <= -TILE_PX || py >= VIEW_PX { continue; } for dy in 0..8 { for dx in 0..8 { pixelhelper::set_pixel(frame, W, px + dx, py + dy, ROUTE_COLOR); } } } // Entity pass — interpolated between the last two tiles. for e in self.sim.world.entities.values() { let (ex, ey) = self.entity_px(e); let px = ex - cx; let py = ey - cy; if px <= -TILE_PX || px >= VIEW_PX || py <= -TILE_PX || py >= VIEW_PX { continue; } if let Some(tile) = self.entity_tileset.get(e.type_id as usize) { pixelhelper::blit_tile(frame, W, px, py, tile); } } // The blit primitives clip against the framebuffer, not the viewport, so partial // tiles on the right edge bleed into the 240..320 strip. Clear it; a real // clip-rect belongs to the UI pass (see roadmap). for y in 0..240usize { frame[y * W + VIEW_PX as usize..y * W + W].fill(0); } } } fn species(type_id: u16) -> &'static str { match type_id { SPRITE_HEN => "hen", SPRITE_FOX => "fox", _ => "something", } } impl Game { fn render_hud(&self, frame: &mut [u16]) { const W: usize = 320; ui::fill_rect(frame, W, HUD_X, 0, HUD_W, 240, HUD_BG); ui::draw_rect(frame, W, HUD_X, 0, HUD_W, 240, HUD_FRAME); let x = HUD_X + HUD_PAD + 1; let inner_w = HUD_W - 2 * (HUD_PAD + 1); let mut y = HUD_PAD + 1; let player = &self.sim.world.entities[&self.player_id]; self.font.draw(frame, W, x, y, HUD_TEXT, "Adventurer"); y += LINE_H; ui::draw_hbar(frame, W, x, y, inner_w, 5, player.hp as u32, player.hp_max as u32, HP_FG, HP_BG); y += 7; let hp = format!("{}/{}", player.hp, player.hp_max); self.font.draw(frame, W, x, y, HUD_DIM, &hp); y += LINE_H; for (label, sprite) in [("hens", SPRITE_HEN), ("foxes", SPRITE_FOX)] { let n = self.count(sprite).to_string(); self.font.draw(frame, W, x, y, HUD_DIM, label); self.font.draw(frame, W, x + inner_w - self.font.text_width(&n), y, HUD_DIM, &n); y += LINE_H; } y += 2; // Divider, then the log filling the rest bottom-up so the newest line is // always visible. ui::fill_rect(frame, W, x, y, inner_w, 1, HUD_FRAME); y += 3; let lines: Vec = self.log.iter() .flat_map(|l| self.font.wrap(l, inner_w)) .collect(); let fit = ((240 - HUD_PAD - 1 - y) / LINE_H) as usize; for line in lines.iter().rev().take(fit).rev() { self.font.draw(frame, W, x, y, HUD_TEXT, line); y += LINE_H; } } } #[cfg(test)] mod snapshot { /// Runs the world headlessly for `$SECONDS` (default 60) of sim time and prints the /// population report — to watch the hens without a window: /// `cargo test -p game population -- --ignored --nocapture` #[test] #[ignore] fn population() { std::env::set_current_dir("..").unwrap(); let secs: usize = std::env::var("SECONDS").ok().and_then(|s| s.parse().ok()).unwrap_or(60); let mut g = super::Game::start("assets/maps/map_test"); let input = crate::input::InputState::new(); let mut frame = vec![0u16; 320 * 240]; for _ in 0..secs * 60 { g.update(&mut frame, 16, &input); } println!("after {secs}s: {} hens, {} foxes", g.count(super::SPRITE_HEN), g.count(super::SPRITE_FOX)); } /// Renders a frame headlessly to the PNG named by `$SNAPSHOT` — a way to eyeball /// the renderer without a window. `$SNAPSHOT_MAP` picks the map, `$SECONDS` how /// much sim time passes first (default 0): /// `SNAPSHOT=/tmp/frame.png cargo test -p game dump_frame -- --ignored` #[test] #[ignore] fn dump_frame() { std::env::set_current_dir("..").unwrap(); let map = std::env::var("SNAPSHOT_MAP").unwrap_or_else(|_| "assets/maps/map_test".into()); let mut g = super::Game::start(&map); let input = crate::input::InputState::new(); let mut frame = vec![0u16; 320 * 240]; let secs: usize = std::env::var("SECONDS").ok().and_then(|s| s.parse().ok()).unwrap_or(0); for _ in 0..secs * 60 { g.update(&mut frame, 16, &input); } g.update(&mut frame, 0, &input); let out = std::env::var("SNAPSHOT").unwrap(); let mut rgb = Vec::with_capacity(320 * 240 * 3); for p in &frame { rgb.push(((p >> 11) as u8) << 3); rgb.push((((p >> 5) & 63) as u8) << 2); rgb.push(((p & 31) as u8) << 3); } let f = std::fs::File::create(out).unwrap(); let mut enc = png::Encoder::new(std::io::BufWriter::new(f), 320, 240); enc.set_color(png::ColorType::Rgb); enc.write_header().unwrap().write_image_data(&rgb).unwrap(); } }