diff --git a/src/game/engine/src/unit.gd b/src/game/engine/src/unit.gd new file mode 100644 index 00000000..ecb411b5 --- /dev/null +++ b/src/game/engine/src/unit.gd @@ -0,0 +1,322 @@ +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 ─────────────────────────────────────────────────────────── +## Unique instance identifier, e.g. "unit_0_3". Set by the spawner. +var id: String = "" +## DataLoader unit type id, e.g. "dwarf_warrior". +var unit_id: String = "" +## Alias for unit_id — used by renderers and UI panels. +var type_id: String = "" +## Display name override; falls back to a humanised `unit_id`. +var display_name: String = "" +## Combat type string from JSON data, e.g. "melee", "civilian", "ranged". +var unit_type: String = "" +## Movement domain string from JSON data, e.g. "land", "flying", "naval". +## Consumed by Pathfinder._is_passable to decide terrain passability. +var domain: String = "land" + +# ── Capability flags ───────────────────────────────────────────────── +## True if this unit can found cities (derived from "found_city" keyword). +var can_found_city: bool = false +## True if this unit can build tile improvements (derived from "build" keyword). +var can_build_improvements: bool = false + +# ── 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 + +# ── Vision ──────────────────────────────────────────────────────────── +## Sight radius in hexes, read by world_map_vision.recalculate_vision. +var vision: 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 + +# ── D20 attribute stubs ─────────────────────────────────────────────── +## These fields are probed by CombatResolver._has_d20_attributes to decide +## whether to send D20-derived stats or flat JSON stats to Rust mc-combat. +## Arena units have no D20 profile, so all stubs default to 0 and combat +## takes the flat-stats path. Real D20 units will populate these later. +var base_str: int = 0 +var base_dex: int = 0 +var base_con: int = 0 +var base_int: int = 0 +var bonus_str: int = 0 +var bonus_dex: int = 0 +var bonus_con: int = 0 +var bonus_int: int = 0 +var bonus_attack: int = 0 +var bonus_defense: int = 0 +var veteran_level: int = 0 +var formation_count: int = 1 +var stimulant_penalty: int = 0 + +# ── Promotions, items, magic ────────────────────────────────────────── +var promo_ids: Array[String] = [] +## Equipped items: each entry is {item_id: String, charges_remaining: int}. +## Must stay strongly typed — GdItemSystem's FFI rejects untyped arrays. +var equipped_items: Array[Dictionary] = [] +## 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 + type_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 + vision = data.get("vision", 2) + # JSON data authored for Age of Dwarves uses `unit_type` for the role + # ("military"/"civilian"/"support") and `combat_type` for the weapon + # profile ("melee"/"ranged"/"siege"). Earlier iterations read + # `combat_type` into this field, which silently produced an empty + # string for every dwarf unit and broke downstream consumers like + # the pathfinder's unit_type gate. Read both fields now — prefer + # `combat_type` when present (some reference units still use it) and + # fall back to `unit_type`, so arena units always report a non-empty + # role string. `domain` carries the pathfinder-facing passability tag. + var combat_type: String = String(data.get("combat_type", "")) + if combat_type.is_empty(): + combat_type = String(data.get("unit_type", "")) + unit_type = combat_type + domain = String(data.get("domain", "land")) + if display_name.is_empty(): + display_name = data.get("name", unit_id.capitalize()) + var is_founder: bool = ( + unit_id.contains("founder") + ) + can_found_city = data.get("can_found_city", is_founder) + var is_builder: bool = ( + unit_id.contains("worker") or unit_id.contains("engineer") + ) + can_build_improvements = data.get( + "can_build_improvements", is_builder + ) + + +# ── Per-turn refresh (called by turn_processor.gd) ──────────────────── + +## Accumulate experience points from combat or events. Called by +## CombatResolver after each resolved engagement for surviving units. +func gain_xp(amount: int) -> void: + if amount <= 0: + return + xp += amount + + +## Whether this unit is eligible to pick a new promotion. Arena-mode games +## don't use the promotion system, so this is a minimal stub returning false. +## Replace with a real threshold check when the promotion pipeline is built. +func can_promote() -> bool: + return false + + +## 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 = get_combat_type() + if combat_type.is_empty(): + return false + return combat_type != "founder" and combat_type != "worker" and combat_type != "civilian" + + +func is_civilian() -> bool: + var combat_type: String = get_combat_type() + return combat_type == "civilian" or combat_type == "founder" or combat_type == "worker" + + +func get_combat_type() -> String: + if unit_type != "": + return unit_type + return _combat_type() + + +func get_attack() -> int: + var total: int = attack + total += UnitStatHelpers.get_promotion_stat(promo_ids, "attack_bonus") + return total + + +func get_defense() -> int: + var total: int = defense + total += UnitStatHelpers.get_promotion_stat(promo_ids, "defense_bonus") + total += get_fortification_bonus() + return total + + +## Defensive bonus earned by fortifying in place. Capped at 2 turns of +## accumulation (same formula that was inlined in `get_defense`). Read by +## CombatResolver._build_unit_dict when packing the attacker/defender +## dict for the Rust combat FFI. +func get_fortification_bonus() -> int: + if not is_fortified: + return 0 + return mini(fortified_turns, 2) * 2 + + +## Alias for `max_hp` used by CombatUtils.handle_unit_death's soul gem +## resurrection path. Kept as a method so the death code doesn't depend on +## the exact field name. +func get_max_hp() -> int: + return max_hp + + +func fortify() -> void: + if is_civilian(): + return + is_fortified = true + movement_remaining = 0 + + +func _combat_type() -> String: + if unit_id.is_empty(): + return "" + var data: Dictionary = DataLoader.get_unit(unit_id) + return data.get("combat_type", "") + + +## List of keyword strings for this unit, sourced from JSON unit data. +## Returns an empty array for units whose `unit_id` has no DataLoader entry. +func get_keywords() -> Array: + if unit_id.is_empty(): + return [] + var data: Dictionary = DataLoader.get_unit(unit_id) + return data.get("keywords", []) + + +## True if this unit has `keyword` in its JSON keyword list. +func has_keyword(keyword: String) -> bool: + return keyword in get_keywords() + + +# ── 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)) diff --git a/src/game/engine/src/unit_renderer.gd b/src/game/engine/src/unit_renderer.gd new file mode 100644 index 00000000..d178da16 --- /dev/null +++ b/src/game/engine/src/unit_renderer.gd @@ -0,0 +1,297 @@ +class_name UnitRenderer +extends Node2D +## Renders unit sprites on the hex map and handles selection/movement overlays. +## Draws colored circles as scaffolding until the sprite pipeline generates real art. + +const HexUtilsScript = preload("res://engine/src/map/hex_utils.gd") +const UnitScript = preload("res://engine/src/entities/unit.gd") +const PlayerScript = preload("res://engine/src/entities/player.gd") + +## Unit circle radius (visual size on hex) +const UNIT_RADIUS: float = 28.0 +## Selection ring radius and width +const SELECTION_RADIUS: float = 36.0 +const SELECTION_WIDTH: float = 3.0 +const SELECTION_COLOR: Color = Color(1.0, 1.0, 0.0, 0.9) + +## Movement range overlay color +const MOVE_RANGE_COLOR: Color = Color(0.2, 0.4, 0.9, 0.25) +const MOVE_RANGE_BORDER_COLOR: Color = Color(0.3, 0.5, 1.0, 0.5) + +## Combat type to marker label mapping for placeholder rendering. +const COMBAT_TYPE_LABELS: Dictionary = { + "founder": "F", + "worker": "W", + "ranged": "R", + "siege": "S", + "flying": "^", + "melee": "M", + "cavalry": "C", + "naval": "N", + "civilian": "V", +} + +## Unit display data: unit_id -> { "position": Vector2i, "color": Color, "label": String } +var _units: Dictionary = {} + +## Currently selected unit ID ("" = none) +var _selected_id: String = "" + +## Movement range positions: Vector2i -> cost (empty = no overlay shown) +var _movement_range: Dictionary = {} + +## Animation pixel overrides: unit_id -> Vector2 (mid-tween position) +var _anim_pixels: Dictionary = {} + +## Unit sprite cache: type_id -> Texture2D (null if not available) +var _unit_sprite_cache: Dictionary = {} + +## Pre-computed hex polygon for movement range overlay +var _hex_poly: PackedVector2Array = HexUtilsScript.hex_polygon + + +func _ready() -> void: + EventBus.unit_moved.connect(_on_unit_moved) + EventBus.unit_created.connect(_on_unit_created) + EventBus.unit_destroyed.connect(_on_unit_destroyed) + + +func sync_units(units: Array) -> void: + ## Rebuild the full unit display from an array of Unit objects. + _units.clear() + _anim_pixels.clear() + for raw_unit: RefCounted in units: + var unit: UnitScript = raw_unit as UnitScript + if unit == null: + continue + var uid: String = _resolve_unit_id(unit) + if uid == "": + continue + var tid: String = _resolve_type_id(unit) + _units[uid] = { + "position": unit.position, + "color": _get_unit_color(unit), + "label": _get_unit_label(unit), + "type_id": tid, + } + _cache_unit_sprite(tid) + queue_redraw() + + +func animate_move(unit_id: String, from: Vector2i, to: Vector2i) -> void: + ## Animate a unit moving between hexes. Updates position after tween completes. + if not _units.has(unit_id): + return + _units[unit_id]["position"] = to + + var from_pixel: Vector2 = HexUtilsScript.axial_to_pixel(from) + HexUtilsScript.hex_center + var to_pixel: Vector2 = HexUtilsScript.axial_to_pixel(to) + HexUtilsScript.hex_center + + _anim_pixels[unit_id] = from_pixel + var tween: Tween = create_tween() + var uid_capture: String = unit_id + tween.tween_method( + func(p: Vector2) -> void: + _anim_pixels[uid_capture] = p + queue_redraw(), + from_pixel, + to_pixel, + 0.3, + ) + tween.tween_callback( + func() -> void: + _anim_pixels.erase(uid_capture) + queue_redraw() + ) + + +func set_selected(unit_id: String, selected: bool) -> void: + ## Show or hide selection ring on a unit. + if selected: + _selected_id = unit_id + elif _selected_id == unit_id: + _selected_id = "" + queue_redraw() + + +func show_movement_range(reachable: Dictionary) -> void: + ## Show movement range overlay. reachable: Vector2i -> cost. + _movement_range = reachable + queue_redraw() + + +func clear_movement_range() -> void: + _movement_range.clear() + queue_redraw() + + +func _draw() -> void: + # Draw movement range overlay (behind units) + _draw_movement_range() + + # Draw units + for uid: String in _units: + var data: Dictionary = _units[uid] + var pixel: Vector2 = Vector2.ZERO + + # Use animation pixel if mid-tween, otherwise compute from position + if _anim_pixels.has(uid): + pixel = _anim_pixels[uid] + else: + var pos: Vector2i = data["position"] + pixel = HexUtilsScript.axial_to_pixel(pos) + HexUtilsScript.hex_center + + # Try sprite first, fall back to colored circle + var type_id: String = data.get("type_id", "") + var sprite: Texture2D = _get_unit_sprite(type_id) + if sprite != null: + var tex_size: Vector2 = sprite.get_size() + draw_texture(sprite, pixel - tex_size * 0.5) + else: + var color: Color = data.get("color", Color.WHITE) + draw_circle(pixel, UNIT_RADIUS, color) + + var label_text: String = data.get("label", "?") + var font: Font = ThemeDB.fallback_font + var font_size: int = 18 + var text_size: Vector2 = font.get_string_size( + label_text, HORIZONTAL_ALIGNMENT_CENTER, -1, font_size + ) + var text_pos: Vector2 = ( + pixel - text_size * 0.5 + Vector2(0, text_size.y * 0.35) + ) + draw_string( + font, text_pos, label_text, + HORIZONTAL_ALIGNMENT_LEFT, -1, font_size, Color.WHITE, + ) + + # Draw selection ring + if uid == _selected_id: + draw_arc(pixel, SELECTION_RADIUS, 0.0, TAU, 32, SELECTION_COLOR, SELECTION_WIDTH) + + +func _draw_movement_range() -> void: + if _movement_range.is_empty(): + return + for pos_key: Vector2i in _movement_range: + var pixel: Vector2 = HexUtilsScript.axial_to_pixel(pos_key) + # Draw filled hex overlay + var points: PackedVector2Array = PackedVector2Array() + points.resize(_hex_poly.size()) + for i: int in _hex_poly.size(): + points[i] = _hex_poly[i] + pixel + draw_colored_polygon(points, MOVE_RANGE_COLOR) + # Draw border + var border: PackedVector2Array = PackedVector2Array() + border.resize(_hex_poly.size() + 1) + for i: int in _hex_poly.size(): + border[i] = _hex_poly[i] + pixel + border[_hex_poly.size()] = _hex_poly[0] + pixel + draw_polyline(border, MOVE_RANGE_BORDER_COLOR, 2.0) + + +## -- Unit data accessors -- + +func _get_unit_color(unit: UnitScript) -> Color: + ## Get the player color for this unit. + var player: RefCounted = GameState.get_player(unit.owner) + if player != null: + var p: PlayerScript = player as PlayerScript + if p != null: + return p.color + return Color(0.6, 0.6, 0.6) + + +func _get_unit_label(unit: UnitScript) -> String: + ## Get a marker label based on the unit's combat type. + ## Falls back to first character of unit_id if combat type is unknown. + var data: Dictionary = DataLoader.get_unit(unit.unit_id) + var combat_type: String = data.get("combat_type", "") + if COMBAT_TYPE_LABELS.has(combat_type): + return COMBAT_TYPE_LABELS[combat_type] + if unit.unit_id.length() > 0: + return unit.unit_id[0].to_upper() + return "?" + + +func _resolve_unit_id(unit: UnitScript) -> String: + ## Return the instance identifier for a unit. + ## Checks 'id' first (if set by world_map), falls back to 'unit_id'. + if "id" in unit and unit.get("id") != "": + return str(unit.get("id")) + return unit.unit_id + + +func _resolve_type_id(unit: UnitScript) -> String: + ## Return the type identifier for sprite/label lookup. + ## Checks 'type_id' first, falls back to 'unit_id'. + if "type_id" in unit and unit.get("type_id") != "": + return str(unit.get("type_id")) + return unit.unit_id + + +func _cache_unit_sprite(type_id: String) -> void: + ## Pre-cache a unit sprite via ThemeAssets. Caches null on miss. + if type_id == "" or _unit_sprite_cache.has(type_id): + return + var texture: Texture2D = ThemeAssets.load_sprite( + "sprites/units/%s.png" % type_id + ) + _unit_sprite_cache[type_id] = texture + + +func _get_unit_sprite(type_id: String) -> Texture2D: + ## Return cached unit sprite or null if unavailable. + if type_id == "": + return null + if _unit_sprite_cache.has(type_id): + return _unit_sprite_cache[type_id] + _cache_unit_sprite(type_id) + return _unit_sprite_cache.get(type_id) + + +## -- Signal handlers -- + +func _on_unit_moved( + unit: RefCounted, from: Vector2i, to: Vector2i +) -> void: + var u: UnitScript = unit as UnitScript + if u == null: + return + var uid: String = _resolve_unit_id(u) + if uid == "" or not _units.has(uid): + return + animate_move(uid, from, to) + + +func _on_unit_created(unit: RefCounted, _player_index: int) -> void: + var u: UnitScript = unit as UnitScript + if u == null: + return + var uid: String = _resolve_unit_id(u) + if uid == "": + return + var tid: String = _resolve_type_id(u) + _units[uid] = { + "position": u.position, + "color": _get_unit_color(u), + "label": _get_unit_label(u), + "type_id": tid, + } + _cache_unit_sprite(tid) + queue_redraw() + + +func _on_unit_destroyed(unit: RefCounted, _killer: RefCounted) -> void: + var u: UnitScript = unit as UnitScript + if u == null: + return + var uid: String = _resolve_unit_id(u) + if uid == "" or not _units.has(uid): + return + _units.erase(uid) + _anim_pixels.erase(uid) + if _selected_id == uid: + _selected_id = "" + _movement_range.clear() + queue_redraw()