Rename iron to metal, add mining and recruitment
Nix package / build (push) Successful in 9m39s
Rust checks / check (push) Successful in 3m44s
Linux package builds (test) / linux (push) Successful in 2m22s
Linux package builds (test) / aur (push) Successful in 7m21s

- Updated CLI, README and documentation to use `--starting-metal` with
  `--starting-iron` retained as an alias.
- Introduced `Resources` struct and constants for wood delivery and mine
  production.
- Added new structures `Mine` and `MineConstruction` with cost,
  production
  and cancel logic.
- Implemented building view for constructing mines and recruitment view
  for spawning miners.
- Extended keybindings with `Building` and `Recruitment` actions and
  updated
  action availability logic.
- Modified game state, board, units and UI widgets to handle metal
  resources
  and new construction mechanics.
- Updated tests and increased total test count to 82, covering
  resources,
  construction, recruitment and context‑aware keybindings.
This commit is contained in:
GarandPLG committed 2026-10-19 17:41:22 +02:00
1 parent 305cc2c4a2
commit a981663152
35 files changed
+2160 -74

No files matched your search

+38
View File
@@ -31,6 +31,10 @@ pub enum Action {
ScrollRight,
/// Toggle selecting cell.
Space,
/// Open or close the construction view.
Building,
/// Open or close the recruitment view.
Recruitment,
/// Unmark last selected cell.
Backspace,
/// Cancel marking cell.
@@ -108,6 +112,33 @@ pub struct KeyBinding {
/// The slice is ordered primarily for readability; the runtime lookup scans
/// this slice linearly, which is acceptable given the modest size of the list.
static KEYBINDINGS: &[KeyBinding] = &[
KeyBinding {
action: Action::Recruitment,
code: KeyCode::Char(' '),
kind: KeyEventKind::Press,
modifiers: KeyModifiers::ALT,
group: Group::Opener,
symbol: "Alt + Space / r",
description: "Recruit",
},
KeyBinding {
action: Action::Recruitment,
code: KeyCode::Char('r'),
kind: KeyEventKind::Press,
modifiers: KeyModifiers::NONE,
group: Group::Opener,
symbol: "r",
description: "Recruit",
},
KeyBinding {
action: Action::Building,
code: KeyCode::Char(' '),
kind: KeyEventKind::Press,
modifiers: KeyModifiers::CONTROL,
group: Group::Opener,
symbol: "Ctrl + Space",
description: "Buildings",
},
KeyBinding {
action: Action::Quit,
code: KeyCode::Char('q'),
@@ -330,6 +361,13 @@ pub fn binding_for(action: Action) -> Option<&'static KeyBinding> {
/// was pressed. Returns `None` when the event does not correspond to any known
/// action.
pub fn event_to_action(event: &KeyEvent) -> Option<Action> {
// Legacy terminals encode Ctrl+Space as NUL instead of a modified space.
if event.code == KeyCode::Null
&& event.kind == KeyEventKind::Press
&& (event.modifiers == KeyModifiers::NONE || event.modifiers == KeyModifiers::CONTROL)
{
return Some(Action::Building);
}
if let Some(b) = KEYBINDINGS
.iter()
.find(|b| b.code == event.code && b.kind == event.kind && b.modifiers == event.modifiers)
+1 -1
View File
@@ -8,4 +8,4 @@ pub use keybindings::{
Action, Group, KeyBinding, binding_for, count_largest_group, event_to_action,
};
pub use main_menu::main_menu_keybindings;
pub use skirmish::skirmish_keybindings;
pub use skirmish::{available_skirmish_actions, skirmish_keybindings};
+118 -3
View File
@@ -1,13 +1,126 @@
use crate::app::{
App,
keybindings::{Action, common_keybindings, event_to_action},
states::skirmish_states::{BoardState, MoveFocusedCell, ZoomLevel},
states::{
SkirmishState,
skirmish_states::{
BoardState, FocusedCell, MoveFocusedCell, Players, ZoomLevel,
structures::{Structure, Structures},
units::{Unit, Units},
},
},
};
use ratatui::{crossterm::event::KeyEvent, layout::Rect};
pub fn skirmish_keybindings(app: &mut App, key_event: &KeyEvent) {
if let Some(action) = event_to_action(&key_event) {
pub fn available_skirmish_actions(state: &SkirmishState) -> Vec<Action> {
if state.recruitment_view {
let mut actions: Vec<Action> = vec![Action::Recruitment, Action::Esc, Action::Quit2];
if state.can_recruit_miner(state.board.get_focused_cell().get_coords(), Players::Player) {
actions.push(Action::Enter);
}
return actions;
}
if state.building_view {
let mut actions: Vec<Action> = vec![Action::Building, Action::Esc, Action::Quit2];
let coords: (usize, usize) = state.board.get_focused_cell().get_coords();
if let Structures::MineConstruction(site) =
state.board.get_ref_cell(coords.0, coords.1).get_structure()
&& site.get_owner() == Players::Player
{
actions.push(Action::Delete);
} else if state.can_start_mine_construction(coords, Players::Player) {
actions.push(Action::Enter);
}
return actions;
}
let mut actions: Vec<Action> = vec![Action::Up, Action::Down, Action::Left, Action::Right];
if state.board.marked_cells.marking_cells {
actions.extend([
Action::Space,
Action::Backspace,
Action::Delete,
Action::Esc,
Action::Tab,
Action::Quit2,
]);
return actions;
}
actions.extend([
Action::ScrollUp,
Action::ScrollDown,
Action::ScrollLeft,
Action::ScrollRight,
Action::Tab,
Action::Building,
Action::Mute,
Action::Quit,
Action::Esc,
]);
if state.board.zoom_level != ZoomLevel::ZoomedIn {
actions.push(Action::ZoomIn);
}
if state.board.zoom_level != ZoomLevel::ZoomedOut {
actions.push(Action::ZoomOut);
}
let cell: &FocusedCell = state.board.get_focused_cell();
let (row, col): (usize, usize) = cell.get_coords();
if matches!(state.board.get_ref_cell(row, col).get_structure(), Structures::Base(base) if base.get_owner() == Players::Player)
{
actions.push(Action::Recruitment);
}
let unit: &Option<Units> = state.board.get_ref_cell(row, col).get_ref_option_unit();
if unit.get_owner() == Players::Player && unit.get_can_dig() {
actions.push(Action::Space);
}
actions
}
pub fn skirmish_keybindings(app: &mut App, key_event: &KeyEvent) -> () {
if let Some(action) = event_to_action(key_event) {
let Some(states) = app.states_mut() else {
common_keybindings(app, action);
return;
};
if states.skirmish.recruitment_view {
match action {
Action::Recruitment | Action::Esc => states.skirmish.recruitment_view = false,
Action::Enter => {
let coords: (usize, usize) = states.skirmish.board.get_focused_cell().get_coords();
states.skirmish.recruit_miner(coords, Players::Player);
}
_ => common_keybindings(app, action),
}
return;
}
if states.skirmish.building_view {
match action {
Action::Building | Action::Esc => states.skirmish.building_view = false,
Action::Enter => {
let coords: (usize, usize) = states.skirmish.board.get_focused_cell().get_coords();
states
.skirmish
.start_mine_construction(coords, Players::Player);
}
Action::Delete => {
let coords: (usize, usize) = states.skirmish.board.get_focused_cell().get_coords();
states
.skirmish
.cancel_mine_construction(coords, Players::Player);
}
_ => common_keybindings(app, action),
}
return;
}
if action == Action::Esc && states.skirmish.board.marked_cells.marking_cells {
states.skirmish.board.marked_cells.marking_cells = false;
states.skirmish.board.end_marking_cells(true);
return;
}
let available: bool = available_skirmish_actions(&states.skirmish).contains(&action);
common_keybindings(app, action);
if !available {
return;
}
let window_area: Rect = app.window_area;
@@ -83,6 +196,8 @@ pub fn skirmish_keybindings(app: &mut App, key_event: &KeyEvent) {
ZoomLevel::ZoomedOut => {}
},
Action::Space => board.toggle_marking(),
Action::Building => states.skirmish.building_view = true,
Action::Recruitment => states.skirmish.recruitment_view = true,
Action::Backspace => board.pop_marked_cell(),
Action::Delete => {
board.marked_cells.marking_cells = false;
+1
View File
@@ -11,5 +11,6 @@ mod widgets;
pub use app::App;
pub use keybind::handle_keybindings;
pub use keybindings::{Action, available_skirmish_actions};
pub use state::GameStates;
pub use view::View;
+6 -2
View File
@@ -1,7 +1,7 @@
use crate::{
app::states::{
MainMenuState, PerkDecksState, SettingsState, SkillsConfigState, SkirmishState,
skirmish_states::BoardState,
skirmish_states::{BoardState, Resources},
},
cli::Cli,
};
@@ -36,6 +36,10 @@ impl GameStates {
),
side_panel: false,
turn_counter: 0,
player_resources: Resources::new(args.starting_wood, args.starting_metal),
enemy_resources: Resources::new(args.starting_wood, args.starting_metal),
building_view: false,
recruitment_view: false,
},
perk_decks: PerkDecksState {
id: 2,
@@ -52,7 +56,7 @@ impl GameStates {
game_mode: args.game_mode.clone(),
perk_deck: args.perk_deck.clone(),
starting_wood: args.starting_wood.clone(),
starting_iron: args.starting_iron.clone(),
starting_metal: args.starting_metal,
supply_limit: args.supply_limit.clone(),
xp_modifier: args.xp_modifier.clone(),
skill_points_limit: args.skill_points_limit.clone(),
+1 -1
View File
@@ -6,7 +6,7 @@ pub struct SettingsState {
pub game_mode: GameMode,
pub perk_deck: PerkDecks,
pub starting_wood: u16,
pub starting_iron: u16,
pub starting_metal: u16,
pub supply_limit: u8,
pub xp_modifier: f32,
pub skill_points_limit: u16,
+109 -2
View File
@@ -1,4 +1,10 @@
use crate::app::states::skirmish_states::BoardState;
use crate::app::states::skirmish_states::{
BoardState, Players, Resources,
resources::{MINE_PRODUCTION_TICKS, WOOD_DELIVERY_AMOUNT, WOOD_DELIVERY_TICKS},
structures::{BuildingTerrain, Mine, Structure, Structures},
units::MinerUnit,
};
use crate::app::widgets::CellWidget;
#[derive(Debug, Clone, PartialEq)]
pub struct SkirmishState {
@@ -7,14 +13,115 @@ pub struct SkirmishState {
pub board: BoardState,
pub side_panel: bool,
pub turn_counter: u64,
pub player_resources: Resources,
pub enemy_resources: Resources,
pub building_view: bool,
pub recruitment_view: bool,
}
impl SkirmishState {
pub fn tick_update(&mut self) {
pub fn can_recruit_miner(&self, (row, col): (usize, usize), owner: Players) -> bool {
let resources: &Resources = match owner {
Players::Player => &self.player_resources,
Players::Enemy => &self.enemy_resources,
Players::Unclaimed => return false,
};
if !resources.can_afford(MinerUnit::RECRUIT_COST) {
return false;
}
self.board.cells.get(row).and_then(|cells: &Vec<CellWidget>| -> Option<&CellWidget> { cells.get(col) })
.is_some_and(|cell: &CellWidget| -> bool {
matches!(cell.get_structure(), Structures::Base(base) if base.get_owner() == owner && base.can_queue_miner())
})
}
pub fn recruit_miner(&mut self, coords: (usize, usize), owner: Players) -> bool {
if !self.can_recruit_miner(coords, owner) {
return false;
}
let cell: &mut CellWidget = &mut self.board.cells[coords.0][coords.1];
let Structures::Base(mut base) = cell.get_structure() else {
return false;
};
if !base.queue_miner() {
return false;
}
cell.set_structure(Structures::Base(base));
let resources: &mut Resources = if owner == Players::Player {
&mut self.player_resources
} else {
&mut self.enemy_resources
};
resources.spend(MinerUnit::RECRUIT_COST)
}
pub fn can_start_mine_construction(&self, coords: (usize, usize), owner: Players) -> bool {
let resources: &Resources = match owner {
Players::Player => &self.player_resources,
Players::Enemy => &self.enemy_resources,
Players::Unclaimed => return false,
};
resources.can_afford(Mine::COST) && self.board.can_build_at(coords, owner, BuildingTerrain::Ore)
}
pub fn start_mine_construction(&mut self, coords: (usize, usize), owner: Players) -> bool {
if !self.can_start_mine_construction(coords, owner)
|| !self.board.start_mine_construction(coords, owner)
{
return false;
}
let resources: &mut Resources = match owner {
Players::Player => &mut self.player_resources,
Players::Enemy => &mut self.enemy_resources,
Players::Unclaimed => return false,
};
resources.spend(Mine::COST)
}
pub fn cancel_mine_construction(&mut self, coords: (usize, usize), owner: Players) -> bool {
if owner == Players::Unclaimed || !self.board.cancel_mine_construction(coords, owner) {
return false;
}
let resources: &mut Resources = if owner == Players::Player {
&mut self.player_resources
} else {
&mut self.enemy_resources
};
resources.add_wood(Mine::COST.wood);
resources.add_metal(Mine::COST.metal);
true
}
pub fn tick_update(&mut self) -> () {
self.board.advance_turn();
self.board.advance_recruitment();
self.board.advance_construction();
// if self.board.is_victory() {}
self.turn_counter += 1;
if self.turn_counter.is_multiple_of(WOOD_DELIVERY_TICKS) {
self.player_resources.add_wood(WOOD_DELIVERY_AMOUNT);
}
if self.turn_counter.is_multiple_of(MINE_PRODUCTION_TICKS) {
for row in &mut self.board.cells {
for cell in row {
if let Structures::Mine(mut mine) = cell.get_structure() {
let staffed: bool = cell.get_ref_option_unit().as_ref().is_some_and(
|unit: &crate::app::states::skirmish_states::units::Units| -> bool {
mine.accepts_unit(unit)
},
);
let amount: u32 = u32::from(mine.produce_with_miner(staffed));
match mine.get_owner() {
Players::Player => self.player_resources.add_metal(amount),
Players::Enemy => self.enemy_resources.add_metal(amount),
Players::Unclaimed => (),
}
cell.set_structure(Structures::Mine(mine));
}
}
}
}
}
}
+145 -11
View File
@@ -4,7 +4,9 @@ use crate::app::{
FocusedCell, Offset,
skirmish_states::{
CellSizes, MarkedCells, MoveFocusedCell, Players, ZoomLevel,
structures::{BaseBuilding, Ore, Stone, Structure, Structures, Tunnel},
structures::{
BaseBuilding, BuildingTerrain, MineConstruction, Ore, Stone, Structure, Structures, Tunnel,
},
tasks::{DiggingTask, MovingTask, Task, Tasks},
units::{MinerUnit, Unit, Units},
},
@@ -32,6 +34,138 @@ pub struct BoardState {
}
impl BoardState {
pub fn advance_recruitment(&mut self) -> () {
for (row, cells) in self.cells.iter_mut().enumerate() {
for (col, cell) in cells.iter_mut().enumerate() {
if let Structures::Base(mut base) = cell.get_structure() {
if base.advance_recruitment(cell.get_ref_option_unit().is_some()) {
*cell.get_mut_option_unit() =
Some(Units::Miner(MinerUnit::new(base.get_owner(), (row, col))));
}
cell.set_structure(Structures::Base(base));
}
}
}
}
fn adjacent_cells(&self, (row, col): (usize, usize)) -> Vec<&CellWidget> {
let coordinates: [(usize, usize); 4] = [
(row.wrapping_sub(1), col),
(row.saturating_add(1), col),
(row, col.wrapping_sub(1)),
(row, col.saturating_add(1)),
];
coordinates
.into_iter()
.filter_map(|(row, col): (usize, usize)| -> Option<&CellWidget> {
self
.cells
.get(row)
.and_then(|cells: &Vec<CellWidget>| -> Option<&CellWidget> { cells.get(col) })
})
.collect()
}
pub fn has_adjacent_builder(&self, coords: (usize, usize), owner: Players) -> bool {
owner != Players::Unclaimed
&& self
.adjacent_cells(coords)
.iter()
.any(|cell: &&CellWidget| -> bool {
let unit: &Option<Units> = cell.get_ref_option_unit();
unit.get_owner() == owner && unit.get_can_build()
})
}
pub fn can_build_at(
&self,
(row, col): (usize, usize),
owner: Players,
terrain: BuildingTerrain,
) -> bool {
if owner == Players::Unclaimed {
return false;
}
let Some(cell) = self
.cells
.get(row)
.and_then(|cells: &Vec<CellWidget>| -> Option<&CellWidget> { cells.get(col) })
else {
return false;
};
if cell.get_ref_option_unit().is_some() {
return false;
}
if !self.has_adjacent_builder((row, col), owner) {
return false;
}
match (terrain, cell.get_structure()) {
(BuildingTerrain::Ore, Structures::Ore(ore)) => {
ore.get_owner() == Players::Unclaimed && ore.get_ore_amount() > 0
}
(BuildingTerrain::Stone, Structures::Stone(_)) => {
self
.adjacent_cells((row, col))
.iter()
.any(|cell: &&CellWidget| -> bool {
matches!(cell.get_structure(), Structures::Tunnel(_))
|| matches!(cell.get_structure(), Structures::Base(_))
&& cell.get_structure().get_owner() == owner
})
}
_ => false,
}
}
pub fn start_mine_construction(&mut self, coords: (usize, usize), owner: Players) -> bool {
if !self.can_build_at(coords, owner, BuildingTerrain::Ore) {
return false;
}
let cell: &mut CellWidget = self.get_mut_cell(coords.0, coords.1);
if let Structures::Ore(ore) = cell.get_structure()
&& let Some(site) = MineConstruction::new(owner, ore)
{
cell.set_structure(Structures::MineConstruction(site));
return true;
}
false
}
pub fn cancel_mine_construction(&mut self, (row, col): (usize, usize), owner: Players) -> bool {
let Some(cell): Option<&mut CellWidget> = self
.cells
.get_mut(row)
.and_then(|cells: &mut Vec<CellWidget>| -> Option<&mut CellWidget> { cells.get_mut(col) })
else {
return false;
};
if let Structures::MineConstruction(site) = cell.get_structure()
&& site.get_owner() == owner
{
cell.set_structure(Structures::Ore(site.cancel()));
return true;
}
false
}
pub fn advance_construction(&mut self) -> () {
for row in 0..self.cells.len() {
for col in 0..self.cells[row].len() {
if let Structures::MineConstruction(mut site) = self.cells[row][col].get_structure()
&& self.has_adjacent_builder((row, col), site.get_owner())
{
site.advance();
let structure: Structures = if let Some(mine) = site.into_mine() {
Structures::Mine(mine)
} else {
Structures::MineConstruction(site)
};
self.cells[row][col].set_structure(structure);
}
}
}
}
pub fn new(
area: &Rect,
map_width: usize,
@@ -79,9 +213,9 @@ impl BoardState {
} else if enemy_base {
Structures::Base(BaseBuilding::new(Players::Enemy))
} else if player_ore {
Structures::Ore(Ore::new(Players::Player, 1))
Structures::Ore(Ore::new(Players::Unclaimed, 1))
} else if enemy_ore {
Structures::Ore(Ore::new(Players::Enemy, 1))
Structures::Ore(Ore::new(Players::Unclaimed, 1))
} else {
Structures::Stone(Stone::new())
};
@@ -286,10 +420,11 @@ impl BoardState {
.get(row)
.and_then(|cells| cells.get(col))
.is_some_and(|cell| {
matches!(
cell.get_structure(),
Structures::Tunnel(_) | Structures::Base(_)
)
self
.marked_cells
.selected_unit
.as_ref()
.is_some_and(|unit: &Units| -> bool { cell.get_structure().can_enter(unit) })
})
}
@@ -419,11 +554,10 @@ impl BoardState {
let cell: &mut CellWidget = self.get_mut_cell(target_row, target_col);
if cell.get_ref_option_unit().is_none() {
let structure: Structures = cell.get_structure();
let walkable: bool = structure.can_enter(&unit);
// Previously planned stone may already be a tunnel when this task starts.
task = match (task, structure) {
(Tasks::Digging(dig), Structures::Tunnel(_) | Structures::Base(_)) => {
Tasks::Moving(MovingTask::from_digging(dig))
}
(Tasks::Digging(dig), _) if walkable => Tasks::Moving(MovingTask::from_digging(dig)),
(Tasks::Moving(moving), Structures::Stone(_)) => {
Tasks::Digging(DiggingTask::from_moving(moving, unit.get_digging_power()))
}
@@ -444,7 +578,7 @@ impl BoardState {
cell.set_structure(Structures::Stone(stone));
}
}
(Tasks::Moving(moving), Structures::Tunnel(_) | Structures::Base(_)) => {
(Tasks::Moving(moving), _) if walkable => {
moving.progress();
if moving.is_finished() {
destination = (target_row, target_col);
+2
View File
@@ -4,6 +4,7 @@ mod game_mode;
mod marked_cells;
mod offset;
mod players;
pub mod resources;
pub mod structures;
pub mod tasks;
pub mod units;
@@ -15,4 +16,5 @@ pub use game_mode::GameMode;
pub use marked_cells::MarkedCells;
pub use offset::Offset;
pub use players::Players;
pub use resources::Resources;
pub use zoom_level::{CellSizes, ZoomLevel};
@@ -0,0 +1,41 @@
use crate::app::threads::TICK_MS;
pub const WOOD_DELIVERY_AMOUNT: u32 = 5;
pub const WOOD_DELIVERY_INTERVAL_MS: u64 = 5000;
pub const WOOD_DELIVERY_TICKS: u64 = WOOD_DELIVERY_INTERVAL_MS.div_ceil(TICK_MS as u64);
pub const MINE_PRODUCTION_TICKS: u64 = 1000_u64.div_ceil(TICK_MS as u64);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Resources {
pub wood: u32,
pub metal: u32,
}
impl Resources {
pub fn can_afford(&self, cost: Self) -> bool {
self.wood >= cost.wood && self.metal >= cost.metal
}
pub fn spend(&mut self, cost: Self) -> bool {
if !self.can_afford(cost) {
return false;
}
self.wood -= cost.wood;
self.metal -= cost.metal;
true
}
pub fn new(wood: u16, metal: u16) -> Self {
Self {
wood: u32::from(wood),
metal: u32::from(metal),
}
}
pub fn add_wood(&mut self, amount: u32) -> () {
self.wood = self.wood.saturating_add(amount);
}
pub fn add_metal(&mut self, amount: u32) -> () {
self.metal = self.metal.saturating_add(amount);
}
}
@@ -1,5 +1,6 @@
use crate::app::states::skirmish_states::{Players, structures::Structure};
use ratatui::style::Color;
use crate::app::threads::TICK_MS;
use ratatui::{style::Color, text::Line};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BaseBuilding {
@@ -7,15 +8,78 @@ pub struct BaseBuilding {
stress: u8,
owner: Players,
level: u8,
queued_miners: u32,
recruitment_ticks: u16,
waiting_for_departure: bool,
}
impl BaseBuilding {
pub const RECRUITMENT_TICKS: u16 = 10000_u16.div_ceil(TICK_MS as u16);
pub fn queued_miners(&self) -> u32 {
self.queued_miners
}
pub fn recruitment_progress(&self) -> u16 {
(u32::from(self.recruitment_ticks) * 100 / u32::from(Self::RECRUITMENT_TICKS)) as u16
}
pub fn can_queue_miner(&self) -> bool {
self.owner != Players::Unclaimed && self.queued_miners < u32::MAX
}
pub fn queue_miner(&mut self) -> bool {
if !self.can_queue_miner() {
return false;
}
self.queued_miners += 1;
true
}
pub fn advance_recruitment(&mut self, occupied: bool) -> bool {
if self.waiting_for_departure {
if occupied {
return false;
}
self.waiting_for_departure = false;
}
if self.queued_miners == 0 {
return false;
}
self.recruitment_ticks = self
.recruitment_ticks
.saturating_add(1)
.min(Self::RECRUITMENT_TICKS);
if self.recruitment_ticks < Self::RECRUITMENT_TICKS || occupied {
return false;
}
self.queued_miners -= 1;
self.recruitment_ticks = 0;
self.waiting_for_departure = true;
true
}
pub fn recruitment_status(&self, occupied: bool) -> &'static str {
if self.queued_miners == 0 {
"Queue empty"
} else if self.waiting_for_departure && occupied {
"Paused: waiting for Miner to leave base"
} else if self.recruitment_ticks == Self::RECRUITMENT_TICKS && occupied {
"Ready: waiting for free base cell"
} else {
"Recruiting"
}
}
pub fn new(owner: Players) -> Self {
Self {
durability: 1500,
stress: 0,
owner,
level: 1,
queued_miners: 0,
recruitment_ticks: 0,
waiting_for_departure: false,
}
}
}
@@ -56,4 +120,19 @@ impl Structure for BaseBuilding {
fn get_owner(&self) -> Players {
self.owner
}
fn text(&self) -> Vec<Line<'_>> {
let mut lines: Vec<Line<'_>> = self.base_text();
lines.push(Line::from(format!(
"Recruitment queue: {}",
self.queued_miners
)));
if self.queued_miners > 0 {
lines.push(Line::from(format!(
"Miner progress: {}%",
self.recruitment_progress()
)));
}
lines
}
}
@@ -0,0 +1,96 @@
use crate::app::states::skirmish_states::{
Players, Resources,
structures::{Ore, Structure},
units::{Unit, Units},
};
use ratatui::{
style::{Color, Stylize},
text::Line,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Mine {
owner: Players,
ore: Ore,
}
impl Mine {
pub const COST: Resources = Resources {
wood: 20,
metal: 10,
};
pub const METAL_PER_CYCLE: u16 = 1;
pub const STAFFED_METAL_PER_CYCLE: u16 = 2;
pub fn new(owner: Players, ore: Ore) -> Option<Self> {
if owner == Players::Unclaimed || ore.get_owner() != Players::Unclaimed {
return None;
}
Some(Self { owner, ore })
}
pub fn get_ore_amount(&self) -> u16 {
self.ore.get_ore_amount()
}
pub fn into_ore(self) -> Ore {
self.ore
}
pub fn produce(&mut self) -> u16 {
self.produce_with_miner(false)
}
pub fn accepts_unit(&self, unit: &Units) -> bool {
matches!(unit, Units::Miner(_)) && unit.get_owner() == self.owner
}
pub fn production_per_cycle(&self, staffed: bool) -> u16 {
let rate: u16 = if staffed {
Self::STAFFED_METAL_PER_CYCLE
} else {
Self::METAL_PER_CYCLE
};
rate.min(self.get_ore_amount())
}
pub fn produce_with_miner(&mut self, staffed: bool) -> u16 {
self.ore.extract(self.production_per_cycle(staffed))
}
}
impl Structure for Mine {
fn get_tag(&self) -> char {
'M'
}
fn get_color(&self) -> Color {
self.owner.get_color()
}
fn get_level(&self) -> char {
self.ore.get_level()
}
fn get_stress(&self) -> u8 {
self.ore.get_stress()
}
fn get_durability(&self) -> u16 {
self.ore.get_durability()
}
fn get_max_durability(&self) -> u16 {
self.ore.get_max_durability()
}
fn get_name(&self) -> &'static str {
"Mine"
}
fn get_owner(&self) -> Players {
self.owner
}
fn text(&self) -> Vec<Line<'_>> {
let mut lines: Vec<Line<'_>> = self.base_text();
lines.push(Line::from_iter([
"Ore: ".gray(),
self.get_ore_amount().to_string().cyan(),
format!("/{}", self.ore.get_max_ore_amount()).gray(),
]));
lines
}
}
@@ -0,0 +1,75 @@
use crate::app::{
states::skirmish_states::{
Players,
structures::{Mine, Ore, Structure},
},
threads::TICK_MS,
};
use ratatui::{style::Color, text::Line};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct MineConstruction {
mine: Mine,
ticks: u16,
}
impl MineConstruction {
pub const BUILD_TICKS: u16 = 10000_u16.div_ceil(TICK_MS as u16);
pub fn new(owner: Players, ore: Ore) -> Option<Self> {
Mine::new(owner, ore).map(|mine: Mine| -> Self { Self { mine, ticks: 0 } })
}
pub fn advance(&mut self) -> () {
self.ticks = self.ticks.saturating_add(1).min(Self::BUILD_TICKS);
}
pub fn is_finished(&self) -> bool {
self.ticks == Self::BUILD_TICKS
}
pub fn progress_percent(&self) -> u16 {
(u32::from(self.ticks) * 100 / u32::from(Self::BUILD_TICKS)) as u16
}
pub fn into_mine(self) -> Option<Mine> {
self.is_finished().then_some(self.mine)
}
pub fn cancel(self) -> Ore {
self.mine.into_ore()
}
}
impl Structure for MineConstruction {
fn get_tag(&self) -> char {
'C'
}
fn get_color(&self) -> Color {
self.mine.get_color()
}
fn get_level(&self) -> char {
self.mine.get_level()
}
fn get_stress(&self) -> u8 {
self.mine.get_stress()
}
fn get_durability(&self) -> u16 {
self.mine.get_durability()
}
fn get_max_durability(&self) -> u16 {
self.mine.get_max_durability()
}
fn get_name(&self) -> &'static str {
"Mine construction"
}
fn get_owner(&self) -> Players {
self.mine.get_owner()
}
fn text(&self) -> Vec<Line<'_>> {
vec![
Line::from(format!("Construction: {}%", self.progress_percent())),
Line::from(format!("Ore: {}", self.mine.get_ore_amount())),
]
}
}
@@ -1,4 +1,6 @@
mod base;
mod mine;
mod mine_construction;
mod ore;
mod stone;
mod structure_trait;
@@ -6,8 +8,16 @@ mod structures_enum;
mod tunnel;
pub use base::BaseBuilding;
pub use mine::Mine;
pub use mine_construction::MineConstruction;
pub use ore::Ore;
pub use stone::Stone;
pub use structure_trait::Structure;
pub use structures_enum::Structures;
pub use tunnel::Tunnel;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BuildingTerrain {
Stone,
Ore,
}
@@ -32,6 +32,12 @@ impl Ore {
5000
}
pub fn extract(&mut self, amount: u16) -> u16 {
let extracted: u16 = amount.min(self.ore_amount);
self.ore_amount -= extracted;
extracted
}
pub fn set_owner(&mut self, new_owner: Players) -> &mut Self {
self.owner = new_owner;
self
@@ -1,6 +1,7 @@
use crate::app::states::skirmish_states::{
Players,
structures::{BaseBuilding, Ore, Stone, Structure, Tunnel},
structures::{BaseBuilding, Mine, MineConstruction, Ore, Stone, Structure, Tunnel},
units::Units,
};
use ratatui::{
style::{Color, Stylize},
@@ -13,9 +14,28 @@ pub enum Structures {
Tunnel(Tunnel),
Stone(Stone),
Ore(Ore),
Mine(Mine),
MineConstruction(MineConstruction),
}
impl Structures {
pub fn can_enter(&self, unit: &Units) -> bool {
match self {
Self::Base(_) | Self::Tunnel(_) => true,
Self::Mine(mine) => mine.accepts_unit(unit),
_ => false,
}
}
pub fn build_mine(&mut self, owner: Players) -> bool {
if let Self::Ore(ore) = *self
&& let Some(mine) = Mine::new(owner, ore)
{
*self = Self::Mine(mine);
return true;
}
false
}
#[inline]
fn s(&self) -> &dyn Structure {
match self {
@@ -23,6 +43,8 @@ impl Structures {
Self::Tunnel(t) => t,
Self::Stone(s) => s,
Self::Ore(o) => o,
Self::Mine(mine) => mine,
Self::MineConstruction(site) => site,
}
}
}
@@ -1,7 +1,7 @@
use ratatui::{style::Stylize, text::Line};
use crate::app::states::skirmish_states::{
Players,
Players, Resources,
tasks::{Task, Tasks},
units::Unit,
};
@@ -18,6 +18,10 @@ pub struct MinerUnit {
}
impl MinerUnit {
pub const RECRUIT_COST: Resources = Resources {
wood: 10,
metal: 15,
};
pub fn new(owner: Players, coords: (usize, usize)) -> Self {
Self {
owner,
@@ -71,6 +75,10 @@ impl Unit for MinerUnit {
self.can_dig
}
fn get_can_build(&self) -> bool {
true
}
fn get_digging_power(&self) -> f32 {
self.digging_power
}
@@ -22,6 +22,10 @@ pub trait Unit {
fn get_can_dig(&self) -> bool;
fn get_can_build(&self) -> bool {
false
}
fn get_digging_power(&self) -> f32;
fn get_coords(&self) -> (usize, usize);
@@ -66,6 +70,10 @@ pub trait Unit {
]));
}
if self.get_can_build() {
lines.push(Line::from("Can build: Yes").green());
}
if !have_any_tasks {
lines.push(Line::from("Tasks Queue: ".gray()));
for task in tasks {
@@ -28,6 +28,9 @@ impl Units {
}
impl Unit for Units {
fn get_can_build(&self) -> bool {
self.u().get_can_build()
}
fn get_tag(&self) -> char {
self.u().get_tag()
}
@@ -82,6 +85,11 @@ impl Unit for Units {
}
impl Unit for Option<Units> {
fn get_can_build(&self) -> bool {
self
.as_ref()
.is_some_and(|unit: &Units| -> bool { unit.get_can_build() })
}
fn is_unit(&self) -> bool {
self.as_ref().map_or(false, |u| u.is_unit())
}
+143 -31
View File
@@ -1,46 +1,29 @@
use crate::app::{
App,
helpers::block_title_helper,
keybindings::{Action, count_largest_group},
states::skirmish_states::BoardState,
widgets::{BoardWidget, KeybindingsWidget, SidePanelWidget},
keybindings::{Action, available_skirmish_actions},
states::skirmish_states::{
BoardState, Players,
resources::{WOOD_DELIVERY_AMOUNT, WOOD_DELIVERY_INTERVAL_MS},
structures::{Mine, Structure, Structures},
units::MinerUnit,
},
widgets::{BoardWidget, CellWidget, KeybindingsWidget, SidePanelWidget},
};
use ratatui::{
buffer::Buffer,
layout::{Alignment, Constraint, Layout, Margin, Rect},
style::{Color, Stylize},
text::Line,
widgets::{Block, Borders, Padding, Paragraph, Widget},
widgets::{Block, Borders, Clear, Padding, Paragraph, Widget},
};
const ACTIONS: &[Action] = &[
Action::Up,
Action::Down,
Action::Left,
Action::Right,
Action::ScrollUp,
Action::ScrollDown,
Action::ScrollLeft,
Action::ScrollRight,
Action::ZoomIn,
Action::ZoomOut,
Action::VolumeUp,
Action::VolumeDown,
Action::Space,
Action::Backspace,
Action::Delete,
Action::Mute,
Action::Tab,
Action::Quit,
Action::Quit2,
Action::Esc,
];
pub fn skirmish_layout(area: Rect) -> [Rect; 3] {
Layout::vertical([
Constraint::Length(4),
Constraint::Fill(1),
Constraint::Length(count_largest_group(&ACTIONS) + 2),
// Four movement rows plus borders keep the board geometry stable across modes.
Constraint::Length(6),
])
.areas(area)
}
@@ -68,8 +51,13 @@ pub fn skirmish_view(app: &App, area: Rect, buf: &mut Buffer) {
let lines: Vec<Line<'_>> = Vec::from_iter([
Line::raw("War in Tunnels").yellow(),
Line::from_iter([
format!("Wood: {} (+{}) | ", states.settings.starting_wood, 5),
format!("Iron: {} (+{}) | ", states.settings.starting_iron, 1),
format!(
"Wood: {} (+{}/{}s) | ",
states.skirmish.player_resources.wood,
WOOD_DELIVERY_AMOUNT,
WOOD_DELIVERY_INTERVAL_MS / 1000
),
format!("Metal: {} | ", states.skirmish.player_resources.metal),
format!("Supply Limit: {}/{} | ", 10, states.settings.supply_limit),
format!(
"Skills points: {} ({}/{}) | ",
@@ -148,6 +136,130 @@ pub fn skirmish_view(app: &App, area: Rect, buf: &mut Buffer) {
}
{
KeybindingsWidget::new(ACTIONS, keybindings_area.height).render(keybindings_area, buf);
let actions: Vec<Action> = available_skirmish_actions(&states.skirmish);
let descriptions: [(Action, &'static str); 11] = [
(
Action::Enter,
if states.skirmish.recruitment_view {
"Queue Miner"
} else {
"Build Mine"
},
),
(
Action::Recruitment,
if states.skirmish.recruitment_view {
"Close recruitment"
} else {
"Recruit"
},
),
(
Action::Delete,
if states.skirmish.building_view {
"Cancel construction"
} else {
"Cancel marking"
},
),
(
Action::Space,
if board.marked_cells.marking_cells {
"Confirm route"
} else {
"Select miner"
},
),
(
Action::Esc,
if states.skirmish.recruitment_view {
"Close recruitment"
} else if states.skirmish.building_view {
"Close buildings"
} else if board.marked_cells.marking_cells {
"Cancel route"
} else {
"Menu"
},
),
(
Action::Building,
if states.skirmish.building_view {
"Close buildings"
} else {
"Build"
},
),
(Action::Tab, "Details"),
(Action::ScrollUp, "Scroll"),
(Action::ScrollDown, "Scroll"),
(Action::ScrollLeft, "Scroll"),
(Action::ScrollRight, "Scroll"),
];
KeybindingsWidget::with_descriptions(&actions, keybindings_area.height, &descriptions)
.render(keybindings_area, buf);
}
if states.skirmish.building_view {
let popup: Rect = main_area.centered(Constraint::Length(64), Constraint::Length(14));
let coords: (usize, usize) = board.get_focused_cell().get_coords();
let structure: Structures = board.get_ref_cell(coords.0, coords.1).get_structure();
let status: String = if let Structures::MineConstruction(site) = structure
&& site.get_owner() == Players::Player
{
format!(
"Construction: {}%\n{}\nDelete: cancel and refund full cost",
site.progress_percent(),
if board.has_adjacent_builder(coords, Players::Player) {
"Builder working"
} else {
"Paused: adjacent friendly builder required"
}
)
} else if matches!(structure, Structures::Mine(_)) && structure.get_owner() == Players::Player {
"Mine completed".to_string()
} else if states
.skirmish
.can_start_mine_construction(coords, Players::Player)
{
"Ready to build: Enter".to_string()
} else {
"Construction unavailable: check site, builder and resources".to_string()
};
Clear.render(popup, buf);
Paragraph::new(format!("Mine\nCost: {} Wood + {} Metal | Build time: 10s\nRequires: neutral Ore + adjacent friendly builder\nProduces: 1 Metal/s (2 with a Miner inside)\n{status}", Mine::COST.wood, Mine::COST.metal))
.block(Block::bordered().title("Buildings").padding(Padding::uniform(1)))
.render(popup, buf);
}
if states.skirmish.recruitment_view {
let popup: Rect = main_area.centered(Constraint::Length(64), Constraint::Length(14));
let coords: (usize, usize) = board.get_focused_cell().get_coords();
let cell: &CellWidget = board.get_ref_cell(coords.0, coords.1);
let text: String = if let Structures::Base(base) = cell.get_structure()
&& base.get_owner() == Players::Player
{
let status: &'static str = base.recruitment_status(cell.get_ref_option_unit().is_some());
let action: &'static str = if states.skirmish.can_recruit_miner(coords, Players::Player) {
"Enter: queue Miner"
} else {
"Insufficient resources or queue full"
};
format!(
"Miner\nCost: {} Wood + {} Metal | Recruit time: 10s\nQueued Miners: {} (includes active)\nCurrent progress: {}%\n{status}\n{action}",
MinerUnit::RECRUIT_COST.wood,
MinerUnit::RECRUIT_COST.metal,
base.queued_miners(),
base.recruitment_progress()
)
} else {
"Select your Base to recruit Miners".to_string()
};
Clear.render(popup, buf);
Paragraph::new(text)
.block(
Block::bordered()
.title("Recruitment")
.padding(Padding::uniform(1)),
)
.render(popup, buf);
}
}
+4
View File
@@ -133,6 +133,10 @@ impl CellWidget {
let middle = lines.len() / 2;
lines[middle] = Line::from(format!("{percent}%")).cyan();
}
if let Structures::MineConstruction(site) = self.structure {
let middle: usize = lines.len() / 2;
lines[middle] = Line::from(format!("{}%", site.progress_percent())).cyan();
}
if let Some(unit) = &self.unit
&& let Some(Tasks::Moving(task)) = unit.get_ref_tasks().front()
&& let Some(percent) = task.get_movement_progress()
+23 -5
View File
@@ -20,6 +20,7 @@ use std::collections::HashSet;
pub struct KeybindingsWidget {
/// Paragraphs, one per used `Group`, containing the formatted keybinding lines.
grouped: Vec<Paragraph<'static>>,
widths: Vec<u16>,
}
impl KeybindingsWidget {
@@ -42,6 +43,14 @@ impl KeybindingsWidget {
///
/// A fully‑initialised `KeybindingsWidget` ready for rendering.
pub fn new(actions: &[Action], area_height: u16) -> Self {
Self::with_descriptions(actions, area_height, &[])
}
pub fn with_descriptions(
actions: &[Action],
area_height: u16,
descriptions: &[(Action, &'static str)],
) -> Self {
let keybindings: Vec<Option<&'static KeyBinding>> =
actions.iter().map(|a| binding_for(*a)).collect();
@@ -51,6 +60,7 @@ impl KeybindingsWidget {
.collect();
let mut grouped_keybindings: Vec<Paragraph<'static>> = Vec::new();
let mut widths: Vec<u16> = Vec::new();
for group in Group::value_variants().iter() {
if !used_groups.contains(group) {
@@ -62,11 +72,20 @@ impl KeybindingsWidget {
.filter(|b| b.as_ref().map_or(false, |kb| kb.group == *group))
.filter_map(|b| {
b.as_ref().map(|kb| {
Line::from_iter([kb.symbol.red().bold(), " - ".into(), kb.description.into()])
let description: &'static str = descriptions
.iter()
.find(|(action, _): &&(Action, &'static str)| -> bool { *action == kb.action })
.map_or(
kb.description,
|(_, description): &(Action, &'static str)| -> &'static str { description },
);
Line::from_iter([kb.symbol.red().bold(), " - ".into(), description.into()])
})
})
.collect();
let width: usize = grouped_lines.iter().map(Line::width).max().unwrap_or(0);
widths.push(u16::try_from(width).unwrap_or(u16::MAX).saturating_add(3));
let height_diff: u16 =
area_height.saturating_sub(grouped_lines.len().saturating_add(2) as u16);
@@ -91,6 +110,7 @@ impl KeybindingsWidget {
Self {
grouped: grouped_keybindings,
widths,
}
}
}
@@ -99,7 +119,7 @@ impl Widget for KeybindingsWidget {
/// Render the widget inside the given rectangular area.
///
/// The widget first draws a surrounding border with the title *Keybindings*.
/// It then divides the inner area into equal horizontal slices—one per
/// It then divides the inner area proportionally to each group's text width—one per
/// used group—and renders each group's paragraph into its slice.
///
/// If no groups are present, the function returns early without drawing
@@ -122,9 +142,7 @@ impl Widget for KeybindingsWidget {
block.render(area, buf);
let base: u16 = if count == 0 { 0 } else { 100u16 / count };
let constraints: Vec<Constraint> = vec![Constraint::Percentage(base); count as usize];
let constraints: Vec<Constraint> = self.widths.into_iter().map(Constraint::Fill).collect();
let chunks: Vec<Rect> = Layout::horizontal(constraints).split(inner).to_vec();
+20 -1
View File
@@ -9,6 +9,7 @@ use ratatui::{
buffer::Buffer,
layout::{Alignment, Constraint, Layout, Margin, Rect},
style::Color,
text::Line,
widgets::{Block, BorderType, Borders, Padding, Paragraph, Widget},
};
@@ -87,7 +88,25 @@ impl Widget for SidePanelWidget<'_> {
let [structure_area, unit_area] =
Layout::vertical(self.get_area_constraints()).areas(inner_area);
Paragraph::new(self.structure.text())
let mut structure_text: Vec<Line<'_>> = self.structure.text();
if let Structures::Base(base) = self.structure
&& base.queued_miners() > 0
&& self.unit.is_some()
{
structure_text.push(Line::from(base.recruitment_status(true)));
}
if let Structures::Mine(mine) = self.structure {
let staffed: bool = self
.unit
.as_ref()
.is_some_and(|unit: &Units| -> bool { mine.accepts_unit(unit) });
structure_text.push(Line::from(format!(
"Metal: {}/s{}",
mine.production_per_cycle(staffed),
if staffed { " (Miner)" } else { "" }
)));
}
Paragraph::new(structure_text)
.alignment(Alignment::Left)
.block(self.get_inner_block(self.structure.get_name().to_string(), Color::Cyan))
.render(structure_area, buf);