feat(unit-system): ✨ Add new unit types and effects, improve unit AI logic, and optimize rendering performance
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
2ba740d0e6
commit
142fcc85b0
3 changed files with 23 additions and 619 deletions
|
|
@ -139,6 +139,7 @@ static func roll_fauna_drops(
|
|||
return
|
||||
var loot_table: Array = fauna.get("loot_table", [])
|
||||
var seed: int = _mix(turn_seed, killer_id, victim_id)
|
||||
var all_drops: Array = []
|
||||
if loot_table.size() > 0:
|
||||
var roller: RefCounted = ClassDB.instantiate("GdLootRoller") as RefCounted
|
||||
if roller != null:
|
||||
|
|
@ -146,6 +147,11 @@ static func roll_fauna_drops(
|
|||
var drops: Array = roller.call("roll_loot_table", table_json, seed)
|
||||
for drop: Dictionary in drops:
|
||||
_apply_stockpile_drop(killer_player, drop)
|
||||
all_drops.append(drop)
|
||||
else:
|
||||
all_drops.append_array(_fallback_roll(loot_table, seed))
|
||||
for drop: Dictionary in all_drops:
|
||||
_apply_stockpile_drop(killer_player, drop)
|
||||
var items_json: String = _items_json()
|
||||
if items_json != "":
|
||||
var roller2: RefCounted = ClassDB.instantiate("GdLootRoller") as RefCounted
|
||||
|
|
@ -154,6 +160,9 @@ static func roll_fauna_drops(
|
|||
"roll_apex_relic", victim_species_id, items_json, seed)
|
||||
for item_id: String in relics:
|
||||
_apply_apex_relic(killer_player, item_id)
|
||||
all_drops.append({"item": item_id, "amount": 1})
|
||||
if all_drops.size() > 0:
|
||||
EventBus.loot_dropped.emit(killer_player, victim_species_id, all_drops)
|
||||
|
||||
|
||||
static func _mix(turn_seed: int, killer_id: int, victim_id: int) -> int:
|
||||
|
|
@ -184,6 +193,20 @@ static func _items_json() -> String:
|
|||
return JSON.stringify(items)
|
||||
|
||||
|
||||
static func _fallback_roll(loot_table: Array, seed_val: int) -> Array:
|
||||
var rng: RandomNumberGenerator = RandomNumberGenerator.new()
|
||||
rng.seed = seed_val
|
||||
var result: Array = []
|
||||
for entry: Dictionary in loot_table:
|
||||
var chance: float = float(entry.get("chance", 0.0))
|
||||
if rng.randf() < chance:
|
||||
result.append({
|
||||
"resource": entry.get("resource", ""),
|
||||
"amount": int(entry.get("amount", 1)),
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
static func _apply_stockpile_drop(player: RefCounted, drop: Dictionary) -> void:
|
||||
if player == null:
|
||||
return
|
||||
|
|
|
|||
|
|
@ -1,322 +0,0 @@
|
|||
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))
|
||||
|
|
@ -1,297 +0,0 @@
|
|||
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()
|
||||
Loading…
Add table
Reference in a new issue