Animate entities from the sheet's states
Idle frames tick on a global clock offset by entity id, walk frames run on a per-entity clock while the position lerp is in flight, and the sim's Attacked/Died events drive the attacker's action, the victim's flinch and a corpse that shows the dead frames for a few seconds. All render-side; the sim is untouched. The snapshot test can dump a strip of frames. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+113
-14
@@ -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,
|
||||
/// 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<usize>,
|
||||
hit_ms: Option<usize>,
|
||||
}
|
||||
|
||||
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<u32, EntityLerp>,
|
||||
corpses: Vec<Corpse>,
|
||||
/// Global idle clock; entities offset it by id so they don't blink in unison.
|
||||
anim_ms: usize,
|
||||
/// Message log, newest last.
|
||||
log: VecDeque<String>,
|
||||
}
|
||||
@@ -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) {
|
||||
for l in self.lerp.values_mut() {
|
||||
l.t_ms = (l.t_ms + dt).min(MOVE_INTERVAL_MS);
|
||||
self.anim_ms += dt;
|
||||
let expire = |c: &mut Option<usize>, 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();
|
||||
}
|
||||
|
||||
+9
-3
@@ -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`),
|
||||
|
||||
Reference in New Issue
Block a user