diff --git a/src/game.rs b/src/game.rs index eecbffc..be2757d 100644 --- a/src/game.rs +++ b/src/game.rs @@ -210,6 +210,8 @@ pub struct Ent { pub anim: f32, pub flash: f32, pub face: f32, + #[serde(default)] + pub dir: f32, // facing direction on the map (radians, 0 = +x), for the eight sprite views pub moving: bool, pub working: bool, #[serde(default)] @@ -282,6 +284,7 @@ impl Ent { anim: 0.0, flash: 0.0, face: 1.0, + dir: std::f32::consts::FRAC_PI_2, moving: false, working: false, slow: 0.0, @@ -1016,7 +1019,9 @@ impl Game { continue; } let mut e = std::mem::replace(&mut self.ents[i], Ent::dummy()); + let (x0, y0) = (e.x, e.y); self.update_ent(&mut e, dt); + self.turn(&mut e, x0, y0); self.ents[i] = e; } self.ents.retain(|e| e.alive); @@ -1621,6 +1626,34 @@ impl Game { } } + // the direction a unit faces: where it walks, otherwise what it works on or fights + fn turn(&self, e: &mut Ent, x0: f32, y0: f32) { + let (dx, dy) = (e.x - x0, e.y - y0); + if dx * dx + dy * dy > 1e-8 { + e.dir = dy.atan2(dx); + return; + } + if !e.working { + return; + } + let tile = |t: usize| { + let (x, y) = xy(t); + Some((x as f32 + 0.5, y as f32 + 0.5)) + }; + let target = match e.job { + Job::Dig(t) | Job::Reinforce(t) | Job::Help(t) | Job::TearBridge(t) => tile(t), + Job::Attack(id) | Job::Torment(id) | Job::Carry(id) => self.find(id).map(|j| (self.ents[j].x, self.ents[j].y)), + Job::Repair(id) | Job::AttackTower(id) => self.towers.iter().find(|t| t.id == id).map(|t| t.pos()), + _ => None, + }; + if let Some((tx, ty)) = target { + let (dx, dy) = (tx - e.x, ty - e.y); + if dx * dx + dy * dy > 0.01 { + e.dir = dy.atan2(dx); + } + } + } + // moves a unit along its path; true = still moving pub(crate) fn step_path(&mut self, e: &mut Ent, dt: f32) -> bool { if e.path.is_empty() { diff --git a/src/gfx.rs b/src/gfx.rs index 7f5e50b..51064a4 100644 --- a/src/gfx.rs +++ b/src/gfx.rs @@ -1095,7 +1095,7 @@ pub const SPRITE_KINDS: [EK; 17] = [ ]; // width of the sprite texture in pixels pub const SPRITE_TEX_W: f32 = (17 * 12) as f32; -pub const SPRITE_TEX_H: f32 = (2 * GEAR_TIERS * SPR_CELL) as f32; +pub const SPRITE_TEX_H: f32 = (2 * VIEWS * GEAR_TIERS * SPR_CELL) as f32; pub fn sprite_index(k: EK) -> u32 { SPRITE_KINDS.iter().position(|&s| s == k).unwrap_or(0) as u32 @@ -1112,12 +1112,106 @@ fn is_hero_kind(k: EK) -> bool { matches!(k, EK::Knight | EK::Archer | EK::Priest | EK::Dwarf | EK::Paladin) } -// the 12x12 cell of a kind with its gear painted around and onto the silhouette -fn sprite_with_gear(k: EK, tier: u32) -> [[Option; 12]; 12] { - let mut g = [[None; 12]; 12]; +// Views of a sprite for eight directions (the others are mirrored): 0 front, 1 front +// three-quarter, 2 side, 3 back three-quarter, 4 back. The drawn sprites face the viewer; +// the turned views are derived from them and face to the right. +pub const VIEWS: u32 = 5; + +type Rows = [[u8; SPR]; SPR]; + +fn is_body(ch: u8) -> bool { + !matches!(ch, b'.' | b'k' | b'e' | b'f') +} + +// the commonest body colour of a sprite (for painting over eyes and faces) +fn body_char(r: &Rows) -> u8 { + let mut n = [0usize; 128]; + for row in r.iter() { + for &ch in row.iter() { + if is_body(ch) && ch < 128 { + n[ch as usize] += 1; + } + } + } + (0..128u8).max_by_key(|&c| n[c as usize]).filter(|&c| n[c as usize] > 0).unwrap_or(b'k') +} + +// moves the eyes one pixel to the right (where there is face or body to move onto) +fn eyes_right(r: &mut Rows) { + for row in r.iter_mut() { + for x in (0..SPR - 1).rev() { + if row[x] == b'e' && row[x + 1] != b'k' && row[x + 1] != b'.' && row[x + 1] != b'e' { + row[x] = row[x + 1]; + row[x + 1] = b'e'; + } + } + } +} + +// removes the middle column of the silhouette: a narrower body, as seen from the side +fn narrow(r: &mut Rows) { + let cols: Vec = (0..SPR).filter(|&x| r.iter().any(|row| row[x] != b'.')).collect(); + let (Some(&l), Some(&rr)) = (cols.first(), cols.last()) else { return }; + let m = (l + rr) / 2; + for row in r.iter_mut() { + for x in (1..=m).rev() { + row[x] = row[x - 1]; + } + row[0] = b'.'; + } +} + +fn sprite_view(k: EK, view: u32) -> Rows { + let mut r: Rows = [[b'.'; SPR]; SPR]; for (y, row) in sprite_rows(k).iter().enumerate() { - for (x, ch) in row.chars().enumerate().take(SPR) { - g[y + 1][x + 1] = pal(ch); + for (x, ch) in row.bytes().enumerate().take(SPR) { + r[y][x] = ch; + } + } + let body = body_char(&r); + match view { + 1 => eyes_right(&mut r), + 2 => { + narrow(&mut r); + // in profile only the eye on the facing side is seen + for row in r.iter_mut() { + let eyes: Vec = (0..SPR).filter(|&x| row[x] == b'e').collect(); + if eyes.len() >= 2 { + let x = eyes[0]; + row[x] = if x > 0 && is_body(row[x - 1]) { row[x - 1] } else if row[x + 1] == b'f' { b'f' } else { body }; + } + } + eyes_right(&mut r); + } + 3 | 4 => { + // from behind: mirrored (the weapon is in the other hand), no eyes, no face + for row in r.iter_mut() { + row.reverse(); + } + for y in 0..SPR { + for x in 0..SPR { + let ch = r[y][x]; + if ch == b'e' || ch == b'f' { + let above = if y > 0 { r[y - 1][x] } else { b'.' }; + r[y][x] = if is_body(above) { above } else { body }; + } + } + } + if view == 3 { + narrow(&mut r); + } + } + _ => {} + } + r +} + +// the 12x12 cell of a kind with its gear painted around and onto the silhouette +fn sprite_with_gear(k: EK, tier: u32, view: u32) -> [[Option; 12]; 12] { + let mut g = [[None; 12]; 12]; + for (y, row) in sprite_view(k, view).iter().enumerate() { + for (x, &ch) in row.iter().enumerate() { + g[y + 1][x + 1] = pal(ch as char); } } if tier == 0 { @@ -1196,19 +1290,22 @@ fn sprite_with_gear(k: EK, tier: u32) -> [[Option; 12]; 12] { g } -// rows 2*tier: sprites with the gear of the tier, rows 2*tier+1: white silhouettes (hit flash) +// row (view * GEAR_TIERS + tier) * 2: sprites with the gear of the tier, the row after it: +// white silhouettes (hit flash) fn gen_sprites() -> Image { let n = SPRITE_KINDS.len() as u32; - let mut img = Image::gen_image_color((n * SPR_CELL) as u16, (2 * GEAR_TIERS * SPR_CELL) as u16, Color::new(0.0, 0.0, 0.0, 0.0)); + let mut img = Image::gen_image_color((n * SPR_CELL) as u16, (2 * VIEWS * GEAR_TIERS * SPR_CELL) as u16, Color::new(0.0, 0.0, 0.0, 0.0)); for (i, &k) in SPRITE_KINDS.iter().enumerate() { - for tier in 0..GEAR_TIERS { - let g = sprite_with_gear(k, tier); - for (y, row) in g.iter().enumerate() { - for (x, px) in row.iter().enumerate() { - if let Some(col) = px { - let (px, py) = (i as u32 * SPR_CELL + x as u32, tier * 2 * SPR_CELL + y as u32); - img.set_pixel(px, py, *col); - img.set_pixel(px, py + SPR_CELL, WHITE); + for view in 0..VIEWS { + for tier in 0..GEAR_TIERS { + let g = sprite_with_gear(k, tier, view); + for (y, row) in g.iter().enumerate() { + for (x, px) in row.iter().enumerate() { + if let Some(col) = px { + let (px, py) = (i as u32 * SPR_CELL + x as u32, (view * GEAR_TIERS + tier) * 2 * SPR_CELL + y as u32); + img.set_pixel(px, py, *col); + img.set_pixel(px, py + SPR_CELL, WHITE); + } } } } @@ -1351,8 +1448,13 @@ impl Gfx { // the whole 12x12 cell with the gear of a level (billboards) pub fn sprite_cell(k: EK, white: bool, level: u32) -> Rect { + Gfx::sprite_cell_view(k, white, level, 0) + } + + // the cell of one of the five views (see VIEWS) + pub fn sprite_cell_view(k: EK, white: bool, level: u32, view: u32) -> Rect { let i = sprite_index(k); - let row = gear_tier(level) * 2 + white as u32; + let row = (view.min(VIEWS - 1) * GEAR_TIERS + gear_tier(level)) * 2 + white as u32; Rect::new((i * SPR_CELL) as f32, (row * SPR_CELL) as f32, SPR_CELL as f32, SPR_CELL as f32) } diff --git a/src/render3d.rs b/src/render3d.rs index 817729b..6e1c8f4 100644 --- a/src/render3d.rs +++ b/src/render3d.rs @@ -1085,6 +1085,9 @@ impl R3d { 0.5 } }; + // the camera's right and viewing direction on the ground (for the eight views) + let rg = vec2(r.x, r.z).normalize_or_zero(); + let tg = vec2(toward.x, toward.z).normalize_or_zero(); let mut mb = MB::new(); let sw = SPRITE_TEX_W; let sh = SPRITE_TEX_H; @@ -1095,12 +1098,81 @@ impl R3d { let w = sprite_size(e.kind); let h = w; let sleeping = matches!(e.job, Job::Sleep(_)) && !e.moving; + // which of the eight directions the unit shows (five views, three of them mirrored) + let d = vec2(e.dir.cos(), e.dir.sin()); + let ang = d.dot(rg).atan2(d.dot(tg)); + let view = ((ang / std::f32::consts::FRAC_PI_4).round().abs() as u32).min(4); + let mirror = ang < 0.0; + let fwd = if mirror { -1.0 } else { 1.0 }; // screen direction the unit faces + let ph = t * 10.0 + e.id as f32 * 1.7; + let (mut sx, mut sy, mut lunge) = (1.0f32, 1.0f32, 0.0f32); let mut bob = if e.moving { (e.anim * 12.0).sin().abs() * 0.06 } else { 0.0 }; + let mut rot = 0.0; + if e.moving { + // a little sway while walking; loads are carried leaning back + rot = (e.anim * 12.0).sin() * 0.05; + if e.carry > 0 || matches!(e.job, Job::Carry(_)) { + rot += 0.12 * fwd; + bob *= 0.6; + } + } else if e.working && !sleeping { + match e.job { + // claiming: stomping the ground, squashed on landing + Job::Claim(_) | Job::Help(_) => { + let k = (ph * 1.1).sin(); + bob = k.max(0.0) * 0.13; + if k < 0.0 { + sy = 1.0 + k * 0.14; + sx = 1.0 - k * 0.1; + } + } + // digging: a swing at the wall + Job::Dig(_) | Job::TearBridge(_) => { + let k = (ph * 1.3).sin(); + rot = -(k * 0.35).max(-0.12) * fwd; + lunge = k.max(0.0) * 0.07; + } + // hammering + Job::Reinforce(_) | Job::Repair(_) => { + let k = (ph * 2.2).sin(); + rot = -k.max(0.0) * 0.25 * fwd; + bob = (ph * 2.2).cos().max(0.0) * 0.03; + } + // fighting: lunging at the enemy + Job::Attack(_) | Job::AttackTower(_) | Job::AttackCore | Job::Torment(_) => { + let k = (ph * 0.9).sin().max(0.0).powi(2); + lunge = k * 0.14; + rot = -k * 0.2 * fwd; + } + // training: quick punches + Job::Train(_) => { + let k = (ph * 1.6).sin(); + lunge = k.max(0.0) * 0.08; + rot = -k * 0.12 * fwd; + bob = (ph * 3.2).sin().abs() * 0.03; + } + // eating: nodding over the food + Job::Eat(_) => { + sy = 1.0 - (ph * 0.8).sin().abs() * 0.1; + rot = -(ph * 0.8).sin().abs() * 0.12 * fwd; + } + // reading and praying: a slow sway + Job::Study(_) | Job::Pray(_) | Job::HealCore => rot = (t * 1.8 + e.id as f32).sin() * 0.07, + _ => rot = (ph * 1.4).sin() * 0.15, + } + } else if !sleeping { + // breathing + sy = 1.0 + (t * 2.4 + e.id as f32).sin() * 0.025; + } // flying creatures hover and flap (on their perch while sleeping) if flies(e.kind) && !sleeping { bob = 0.45 + (t * 7.0 + e.anim * 3.0).sin() * 0.06; + sx *= 1.0 + (t * 14.0 + e.id as f32).sin() * 0.06; + } else if sleeping { + // asleep: lying in bed + rot = std::f32::consts::FRAC_PI_2 * 0.9 * fwd; + bob = 0.04; } - let mut rot = if e.working && !sleeping { (t * 14.0 + e.anim).sin() * 0.15 } else { 0.0 }; // prisoners: lying unconscious, carried above an imp, or shaking in the torture chamber match e.captive { 1 => rot = std::f32::consts::FRAC_PI_2, @@ -1111,11 +1183,16 @@ impl R3d { 3 => rot = (t * 25.0 + e.anim).sin() * 0.08, _ => {} } + if e.chained { + // pulling at the chains + rot = (t * 3.0 + e.id as f32).sin() * 0.1; + lunge = 0.0; + } let white = e.flash > 0.07; - let src = Gfx::sprite_cell(e.kind, white, e.level); + let src = Gfx::sprite_cell_view(e.kind, white, e.level, if e.captive != 0 { 0 } else { view }); let (mut u0, v0) = (src.x / sw, src.y / sh); let (mut u1, v1) = ((src.x + src.w) / sw, (src.y + src.h) / sh); - if e.face < 0.0 { + if mirror { std::mem::swap(&mut u0, &mut u1); } let a = amb(e.x, e.y) * if sleeping || e.captive == 1 { 0.6 } else { 1.0 }; @@ -1128,9 +1205,10 @@ impl R3d { }; // pulled towards the camera: unchanged on screen (orthographic), but walls behind // the unit no longer cut into the sprite - let base = vec3(e.x, bob, e.y) + toward * 0.45; + let base = vec3(e.x, bob, e.y) + toward * 0.45 + r * (lunge * fwd); let (sr, cr) = rot.sin_cos(); let corner = |dx: f32, dy: f32| { + let (dx, dy) = (dx * sx, dy * sy); let x = dx * cr - dy * sr; let y = dx * sr + dy * cr; base + r * x + Vec3::Y * y