feat(unit-specific): Introduce UnitManager and UnitRenderer for unit lifecycle management and visual rendering, plus update Tile logic for unit placement and load unit data from data_loader.gd

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-04-09 11:56:38 -07:00
parent 45137691bc
commit a3de8efdd5
4 changed files with 139 additions and 21 deletions

View file

@ -205,6 +205,13 @@ func _log_load_summary() -> void:
total += count
print("DataLoader: Loaded %d entries from theme '%s'" % [total, _active_theme])
# -- Bulk category access --
func get_data(category: String) -> Dictionary:
## Return the full id→entry dictionary for a data category.
## Returns an empty dictionary for unknown or empty categories.
return _data.get(category, {})
# -- Single-item lookups --
func get_terrain(id: String) -> Dictionary:

View file

@ -6,7 +6,13 @@ extends Resource
const ImprovementScript: GDScript = preload("res://engine/src/entities/improvement.gd")
const TileSerializerScript: GDScript = preload("res://engine/src/map/tile_serializer.gd")
var biome_id: String = "" # biome classification result
var biome_id: String = "": # biome classification result
set(value):
if value == null:
push_warning("Tile: null biome_id assignment at %s, defaulting to 'plains'" % str(position))
biome_id = "plains"
else:
biome_id = value
var substrate_id: String = "" # geological substrate from elevation
var water_body_id: int = -1 # water body index (-1 if land)
var water_body_type: String = "" # pond/river/lake/large_lake/ocean

View file

@ -1,2 +1,60 @@
class_name UnitManager
extends RefCounted
## Manages per-turn unit lifecycle: movement refresh, vision calculation,
## and unit queries for the active player.
const HexUtilsScript: GDScript = preload("res://engine/src/map/hex_utils.gd")
const PathfinderScript: GDScript = preload("res://engine/src/map/pathfinder.gd")
const PlayerScript: GDScript = preload("res://engine/src/entities/player.gd")
const UnitScript: GDScript = preload("res://engine/src/entities/unit.gd")
const GameMapScript: GDScript = preload("res://engine/src/map/game_map.gd")
const TileScript: GDScript = preload("res://engine/src/map/tile.gd")
## Default vision radius when a unit's JSON data has no "vision" field.
const DEFAULT_VISION_RADIUS: int = 2
func refresh_player_units(player: RefCounted) -> void:
## Reset per-turn state for all units owned by this player.
## Called at the start of each player's turn by TurnManager.
for raw_unit: RefCounted in player.units:
var unit: UnitScript = raw_unit as UnitScript
if unit != null:
unit.refresh_turn()
func recalculate_vision(player: RefCounted, game_map: RefCounted) -> void:
## Update tile visibility for this player based on unit positions.
## Tiles previously visible become fog-of-war (1); tiles in range
## of any unit become currently visible (2).
var gm: GameMapScript = game_map as GameMapScript
if gm == null:
return
_dim_current_vision(player, gm)
for raw_unit: RefCounted in player.units:
var unit: UnitScript = raw_unit as UnitScript
if unit == null:
continue
var vision_radius: int = _get_unit_vision(unit)
var visible_tiles: Array[Vector2i] = PathfinderScript.visible_hexes(
gm, unit.position, vision_radius
)
for pos: Vector2i in visible_tiles:
var tile: TileScript = gm.get_tile(pos) as TileScript
if tile != null:
tile.set_visibility(player.index, 2)
func _dim_current_vision(player: RefCounted, game_map: GameMapScript) -> void:
## Downgrade all currently-visible (2) tiles to fog-of-war (1).
for axial: Vector2i in game_map.tiles:
var tile: TileScript = game_map.tiles[axial] as TileScript
if tile != null and tile.get_visibility(player.index) == 2:
tile.set_visibility(player.index, 1)
func _get_unit_vision(unit: UnitScript) -> int:
## Read the vision radius from the unit's JSON data.
## Falls back to DEFAULT_VISION_RADIUS when the field is absent.
var data: Dictionary = DataLoader.get_unit(unit.unit_id)
return data.get("vision", DEFAULT_VISION_RADIUS)

View file

@ -18,6 +18,19 @@ const SELECTION_COLOR: Color = Color(1.0, 1.0, 0.0, 0.9)
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 = {
"settler": "F", "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 = {}
@ -51,15 +64,17 @@ func sync_units(units: Array) -> void:
var unit: UnitScript = raw_unit as UnitScript
if unit == null:
continue
if unit.id == "":
var uid: String = _resolve_unit_id(unit)
if uid == "":
continue
_units[unit.id] = {
var tid: String = _resolve_type_id(unit)
_units[uid] = {
"position": unit.position,
"color": _get_unit_color(unit),
"label": _get_unit_label(unit),
"type_id": unit.type_id,
"type_id": tid,
}
_cache_unit_sprite(unit.type_id)
_cache_unit_sprite(tid)
queue_redraw()
@ -188,12 +203,33 @@ func _get_unit_color(unit: UnitScript) -> Color:
func _get_unit_label(unit: UnitScript) -> String:
## Get a short label for the unit (first character of type_id, uppercased).
if unit.type_id.length() > 0:
return unit.type_id[0].to_upper()
## 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):
@ -219,7 +255,10 @@ func _get_unit_sprite(type_id: String) -> Texture2D:
func _on_unit_moved(
unit: RefCounted, from: Vector2i, to: Vector2i
) -> void:
var uid: String = unit.id if "id" in unit else ""
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)
@ -227,24 +266,32 @@ func _on_unit_moved(
func _on_unit_created(unit: RefCounted, _player_index: int) -> void:
var u: UnitScript = unit as UnitScript
if u == null or u.id == "":
if u == null:
return
_units[u.id] = {
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": u.type_id,
"type_id": tid,
}
_cache_unit_sprite(u.type_id)
_cache_unit_sprite(tid)
queue_redraw()
func _on_unit_destroyed(unit: RefCounted, _killer: RefCounted) -> void:
var uid: String = unit.id if "id" in unit else ""
if uid != "" and _units.has(uid):
_units.erase(uid)
_anim_pixels.erase(uid)
if _selected_id == uid:
_selected_id = ""
_movement_range.clear()
queue_redraw()
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()