feat(city-defense): population HP scaling + garrison combat strength boost
- City max_hp scales with population: base 200 + 10 per pop (pop 1 = 210) - max_hp recalculated on growth/starvation, building bonuses preserved - Garrison unit's attack adds to city bombard combat strength - Renamed DEFAULT_CITY_HP → BASE_CITY_HP, added HP_PER_POP constant Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
9bbd80a426
commit
08abd276c5
4 changed files with 45 additions and 17 deletions
|
|
@ -318,10 +318,17 @@ func _count_flanking(attacker: RefCounted, near_def: Array) -> int:
|
|||
|
||||
## Terrain defense bonus from the tile the unit stands on.
|
||||
## Compute city bombard retaliation damage.
|
||||
## City strength = population * 3 + building bombard bonuses.
|
||||
## City strength = population * 3 + garrison attack + building bombard bonuses.
|
||||
## Damage = 15 * (city_strength / attacker_strength), clamped [5, 30].
|
||||
func _compute_city_bombard(city: RefCounted, attacker: RefCounted) -> int:
|
||||
var city_str: float = float(city.population) * 3.0
|
||||
# Garrison unit boosts city combat strength
|
||||
var primary: Dictionary = GameState.get_primary_layer()
|
||||
var garrison: RefCounted = CombatUtilsScript.get_garrison(
|
||||
city.position, primary.get("units", [])
|
||||
)
|
||||
if garrison != null and garrison is UnitScript:
|
||||
city_str += float(garrison.attack)
|
||||
# Add castle bombard bonus if present
|
||||
if city.has_building("castle"):
|
||||
var bdata: Dictionary = DataLoader.get_building("castle")
|
||||
|
|
|
|||
|
|
@ -102,8 +102,11 @@ impl TileYield {
|
|||
/// Food consumed per citizen per turn (Civ5 standard).
|
||||
pub const FOOD_PER_POP: f64 = 2.0;
|
||||
|
||||
/// Default city HP (Civ5: 200).
|
||||
pub const DEFAULT_CITY_HP: u32 = 200;
|
||||
/// Base city HP before population scaling.
|
||||
pub const BASE_CITY_HP: u32 = 200;
|
||||
|
||||
/// HP gained per population point.
|
||||
pub const HP_PER_POP: u32 = 10;
|
||||
|
||||
/// Civ5 growth formula: 15 + 6*(pop-1) + floor((pop-1)^1.8).
|
||||
/// Returns the food threshold for the next population point.
|
||||
|
|
@ -181,8 +184,8 @@ impl Default for City {
|
|||
culture_stored: 0.0,
|
||||
culture_expansions: 0,
|
||||
owned_tiles: Vec::new(),
|
||||
hp: DEFAULT_CITY_HP,
|
||||
max_hp: DEFAULT_CITY_HP,
|
||||
hp: BASE_CITY_HP + HP_PER_POP, // pop 1 at founding
|
||||
max_hp: BASE_CITY_HP + HP_PER_POP,
|
||||
focus: CityFocus::Default,
|
||||
worked_tiles: Vec::new(),
|
||||
buildings: Vec::new(),
|
||||
|
|
@ -230,8 +233,8 @@ impl City {
|
|||
culture_stored: 0.0,
|
||||
culture_expansions: 0,
|
||||
owned_tiles: vec![position],
|
||||
hp: DEFAULT_CITY_HP,
|
||||
max_hp: DEFAULT_CITY_HP,
|
||||
hp: BASE_CITY_HP + HP_PER_POP, // pop 1 at founding
|
||||
max_hp: BASE_CITY_HP + HP_PER_POP,
|
||||
focus: CityFocus::Default,
|
||||
worked_tiles: vec![position],
|
||||
buildings: Vec::new(),
|
||||
|
|
@ -327,6 +330,7 @@ impl City {
|
|||
// Growth: population increases, surplus food carries over
|
||||
self.food_stored -= self.growth_threshold();
|
||||
self.population += 1;
|
||||
self.recalc_max_hp();
|
||||
// Clamp negative carried food to 0
|
||||
if self.food_stored < 0.0 {
|
||||
self.food_stored = 0.0;
|
||||
|
|
@ -337,6 +341,7 @@ impl City {
|
|||
if self.food_stored < 0.0 && self.population > 1 {
|
||||
// Starvation: lose a citizen, reset food to 0
|
||||
self.population -= 1;
|
||||
self.recalc_max_hp();
|
||||
self.food_stored = 0.0;
|
||||
return -1;
|
||||
}
|
||||
|
|
@ -443,6 +448,21 @@ impl City {
|
|||
|
||||
// ── Defense ────────────────────────────────────────────────────
|
||||
|
||||
/// Adjust max_hp after population change (+10 HP per pop).
|
||||
/// Building bonuses (applied via set_max_hp) are preserved as the excess
|
||||
/// above the population-based value.
|
||||
fn recalc_max_hp(&mut self) {
|
||||
let new_base = BASE_CITY_HP + self.population * HP_PER_POP;
|
||||
if new_base > self.max_hp {
|
||||
// Growth: increase max and heal the difference
|
||||
let delta = new_base - self.max_hp;
|
||||
self.max_hp = new_base;
|
||||
self.hp = (self.hp + delta).min(self.max_hp);
|
||||
}
|
||||
// Starvation: don't reduce max_hp below new_base (building bonuses stay)
|
||||
// HP is clamped on next heal/damage
|
||||
}
|
||||
|
||||
pub fn take_damage(&mut self, damage: u32) {
|
||||
self.hp = self.hp.saturating_sub(damage);
|
||||
}
|
||||
|
|
@ -582,7 +602,7 @@ mod tests {
|
|||
assert_eq!(city.position, (5, 5));
|
||||
assert_eq!(city.population, 1);
|
||||
assert_eq!(city.food_stored, 0.0);
|
||||
assert_eq!(city.hp, DEFAULT_CITY_HP);
|
||||
assert_eq!(city.hp, BASE_CITY_HP + HP_PER_POP); // pop 1
|
||||
assert_eq!(city.owned_tiles, vec![(5, 5)]);
|
||||
assert_eq!(city.worked_tiles, vec![(5, 5)]);
|
||||
}
|
||||
|
|
@ -743,16 +763,17 @@ mod tests {
|
|||
#[test]
|
||||
fn defense_mechanics() {
|
||||
let mut city = City::found("Ironhold", (5, 5), true, 1);
|
||||
assert_eq!(city.hp, DEFAULT_CITY_HP);
|
||||
let initial_hp = BASE_CITY_HP + HP_PER_POP; // 210 at pop 1
|
||||
assert_eq!(city.hp, initial_hp);
|
||||
city.take_damage(50);
|
||||
assert_eq!(city.hp, DEFAULT_CITY_HP - 50);
|
||||
assert_eq!(city.hp, initial_hp - 50);
|
||||
city.heal(20);
|
||||
assert_eq!(city.hp, DEFAULT_CITY_HP - 30);
|
||||
assert_eq!(city.hp, initial_hp - 30);
|
||||
// Heal doesn't exceed max
|
||||
city.heal(1000);
|
||||
assert_eq!(city.hp, DEFAULT_CITY_HP);
|
||||
assert_eq!(city.hp, initial_hp);
|
||||
// Destroy
|
||||
city.take_damage(DEFAULT_CITY_HP);
|
||||
city.take_damage(initial_hp);
|
||||
assert!(city.is_destroyed());
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ pub use production::{
|
|||
// Re-export city types
|
||||
pub use city::{
|
||||
City, CityFocus, CityYields, TileYield,
|
||||
FOOD_PER_POP, DEFAULT_CITY_HP,
|
||||
FOOD_PER_POP, BASE_CITY_HP, HP_PER_POP,
|
||||
growth_threshold, culture_expansion_threshold,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@
|
|||
//! Siege units get a bonus against cities.
|
||||
//! Capture: city HP reaches 0 AND a melee unit occupies the tile → ownership transfers.
|
||||
|
||||
/// Default city HP.
|
||||
pub const DEFAULT_CITY_HP: i32 = 200;
|
||||
/// Base city HP (before population scaling and building bonuses).
|
||||
pub const BASE_CITY_HP: i32 = 200;
|
||||
|
||||
/// HP added per wall tier (3 tiers: ancient walls, medieval walls, renaissance walls).
|
||||
pub const WALL_HP_PER_TIER: i32 = 50;
|
||||
|
|
@ -52,7 +52,7 @@ pub fn siege_city_bonus() -> f32 {
|
|||
|
||||
/// Total city HP including wall tiers.
|
||||
pub fn city_total_hp(wall_tier: i32) -> i32 {
|
||||
DEFAULT_CITY_HP + wall_tier.clamp(0, 3) * WALL_HP_PER_TIER
|
||||
BASE_CITY_HP + wall_tier.clamp(0, 3) * WALL_HP_PER_TIER
|
||||
}
|
||||
|
||||
/// Compute how ranged damage is split between city HP and garrison HP.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue