diff --git a/.claude/skills/pixellab/SKILL.md b/.claude/skills/pixellab/SKILL.md index d681cf7a..4f2dee53 100644 --- a/.claude/skills/pixellab/SKILL.md +++ b/.claude/skills/pixellab/SKILL.md @@ -74,5 +74,8 @@ Generation takes ~5-20s per image; the batch script retries on 429/5xx. - `assets/pixellab/tiles/*.png` — terrain/furniture (opaque) - Generated PNGs are committed to git (they are small and regeneration costs credits). -The Bevy frontend (`src/bin/bevy.rs`) still renders flat colored rects; wiring -these sprites in is Milestone 3 in DESIGN.md. +The Bevy frontend (`src/bin/bevy.rs`) loads these via an `Art` resource: +textured tiles fall back to flat colors for tile types without art, and a few +types reuse a base texture with a tint (security doors = tinted `door.png`, +trap variants = tinted `trap_spike.png`). When you generate a dedicated +texture for one of those, add it to the manifest and update `Art::load`. diff --git a/DESIGN.md b/DESIGN.md index 68e621e7..dba9b2c1 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -135,6 +135,10 @@ strike team hit my lair — and I never pressed SPACE."* ### Milestone 3 — Make it beautiful (Bevy) - Tileset, sprites, animation, lighting, camera/zoom, real UI. Stylized 2D/2.5D. - Style layer of the villain (costume, lair aesthetic, world reactions) rides here. +- STARTED 2026-07-05: Pixel Lab art pipeline (see pixellab skill), 23-asset + starter pack, and the Bevy frontend ported onto the shared `Sim` (deleting its + duplicated wave-loop logic) with sprites wired in. Still to come: camera + zoom/pan, animation, lighting, real menu UI. ### Milestone 4 — Shared overworld (later, design-for-now) - Async multiplayer: each player owns their deterministic lair sim; a shared world diff --git a/README.md b/README.md index cbec042e..cf05b483 100644 --- a/README.md +++ b/README.md @@ -71,10 +71,18 @@ The threat readout in the sidebar shows when the next raid is coming. Raids are ```bash cd ~/code/supervillain -cargo build --release -./target/release/supervillain + +# Terminal frontend +cargo run --release + +# Graphical (Bevy) frontend with pixel art +cargo run --release --features bevy_ui --bin supervillain-bevy ``` +Note: run the Bevy frontend via `cargo run` (it resolves `assets/` from the +project root). If you launch the binary directly, set `BEVY_ASSET_ROOT` to the +repository root. + ## Requirements - Rust 1.70+ (uses 2024 edition) diff --git a/devlogs/2026-07-05-bevy-sim-port.md b/devlogs/2026-07-05-bevy-sim-port.md new file mode 100644 index 00000000..05bd733b --- /dev/null +++ b/devlogs/2026-07-05-bevy-sim-port.md @@ -0,0 +1,51 @@ +# 2026-07-05 — Bevy frontend ported to Sim, sprites wired in + +The renderer-agnostic architecture paid off for the first time today. + +## The problem + +`src/bin/bevy.rs` predated the sim extraction and carried its own copy of the +old wave-based game rules — research purchases, hiring, scheme launching, and +the whole defend-phase stepping loop, ~700 lines of duplicated logic that had +already diverged from the terminal game (it still had SPACE-starts-wave, used +`W` for the world map, and ticked schemes on wave-clear). + +## What shipped + +Rewrote the file as a thin view over `supervillain::sim::Sim`, mirroring the +terminal frontend exactly: same continuous clock (a repeating `Timer` mapping +wall-clock to sim ticks with bounded catch-up), same controls (WASD/hjkl/arrows, +SPACE pauses, +/- speed, m for schemes, 1-9 powers), same threat readout. +Net: 1,387 lines of divergent logic became ~800 lines of pure view code, and +the two frontends now literally cannot disagree about game rules. + +Pixel art is wired in via an `Art` resource: + +- `ImagePlugin::default_nearest()` so 32px sprites stay crisp instead of + bilinear-smeared. +- 12 tile types get their generated textures; unpowered functional tiles render + dimmed (0.45 tint) instead of the old brighten-when-powered rect trick. +- Tile types without art yet reuse a base texture with a tint — security doors + are increasingly-red `door.png`, elec/gas/laser/wind traps are tinted + `trap_spike.png` — and the remaining few (medbay, hotel, freezer...) fall + back to flat colors until we generate more art. +- Units use their class/type sprites; the villain finally renders as an actual + character instead of a translucent cursor square. + +## Gotchas + +- `MinionClass`/`AgentType` needed `Hash` derives to key the texture maps — + trivial lib change. +- Bevy resolves `assets/` from `BEVY_ASSET_ROOT`, then `CARGO_MANIFEST_DIR`, + then the executable's directory. So `cargo run` works, but running + `target/debug/supervillain-bevy` directly silently loads zero assets. Caught + by an 8-second live-launch smoke test grepping the log for asset errors — + documented in the README. +- `ResMut` deref makes two `&mut game.sim.x` borrows in one call an E0499; + bind `let sim = &mut game.sim` first to split field borrows. + +## Next + +- Camera zoom/pan (the 80x40 map at 16px/tile is small on a 1280x720 window). +- Sprite facing/rotation via Pixel Lab `/rotate` once entities track direction. +- Real menu panels instead of sidebar text lists. diff --git a/src/bin/bevy.rs b/src/bin/bevy.rs index c20ce023..19196b06 100644 --- a/src/bin/bevy.rs +++ b/src/bin/bevy.rs @@ -1,1186 +1,700 @@ //! Supervillain — Bevy frontend //! -//! A graphical roguelike inspired by Supervillain, rendered with Bevy. +//! A thin view/input layer over `supervillain::sim::Sim`, exactly like the +//! terminal frontend: all game rules live in the sim; this binary owns the +//! wall-clock-to-tick mapping (speed and pause), rendering, and menus. +//! +//! Art comes from the Pixel Lab pipeline (`assets/pixellab/`, see the +//! pixellab skill). Tiles without generated art yet fall back to flat colors, +//! and a few reuse a base texture with a tint (security doors, trap variants). + +use std::collections::HashMap; use bevy::prelude::*; use bevy::window::WindowResolution; -use supervillain::{build, combat, entities, map, tiles}; +use supervillain::build::{BuildMode, BuildResult}; +use supervillain::entities::{AgentType, MinionClass}; +use supervillain::sim::{Sim, Threat}; +use supervillain::tiles::{self, TileType}; // ─── Constants ─────────────────────────────────────────────────────────────── const TILE_SIZE: f32 = 16.0; -const MAP_WIDTH: i32 = 80; -const MAP_HEIGHT: i32 = 40; const SIDEBAR_WIDTH: f32 = 300.0; -// ─── Colors ───────────────────────────────────────────────────────────────── +// ─── Art ───────────────────────────────────────────────────────────────────── -fn tile_color(tile: tiles::TileType) -> Color { - match tile { - tiles::TileType::Floor => Color::srgb(0.15, 0.15, 0.2), - tiles::TileType::Wall => Color::srgb(0.3, 0.3, 0.35), - tiles::TileType::Rock => Color::srgb(0.1, 0.1, 0.12), - tiles::TileType::Throne => Color::srgb(0.6, 0.1, 0.6), - tiles::TileType::Entry => Color::srgb(0.1, 0.4, 0.1), - tiles::TileType::PowerCore => Color::srgb(0.2, 0.6, 0.8), - tiles::TileType::Lab => Color::srgb(0.3, 0.5, 0.2), - tiles::TileType::Vault => Color::srgb(0.5, 0.4, 0.1), - tiles::TileType::Barracks => Color::srgb(0.4, 0.3, 0.2), - tiles::TileType::GuardPost => Color::srgb(0.5, 0.3, 0.1), - tiles::TileType::Medbay => Color::srgb(0.1, 0.5, 0.3), - tiles::TileType::TrainingRoom => Color::srgb(0.4, 0.2, 0.4), - tiles::TileType::Hotel => Color::srgb(0.5, 0.5, 0.1), - tiles::TileType::Freezer => Color::srgb(0.3, 0.6, 0.8), - tiles::TileType::FakeEntry => Color::srgb(0.1, 0.35, 0.1), - tiles::TileType::Door => Color::srgb(0.4, 0.3, 0.15), - tiles::TileType::SecurityDoor1 => Color::srgb(0.5, 0.35, 0.15), - tiles::TileType::SecurityDoor2 => Color::srgb(0.6, 0.3, 0.1), - tiles::TileType::SecurityDoor3 => Color::srgb(0.7, 0.2, 0.1), - tiles::TileType::TrapSpike => Color::srgb(0.6, 0.1, 0.1), - tiles::TileType::TrapFire => Color::srgb(0.8, 0.3, 0.0), - tiles::TileType::TrapElec => Color::srgb(0.3, 0.3, 0.8), - tiles::TileType::TrapGas => Color::srgb(0.3, 0.6, 0.1), - tiles::TileType::TrapLaser => Color::srgb(0.8, 0.1, 0.1), - tiles::TileType::TrapWind => Color::srgb(0.5, 0.5, 0.6), - tiles::TileType::Armory => Color::srgb(0.6, 0.2, 0.2), - tiles::TileType::ControlRoom => Color::srgb(0.5, 0.5, 0.2), - tiles::TileType::LootRoom => Color::srgb(0.5, 0.2, 0.5), - } +/// Loaded pixel art. Tiles map to a texture plus a tint (WHITE = as-authored); +/// tiles absent from the map render as flat colored rects. +#[derive(Resource)] +struct Art { + tiles: HashMap, Color)>, + villain: Handle, + henchman: Handle, + minions: HashMap>, + agents: HashMap>, } -/// Brighten a color by a small amount for the "powered glow" effect. -fn brighten(color: Color, amount: f32) -> Color { - let srgba = color.to_srgba(); - Color::srgba( - (srgba.red + amount).min(1.0), - (srgba.green + amount).min(1.0), - (srgba.blue + amount).min(1.0), - srgba.alpha, - ) -} +impl Art { + fn load(assets: &AssetServer) -> Self { + let tile = |name: &str| assets.load(format!("pixellab/tiles/{name}.png")); + let sprite = |name: &str| assets.load(format!("pixellab/sprites/{name}.png")); -// ─── Components ────────────────────────────────────────────────────────────── + let mut tiles = HashMap::new(); + let mut t = |ty: TileType, handle: Handle, tint: Color| { + tiles.insert(ty, (handle, tint)); + }; -#[derive(Component)] -struct MapTile; + let floor = tile("floor"); + let door = tile("door"); + let spike = tile("trap_spike"); + + t(TileType::Floor, floor.clone(), Color::WHITE); + t(TileType::Wall, tile("wall"), Color::WHITE); + t(TileType::Rock, tile("rock"), Color::WHITE); + t(TileType::Throne, tile("throne"), Color::WHITE); + t(TileType::PowerCore, tile("power_core"), Color::WHITE); + t(TileType::Lab, tile("lab"), Color::WHITE); + t(TileType::Vault, tile("vault"), Color::WHITE); + t(TileType::Barracks, tile("barracks"), Color::WHITE); + t(TileType::GuardPost, tile("guard_post"), Color::WHITE); + t(TileType::TrapSpike, spike.clone(), Color::WHITE); + t(TileType::TrapFire, tile("trap_fire"), Color::WHITE); + t(TileType::Door, door.clone(), Color::WHITE); + // Reuse base textures with tints until dedicated art exists. + t(TileType::Entry, floor.clone(), Color::srgb(0.6, 1.0, 0.6)); + t(TileType::FakeEntry, floor, Color::srgb(0.5, 0.8, 0.5)); + t( + TileType::SecurityDoor1, + door.clone(), + Color::srgb(1.0, 0.85, 0.6), + ); + t( + TileType::SecurityDoor2, + door.clone(), + Color::srgb(1.0, 0.65, 0.45), + ); + t(TileType::SecurityDoor3, door, Color::srgb(1.0, 0.45, 0.35)); + t( + TileType::TrapElec, + spike.clone(), + Color::srgb(0.6, 0.6, 1.0), + ); + t(TileType::TrapGas, spike.clone(), Color::srgb(0.6, 1.0, 0.5)); + t( + TileType::TrapLaser, + spike.clone(), + Color::srgb(1.0, 0.5, 0.5), + ); + t(TileType::TrapWind, spike, Color::srgb(0.8, 0.8, 0.9)); -#[derive(Component)] -struct TilePosition { - x: i32, - y: i32, + Self { + tiles, + villain: sprite("villain"), + henchman: sprite("henchman"), + minions: HashMap::from([ + (MinionClass::Worker, sprite("minion_worker")), + (MinionClass::Guard, sprite("minion_guard")), + (MinionClass::Technician, sprite("minion_technician")), + (MinionClass::Scientist, sprite("minion_scientist")), + ]), + agents: HashMap::from([ + (AgentType::Investigator, sprite("agent_investigator")), + (AgentType::Thief, sprite("agent_thief")), + (AgentType::Saboteur, sprite("agent_saboteur")), + (AgentType::Soldier, sprite("agent_soldier")), + (AgentType::SuperAgent, sprite("agent_super")), + ]), + } + } } -#[derive(Component)] -struct GameCamera; - -#[derive(Component)] -struct PlayerMarker; - -#[derive(Component)] -struct MinionMarker; - -#[derive(Component)] -struct AgentMarker; +/// Flat colors for tiles with no texture yet. +fn tile_color(tile: TileType) -> Color { + match tile { + TileType::Medbay => Color::srgb(0.1, 0.5, 0.3), + TileType::TrainingRoom => Color::srgb(0.4, 0.2, 0.4), + TileType::Hotel => Color::srgb(0.5, 0.5, 0.1), + TileType::Freezer => Color::srgb(0.3, 0.6, 0.8), + TileType::Armory => Color::srgb(0.6, 0.2, 0.2), + TileType::ControlRoom => Color::srgb(0.5, 0.5, 0.2), + TileType::LootRoom => Color::srgb(0.5, 0.2, 0.5), + _ => Color::srgb(0.15, 0.15, 0.2), + } +} -#[derive(Component)] -struct HenchmanMarker; +// ─── App state ─────────────────────────────────────────────────────────────── -#[derive(Component)] -struct CursorMarker; +#[derive(PartialEq, Clone, Copy, Debug)] +enum Screen { + Title, + Playing, + GameOver, +} #[derive(Resource)] -struct GameState { - game_map: map::GameMap, - player: entities::Player, - minions: Vec, - agents: Vec, - henchmen: Vec, - combat: combat::CombatSystem, - phase: Phase, - defend_turn: i32, - defend_speed: u64, - defend_paused: bool, - defend_timer: Timer, - wave_announce_turns: i32, - build_mode: Option, +struct Game { + sim: Sim, + screen: Screen, + build_mode: Option, research_menu: bool, research_selected: usize, hire_menu: bool, hire_selected: usize, + world_map: bool, world_map_selected: usize, + paused: bool, + /// Wall-clock milliseconds per simulation tick. + tick_ms: u64, + tick_timer: Timer, log: Vec, } -impl GameState { - /// Creates a placeholder GameState that will be fully initialized in the setup system. - fn placeholder() -> Self { - let game_map = map::GameMap::new(MAP_WIDTH, MAP_HEIGHT); - let throne = game_map.throne_pos().unwrap_or((40, 20)); +impl Game { + fn new() -> Self { Self { - game_map, - player: { - let mut p = entities::Player::new(throne.0, throne.1); - p.update_powers(); - p - }, - minions: Vec::new(), - agents: Vec::new(), - henchmen: entities::Henchman::starting_roster(throne.0, throne.1), - combat: combat::CombatSystem::new(), - phase: Phase::Title, - defend_turn: 0, - defend_speed: 150, - defend_paused: false, - defend_timer: Timer::from_seconds(0.15, TimerMode::Repeating), - wave_announce_turns: 0, + sim: Sim::new(), + screen: Screen::Title, build_mode: None, research_menu: false, research_selected: 0, hire_menu: false, hire_selected: 0, + world_map: false, world_map_selected: 0, + paused: false, + tick_ms: 150, + tick_timer: Timer::from_seconds(0.15, TimerMode::Repeating), log: Vec::new(), } } + + fn drain_sim_log(&mut self) { + self.log.extend(self.sim.drain_log()); + let len = self.log.len(); + if len > 12 { + self.log.drain(..len - 12); + } + } + + fn set_speed(&mut self, tick_ms: u64) { + self.tick_ms = tick_ms; + self.tick_timer = Timer::from_seconds(tick_ms as f32 / 1000.0, TimerMode::Repeating); + self.log.push(format!("Speed: {tick_ms}ms/tick")); + } } -#[derive(PartialEq, Clone, Copy, Debug)] -enum Phase { - Title, - Build, - Defend, - WorldMap, - GameOver, +// ─── Components ────────────────────────────────────────────────────────────── + +#[derive(Component)] +struct MapTile; + +#[derive(Component)] +struct TilePosition { + x: i32, + y: i32, } -// ─── Plugins ───────────────────────────────────────────────────────────────── +#[derive(Component)] +struct UnitMarker; + +#[derive(Component)] +struct CursorMarker; + +#[derive(Component)] +struct UiText; + +#[derive(Component)] +struct OverlayText; + +// ─── Main ──────────────────────────────────────────────────────────────────── fn main() { App::new() - .add_plugins(DefaultPlugins.set(WindowPlugin { - primary_window: Some(Window { - title: "Supervillain — Supervillain Roguelike".to_string(), - resolution: WindowResolution::new(1280, 720), - ..default() - }), - ..default() - })) - .add_plugins(bevy::diagnostic::FrameTimeDiagnosticsPlugin::default()) + .add_plugins( + DefaultPlugins + .set(WindowPlugin { + primary_window: Some(Window { + title: "Supervillain".to_string(), + resolution: WindowResolution::new(1280, 720), + ..default() + }), + ..default() + }) + // Crisp pixel art — no bilinear smearing. + .set(ImagePlugin::default_nearest()), + ) .insert_resource(ClearColor(Color::srgb(0.02, 0.02, 0.04))) - .insert_resource(GameState::placeholder()) + .insert_resource(Game::new()) .add_systems(Startup, (setup, setup_ui)) .add_systems( Update, ( handle_input, - update_defend_phase, + advance_sim, render_map_tiles, - render_entities, + render_units, render_ui, - ), + ) + .chain(), ) .run(); } // ─── Setup ─────────────────────────────────────────────────────────────────── -fn setup(mut commands: Commands, mut game_state: ResMut) { - // Initialize game state - let game_map = map::GameMap::new(MAP_WIDTH, MAP_HEIGHT); - let throne = game_map.throne_pos().unwrap_or((40, 20)); - let mut player = entities::Player::new(throne.0, throne.1); - player.update_powers(); - let henchmen = entities::Henchman::starting_roster(throne.0, throne.1); - - // Spawn starting workers near throne - let mut minions = Vec::new(); - for (dx, dy) in [(-1, 0), (1, 0), (0, -1)] { - let mx = throne.0 + dx; - let my = throne.1 + dy; - if game_map.is_walkable(mx, my) { - minions.push(entities::Minion::new( - mx, - my, - entities::MinionClass::Worker, - 0, - 0, - )); - } - } - - // Spawn minions at guard posts - let guard_posts = game_map.tiles_of_type(tiles::TileType::GuardPost); - for &(gx, gy) in &guard_posts { - minions.push(entities::Minion::new( - gx, - gy, - entities::MinionClass::Guard, - 0, - 0, - )); - } +fn setup(mut commands: Commands, assets: Res, mut game: ResMut) { + let art = Art::load(&assets); - *game_state = GameState { - game_map, - player, - minions, - agents: Vec::new(), - henchmen, - combat: combat::CombatSystem::new(), - phase: Phase::Title, - defend_turn: 0, - defend_speed: 150, - defend_paused: false, - defend_timer: Timer::from_seconds(0.15, TimerMode::Repeating), - wave_announce_turns: 0, - build_mode: None, - research_menu: false, - research_selected: 0, - hire_menu: false, - hire_selected: 0, - world_map_selected: 0, - log: Vec::new(), - }; + let map_w = game.sim.map.width; + let map_h = game.sim.map.height; - // Camera - let map_pixel_w = MAP_WIDTH as f32 * TILE_SIZE; - let map_pixel_h = MAP_HEIGHT as f32 * TILE_SIZE; + // Camera centered on the map. commands.spawn(( Camera2d, - Transform::from_xyz(map_pixel_w / 2.0, -map_pixel_h / 2.0, 1000.0), - GameCamera, + Transform::from_xyz( + map_w as f32 * TILE_SIZE / 2.0, + -(map_h as f32 * TILE_SIZE / 2.0), + 1000.0, + ), )); - // Spawn map tiles as sprites with TilePosition - for y in 0..MAP_HEIGHT { - for x in 0..MAP_WIDTH { - let tile = game_state.game_map.get_tile(x, y); - let color = tile_color(tile); - let pos = Vec3::new( - x as f32 * TILE_SIZE + TILE_SIZE / 2.0, - -(y as f32 * TILE_SIZE + TILE_SIZE / 2.0), - 0.0, - ); + // One sprite per tile; textures/colors assigned by render_map_tiles. + for y in 0..map_h { + for x in 0..map_w { commands.spawn(( - Sprite::from_color(color, Vec2::splat(TILE_SIZE - 1.0)), - Transform::from_translation(pos), + Sprite { + custom_size: Some(Vec2::splat(TILE_SIZE)), + ..default() + }, + Transform::from_translation(grid_to_world(x, y, 0.0)), MapTile, TilePosition { x, y }, )); } } - // Cursor sprite + // Cursor highlight. + let throne = game.sim.map.throne_pos().unwrap_or((40, 20)); commands.spawn(( - Sprite::from_color(Color::srgba(1.0, 1.0, 1.0, 0.3), Vec2::splat(TILE_SIZE)), - Transform::from_translation(Vec3::new( - throne.0 as f32 * TILE_SIZE + TILE_SIZE / 2.0, - -(throne.1 as f32 * TILE_SIZE + TILE_SIZE / 2.0), - 5.0, - )), + Sprite::from_color(Color::srgba(1.0, 1.0, 1.0, 0.25), Vec2::splat(TILE_SIZE)), + Transform::from_translation(grid_to_world(throne.0, throne.1, 5.0)), CursorMarker, )); + + commands.insert_resource(art); + game.drain_sim_log(); +} + +fn grid_to_world(x: i32, y: i32, z: f32) -> Vec3 { + Vec3::new( + x as f32 * TILE_SIZE + TILE_SIZE / 2.0, + -(y as f32 * TILE_SIZE + TILE_SIZE / 2.0), + z, + ) } // ─── Input ─────────────────────────────────────────────────────────────────── +fn menu_nav(keyboard: &ButtonInput) -> i32 { + let down = keyboard.just_pressed(KeyCode::ArrowDown) + || keyboard.just_pressed(KeyCode::KeyJ) + || keyboard.just_pressed(KeyCode::KeyS); + let up = keyboard.just_pressed(KeyCode::ArrowUp) + || keyboard.just_pressed(KeyCode::KeyK) + || keyboard.just_pressed(KeyCode::KeyW); + i32::from(down) - i32::from(up) +} + +fn movement(keyboard: &ButtonInput) -> (i32, i32) { + let mut dx = 0; + let mut dy = 0; + if keyboard.just_pressed(KeyCode::ArrowLeft) + || keyboard.just_pressed(KeyCode::KeyH) + || keyboard.just_pressed(KeyCode::KeyA) + { + dx -= 1; + } + if keyboard.just_pressed(KeyCode::ArrowRight) + || keyboard.just_pressed(KeyCode::KeyL) + || keyboard.just_pressed(KeyCode::KeyD) + { + dx += 1; + } + if keyboard.just_pressed(KeyCode::ArrowUp) + || keyboard.just_pressed(KeyCode::KeyK) + || keyboard.just_pressed(KeyCode::KeyW) + { + dy -= 1; + } + if keyboard.just_pressed(KeyCode::ArrowDown) + || keyboard.just_pressed(KeyCode::KeyJ) + || keyboard.just_pressed(KeyCode::KeyS) + { + dy += 1; + } + (dx, dy) +} + +#[allow(clippy::too_many_lines)] fn handle_input( keyboard: Res>, - mut game_state: ResMut, - mut cursor_query: Query<&mut Transform, With>, + mut game: ResMut, + mut cursor: Query<&mut Transform, With>, mut exit: MessageWriter, ) { - // Title screen — any key starts - if game_state.phase == Phase::Title { - if keyboard.any_pressed([KeyCode::Space, KeyCode::Enter]) { - game_state.phase = Phase::Build; + match game.screen { + Screen::Title => { + if keyboard.just_pressed(KeyCode::Space) || keyboard.just_pressed(KeyCode::Enter) { + game.screen = Screen::Playing; + } + return; } - return; - } - - // Game over — Escape exits - if game_state.phase == Phase::GameOver { - if keyboard.just_pressed(KeyCode::Escape) { - exit.write(AppExit::Success); + Screen::GameOver => { + if keyboard.just_pressed(KeyCode::Escape) { + exit.write(AppExit::Success); + } + return; } - return; + Screen::Playing => {} } // ── Research menu ────────────────────────────────────────────────────── - if game_state.research_menu { - let unlocks = tiles::research_unlocks(); - if keyboard.just_pressed(KeyCode::KeyN) || keyboard.just_pressed(KeyCode::ArrowDown) { - if game_state.research_selected < unlocks.len() - 1 { - game_state.research_selected += 1; - } + if game.research_menu { + let count = tiles::research_unlocks().len(); + let nav = menu_nav(&keyboard); + if nav > 0 && game.research_selected + 1 < count { + game.research_selected += 1; } - if keyboard.just_pressed(KeyCode::KeyP) || keyboard.just_pressed(KeyCode::ArrowUp) { - if game_state.research_selected > 0 { - game_state.research_selected -= 1; - } + if nav < 0 { + game.research_selected = game.research_selected.saturating_sub(1); } - if keyboard.just_pressed(KeyCode::Space) { - let unlocks = tiles::research_unlocks(); - if game_state.research_selected < unlocks.len() { - let (key, cost, name) = unlocks[game_state.research_selected]; - let already_unlocked = game_state.player.unlocked.contains(key); - let current_research = game_state.player.research; - if already_unlocked { - game_state.log.push("Already researched!".to_string()); - } else if current_research < cost { - game_state.log.push(format!( - "Not enough research points! Need {}, have {}", - cost, current_research - )); - } else { - game_state.player.research -= cost; - game_state.player.unlocked.insert(key.to_string()); - // Apply special effects - match key { - "power_core_mk2" => game_state.player.power_core_mk2 = true, - "minion_armor" => { - for minion in &mut game_state.minions { - minion.armor += 5; - minion.entity.max_hp += 15; - minion.entity.hp = minion.entity.max_hp; - } - game_state.player.minion_hp_bonus += 15; - } - "minion_weapons" => { - for minion in &mut game_state.minions { - minion.entity.damage += 5; - } - game_state.player.minion_damage_bonus += 5; - } - _ => {} - } - game_state.log.push(format!("Researched: {name}!")); - game_state.player.update_powers(); // Check if new powers unlocked - } - } + if keyboard.just_pressed(KeyCode::Enter) || keyboard.just_pressed(KeyCode::Space) { + let idx = game.research_selected; + game.sim.buy_research(idx); + game.drain_sim_log(); } - if keyboard.just_pressed(KeyCode::Escape) { - game_state.research_menu = false; - } - // Update cursor to player position when in research menu - if let Ok(mut transform) = cursor_query.single_mut() { - transform.translation.x = - game_state.player.entity.x as f32 * TILE_SIZE + TILE_SIZE / 2.0; - transform.translation.y = - -(game_state.player.entity.y as f32 * TILE_SIZE + TILE_SIZE / 2.0); + if keyboard.just_pressed(KeyCode::Escape) || keyboard.just_pressed(KeyCode::KeyR) { + game.research_menu = false; } return; } // ── Hire menu ────────────────────────────────────────────────────────── - if game_state.hire_menu { - let classes = entities::MinionClass::all(); - if keyboard.just_pressed(KeyCode::KeyN) || keyboard.just_pressed(KeyCode::ArrowDown) { - if game_state.hire_selected < classes.len() - 1 { - game_state.hire_selected += 1; - } + if game.hire_menu { + let count = MinionClass::all().len(); + let nav = menu_nav(&keyboard); + if nav > 0 && game.hire_selected + 1 < count { + game.hire_selected += 1; } - if keyboard.just_pressed(KeyCode::KeyP) || keyboard.just_pressed(KeyCode::ArrowUp) { - if game_state.hire_selected > 0 { - game_state.hire_selected -= 1; - } + if nav < 0 { + game.hire_selected = game.hire_selected.saturating_sub(1); } - if keyboard.just_pressed(KeyCode::Space) { - let classes = entities::MinionClass::all(); - if game_state.hire_selected < classes.len() { - let class = classes[game_state.hire_selected]; - let unlock_key = class.unlock_key(); - if !game_state.player.unlocked.contains(unlock_key) { - game_state - .log - .push(format!("{} not researched yet!", class.name())); - } else { - let cost = class.hire_cost(); - if game_state.player.gold < cost { - game_state.log.push(format!( - "Need {} gold to hire {}!", - cost, - class.name() - )); - } else { - let alive_minions = - game_state.minions.iter().filter(|m| m.entity.alive).count() as i32; - let minion_cap = game_state.player.minion_cap; - if alive_minions >= minion_cap { - game_state.log.push(format!( - "Minion cap reached! ({}/{})", - alive_minions, minion_cap - )); - } else { - // Place near throne - let throne = game_state.game_map.throne_pos().unwrap_or((40, 20)); - let mut placed = false; - for dist in 1..5 { - for (dx, dy) in [(0, -dist), (0, dist), (-dist, 0), (dist, 0)] { - let mx = throne.0 + dx; - let my = throne.1 + dy; - if game_state.game_map.is_walkable(mx, my) { - game_state.player.gold -= cost; - let dmg_bonus = game_state.player.minion_damage_bonus; - let hp_bonus = game_state.player.minion_hp_bonus; - let minion = entities::Minion::new( - mx, my, class, dmg_bonus, hp_bonus, - ); - game_state.minions.push(minion); - game_state.log.push(format!( - "Hired {} for {} gold!", - class.name(), - cost - )); - placed = true; - break; - } - } - if placed { - break; - } - } - if !placed { - game_state - .log - .push("No room near throne to place minion!".to_string()); - } - } - } - } - } + if keyboard.just_pressed(KeyCode::Enter) || keyboard.just_pressed(KeyCode::Space) { + let idx = game.hire_selected; + game.sim.hire_minion(idx); + game.drain_sim_log(); } - if keyboard.just_pressed(KeyCode::Escape) { - game_state.hire_menu = false; - } - // Update cursor to player position when in hire menu - if let Ok(mut transform) = cursor_query.single_mut() { - transform.translation.x = - game_state.player.entity.x as f32 * TILE_SIZE + TILE_SIZE / 2.0; - transform.translation.y = - -(game_state.player.entity.y as f32 * TILE_SIZE + TILE_SIZE / 2.0); + if keyboard.just_pressed(KeyCode::Escape) || keyboard.just_pressed(KeyCode::KeyF) { + game.hire_menu = false; } return; } - // ── World map / schemes ───────────────────────────────────────────────── - if game_state.phase == Phase::WorldMap { - let schemes = tiles::available_schemes(); - if keyboard.just_pressed(KeyCode::KeyN) || keyboard.just_pressed(KeyCode::ArrowDown) { - if game_state.world_map_selected < schemes.len() - 1 { - game_state.world_map_selected += 1; - } + // ── World map / schemes ──────────────────────────────────────────────── + if game.world_map { + let count = tiles::available_schemes().len(); + let nav = menu_nav(&keyboard); + if nav > 0 && game.world_map_selected + 1 < count { + game.world_map_selected += 1; } - if keyboard.just_pressed(KeyCode::KeyP) || keyboard.just_pressed(KeyCode::ArrowUp) { - if game_state.world_map_selected > 0 { - game_state.world_map_selected -= 1; - } + if nav < 0 { + game.world_map_selected = game.world_map_selected.saturating_sub(1); } - if keyboard.just_pressed(KeyCode::Space) { - let idx = game_state.world_map_selected; - let schemes = tiles::available_schemes(); - if idx < schemes.len() { - let scheme = &schemes[idx]; - let alive_minions = - game_state.minions.iter().filter(|m| m.entity.alive).count() as i32; - if alive_minions < scheme.minion_cost { - game_state.log.push(format!( - "Need {} minions for this scheme!", - scheme.minion_cost - )); - } else { - // Remove minions (send them on the mission) - let mut removed = 0i32; - let cost = scheme.minion_cost; - game_state.minions.retain(|m| { - if removed >= cost { - return true; - } - if m.entity.alive && m.class == entities::MinionClass::Worker { - removed += 1; - false - } else { - true - } - }); - if removed < cost { - game_state.minions.retain(|m| { - if removed >= cost { - return true; - } - if m.entity.alive { - removed += 1; - false - } else { - true - } - }); - } - - game_state - .player - .active_schemes - .push(entities::ActiveScheme { - name: scheme.name.to_string(), - turns_remaining: scheme.duration_turns, - gold_reward: scheme.gold_reward, - heat_cost: scheme.heat_cost, - notoriety_reward: scheme.notoriety_reward, - minion_cost: scheme.minion_cost, - loot: scheme.loot.map(|s| s.to_string()), - }); - game_state - .log - .push(format!("Launched scheme: {}!", scheme.name)); - game_state.log.push(format!( - " {} minions deployed, {} turns", - scheme.minion_cost, scheme.duration_turns - )); - game_state.phase = Phase::Build; - } + if keyboard.just_pressed(KeyCode::Enter) || keyboard.just_pressed(KeyCode::Space) { + let idx = game.world_map_selected; + if game.sim.launch_scheme(idx) { + game.world_map = false; } + game.drain_sim_log(); } - if keyboard.just_pressed(KeyCode::Escape) { - game_state.phase = Phase::Build; - } - // Update cursor to player position when in world map - if let Ok(mut transform) = cursor_query.single_mut() { - transform.translation.x = - game_state.player.entity.x as f32 * TILE_SIZE + TILE_SIZE / 2.0; - transform.translation.y = - -(game_state.player.entity.y as f32 * TILE_SIZE + TILE_SIZE / 2.0); + if keyboard.just_pressed(KeyCode::Escape) || keyboard.just_pressed(KeyCode::KeyM) { + game.world_map = false; } return; } // ── Build mode ───────────────────────────────────────────────────────── - if game_state.build_mode.is_some() { - let speed = if keyboard.pressed(KeyCode::ShiftLeft) { - 2 - } else { - 1 - }; - - // Cursor movement in build mode — take build_mode out, move, put back - if keyboard.just_pressed(KeyCode::KeyH) - || keyboard.just_pressed(KeyCode::ArrowLeft) - || keyboard.just_pressed(KeyCode::KeyL) - || keyboard.just_pressed(KeyCode::ArrowRight) - || keyboard.just_pressed(KeyCode::KeyK) - || keyboard.just_pressed(KeyCode::ArrowUp) - || keyboard.just_pressed(KeyCode::KeyJ) - || keyboard.just_pressed(KeyCode::ArrowDown) - { - let mut bm = game_state.build_mode.take().unwrap(); - let (dx, dy) = if keyboard.just_pressed(KeyCode::KeyH) - || keyboard.just_pressed(KeyCode::ArrowLeft) - { - (-speed, 0) - } else if keyboard.just_pressed(KeyCode::KeyL) - || keyboard.just_pressed(KeyCode::ArrowRight) - { - (speed, 0) - } else if keyboard.just_pressed(KeyCode::KeyK) - || keyboard.just_pressed(KeyCode::ArrowUp) - { - (0, -speed) - } else { - (0, speed) - }; - bm.move_cursor(dx, dy, &game_state.game_map); - game_state.build_mode = Some(bm); + if game.build_mode.is_some() { + let (dx, dy) = movement(&keyboard); + if dx != 0 || dy != 0 { + let mut bm = game.build_mode.take().unwrap(); + bm.move_cursor(dx, dy, &game.sim.map); + game.build_mode = Some(bm); } - - // TAB: cycle category — take build_mode out, operate, put back if keyboard.just_pressed(KeyCode::Tab) { - let mut bm = game_state.build_mode.take().unwrap(); - let player = game_state.player.clone(); - bm.select_category(1, &player); - game_state.build_mode = Some(bm); + let mut bm = game.build_mode.take().unwrap(); + bm.select_category(1, &game.sim.player); + game.build_mode = Some(bm); } - - // N: cycle item forward if keyboard.just_pressed(KeyCode::KeyN) { - let mut bm = game_state.build_mode.take().unwrap(); - let player = game_state.player.clone(); - bm.select_item(1, &player); - game_state.build_mode = Some(bm); + let mut bm = game.build_mode.take().unwrap(); + bm.select_item(1, &game.sim.player); + game.build_mode = Some(bm); } - - // P: cycle item backward if keyboard.just_pressed(KeyCode::KeyP) { - let mut bm = game_state.build_mode.take().unwrap(); - let player = game_state.player.clone(); - bm.select_item(-1, &player); - game_state.build_mode = Some(bm); - } - - // SPACE: place tile — destructure game_state to split borrows - if keyboard.just_pressed(KeyCode::Space) { - let GameState { - build_mode, - game_map, - player, - minions, - log, - .. - } = &mut *game_state; - - let mut bm = build_mode.take().unwrap(); - let result = bm.place_tile(game_map, player); + let mut bm = game.build_mode.take().unwrap(); + bm.select_item(-1, &game.sim.player); + game.build_mode = Some(bm); + } + if keyboard.just_pressed(KeyCode::Space) || keyboard.just_pressed(KeyCode::Enter) { + let mut bm = game.build_mode.take().unwrap(); + let sim = &mut game.sim; + let result = bm.place_tile(&mut sim.map, &mut sim.player); let msg = bm.message.clone(); - let cx = bm.cursor_x; - let cy = bm.cursor_y; - *build_mode = Some(bm); - - match result { - build::BuildResult::GuardPostPlaced => { - log.push(msg); - // Spawn guard minion at guard post - let alive_minions = minions.iter().filter(|m| m.entity.alive).count() as i32; - let minion_cap = player.minion_cap; - if alive_minions < minion_cap { - let dmg_bonus = player.minion_damage_bonus; - let hp_bonus = player.minion_hp_bonus; - let minion = entities::Minion::new( - cx, - cy, - entities::MinionClass::Guard, - dmg_bonus, - hp_bonus, - ); - minions.push(minion); - log.push("Minion deployed at guard post!".to_string()); - } else { - log.push(format!( - "Minion cap reached! ({}/{})", - alive_minions, minion_cap - )); - } - } - build::BuildResult::Success => { - log.push(msg); - } - build::BuildResult::None => { - log.push(msg); - } + let (cx, cy) = (bm.cursor_x, bm.cursor_y); + game.build_mode = Some(bm); + game.log.push(msg); + if result == BuildResult::GuardPostPlaced { + game.sim.spawn_minion_at_guard_post(cx, cy); } - - // Update power after placing - let cores = game_map.tiles_of_type(tiles::TileType::PowerCore); - game_map.update_power(&cores); + game.sim.recompute_derived(); + game.drain_sim_log(); } - - // X: demolish tile — destructure game_state to split borrows if keyboard.just_pressed(KeyCode::KeyX) { - let GameState { - build_mode, - game_map, - player, - log, - .. - } = &mut *game_state; - - let mut bm = build_mode.take().unwrap(); - let msg = bm.demolish_tile(game_map, player); - *build_mode = Some(bm); - log.push(msg); - // Update power after demolishing - let cores = game_map.tiles_of_type(tiles::TileType::PowerCore); - game_map.update_power(&cores); - } - - // ESC: exit build mode - if keyboard.just_pressed(KeyCode::Escape) { - game_state.build_mode = None; - } - - // Update cursor sprite to follow build cursor - if let Ok(mut transform) = cursor_query.single_mut() { - if let Some(ref bm) = game_state.build_mode { - transform.translation.x = bm.cursor_x as f32 * TILE_SIZE + TILE_SIZE / 2.0; - transform.translation.y = -(bm.cursor_y as f32 * TILE_SIZE + TILE_SIZE / 2.0); - } + let mut bm = game.build_mode.take().unwrap(); + let sim = &mut game.sim; + let msg = bm.demolish_tile(&mut sim.map, &mut sim.player); + game.build_mode = Some(bm); + game.log.push(msg); + game.sim.recompute_derived(); + } + if keyboard.just_pressed(KeyCode::Escape) || keyboard.just_pressed(KeyCode::KeyB) { + game.build_mode = None; + } + if let Ok(mut transform) = cursor.single_mut() + && let Some(bm) = &game.build_mode + { + transform.translation = grid_to_world(bm.cursor_x, bm.cursor_y, 5.0); } return; } - // ── Normal Build phase movement ─────────────────────────────────────── - let speed = if keyboard.pressed(KeyCode::ShiftLeft) { - 2 - } else { - 1 - }; - - if keyboard.just_pressed(KeyCode::KeyH) || keyboard.just_pressed(KeyCode::ArrowLeft) { - game_state.player.entity.x -= speed; - } - if keyboard.just_pressed(KeyCode::KeyL) || keyboard.just_pressed(KeyCode::ArrowRight) { - game_state.player.entity.x += speed; - } - if keyboard.just_pressed(KeyCode::KeyK) || keyboard.just_pressed(KeyCode::ArrowUp) { - game_state.player.entity.y -= speed; - } - if keyboard.just_pressed(KeyCode::KeyJ) || keyboard.just_pressed(KeyCode::ArrowDown) { - game_state.player.entity.y += speed; - } - - // Phase transitions - if keyboard.just_pressed(KeyCode::Space) && game_state.phase == Phase::Build { - start_wave(&mut game_state); + // ── Normal play ──────────────────────────────────────────────────────── + let (dx, dy) = movement(&keyboard); + if dx != 0 || dy != 0 { + game.sim.move_player(dx, dy); } - // Save/Load (Ctrl+S, Ctrl+L) - if keyboard.just_pressed(KeyCode::KeyS) && keyboard.pressed(KeyCode::ControlLeft) { - let state = create_save_state(&game_state); - match supervillain::save::save_game(&state) { - Ok(()) => game_state.log.push("Game saved!".to_string()), - Err(e) => game_state.log.push(format!("Save failed: {}", e)), - } + if keyboard.just_pressed(KeyCode::Space) || keyboard.just_pressed(KeyCode::KeyP) { + game.paused = !game.paused; + let msg = if game.paused { + "TIME PAUSED" + } else { + "Time resumes." + }; + game.log.push(msg.to_string()); + } + if keyboard.just_pressed(KeyCode::Equal) || keyboard.just_pressed(KeyCode::NumpadAdd) { + let ms = game.tick_ms.saturating_sub(50).max(20); + game.set_speed(ms); + } + if keyboard.just_pressed(KeyCode::Minus) || keyboard.just_pressed(KeyCode::NumpadSubtract) { + let ms = game.tick_ms.saturating_add(50).min(500); + game.set_speed(ms); + } + + if keyboard.just_pressed(KeyCode::KeyB) { + game.build_mode = Some(BuildMode::new(&game.sim.player)); + } + if keyboard.just_pressed(KeyCode::KeyR) { + game.research_menu = true; + game.research_selected = 0; + } + if keyboard.just_pressed(KeyCode::KeyF) { + game.hire_menu = true; + game.hire_selected = 0; + } + if keyboard.just_pressed(KeyCode::KeyM) { + game.world_map = true; + game.world_map_selected = 0; + } + + // Superpowers. + const POWER_KEYS: [KeyCode; 9] = [ + KeyCode::Digit1, + KeyCode::Digit2, + KeyCode::Digit3, + KeyCode::Digit4, + KeyCode::Digit5, + KeyCode::Digit6, + KeyCode::Digit7, + KeyCode::Digit8, + KeyCode::Digit9, + ]; + for (i, key) in POWER_KEYS.iter().enumerate() { + if keyboard.just_pressed(*key) { + game.sim.activate_power(i); + game.drain_sim_log(); + } + } + + // Save / load. + let ctrl = keyboard.pressed(KeyCode::ControlLeft) || keyboard.pressed(KeyCode::SuperLeft); + if ctrl && keyboard.just_pressed(KeyCode::KeyS) { + let state = game.sim.create_save_state(); + let msg = match supervillain::save::save_game(&state) { + Ok(()) => "Game saved!".to_string(), + Err(e) => format!("Save failed: {e}"), + }; + game.log.push(msg); } - if keyboard.just_pressed(KeyCode::KeyL) && keyboard.pressed(KeyCode::ControlLeft) { + if ctrl && keyboard.just_pressed(KeyCode::KeyL) { if supervillain::save::save_exists() { match supervillain::save::load_game() { Ok(state) => { - apply_save_state(&mut game_state, state); - game_state.log.push("Game loaded!".to_string()); + game.sim.apply_save_state(state); + game.log.push("Game loaded!".to_string()); + } + Err(e) => { + let msg = format!("Load failed: {e}"); + game.log.push(msg); } - Err(e) => game_state.log.push(format!("Load failed: {}", e)), } } else { - game_state.log.push("No save file found.".to_string()); - } - } - - if keyboard.just_pressed(KeyCode::KeyB) && game_state.phase == Phase::Build { - game_state.build_mode = Some(build::BuildMode::new(&game_state.player)); - } - if keyboard.just_pressed(KeyCode::KeyR) && game_state.phase == Phase::Build { - game_state.research_menu = true; - game_state.research_selected = 0; - } - if keyboard.just_pressed(KeyCode::KeyF) && game_state.phase == Phase::Build { - game_state.hire_menu = true; - game_state.hire_selected = 0; - } - if keyboard.just_pressed(KeyCode::KeyW) && game_state.phase == Phase::Build { - game_state.phase = Phase::WorldMap; - game_state.world_map_selected = 0; - } - if keyboard.just_pressed(KeyCode::Escape) { - if game_state.build_mode.is_some() { - game_state.build_mode = None; - } else if game_state.research_menu { - game_state.research_menu = false; - } else if game_state.hire_menu { - game_state.hire_menu = false; - } else if game_state.phase == Phase::WorldMap { - game_state.phase = Phase::Build; + game.log.push("No save file found.".to_string()); } } - // Defend phase controls - if game_state.phase == Phase::Defend { - if keyboard.just_pressed(KeyCode::KeyP) { - game_state.defend_paused = !game_state.defend_paused; - } - if keyboard.just_pressed(KeyCode::Equal) || keyboard.just_pressed(KeyCode::NumpadAdd) { - game_state.defend_speed = game_state.defend_speed.saturating_sub(25).max(25); - } - if keyboard.just_pressed(KeyCode::Minus) || keyboard.just_pressed(KeyCode::NumpadSubtract) { - game_state.defend_speed = game_state.defend_speed.saturating_add(25).min(500); - } - - // Superpower activation (number keys 1-9) - let power_keys = [ - KeyCode::Digit1, - KeyCode::Digit2, - KeyCode::Digit3, - KeyCode::Digit4, - KeyCode::Digit5, - KeyCode::Digit6, - KeyCode::Digit7, - KeyCode::Digit8, - KeyCode::Digit9, - ]; - for (i, key) in power_keys.iter().enumerate() { - if keyboard.just_pressed(*key) { - activate_power(&mut game_state, i); - } - } - } - - // Update cursor position to follow player (when not in build mode) - if let Ok(mut transform) = cursor_query.single_mut() { - transform.translation.x = game_state.player.entity.x as f32 * TILE_SIZE + TILE_SIZE / 2.0; - transform.translation.y = - -(game_state.player.entity.y as f32 * TILE_SIZE + TILE_SIZE / 2.0); + // Cursor follows the villain outside build mode. + if let Ok(mut transform) = cursor.single_mut() { + transform.translation = + grid_to_world(game.sim.player.entity.x, game.sim.player.entity.y, 5.0); } } -fn start_wave(game_state: &mut ResMut) { - let gs = &mut **game_state; - gs.player.wave += 1; - let wave = gs.player.wave; - gs.phase = Phase::Defend; - gs.defend_turn = 0; - gs.wave_announce_turns = 5; - - let entries = gs.game_map.entry_positions(); - let heat = gs.player.heat; - let new_agents = entities::generate_agent_wave(wave, heat, &entries); - gs.agents = new_agents; - - // Calculate initial paths — collect positions first, then iterate mutably - let throne = gs.game_map.throne_pos().unwrap_or((40, 20)); - let agent_positions: Vec<(i32, i32)> = - gs.agents.iter().map(|a| (a.entity.x, a.entity.y)).collect(); - let game_map = &gs.game_map; - for (i, agent) in gs.agents.iter_mut().enumerate() { - let target = agent.preferred_target(game_map, throne); - let avoid: std::collections::HashSet<(i32, i32)> = agent_positions - .iter() - .enumerate() - .filter(|(j, _)| *j != i) - .map(|(_, pos)| *pos) - .collect(); - agent.calculate_path(game_map, target, &avoid); - } - - gs.log.push(format!("=== WAVE {} INCOMING! ===", wave)); -} - -fn activate_power(game_state: &mut ResMut, index: usize) { - let gs = &mut **game_state; - if index >= gs.player.powers.len() { - return; - } - if !gs.player.powers[index].is_ready() { - gs.log.push(format!( - "{} is on cooldown ({}t)", - gs.player.powers[index].name, gs.player.powers[index].current_cooldown - )); - return; - } - - let power = gs.player.powers[index].clone(); - let messages = gs.combat.activate_power( - &power, - &mut gs.player, - &mut gs.agents, - &mut gs.minions, - &gs.game_map, - ); - - gs.player.powers[index].use_power(); - - for msg in &messages { - gs.log.push(msg.clone()); - } - - for kill in gs.combat.kill_events.clone() { - gs.player.gold += kill.gold_value; - gs.player.score += kill.gold_value; - gs.player.kills += 1; - } -} - -// ─── Defend Phase ──────────────────────────────────────────────────────────── - -fn update_defend_phase(time: Res