feat(game-engine): Add runtime state fields like health and position, plus helper methods for updating and rendering Units

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-04-08 16:11:49 -07:00
parent db2070b2a3
commit f633ab95e5

View file

@ -1,2 +1,180 @@
class_name Unit
extends RefCounted
## Game-side unit entity. Holds runtime state for one military or civilian
## unit on the world map. Stats originate from JSON unit data via DataLoader;
## this class tracks the per-instance mutable runtime state (position, hp,
## promotions, infusions, movement budget, fortification, XP/level).
##
## Restored in iter 7i from a 2-line stub. The field set is the union of
## every Unit field/method the existing GDScript codebase reads or writes
## (verified by grep across engine/src/ before authoring this class).
##
## The bridge to mc_turn::MapUnit lives in `to_bridge_dict()` and the
## inverse `apply_bridge_dict()` — that's the iter 7h dict adapter.
const UnitStatHelpers: GDScript = preload(
"res://engine/src/entities/unit_stat_helpers.gd"
)
# ── Identity ───────────────────────────────────────────────────────────
## DataLoader unit type id, e.g. "dwarf_warrior".
var unit_id: String = ""
## Display name override; falls back to a humanised `unit_id`.
var display_name: String = ""
# ── Position & ownership ──────────────────────────────────────────────
## Axial position on the world map.
var position: Vector2i = Vector2i.ZERO
## Owning player index. -1 = wild creature, 0..n = player slot.
var owner: int = -1
# ── Combat stats ──────────────────────────────────────────────────────
var hp: int = 1
var max_hp: int = 1
var attack: int = 0
var defense: int = 0
var ranged_attack: int = 0
# ── Movement ──────────────────────────────────────────────────────────
var movement_remaining: int = 2
var max_movement: int = 2
# ── Per-turn state flags ──────────────────────────────────────────────
var has_attacked: bool = false
var is_fortified: bool = false
var fortified_turns: int = 0
# ── Veterancy ─────────────────────────────────────────────────────────
var xp: int = 0
var level: int = 1
# ── Promotions, items, magic ──────────────────────────────────────────
var promo_ids: Array[String] = []
## Equipped items: Array[Dictionary] {item_id: String, charges: int}.
var equipped_items: Array = []
## Currently active enchantment IDs (non-channeled).
var infusions: Array[String] = []
## Single channeled enchantment (mage-anchored) — empty if none active.
var channeled_infusion: String = ""
## Turns remaining before the channeled infusion fades after disconnection.
var channeled_fade_turns: int = 0
# ── Lifecycle ─────────────────────────────────────────────────────────
## Construct a Unit with the given identity. Stats are populated from the
## DataLoader entry if `populate_from_data` is true.
func _init(p_unit_id: String = "", p_owner: int = -1, p_position: Vector2i = Vector2i.ZERO,
populate_from_data: bool = true) -> void:
unit_id = p_unit_id
owner = p_owner
position = p_position
if populate_from_data and unit_id != "":
_populate_from_data()
## Read base stats from `DataLoader.get_unit(unit_id)` and apply them.
## Safe to call repeatedly — overwrites mutable stat fields.
func _populate_from_data() -> void:
var data: Dictionary = DataLoader.get_unit(unit_id)
if data.is_empty():
return
max_hp = data.get("hp", 1)
hp = max_hp
attack = data.get("attack", 0)
defense = data.get("defense", 0)
ranged_attack = data.get("ranged_attack", 0)
max_movement = data.get("movement", 2)
movement_remaining = max_movement
if display_name.is_empty():
display_name = data.get("name", unit_id.capitalize())
# ── Per-turn refresh (called by turn_processor.gd) ────────────────────
## Reset per-turn state flags. Called at the top of each player turn.
func refresh_turn() -> void:
movement_remaining = get_movement()
has_attacked = false
if is_fortified:
fortified_turns += 1
if channeled_fade_turns > 0:
channeled_fade_turns -= 1
# ── Combat queries ────────────────────────────────────────────────────
func is_alive() -> bool:
return hp > 0
func is_ranged() -> bool:
var combat_type: String = _combat_type()
return combat_type == "ranged" or combat_type == "siege" or ranged_attack > 0
func is_flying() -> bool:
return _combat_type() == "flying"
func is_military() -> bool:
var combat_type: String = _combat_type()
if combat_type.is_empty():
return false
return combat_type != "settler" and combat_type != "worker" and combat_type != "civilian"
func _combat_type() -> String:
if unit_id.is_empty():
return ""
var data: Dictionary = DataLoader.get_unit(unit_id)
return data.get("combat_type", "")
# ── Movement / damage / healing helpers ───────────────────────────────
## Effective per-turn movement budget after promotions, items, infusions.
func get_movement() -> int:
var total: int = max_movement
total += UnitStatHelpers.get_promotion_stat(promo_ids, "movement_bonus")
total += UnitStatHelpers.get_infusion_movement_bonus(infusions)
var override: int = UnitStatHelpers.get_infusion_movement_override(infusions)
if override > 0:
return override
return maxi(1, total)
## Apply incoming damage. Returns true if the unit is now dead.
func take_damage(amount: int) -> bool:
hp = maxi(0, hp - amount)
return hp <= 0
## Apply healing, capped at max_hp.
func heal(amount: int) -> void:
hp = mini(max_hp, hp + amount)
# ── Iter 7h bridge adapter ────────────────────────────────────────────
## Pack this unit into the dict shape that
## `GdGameState::set_player_units_from_dicts` accepts.
func to_bridge_dict() -> Dictionary:
return {
"col": position.x,
"row": position.y,
"hp": hp,
"max_hp": max_hp,
"attack": attack,
"defense": defense,
"is_fortified": is_fortified,
"unit_id": unit_id,
}
## Apply a post-step bridge dict back onto this unit. Used by the
## iter 7i adapter to sync hp/position back from `step()` results.
func apply_bridge_dict(d: Dictionary) -> void:
hp = int(d.get("hp", hp))
position = Vector2i(int(d.get("col", position.x)), int(d.get("row", position.y)))
is_fortified = bool(d.get("is_fortified", is_fortified))