From aae1d1a56a935b2aeba7eb953b71106b190da974 Mon Sep 17 00:00:00 2001 From: salmonik Date: Sat, 11 Jul 2026 01:35:02 +0200 Subject: animations, introduction, no terminal --- src/game.rs | 84 ++++++++++++++++++++++++----- src/main.rs | 56 +++++++++++++------- src/renderer.rs | 162 +++++++++++++++++++++++++++++++++++++++++++++++--------- 3 files changed, 245 insertions(+), 57 deletions(-) diff --git a/src/game.rs b/src/game.rs index 80ee0c6..ca30207 100644 --- a/src/game.rs +++ b/src/game.rs @@ -608,6 +608,7 @@ impl Player { const MOVE_DURATION: f64 = 0.18; // seconds to walk one tile const TURN_DURATION: f64 = 0.12; // seconds for a 90° turn const ENEMY_ANIM_DURATION: f64 = 0.10; // seconds for enemy movement +const ATTACK_DURATION: f64 = 0.12; // seconds for attack lunge pub const MAX_HP: u32 = 5; pub const MAX_SANITY: u32 = 100; @@ -659,6 +660,19 @@ pub struct Game { // ---- animation: enemies ---- enemy_anim_t: f64, + + // ---- animation: attack ---- + pub attacking: bool, + pub attack_t: f64, + attack_base_x: f64, + attack_base_y: f64, + + // ---- screen flash ---- + pub flash_color: u32, + pub flash_alpha: f64, + + // ---- controls overlay ---- + pub show_controls: bool, } fn smoothstep(t: f64) -> f64 { @@ -703,6 +717,13 @@ impl Game { turn_delta: 0.0, turn_t: 0.0, enemy_anim_t: 0.0, + attacking: false, + attack_t: 0.0, + attack_base_x: 0.0, + attack_base_y: 0.0, + flash_color: 0, + flash_alpha: 0.0, + show_controls: true, } } @@ -717,6 +738,11 @@ impl Game { /// Returns true when the current animation phase finishes and we should /// advance the state machine. pub fn update_animation(&mut self, dt: f64) -> bool { + // Flash always fades, regardless of state. + if self.flash_alpha > 0.0 { + self.flash_alpha = (self.flash_alpha - dt * 4.0).max(0.0); + } + // Turn animation runs independently — turning never consumes a turn. if self.turning { self.turn_t += dt / TURN_DURATION; @@ -751,6 +777,28 @@ impl Game { } } + // Animate attack lunge. + if self.attacking { + self.attack_t += dt / ATTACK_DURATION; + if self.attack_t >= 1.0 { + self.player.x = self.attack_base_x; + self.player.y = self.attack_base_y; + self.attacking = false; + } else { + // Lunge forward then back. + let lunge = if self.attack_t < 0.5 { + smoothstep(self.attack_t * 2.0) * 0.25 + } else { + (1.0 - smoothstep((self.attack_t - 0.5) * 2.0)) * 0.25 + }; + let fx = self.player.angle.cos(); + let fy = self.player.angle.sin(); + self.player.x = self.attack_base_x + fx * lunge; + self.player.y = self.attack_base_y + fy * lunge; + done = false; + } + } + if done { self.state = GameState::ResolvingTurn; self.resolve_turn(); @@ -921,12 +969,10 @@ impl Game { let new_hp = hp.saturating_sub(total_dmg); if new_hp == 0 { - // Enemy dies. self.maze.take_entity(fx, fy); self.kills += 1; self.set_message(&format!("You slay the enemy! ({} dmg)", total_dmg)); } else { - // Enemy survives and retaliates. self.maze.entity_at_mut(fx, fy).map(|e| { if let Entity::Enemy { hp: ref mut h, .. } = e { *h = new_hp; @@ -941,7 +987,9 @@ impl Game { self.state = GameState::GameOver; } } - self.advance_turn(); + self.flash_color = 0xFF_CC3333; + self.flash_alpha = 0.4; + self.begin_attack_animation(); true } @@ -950,11 +998,9 @@ impl Game { let new_hp = hp.saturating_sub(1); if !persistent || new_hp == 0 { - // Vanishes. self.maze.take_entity(fx, fy); self.set_message("The hallucination dissipates."); } else { - // Persistent — doesn't vanish yet. self.maze.entity_at_mut(fx, fy).map(|e| { if let Entity::Hallucination { hp: ref mut h, .. } = e { *h = new_hp; @@ -962,18 +1008,30 @@ impl Game { }); self.set_message("You strike the apparition - it remains."); } - self.advance_turn(); + self.flash_color = 0xFF_8833CC; + self.flash_alpha = 0.35; + self.begin_attack_animation(); true } _ => { self.set_message("You swing at the air."); - self.advance_turn(); + self.flash_color = 0xFF_FFFFFF; + self.flash_alpha = 0.05; + self.begin_attack_animation(); true } } } + fn begin_attack_animation(&mut self) { + self.attack_base_x = self.player.x; + self.attack_base_y = self.player.y; + self.attacking = true; + self.attack_t = 0.0; + self.state = GameState::AnimatingPlayer; + } + // ---- inventory -------------------------------------------------------- /// Use the currently selected inventory item (Z key). @@ -1107,7 +1165,10 @@ impl Game { /// Apply all per-turn effects (weakness, heartbeat, HP drain, message decay). fn resolve_turn(&mut self) { - self.weakness = (self.weakness + 1).min(MAX_WEAKNESS); + // Weakness grows every 3 turns, not every turn. + if self.heartbeat_count % 3 == 0 { + self.weakness = (self.weakness + 1).min(MAX_WEAKNESS); + } self.heartbeat_count += 1; // Message decay. @@ -1118,14 +1179,11 @@ impl Game { } } - // HP drain at max weakness. + // HP drain at max weakness — stops at 1, never kills. if self.weakness >= MAX_WEAKNESS { - if self.hp > 0 { + if self.hp > 1 { self.hp -= 1; } - if self.hp == 0 { - self.state = GameState::GameOver; - } } } diff --git a/src/main.rs b/src/main.rs index 391bb90..a59edcd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,5 @@ +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + mod game; mod renderer; mod texture; @@ -11,17 +13,6 @@ const SCREEN_W: usize = 640; const SCREEN_H: usize = 480; fn main() { - println!("=== DUNGEON CRAWLER ==="); - println!(" W / S — forward / backward"); - println!(" A / D — strafe left / right"); - println!(" Q / E — turn 90° left / right"); - println!(" Space — attack"); - println!(" Z — use selected inventory item"); - println!(" X — discard selected inventory item"); - println!(" 1-4 — select inventory slot"); - println!(" C — skip turn"); - println!(" Esc — quit\n"); - let mut game = Game::new(); let renderer = Renderer::new(SCREEN_W, SCREEN_H); @@ -33,6 +24,9 @@ fn main() { ) .expect("Failed to create window"); + // Try to centre on screen (best-effort; not all backends support this). + window.set_position(640, 240); + let mut framebuffer = vec![0u32; SCREEN_W * SCREEN_H]; let mut last = Instant::now(); let frame_budget = std::time::Duration::from_micros(16600); // ~60 fps @@ -52,6 +46,7 @@ fn main() { let mut prev_2 = false; let mut prev_3 = false; let mut prev_4 = false; + let mut prev_any = false; while window.is_open() && !window.is_key_down(Key::Escape) { let frame_start = Instant::now(); @@ -73,9 +68,35 @@ fn main() { let cur_2 = window.is_key_down(Key::Key2); let cur_3 = window.is_key_down(Key::Key3); let cur_4 = window.is_key_down(Key::Key4); + let cur_any = cur_w + || cur_s + || cur_a + || cur_d + || cur_q + || cur_e + || cur_space + || cur_z + || cur_x + || cur_c + || cur_1 + || cur_2 + || cur_3 + || cur_4 + || window.is_key_down(Key::Enter) + || window.is_key_down(Key::Escape); + + // ---- input ---- + let was_showing_controls = game.show_controls; + + if game.show_controls { + if cur_any && !prev_any { + game.show_controls = false; + } + } - // ---- input (edge-triggered, only in WaitingForInput) ---- - if game.can_act() { + // Only process game input if controls were NOT showing at the start + // of this frame (prevents the dismissing key from triggering an action). + if !was_showing_controls && game.can_act() { if cur_w && !prev_w { game.move_forward(); } else if cur_s && !prev_s { @@ -107,7 +128,7 @@ fn main() { } } - // Update previous states. + // Update previous states for next frame. prev_w = cur_w; prev_s = cur_s; prev_a = cur_a; @@ -122,23 +143,18 @@ fn main() { prev_2 = cur_2; prev_3 = cur_3; prev_4 = cur_4; + prev_any = cur_any; // ---- advance animations / state machine ---- game.update_animation(dt); // ---- death check ---- if game.state == GameState::GameOver { - let score = (game.kills * 100) + (game.depth.pow(2) * 50) + 500; // fists = base damage 1 - println!( - "You had become yet another legend of this dungeon.\nScore: {}", - score - ); std::process::exit(0); } // ---- win check (placeholder — becomes staircase in Phase 1) ---- if game.check_win() { - println!("You found the exit in {} heartbeats!", game.heartbeats()); std::process::exit(0); } diff --git a/src/renderer.rs b/src/renderer.rs index eec89b2..6eff9fa 100644 --- a/src/renderer.rs +++ b/src/renderer.rs @@ -38,14 +38,14 @@ const LETTERS: &[(&str, [u8; 5])] = &[ ("2", [0b01110, 0b10001, 0b00010, 0b00100, 0b11111]), ("3", [0b01110, 0b10001, 0b00110, 0b10001, 0b01110]), ("4", [0b00010, 0b00110, 0b01010, 0b11111, 0b00010]), - ("5", [0b11111, 0b10000, 0b11110, 0b10001, 0b01110]), + ("5", [0b11111, 0b10000, 0b11110, 0b00001, 0b11110]), ("6", [0b01110, 0b10000, 0b11110, 0b10001, 0b01110]), ("7", [0b11111, 0b00001, 0b00010, 0b00100, 0b00100]), ("8", [0b01110, 0b10001, 0b01110, 0b10001, 0b01110]), ("9", [0b01110, 0b10001, 0b01111, 0b00001, 0b01110]), // Punctuation & symbols ("!", [0b00100, 0b00100, 0b00100, 0b00000, 0b00100]), - ("@", [0b01110, 0b10001, 0b10111, 0b10000, 0b01110]), + ("@", [0b01110, 0b10001, 0b10110, 0b10000, 0b01110]), ("+", [0b00000, 0b00100, 0b01110, 0b00100, 0b00000]), (":", [0b00000, 0b00100, 0b00000, 0b00100, 0b00000]), ("%", [0b10001, 0b00010, 0b00100, 0b01000, 0b10001]), @@ -98,6 +98,8 @@ impl Renderer { let fov = std::f64::consts::PI / 3.0; // Per-column wall distances for entity occlusion. + // Store Euclidean (for occlusion) and perp (for rendering). + let mut wall_euclidean = vec![f64::MAX; self.screen_w]; let mut wall_dists = vec![f64::MAX; self.screen_w]; for sx in 0..self.screen_w { @@ -170,6 +172,7 @@ impl Renderer { } let perp_dist = (euclidean * (ray_angle - game.player.angle).cos().abs()).max(0.001); + wall_euclidean[sx] = euclidean; wall_dists[sx] = perp_dist; let wall_h = (self.screen_h as f64 / perp_dist) as isize; @@ -230,7 +233,7 @@ impl Renderer { } // ---- entity sprites (drawn after walls so occlusion is easy) ---- - self.draw_entities(game, buffer, &wall_dists, fov, half_h); + self.draw_entities(game, buffer, &wall_euclidean, fov, half_h); // ---- camera bob ---- let bob = game.camera_bob().round() as isize; @@ -257,6 +260,33 @@ impl Renderer { // ---- HUD ---- self.draw_hud(buffer, game); + + // ---- controls overlay (shown on start) ---- + if game.show_controls { + self.draw_controls_overlay(buffer); + } + + // ---- screen flash ---- + if game.flash_alpha > 0.001 { + let r = ((game.flash_color >> 16) & 0xFF) as f64; + let g = ((game.flash_color >> 8) & 0xFF) as f64; + let b = (game.flash_color & 0xFF) as f64; + let a = game.flash_alpha; + for py in 0..self.screen_h { + for px in 0..self.screen_w { + let idx = py * self.screen_w + px; + let existing = buffer[idx]; + let er = ((existing >> 16) & 0xFF) as f64; + let eg = ((existing >> 8) & 0xFF) as f64; + let eb = (existing & 0xFF) as f64; + let fr = (er * (1.0 - a) + r * a) as u8; + let fg = (eg * (1.0 - a) + g * a) as u8; + let fb = (eb * (1.0 - a) + b * a) as u8; + buffer[idx] = + 0xFF00_0000 | ((fr as u32) << 16) | ((fg as u32) << 8) | (fb as u32); + } + } + } } // ---- entity sprites --------------------------------------------------- @@ -265,13 +295,25 @@ impl Renderer { &self, game: &Game, buffer: &mut [u32], - wall_dists: &[f64], + wall_euclidean: &[f64], fov: f64, half_h: f64, ) { let mw = game.maze.width(); let mh = game.maze.height(); + // Collect visible entities with their screen info. + struct Sprite { + sx: isize, + sy: isize, + scale: f64, + ch: char, + color: u32, + cam_z: f64, + } + + let mut sprites: Vec = Vec::new(); + for y in 0..mh { for x in 0..mw { let entity = match game.maze.entity_at(x, y) { @@ -290,14 +332,13 @@ impl Renderer { let rx = -fy; let ry = fx; - let cam_z = dx * fx + dy * fy; // depth + let cam_z = dx * fx + dy * fy; if cam_z <= 0.1 { - continue; // behind camera + continue; } let cam_x = dx * rx + dy * ry; - // Check if entity is within FOV. let half_fov_tan = (fov / 2.0).tan(); let screen_x_norm = cam_x / (cam_z * half_fov_tan); if screen_x_norm < -1.0 || screen_x_norm > 1.0 { @@ -306,35 +347,46 @@ impl Renderer { let sx = ((screen_x_norm + 1.0) * 0.5 * self.screen_w as f64) as isize; - // Entity height on screen. let entity_h = (self.screen_h as f64 / cam_z * 0.4) as isize; let sy = (half_h - entity_h as f64 * 0.3) as isize; - let (ch, color) = entity_char_and_color(entity); - - // Check occlusion: is any wall column this entity overlaps with closer? - let left = (sx - entity_h / 2).max(0) as usize; - let right = (sx + entity_h / 2).min(self.screen_w as isize - 1) as usize; - let visible = (left..=right).any(|col| cam_z < wall_dists[col]); - - if !visible { + // Occlusion: entity is visible only if its centre column is not behind a wall. + // Compare Euclidean distance to entity vs Euclidean distance to wall. + let col = sx.clamp(0, self.screen_w as isize - 1) as usize; + if cam_z >= wall_euclidean[col] { continue; } let scale = (entity_h.max(6) as f64 / LETTER_H as f64).max(0.5); + let (ch, color) = entity_char_and_color(entity); - self.draw_char_scaled( - buffer, - ch, + sprites.push(Sprite { sx, sy, scale, + ch, color, cam_z, - game.weakness_factor(), - ); + }); } } + + // Sort farthest first so nearby entities draw on top. + sprites.sort_by(|a, b| b.cam_z.partial_cmp(&a.cam_z).unwrap()); + + for s in &sprites { + self.draw_char_scaled( + buffer, + s.ch, + s.sx, + s.sy, + s.scale, + s.color, + s.cam_z, + game.weakness_factor(), + wall_euclidean, + ); + } } fn draw_char_scaled( @@ -345,8 +397,9 @@ impl Renderer { cy: isize, scale: f64, color: u32, - distance: f64, + cam_z: f64, weakness: f64, + wall_euclidean: &[f64], ) { let glyph = match find_glyph(ch) { Some(g) => g, @@ -354,7 +407,7 @@ impl Renderer { }; let darken_scale = 1.0 + weakness * 12.0; - let bright = 1.0 / (1.0 + distance * 0.15 * darken_scale); + let bright = 1.0 / (1.0 + cam_z * 0.15 * darken_scale); let bright_fx = (bright * 256.0) as u32; let final_color = darken_fx(color, bright_fx); @@ -377,6 +430,10 @@ impl Renderer { for py in py_start.max(0)..py_end.min(self.screen_h as isize) { for px in px_start.max(0)..px_end.min(self.screen_w as isize) { + // Per-column occlusion: skip if wall is closer. + if cam_z >= wall_euclidean[px as usize] { + continue; + } let idx = py as usize * self.screen_w + px as usize; buffer[idx] = final_color; } @@ -468,9 +525,66 @@ impl Renderer { } } } - x += LETTER_W + 1; // 1px gap + x += LETTER_W + 1; } } + + fn draw_controls_overlay(&self, buffer: &mut [u32]) { + // Semi-transparent darken. + for py in 0..self.screen_h { + for px in 0..self.screen_w { + let idx = py * self.screen_w + px; + let c = buffer[idx]; + let r = ((c >> 16) & 0xFF) as u32; + let g = ((c >> 8) & 0xFF) as u32; + let b = (c & 0xFF) as u32; + buffer[idx] = 0xFF00_0000 | ((r / 3) << 16) | ((g / 3) << 8) | (b / 3); + } + } + + let title_color = 0xFF_CC8844; + let key_color = 0xFF_88CC88; + let desc_color = 0xFF_CCCCCC; + let hint_color = 0xFF_888888; + + let cx = self.screen_w / 2; + let mut y = 40usize; + + // Title. + let title = "DUNGEON CRAWLER"; + let title_w = title.len() * (LETTER_W + 1) - 1; + self.draw_text(buffer, cx - title_w / 2, y, title, title_color); + y += LETTER_H + 10; + + // Controls. + let lines: &[(&str, &str)] = &[ + ("WASD", "MOVE / STRAFE"), + ("Q / E", "TURN 90 LEFT / RIGHT"), + ("SPACE", "ATTACK"), + ("Z", "USE SELECTED ITEM"), + ("X", "DISCARD SELECTED ITEM"), + ("1 - 4", "SELECT INVENTORY SLOT"), + ("C", "SKIP TURN"), + ("ESC", "QUIT"), + ]; + + for (key, desc) in lines { + let key_px = key.len() * (LETTER_W + 1); + let gap_px = 2 * (LETTER_W + 1); + let desc_px = desc.len() * (LETTER_W + 1); + let total_w = key_px + gap_px + desc_px; + let start_x = cx - total_w / 2; + + self.draw_text(buffer, start_x, y, key, key_color); + self.draw_text(buffer, start_x + key_px + gap_px, y, desc, desc_color); + y += LETTER_H + 4; + } + + y += 12; + let hint = "PRESS ANY KEY TO BEGIN"; + let hint_w = hint.len() * (LETTER_W + 1) - 1; + self.draw_text(buffer, cx - hint_w / 2, y, hint, hint_color); + } } // ---- helpers ---- -- cgit v1.2.3