diff --git a/game/src/game.rs b/game/src/game.rs index e97bb1e..a324499 100755 --- a/game/src/game.rs +++ b/game/src/game.rs @@ -65,13 +65,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. +/// Animation timing. All cosmetic: frames are picked from clocks that run on frame +/// time, the sim knows nothing about them. +const IDLE_FRAME_MS: usize = 450; +const WALK_FRAME_MS: usize = 80; // 4 frames ≈ 2 cycles per tile +const ACTION_MS: usize = 250; // attacker's swing, shown from the bump on +const HIT_MS: usize = 250; // victim's flinch +const DEAD_FRAME_MS: usize = 300; // corpse: frame 0 this long, then the last frame +const CORPSE_MS: usize = 6000; + +/// Render-side state for one entity: the lerp between its last two tiles, plus the +/// animation clocks. Positions are tiles; rendering lerps between them in pixels over +/// one movement interval. struct EntityLerp { - prev: (i32, i32), - cur: (i32, i32), - t_ms: usize, + prev: (i32, i32), + cur: (i32, i32), + t_ms: usize, + /// Time spent walking, accumulated across steps so the cycle stays continuous. + walk_ms: usize, + /// Time since the entity last attacked / was hit, while the animation plays. + act_ms: Option, + hit_ms: Option, +} + +impl EntityLerp { + fn walking(&self) -> bool { + self.prev != self.cur && self.t_ms < MOVE_INTERVAL_MS + } +} + +/// A dead entity's sprite, lying where it fell until it fades from the world. +struct Corpse { + type_id: u16, + at: (i32, i32), + ms: usize, } pub struct Game { @@ -99,6 +126,9 @@ pub struct Game { /// Per-entity interpolation state, keyed by entity id. Purely cosmetic — all game /// logic keeps using the sim's tile positions. lerp: HashMap, + corpses: Vec, + /// Global idle clock; entities offset it by id so they don't blink in unison. + anim_ms: usize, /// Message log, newest last. log: VecDeque, } @@ -149,6 +179,8 @@ impl Game { goal: None, cam: (0.0, 0.0), lerp: HashMap::new(), + corpses: Vec::new(), + anim_ms: 0, log: VecDeque::new(), }; game.cam = game.cam_target(); @@ -229,6 +261,16 @@ impl Game { // Only fights involving the player make the log; the hens' own drama is // visible on the map. for ev in self.sim.take_events() { + // Animation hooks first, independent of who was involved. + match ev { + Event::Attacked { by, target, .. } => { + if let Some(l) = self.lerp.get_mut(&by) { l.act_ms = Some(0); } + if let Some(l) = self.lerp.get_mut(&target) { l.hit_ms = Some(0); } + } + Event::Died { type_id, at, .. } => { + self.corpses.push(Corpse { type_id, at: (at.0 as i32, at.1 as i32), ms: 0 }); + } + } match ev { Event::Attacked { by, target, target_type, damage } if by == self.player_id || target == self.player_id => @@ -423,16 +465,52 @@ impl Game { l.t_ms = 0; } }) - .or_insert(EntityLerp { prev: cur, cur, t_ms: 0 }); + .or_insert(EntityLerp { prev: cur, cur, t_ms: 0, walk_ms: 0, act_ms: None, hit_ms: None }); } self.lerp.retain(|id, _| entities.contains_key(id)); } - /// Advance all interpolation clocks; each lerp completes after one movement interval. + /// Advance all interpolation and animation clocks; each lerp completes after one + /// movement interval, one-shot animations expire, corpses fade. fn step_lerp(&mut self, dt: usize) { + self.anim_ms += dt; + let expire = |c: &mut Option, limit| { + if let Some(ms) = c { + *ms += dt; + if *ms >= limit { *c = None; } + } + }; for l in self.lerp.values_mut() { + if l.walking() { + l.walk_ms += dt; + } l.t_ms = (l.t_ms + dt).min(MOVE_INTERVAL_MS); + expire(&mut l.act_ms, ACTION_MS); + expire(&mut l.hit_ms, HIT_MS); } + for c in &mut self.corpses { + c.ms += dt; + } + self.corpses.retain(|c| c.ms < CORPSE_MS); + } + + /// The frame to draw for a live entity: a flinch beats a swing beats walking beats + /// idling. Missing states fall back to idle inside `Sprites::frame`. + fn entity_frame(&self, e: &Entity) -> Option<&Tile> { + let idle = (self.anim_ms / IDLE_FRAME_MS) + e.id as usize; + let Some(l) = self.lerp.get(&e.id) else { + return self.sprites.frame(e.type_id, State::Idle, idle); + }; + let (state, i) = if let Some(ms) = l.hit_ms { + (State::Hit, ms * 2 / HIT_MS) + } else if let Some(ms) = l.act_ms { + (State::Action1, ms * 4 / ACTION_MS) + } else if l.walking() { + (State::Walk, l.walk_ms / WALK_FRAME_MS) + } else { + (State::Idle, idle) + }; + self.sprites.frame(e.type_id, state, i) } /// An entity's render position in world pixels: between its previous and current @@ -497,13 +575,24 @@ impl Game { } } - // Entity pass — interpolated between the last two tiles. + // Corpses — under the living. + for c in &self.corpses { + let px = c.at.0 * TILE_PX - cx; + let py = c.at.1 * TILE_PX - cy; + if px <= -TILE_PX || px >= VIEW_PX || py <= -TILE_PX || py >= VIEW_PX { continue; } + let i = if c.ms < DEAD_FRAME_MS { 0 } else { 1 }; + if let Some(tile) = self.sprites.frame(c.type_id, State::Dead, i) { + pixelhelper::blit_tile(frame, W, px, py, tile); + } + } + + // Entity pass — interpolated between the last two tiles, animated. 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.sprites.frame(e.type_id, State::Idle, 0) { + if let Some(tile) = self.entity_frame(e) { pixelhelper::blit_tile(frame, W, px, py, tile); } } @@ -595,16 +684,26 @@ mod snapshot { 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 { + // `$FRAMES` > 1 writes a strip of consecutive frames (40 ms apart) side by + // side — for eyeballing animation. + let frames: usize = std::env::var("FRAMES").ok().and_then(|s| s.parse().ok()).unwrap_or(1); + let mut rgb = Vec::with_capacity(320 * 240 * 3 * frames); + let mut strip = vec![0u16; 320 * frames * 240]; + for i in 0..frames { + g.update(&mut frame, if i == 0 { 0 } else { 40 }, &input); + for y in 0..240 { + strip[y * 320 * frames + i * 320..y * 320 * frames + (i + 1) * 320] + .copy_from_slice(&frame[y * 320..(y + 1) * 320]); + } + } + for p in &strip { rgb.push(((p >> 11) as u8) << 3); rgb.push((((p >> 5) & 63) as u8) << 2); rgb.push(((p & 31) as u8) << 3); } + let out = std::env::var("SNAPSHOT").unwrap(); let f = std::fs::File::create(out).unwrap(); - let mut enc = png::Encoder::new(std::io::BufWriter::new(f), 320, 240); + let mut enc = png::Encoder::new(std::io::BufWriter::new(f), (320 * frames) as u32, 240); enc.set_color(png::ColorType::Rgb); enc.write_header().unwrap().write_image_data(&rgb).unwrap(); } diff --git a/notes/roadmap.md b/notes/roadmap.md index 54aca05..c77a221 100755 --- a/notes/roadmap.md +++ b/notes/roadmap.md @@ -55,9 +55,15 @@ crate's `tick`/`set_action` interface is the seam a server could be wrapped arou See `notes/07-map-loader.md` - [x] Tile rendering: chunk-based world rendering straight from `World` (`render_viewport`) - [x] Sprite-based entity rendering: entity `type_id` → sprite row in `entities.png` - (via the `entities.toml` manifest, see Assets above), idle frame blitted with - transparency. Animation (walk while the lerp runs, hit/dead from events) is the - next step — the sheet layout and loader already carry the frames. + (via the `entities.toml` manifest, see Assets above), blitted with transparency. +- [x] Sprite animation, purely render-side (`Game::entity_frame`): idle on a global + clock offset by entity id (so a flock doesn't blink in unison), walk while the + position lerp runs (a per-entity walk clock accumulates across steps so the cycle + stays continuous), action 1 on the attacker and hit on the victim for 250 ms from + the `Attacked` event, and `Died` leaves a `Corpse` showing the dead frames for a + few seconds. Priority: hit > action > walk > idle; missing states fall back to + idle. `FRAMES=6 SNAPSHOT=… cargo test -p game dump_frame -- --ignored` dumps a + strip of consecutive frames. - [x] Hybrid tick loop: 24 Hz base tick, movement in `entity_tick` every 4th tick (6 Hz); the game loop drives the sim with a fixed-timestep accumulator. See `notes/06-hybrid-tick-loop.md` - [x] Intent model: one scheduled action per entity per movement window (`Sim::set_action`),