generated from GarandPLG/rust-flake-template
Rename iron to metal, add mining and recruitment
- 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:
1 parent
305cc2c4a2
commit
a981663152
35 files changed
+2160
-74
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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};
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user