feat(@projects/@magic-civilization): ✨ add volley and charge action queues
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
81f4d2f484
commit
006b27b6ee
5 changed files with 398 additions and 15 deletions
|
|
@ -2292,6 +2292,8 @@ impl IRefCounted for GdGameState {
|
|||
pending_auto_join_requests: Default::default(),
|
||||
pending_building_actions: Default::default(),
|
||||
pending_pillage_requests: Default::default(),
|
||||
pending_volley_requests: Default::default(),
|
||||
pending_charge_requests: Default::default(),
|
||||
tile_improvements: Default::default(),
|
||||
improvement_registry: Default::default(),
|
||||
},
|
||||
|
|
@ -2851,6 +2853,79 @@ impl GdGameState {
|
|||
indirect_fire,
|
||||
});
|
||||
}
|
||||
|
||||
/// Queue a Volley AoE request from GDScript (ranged unit selects target hex).
|
||||
/// The target hex centre + 2 deterministic neighbours are hit for `attack/2` each.
|
||||
/// Resolution happens in `TurnProcessor::process_volley_requests` each turn.
|
||||
#[func]
|
||||
pub fn queue_volley(
|
||||
&mut self,
|
||||
attacker_player: i64,
|
||||
attacker_unit: i64,
|
||||
target_col: i64,
|
||||
target_row: i64,
|
||||
) {
|
||||
use mc_turn::VolleyRequest;
|
||||
self.inner.pending_volley_requests.push(VolleyRequest {
|
||||
attacker_player: attacker_player as u8,
|
||||
attacker_unit: attacker_unit as usize,
|
||||
target_col: target_col as i32,
|
||||
target_row: target_row as i32,
|
||||
});
|
||||
}
|
||||
|
||||
/// Queue a Charge request from GDScript (cavalry unit selects target hex).
|
||||
/// The unit moves up to 2 hexes toward the target and resolves melee combat
|
||||
/// with +30% charge attack bonus. Resolution in `process_charge_requests`.
|
||||
#[func]
|
||||
pub fn queue_charge(
|
||||
&mut self,
|
||||
attacker_player: i64,
|
||||
attacker_unit: i64,
|
||||
target_col: i64,
|
||||
target_row: i64,
|
||||
) {
|
||||
use mc_turn::ChargeRequest;
|
||||
self.inner.pending_charge_requests.push(ChargeRequest {
|
||||
attacker_player: attacker_player as u8,
|
||||
attacker_unit: attacker_unit as usize,
|
||||
target_col: target_col as i32,
|
||||
target_row: target_row as i32,
|
||||
});
|
||||
}
|
||||
|
||||
/// Set the `is_amphibious` flag on a unit. Call at spawn time for units with
|
||||
/// the `"amphibious"` keyword so the movement pathfinder treats ocean/coast
|
||||
/// hexes as passable for this unit.
|
||||
#[func]
|
||||
pub fn set_unit_amphibious(
|
||||
&mut self,
|
||||
player_idx: i64,
|
||||
unit_idx: i64,
|
||||
is_amphibious: bool,
|
||||
) {
|
||||
let pi = player_idx as usize;
|
||||
let ui = unit_idx as usize;
|
||||
if let Some(unit) = self.inner.players.get_mut(pi).and_then(|p| p.units.get_mut(ui)) {
|
||||
unit.is_amphibious = is_amphibious;
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the `facing_edge` on a unit (0–5 hex direction). Called by Wheel action
|
||||
/// handler in GDScript or directly when a specific direction is desired.
|
||||
#[func]
|
||||
pub fn set_unit_facing_edge(
|
||||
&mut self,
|
||||
player_idx: i64,
|
||||
unit_idx: i64,
|
||||
facing_edge: i64,
|
||||
) {
|
||||
let pi = player_idx as usize;
|
||||
let ui = unit_idx as usize;
|
||||
if let Some(unit) = self.inner.players.get_mut(pi).and_then(|p| p.units.get_mut(ui)) {
|
||||
unit.facing_edge = (facing_edge as u8) % 6;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── GdTurnProcessor ─────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -89,19 +89,38 @@ pub fn invoke(
|
|||
ActionKind::Sentry => handle_sentry(state, player_idx, unit_idx),
|
||||
ActionKind::Unsentry => handle_unsentry(state, player_idx, unit_idx),
|
||||
// p2-53g ranged actions
|
||||
// Volley requires a target hex — routed through the bridge's target-pick path.
|
||||
// Queue-drain (pending_volley_requests + process_volley_requests phase) is
|
||||
// not yet implemented; deferred pending bridge plumbing.
|
||||
ActionKind::Volley => Err(ActionError { kind, reason: DisabledReason::WrongTerrain }),
|
||||
// Volley requires a target hex; callers must queue a VolleyRequest on
|
||||
// GameState::pending_volley_requests — invoking here is a no-op gate.
|
||||
// Actual AoE resolution happens in TurnProcessor::process_volley_requests.
|
||||
ActionKind::Volley => {
|
||||
// Validate the unit exists; the actual damage resolution happens in
|
||||
// TurnProcessor::process_volley_requests when the queue is drained.
|
||||
let _unit = get_unit_mut(state, player_idx, unit_idx, ActionKind::Volley)?;
|
||||
Ok(())
|
||||
}
|
||||
ActionKind::AimedShot => ranged::handle_aimed_shot(state, player_idx, unit_idx),
|
||||
ActionKind::FireArrows => ranged::handle_fire_arrows(state, player_idx, unit_idx),
|
||||
ActionKind::StopFireArrows => ranged::handle_stop_fire_arrows(state, player_idx, unit_idx),
|
||||
// p2-53h cavalry actions
|
||||
// Charge requires a target hex — routed through bridge's target-pick path.
|
||||
// Queue-drain (pending_charge_requests) not yet implemented; deferred pending
|
||||
// bridge plumbing.
|
||||
// Wheel requires facing/edge state not yet present on MapUnit; deferred.
|
||||
ActionKind::Charge | ActionKind::Wheel => Err(ActionError { kind, reason: DisabledReason::WrongTerrain }),
|
||||
// Charge requires a target hex; callers must queue a ChargeRequest on
|
||||
// GameState::pending_charge_requests — invoking here is a no-op gate.
|
||||
// Actual 2-hex move + combat resolution happens in process_charge_requests.
|
||||
ActionKind::Charge => {
|
||||
let _unit = get_unit_mut(state, player_idx, unit_idx, ActionKind::Charge)?;
|
||||
Ok(())
|
||||
}
|
||||
// Wheel: update the unit's facing_edge to the new hex direction.
|
||||
// The target edge (0–5) is passed via the action parameter; since invoke()
|
||||
// doesn't carry parameters, the bridge calls a dedicated set_facing_edge
|
||||
// helper OR the Wheel action_handler reads from pending_charge_target (col
|
||||
// used as edge index when target_row == -1 sentinel). For now: Wheel sets
|
||||
// facing_edge to the next clockwise direction as a minimal implementation.
|
||||
// The bridge should call queue_wheel(player, unit, edge) for full control.
|
||||
ActionKind::Wheel => {
|
||||
let unit = get_unit_mut(state, player_idx, unit_idx, ActionKind::Wheel)?;
|
||||
unit.facing_edge = (unit.facing_edge + 1) % 6;
|
||||
Ok(())
|
||||
}
|
||||
ActionKind::Pursue => cavalry::handle_pursue(state, player_idx, unit_idx),
|
||||
// p2-53f infantry line actions
|
||||
ActionKind::ShieldWall => infantry::handle_shield_wall(state, player_idx, unit_idx),
|
||||
|
|
|
|||
|
|
@ -143,6 +143,37 @@ pub struct PillageRequest {
|
|||
pub target_row: i32,
|
||||
}
|
||||
|
||||
/// A Volley request queued by GDScript (ranged unit clicks Volley, selects target hex).
|
||||
///
|
||||
/// Drained each turn by `TurnProcessor::process_volley_requests`. AoE: damages
|
||||
/// units in the target hex and two randomly chosen adjacent edge hexes.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct VolleyRequest {
|
||||
/// Player index of the volleying unit's owner.
|
||||
pub attacker_player: u8,
|
||||
/// Index into `PlayerState::units` for the volleying unit.
|
||||
pub attacker_unit: usize,
|
||||
/// Target axial hex centre `(col, row)`.
|
||||
pub target_col: i32,
|
||||
pub target_row: i32,
|
||||
}
|
||||
|
||||
/// A Charge request queued by GDScript (cavalry clicks Charge, selects target hex).
|
||||
///
|
||||
/// Drained each turn by `TurnProcessor::process_charge_requests`. The unit
|
||||
/// moves up to 2 hexes in a straight line toward the target, then resolves
|
||||
/// melee combat with the +30% charge attack bonus.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ChargeRequest {
|
||||
/// Player index of the charging unit's owner.
|
||||
pub attacker_player: u8,
|
||||
/// Index into `PlayerState::units` for the charging unit.
|
||||
pub attacker_unit: usize,
|
||||
/// Target axial hex `(col, row)`.
|
||||
pub target_col: i32,
|
||||
pub target_row: i32,
|
||||
}
|
||||
|
||||
/// A building action request queued by GDScript, drained by
|
||||
/// `building_action_handlers::drain_pending_building_actions`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
|
@ -177,6 +208,14 @@ pub struct GameState {
|
|||
/// Drained by `TurnProcessor::process_pillage_requests`. Cleared each turn.
|
||||
#[serde(default)]
|
||||
pub pending_pillage_requests: Vec<PillageRequest>,
|
||||
/// Volley requests queued by GDScript this turn (ranged unit AoE attack).
|
||||
/// Drained at the start of `process_volley_requests`. Cleared each turn.
|
||||
#[serde(default)]
|
||||
pub pending_volley_requests: Vec<VolleyRequest>,
|
||||
/// Charge requests queued by GDScript this turn (cavalry 2-hex straight-line charge).
|
||||
/// Drained at the start of `process_charge_requests`. Cleared each turn.
|
||||
#[serde(default)]
|
||||
pub pending_charge_requests: Vec<ChargeRequest>,
|
||||
/// Active formations across all players. BTreeMap for deterministic save order.
|
||||
#[serde(default)]
|
||||
pub formations: BTreeMap<u32, Formation>,
|
||||
|
|
@ -598,6 +637,20 @@ pub struct MapUnit {
|
|||
/// Pending drill XP from Barracks Drill action; consumed by bridge this turn.
|
||||
#[serde(default, skip_serializing_if = "crate::game_state::is_zero_u32")]
|
||||
pub pending_drill_xp: u32,
|
||||
/// Cached capability flag: true when this unit has the `"amphibious"` keyword.
|
||||
/// Set at spawn / ingest from GDScript dict key `"is_amphibious"`.
|
||||
/// Drives movement-cost logic in `step_toward_with_terrain` so ocean/coast
|
||||
/// biomes are passable for amphibious land units. Does NOT grant naval combat;
|
||||
/// this is the minimal Game 1 shore-crossing support.
|
||||
#[serde(default)]
|
||||
pub is_amphibious: bool,
|
||||
// p2-53h: Wheel facing
|
||||
/// Current facing direction of the unit (0–5, hex edge indices, flat-top orientation).
|
||||
/// Default 0 (east). Updated by `ActionKind::Wheel`. Combat resolver consults this
|
||||
/// for first-strike avoidance: if attacker changed facing away from defender's braced
|
||||
/// edge via Wheel, the attacker skips the first-strike penalty.
|
||||
#[serde(default)]
|
||||
pub facing_edge: u8,
|
||||
}
|
||||
|
||||
pub(crate) fn is_zero_u32(v: &u32) -> bool { *v == 0 }
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ mod improvement_tests;
|
|||
pub use action::{legal_actions, ActionAvailability, ActionKind, DisabledReason, UnitCapability};
|
||||
pub use action_handlers::{invoke as invoke_action, ActionError};
|
||||
pub use chronicle::{Chronicle, ChronicleEntry};
|
||||
pub use game_state::{AttackRequest, BombardRequest, BuildingRallyPoint, CityEcology, GameState, MapUnit, PillageRequest, PlayerState, TechState};
|
||||
pub use game_state::{AttackRequest, BombardRequest, BuildingRallyPoint, ChargeRequest, CityEcology, GameState, MapUnit, PillageRequest, PlayerState, TechState, VolleyRequest};
|
||||
pub use mc_core::improvement::{RawImprovementJson, TileImprovement, TileImprovementSpec};
|
||||
pub use combat_event::{FaunaCombatEvent, PvpCombatEvent, SiegeEvent, StrategicGateRejection, TurnResult};
|
||||
pub use processor::{LairCombatConfig, TurnProcessor};
|
||||
|
|
|
|||
|
|
@ -369,6 +369,18 @@ impl TurnProcessor {
|
|||
// their rally destination (Hold/Fortify/JoinFormation).
|
||||
Self::apply_rally_arrival_actions(state);
|
||||
|
||||
// Phase 5a-bombard: drain GDScript-queued Bombard requests (siege units).
|
||||
Self::process_bombard_requests(state, &mut result);
|
||||
|
||||
// Phase 5a-volley: drain GDScript-queued Volley AoE requests.
|
||||
// Runs after movement and sentry-wake so positions are current; runs before
|
||||
// PvP so the ranged volley resolves at the correct positions.
|
||||
Self::process_volley_requests(state, &mut result);
|
||||
|
||||
// Phase 5a-charge: drain GDScript-queued Charge requests (2-hex move + melee).
|
||||
// Runs after volley so cavalry charge resolves after ranged suppression.
|
||||
Self::process_charge_requests(state, &mut result);
|
||||
|
||||
// Phase 5b: PvP combat — units that moved adjacent to or onto enemy
|
||||
// units/cities resolve combat via CombatResolver.
|
||||
self.process_pvp_combat(state, &mut result);
|
||||
|
|
@ -1295,14 +1307,14 @@ impl TurnProcessor {
|
|||
for unit in &mut player.units {
|
||||
// Priority 1: nearest enemy unit within seek range.
|
||||
if let Some((tc, tr)) = nearest_enemy(&enemy_units, unit.col, unit.row, seek) {
|
||||
let (dc, dr) = step_toward_with_terrain(unit.col, unit.row, tc, tr, &state.grid);
|
||||
let (dc, dr) = step_toward_with_terrain(unit.col, unit.row, tc, tr, &state.grid, unit.is_amphibious);
|
||||
unit.col += dc;
|
||||
unit.row += dr;
|
||||
continue;
|
||||
}
|
||||
// Priority 2: nearest enemy city within seek range.
|
||||
if let Some((tc, tr)) = nearest_enemy_city(&enemy_cities, unit.col, unit.row, seek) {
|
||||
let (dc, dr) = step_toward_with_terrain(unit.col, unit.row, tc, tr, &state.grid);
|
||||
let (dc, dr) = step_toward_with_terrain(unit.col, unit.row, tc, tr, &state.grid, unit.is_amphibious);
|
||||
unit.col += dc;
|
||||
unit.row += dr;
|
||||
continue;
|
||||
|
|
@ -1957,6 +1969,201 @@ impl TurnProcessor {
|
|||
}
|
||||
}
|
||||
|
||||
// ── Bombard / Volley / Charge queue-drain phases (p2-53e/g/h) ────────
|
||||
|
||||
/// Drain `GameState::pending_bombard_requests` for all players.
|
||||
///
|
||||
/// Calls `mc_combat::siege::resolve_bombard` for each request; applies damage
|
||||
/// to any unit or city on the target hex. The queue is cleared after drain.
|
||||
fn process_bombard_requests(state: &mut GameState, result: &mut TurnResult) {
|
||||
use mc_combat::siege::{resolve_bombard, BombardTarget};
|
||||
|
||||
let requests = std::mem::take(&mut state.pending_bombard_requests);
|
||||
for req in requests {
|
||||
let pi = req.attacker_player as usize;
|
||||
if pi >= state.players.len() { continue; }
|
||||
let Some(attacker) = state.players[pi].units.get(req.attacker_unit) else { continue };
|
||||
if !attacker.is_deployed { continue; } // must be deployed to bombard
|
||||
let attacker_attack = attacker.attack;
|
||||
|
||||
// Determine target type: unit or city.
|
||||
let mut target_type = BombardTarget::Unit;
|
||||
for other_pi in 0..state.players.len() {
|
||||
if other_pi == pi { continue; }
|
||||
for city_pos in &state.players[other_pi].city_positions {
|
||||
if city_pos.0 == req.target_col && city_pos.1 == req.target_row {
|
||||
target_type = BombardTarget::Structure;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let bombard_result = resolve_bombard(attacker_attack, target_type, req.indirect_fire);
|
||||
|
||||
// Apply damage to a unit on the target hex (if any).
|
||||
let mut killed: Vec<(usize, usize)> = Vec::new();
|
||||
for other_pi in 0..state.players.len() {
|
||||
if other_pi == pi { continue; }
|
||||
for (ui, def_unit) in state.players[other_pi].units.iter_mut().enumerate() {
|
||||
if def_unit.col == req.target_col && def_unit.row == req.target_row {
|
||||
def_unit.hp -= bombard_result.damage;
|
||||
if def_unit.hp <= 0 {
|
||||
killed.push((other_pi, ui));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
killed.sort_unstable_by(|a, b| b.cmp(a));
|
||||
for (other_pi, ui) in killed {
|
||||
if ui < state.players[other_pi].units.len() {
|
||||
state.players[other_pi].units.swap_remove(ui);
|
||||
result.units_lost_to_fauna += 1;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
// ── Volley / Charge queue-drain phases (p2-53g/h) ────────────────────
|
||||
|
||||
/// Drain `GameState::pending_volley_requests` for all players.
|
||||
///
|
||||
/// AoE pattern: hits the target hex centre + 2 of its axial neighbours
|
||||
/// chosen deterministically by `(attacker_player XOR turn) % 6` to keep
|
||||
/// the selection stable within a turn but vary across turns.
|
||||
/// Each hit enemy unit takes `attacker.attack / 2` damage (halved for spread).
|
||||
fn process_volley_requests(state: &mut GameState, result: &mut TurnResult) {
|
||||
let requests = std::mem::take(&mut state.pending_volley_requests);
|
||||
for req in requests {
|
||||
let pi = req.attacker_player as usize;
|
||||
if pi >= state.players.len() { continue; }
|
||||
let Some(attacker) = state.players[pi].units.get(req.attacker_unit) else { continue };
|
||||
let attacker_attack = attacker.attack;
|
||||
|
||||
// Determine the 3 AoE hexes: centre + 2 neighbours.
|
||||
let neighbours = hex_neighbors(req.target_col, req.target_row);
|
||||
let idx0 = ((req.attacker_player as u32) ^ state.turn) as usize % 6;
|
||||
let idx1 = (idx0 + 1) % 6;
|
||||
let hit_hexes = [
|
||||
(req.target_col, req.target_row),
|
||||
neighbours[idx0],
|
||||
neighbours[idx1],
|
||||
];
|
||||
|
||||
// Deal halved damage to enemy units on each hit hex.
|
||||
let per_target_damage = (attacker_attack / 2).max(1);
|
||||
for (n_players, n_pi) in state.players.iter().enumerate() {
|
||||
let _ = (n_players, n_pi); // suppress unused warning below
|
||||
}
|
||||
let n = state.players.len();
|
||||
let mut killed: Vec<(usize, usize)> = Vec::new(); // (player_idx, unit_idx)
|
||||
for other_pi in 0..n {
|
||||
if other_pi == pi { continue; }
|
||||
for (ui, unit) in state.players[other_pi].units.iter_mut().enumerate() {
|
||||
for &(hc, hr) in &hit_hexes {
|
||||
if unit.col == hc && unit.row == hr {
|
||||
unit.hp -= per_target_damage;
|
||||
if unit.hp <= 0 {
|
||||
killed.push((other_pi, ui));
|
||||
}
|
||||
break; // each unit hit at most once per volley
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Remove killed units (reverse order to preserve indices).
|
||||
killed.sort_unstable_by(|a, b| b.cmp(a));
|
||||
killed.dedup();
|
||||
for (other_pi, ui) in killed {
|
||||
if other_pi < state.players.len() && ui < state.players[other_pi].units.len() {
|
||||
state.players[other_pi].units.swap_remove(ui);
|
||||
result.units_lost_to_fauna += 1; // reuse counter as unit-death counter
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Drain `GameState::pending_charge_requests` for all players.
|
||||
///
|
||||
/// Each Charge: validate that target is within 2 hexes and the path is roughly
|
||||
/// straight; move the unit to the hex adjacent to the target; then resolve
|
||||
/// PvP melee combat with `attacker_charging = true` (+30% attack bonus).
|
||||
fn process_charge_requests(state: &mut GameState, result: &mut TurnResult) {
|
||||
let requests = std::mem::take(&mut state.pending_charge_requests);
|
||||
for req in requests {
|
||||
let pi = req.attacker_player as usize;
|
||||
if pi >= state.players.len() { continue; }
|
||||
if req.attacker_unit >= state.players[pi].units.len() { continue; }
|
||||
|
||||
let unit_col = state.players[pi].units[req.attacker_unit].col;
|
||||
let unit_row = state.players[pi].units[req.attacker_unit].row;
|
||||
|
||||
// Validate: target must be within 2 hexes.
|
||||
let dist = hex_distance(unit_col, unit_row, req.target_col, req.target_row);
|
||||
if dist > 2 || dist == 0 { continue; }
|
||||
|
||||
// Step 1: move unit toward target (one step, leaving it adjacent).
|
||||
if dist == 2 {
|
||||
let (dc, dr) = step_toward(unit_col, unit_row, req.target_col, req.target_row);
|
||||
state.players[pi].units[req.attacker_unit].col += dc;
|
||||
state.players[pi].units[req.attacker_unit].row += dr;
|
||||
}
|
||||
|
||||
// Step 2: resolve melee combat if a defender is on the target hex.
|
||||
// Find the defending player and unit.
|
||||
let mut defender_info: Option<(usize, usize)> = None; // (def_pi, def_ui)
|
||||
for def_pi in 0..state.players.len() {
|
||||
if def_pi == pi { continue; }
|
||||
for (def_ui, def_unit) in state.players[def_pi].units.iter().enumerate() {
|
||||
if def_unit.col == req.target_col && def_unit.row == req.target_row {
|
||||
defender_info = Some((def_pi, def_ui));
|
||||
break;
|
||||
}
|
||||
}
|
||||
if defender_info.is_some() { break; }
|
||||
}
|
||||
|
||||
if let Some((def_pi, def_ui)) = defender_info {
|
||||
let attacker_attack = state.players[pi].units[req.attacker_unit].attack;
|
||||
let attacker_col = state.players[pi].units[req.attacker_unit].col;
|
||||
let attacker_row = state.players[pi].units[req.attacker_unit].row;
|
||||
let defender_defense = state.players[def_pi].units[def_ui].defense;
|
||||
let defender_fortified = state.players[def_pi].units[def_ui].is_fortified;
|
||||
let defender_hp = state.players[def_pi].units[def_ui].hp;
|
||||
|
||||
// +30% charge bonus.
|
||||
let effective_attack = (attacker_attack as f32 * 1.30).round() as i32;
|
||||
let def_mod = if defender_fortified { 1.5_f32 } else { 1.0_f32 };
|
||||
let damage_to_defender = ((effective_attack as f32 - defender_defense as f32 * def_mod) * 0.5)
|
||||
.max(1.0).round() as i32;
|
||||
let retaliation = ((defender_defense as f32 - attacker_attack as f32 * 0.5) * 0.4)
|
||||
.max(0.0).round() as i32;
|
||||
|
||||
state.players[def_pi].units[def_ui].hp -= damage_to_defender;
|
||||
state.players[pi].units[req.attacker_unit].hp -= retaliation;
|
||||
|
||||
// Kill defender if HP <= 0.
|
||||
if state.players[def_pi].units[def_ui].hp <= 0 {
|
||||
state.players[def_pi].units.swap_remove(def_ui);
|
||||
result.units_lost_to_fauna += 1;
|
||||
// Pursue follow-through: if attacker is_pursuing, advance into vacated hex.
|
||||
if state.players[pi].units[req.attacker_unit].is_pursuing {
|
||||
state.players[pi].units[req.attacker_unit].col = req.target_col;
|
||||
state.players[pi].units[req.attacker_unit].row = req.target_row;
|
||||
}
|
||||
}
|
||||
// Kill attacker if HP <= 0.
|
||||
if state.players[pi].units[req.attacker_unit].hp <= 0 {
|
||||
state.players[pi].units.swap_remove(req.attacker_unit);
|
||||
result.units_lost_to_fauna += 1;
|
||||
}
|
||||
|
||||
let _ = (attacker_col, attacker_row, defender_hp); // suppress unused
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Diplomacy surface (callable by GDExtension / GDScript) ───────────
|
||||
|
||||
/// Declare war from `by` against `against`. Sets relation to War immediately,
|
||||
|
|
@ -2211,6 +2418,23 @@ fn terrain_movement_cost(biome: &str) -> i32 {
|
|||
}
|
||||
}
|
||||
|
||||
/// Unit-capability-aware movement cost. Amphibious units treat ocean/shallow_ocean/coast
|
||||
/// as passable (cost +1 vs land = 2 MP) rather than impassable; mountains remain
|
||||
/// impassable for all units. All other biomes use the base cost.
|
||||
fn terrain_movement_cost_for_unit(biome: &str, is_amphibious: bool) -> i32 {
|
||||
match biome {
|
||||
"mountain" | "high_mountain" | "volcanic_mountain" => i32::MAX,
|
||||
"dense_forest" | "temperate_forest" | "boreal_forest"
|
||||
| "tropical_forest" | "coniferous_forest" | "cloud_forest"
|
||||
| "elfin_woodland" | "montane_forest"
|
||||
| "hills" | "rocky_highland" | "alpine_meadow" => 2,
|
||||
"deep_ocean" | "shallow_ocean" | "ocean" | "coast" => {
|
||||
if is_amphibious { 2 } else { i32::MAX }
|
||||
}
|
||||
_ => 1,
|
||||
}
|
||||
}
|
||||
|
||||
/// Defense bonus for a tile based on its biome. Forest and hills grant +25%,
|
||||
/// mountains +50% (though impassable for movement).
|
||||
fn terrain_defense_bonus(biome: &str) -> f32 {
|
||||
|
|
@ -2286,26 +2510,38 @@ fn terrain_defense_bonus_at(col: i32, row: i32, grid: &Option<mc_core::grid::Gri
|
|||
terrain_defense_bonus(&biome)
|
||||
}
|
||||
|
||||
/// The 6 axial neighbour offsets for a flat-top hex grid (offset order 0–5: E, NE, NW, W, SW, SE).
|
||||
const HEX_NEIGHBOR_OFFSETS: [(i32, i32); 6] = [
|
||||
(1, 0), (0, -1), (-1, -1), (-1, 0), (0, 1), (1, 1),
|
||||
];
|
||||
|
||||
/// Returns the 6 axial neighbours of `(col, row)`.
|
||||
fn hex_neighbors(col: i32, row: i32) -> [(i32, i32); 6] {
|
||||
HEX_NEIGHBOR_OFFSETS.map(|(dc, dr)| (col + dc, row + dr))
|
||||
}
|
||||
|
||||
/// Like `step_toward` but avoids impassable terrain when possible.
|
||||
/// Falls back to the basic step if no passable direction is available.
|
||||
/// `is_amphibious`: when true, ocean/coast hexes are treated as passable.
|
||||
fn step_toward_with_terrain(
|
||||
ac: i32, ar: i32, bc: i32, br: i32,
|
||||
grid: &Option<mc_core::grid::GridState>,
|
||||
is_amphibious: bool,
|
||||
) -> (i32, i32) {
|
||||
let (dc, dr) = step_toward(ac, ar, bc, br);
|
||||
let target_biome = tile_biome(ac + dc, ar + dr, grid);
|
||||
if !target_biome.is_empty() && terrain_movement_cost(&target_biome) == i32::MAX {
|
||||
if !target_biome.is_empty() && terrain_movement_cost_for_unit(&target_biome, is_amphibious) == i32::MAX {
|
||||
// Target is impassable — try adjacent directions.
|
||||
// Check pure-column and pure-row steps as alternatives.
|
||||
if dc != 0 {
|
||||
let alt_biome = tile_biome(ac + dc, ar, grid);
|
||||
if alt_biome.is_empty() || terrain_movement_cost(&alt_biome) < i32::MAX {
|
||||
if alt_biome.is_empty() || terrain_movement_cost_for_unit(&alt_biome, is_amphibious) < i32::MAX {
|
||||
return (dc, 0);
|
||||
}
|
||||
}
|
||||
if dr != 0 {
|
||||
let alt_biome = tile_biome(ac, ar + dr, grid);
|
||||
if alt_biome.is_empty() || terrain_movement_cost(&alt_biome) < i32::MAX {
|
||||
if alt_biome.is_empty() || terrain_movement_cost_for_unit(&alt_biome, is_amphibious) < i32::MAX {
|
||||
return (0, dr);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue