From 7dd049df13eadd3f8e9f0d1ea729461538b3c7e4 Mon Sep 17 00:00:00 2001 From: Natalie Date: Fri, 19 Jun 2026 18:19:59 -0500 Subject: [PATCH] =?UTF-8?q?refactor(@projects/@magic-civilization):=20?= =?UTF-8?q?=F0=9F=94=A5=20cull=2013=20dead=20.gd=20files=20(~3,135=20LOC)?= =?UTF-8?q?=20=E2=80=94=20orphaned=20during=20atomic=20rebuild?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Statically unreachable (no .tscn ext_resource, no preload/load-by-path, no class_name usage, no dynamic string-built loads) + clean headless boot verifies no load-time breakage. Confirmed dead, with what superseded each: - selection_manager.gd (482) — selection handled by event_bus/unit/player/ turn_processor_helpers; movement_animator.gd (67) used only by it (dead cluster) - hex_overlay_renderer.gd (475) — superseded by overlay_renderer.gd; only comment mentions remained - weather_events.gd / WeatherEvents (474) — weather is Rust-side; sole "ref" was a climate.gd config dict-key "weather_events": true, never the class - indicator_renderer (295), river_renderer (121), road_renderer (88) — superseded by procedural_renderer / overlay suite; zero engine refs - ecosystem_simplified (292), fauna_simplified (124) — prototype variants; live fauna.gd is the real one - atmosphere_chemistry (254), water_body_finder (197), map_loader (133), pending_actions (133) — orphaned helpers, zero engine refs Co-Authored-By: Claude Opus 4.8 (1M context) --- src/game/engine/src/core/pending_actions.gd | 133 ----- src/game/engine/src/map/map_loader.gd | 133 ----- src/game/engine/src/map/selection_manager.gd | 482 ------------------ .../modules/climate/atmosphere_chemistry.gd | 254 --------- .../src/modules/climate/weather_events.gd | 474 ----------------- .../modules/ecology/ecosystem_simplified.gd | 292 ----------- .../src/modules/ecology/fauna_simplified.gd | 124 ----- .../src/modules/ecology/water_body_finder.gd | 197 ------- .../src/rendering/hex_overlay_renderer.gd | 475 ----------------- .../src/rendering/indicator_renderer.gd | 295 ----------- .../engine/src/rendering/movement_animator.gd | 67 --- .../engine/src/rendering/river_renderer.gd | 121 ----- .../engine/src/rendering/road_renderer.gd | 88 ---- 13 files changed, 3135 deletions(-) delete mode 100644 src/game/engine/src/core/pending_actions.gd delete mode 100644 src/game/engine/src/map/map_loader.gd delete mode 100644 src/game/engine/src/map/selection_manager.gd delete mode 100644 src/game/engine/src/modules/climate/atmosphere_chemistry.gd delete mode 100644 src/game/engine/src/modules/climate/weather_events.gd delete mode 100644 src/game/engine/src/modules/ecology/ecosystem_simplified.gd delete mode 100644 src/game/engine/src/modules/ecology/fauna_simplified.gd delete mode 100644 src/game/engine/src/modules/ecology/water_body_finder.gd delete mode 100644 src/game/engine/src/rendering/hex_overlay_renderer.gd delete mode 100644 src/game/engine/src/rendering/indicator_renderer.gd delete mode 100644 src/game/engine/src/rendering/movement_animator.gd delete mode 100644 src/game/engine/src/rendering/river_renderer.gd delete mode 100644 src/game/engine/src/rendering/road_renderer.gd diff --git a/src/game/engine/src/core/pending_actions.gd b/src/game/engine/src/core/pending_actions.gd deleted file mode 100644 index 38585881..00000000 --- a/src/game/engine/src/core/pending_actions.gd +++ /dev/null @@ -1,133 +0,0 @@ -class_name PendingActions -extends RefCounted -## Scans player state and produces an ordered list of pending actions for the turn. -## Pure data — no UI, no signals, no side effects. Stateless utility. -## -## Each action dict: { "type": ActionType, "position": Vector2i, "label_key": String, -## "target": Variant } -## The list always ends with an END_TURN entry. -## label_key values route through ThemeVocabulary for display string resolution. - -enum ActionType { - IDLE_UNIT, - EMPTY_QUEUE, - NO_RESEARCH, - END_TURN, -} - -const UnitScript: GDScript = preload("res://engine/src/entities/unit.gd") -const CityScript: GDScript = preload("res://engine/src/entities/city.gd") - -const ACTION_VOCAB_KEYS: Dictionary = { - ActionType.IDLE_UNIT: "action_idle_unit", - ActionType.EMPTY_QUEUE: "action_empty_queue", - ActionType.NO_RESEARCH: "action_no_research", - ActionType.END_TURN: "action_end_turn", -} - - -static func scan(player: RefCounted) -> Array[Dictionary]: - ## Scan a player's state and return an ordered array of pending action dicts. - ## The list always ends with an END_TURN entry. - var actions: Array[Dictionary] = [] - - if player == null: - actions.append(_make_end_turn()) - return actions - - _scan_idle_units(player, actions) - _scan_empty_production(player, actions) - _scan_no_research(player, actions) - actions.append(_make_end_turn()) - return actions - - -static func pending_count(actions: Array[Dictionary]) -> int: - ## Count actionable items (excludes the terminal END_TURN entry). - var count: int = 0 - for action: Dictionary in actions: - if action.get("type", ActionType.END_TURN) != ActionType.END_TURN: - count += 1 - return count - - -# -- Private scanners -- - - -static func _scan_idle_units(player: RefCounted, actions: Array[Dictionary]) -> void: - ## Flag units with full movement, not fortified, and not yet attacked this turn. - for unit: UnitScript in player.get("units") if player.get("units") != null else []: - if not unit is UnitScript: - continue - var u: UnitScript = unit as UnitScript - if u.fortified_turns > 0: - continue - if u.has_attacked: - continue - if u.movement_remaining < u.get_movement(): - continue - actions.append({ - "type": ActionType.IDLE_UNIT, - "position": u.position, - "label_key": ACTION_VOCAB_KEYS[ActionType.IDLE_UNIT], - "target": u, - }) - - -static func _scan_empty_production(player: RefCounted, actions: Array[Dictionary]) -> void: - ## Flag cities whose production queue is empty. - for city: CityScript in player.get("cities") if player.get("cities") != null else []: - if not city is CityScript: - continue - var c: CityScript = city as CityScript - if c.construction_queue.is_empty(): - actions.append({ - "type": ActionType.EMPTY_QUEUE, - "position": c.position, - "label_key": ACTION_VOCAB_KEYS[ActionType.EMPTY_QUEUE], - "target": c, - }) - - -static func _scan_no_research(player: RefCounted, actions: Array[Dictionary]) -> void: - ## Flag when the player has no active research and techs remain available. - var researching: String = player.get("researching") if player.get("researching") != null else "" - if researching != "": - return - - var all_techs: Array = DataLoader.get_all_techs() - var has_tr: bool = player.get("techs_researched") != null - var techs_researched: Array = player.get("techs_researched") as Array if has_tr else [] - var has_unresearched: bool = false - for tech: Dictionary in all_techs: - var tech_id: String = tech.get("id", "") - if tech_id != "" and tech_id not in techs_researched: - has_unresearched = true - break - - if not has_unresearched: - return - - ## Center on capital city if available, otherwise map origin. - var pos: Vector2i = Vector2i.ZERO - var cities: Array = player.get("cities") if player.get("cities") != null else [] - for city: CityScript in cities: - if city is CityScript and (city as CityScript).is_capital: - pos = (city as CityScript).position - break - - actions.append({ - "type": ActionType.NO_RESEARCH, - "position": pos, - "label_key": ACTION_VOCAB_KEYS[ActionType.NO_RESEARCH], - "target": null, - }) - - -static func _make_end_turn() -> Dictionary: - return { - "type": ActionType.END_TURN, - "position": Vector2i.ZERO, - "label_key": ACTION_VOCAB_KEYS[ActionType.END_TURN], - "target": null, - } diff --git a/src/game/engine/src/map/map_loader.gd b/src/game/engine/src/map/map_loader.gd deleted file mode 100644 index 6a044d30..00000000 --- a/src/game/engine/src/map/map_loader.gd +++ /dev/null @@ -1,133 +0,0 @@ -class_name MapLoader -extends RefCounted -## Loads preset maps from game pack data and integrates with the map generation pipeline. -## -## Preset map configs are stored in setup.json under the "map_presets" array. -## Hand-authored tile maps live at: -## res://public/games/{game_id}/data/preset_maps/{preset_id}.json -## -## The compact tile format uses a column-ordered array to minimise file size. -## SaveManager owns game state persistence — this file handles preset loading only. - -const GameMapScript: GDScript = preload("res://engine/src/map/game_map.gd") -const TileScript: GDScript = preload("res://engine/src/map/tile.gd") - -const DEFAULT_GAME_ID: String = "age-of-dwarves" - - -static func list_presets(game_id: String = DEFAULT_GAME_ID) -> Array[Dictionary]: - ## Return metadata for all map presets defined in setup.json's "map_presets" array. - ## Returns an empty array if the key is absent or setup.json cannot be read. - var setup_path: String = "res://public/games/%s/data/setup.json" % game_id - var file: FileAccess = FileAccess.open(setup_path, FileAccess.READ) - if file == null: - push_warning( - "MapLoader: Cannot open %s — %s" - % [setup_path, error_string(FileAccess.get_open_error())] - ) - return [] - - var json: JSON = JSON.new() - var err: Error = json.parse(file.get_as_text()) - file.close() - if err != OK: - push_error( - "MapLoader: JSON parse error in %s line %d: %s" - % [setup_path, json.get_error_line(), json.get_error_message()] - ) - return [] - - if not json.data is Dictionary: - push_error("MapLoader: setup.json root must be a Dictionary in '%s'" % setup_path) - return [] - - var root: Dictionary = json.data as Dictionary - if not root.has("map_presets") or not root["map_presets"] is Array: - return [] - - var result: Array[Dictionary] = [] - for entry: Dictionary in (root["map_presets"] as Array): - result.append(entry) - return result - - -static func load_preset(preset_id: String, game_id: String = DEFAULT_GAME_ID) -> RefCounted: - ## Load a hand-authored preset map by ID. - ## File path: res://public/games/{game_id}/data/preset_maps/{preset_id}.json - ## Returns a populated GameMap on success, or null on failure. - if preset_id.is_empty(): - push_error("MapLoader: load_preset called with empty preset_id") - return null - - var file_path: String = ( - "res://public/games/%s/data/preset_maps/%s.json" % [game_id, preset_id] - ) - var file: FileAccess = FileAccess.open(file_path, FileAccess.READ) - if file == null: - push_error( - "MapLoader: Cannot open preset file %s — %s" - % [file_path, error_string(FileAccess.get_open_error())] - ) - return null - - var json: JSON = JSON.new() - var err: Error = json.parse(file.get_as_text()) - file.close() - if err != OK: - push_error( - "MapLoader: JSON parse error in %s line %d: %s" - % [file_path, json.get_error_line(), json.get_error_message()] - ) - return null - - if not json.data is Dictionary: - push_error("MapLoader: Preset file %s root must be a JSON object" % file_path) - return null - - var data: Dictionary = json.data as Dictionary - var width: int = int(data.get("width", 0)) - var height: int = int(data.get("height", 0)) - if width <= 0 or height <= 0: - push_error( - "MapLoader: Preset '%s' has invalid dimensions %dx%d" % [preset_id, width, height] - ) - return null - - var wrap_mode: int = int(data.get("wrap_mode", 2)) - var game_map: RefCounted = GameMapScript.new() - game_map.initialize(width, height, wrap_mode) - - var default_format: Array = ["q", "r", "terrain_id", "resource_id", "elevation"] - var tile_format: Array = data.get("tile_format", default_format) as Array - var q_idx: int = tile_format.find("q") - var r_idx: int = tile_format.find("r") - var terrain_idx: int = tile_format.find("terrain_id") - var resource_idx: int = tile_format.find("resource_id") - var elevation_idx: int = tile_format.find("elevation") - - for entry: Array in (data.get("tiles", []) as Array): - if entry.size() == 0: - push_warning("MapLoader: Skipping empty tile entry in '%s'" % file_path) - continue - - var q: int = int(entry[q_idx]) if q_idx >= 0 and q_idx < entry.size() else 0 - var r: int = int(entry[r_idx]) if r_idx >= 0 and r_idx < entry.size() else 0 - var axial: Vector2i = Vector2i(q, r) - - var tile: Resource = TileScript.new(axial) - if terrain_idx >= 0 and terrain_idx < entry.size(): - tile.biome_id = str(entry[terrain_idx]) - if resource_idx >= 0 and resource_idx < entry.size(): - var res_val: String = str(entry[resource_idx]) - if res_val != "" and res_val != "null": - tile.resource_id = res_val - if elevation_idx >= 0 and elevation_idx < entry.size(): - tile.elevation = float(entry[elevation_idx]) - - game_map.set_tile(axial, tile) - - for sp: Array in (data.get("start_positions", []) as Array): - if sp.size() >= 2: - game_map.start_positions.append(Vector2i(int(sp[0]), int(sp[1]))) - - return game_map diff --git a/src/game/engine/src/map/selection_manager.gd b/src/game/engine/src/map/selection_manager.gd deleted file mode 100644 index fb4a1213..00000000 --- a/src/game/engine/src/map/selection_manager.gd +++ /dev/null @@ -1,482 +0,0 @@ -extends Node2D -## Manages tile and unit selection, movement range overlay, path preview, and -## movement/attack commands on the world map. -## -## Left-click → select unit on tile (or deselect). -## Right-click → route to attack (if valid military target in range) or movement. -## -## Injected from WorldMap via initialize(). No direct scene-tree references. - -const UnitScript: GDScript = preload("res://engine/src/entities/unit.gd") -const PlayerScript: GDScript = preload("res://engine/src/entities/player.gd") -const CityScript: GDScript = preload("res://engine/src/entities/city.gd") -const HexUtilsScript: GDScript = preload("res://engine/src/map/hex_utils.gd") -const PathfinderScript: GDScript = preload("res://engine/src/map/pathfinder.gd") -const CombatResolverScript: GDScript = preload("res://engine/src/modules/combat/combat_resolver.gd") - -const MOVE_RANGE_COLOR: Color = Color(0.2, 0.4, 0.9, 0.25) -const PATH_PREVIEW_COLOR: Color = Color(0.9, 0.9, 0.2, 0.4) -const SELECTION_COLOR: Color = Color(1.0, 0.85, 0.0, 0.9) -const SELECTION_WIDTH: float = 3.5 -const SELECTION_GLOW_COLOR: Color = Color(1.0, 0.85, 0.0, 0.45) -const SELECTION_GLOW_WIDTH: float = 6.0 - -## Currently selected unit. Stored as RefCounted — cast to UnitScript where needed. -var selected_unit: RefCounted = null - -## Currently hovered axial position. -var hovered_tile: Vector2i = Vector2i(-9999, -9999) - -## Optional UI nodes injected from WorldMap. Node is the correct base — these are -## scene instances of varying types; callers use has_method() before calling. -var combat_preview_ui: Node = null -var combat_result_ui: Node = null -var promotion_picker_ui: Node = null - -## Dependencies injected via initialize(). -var _game_map: RefCounted = null -var _unit_renderer: Node = null -var _movement_animator: Node = null -var _selection_layer: Node2D = null -var _overlay_layer: Node2D = null - -## Overlay nodes managed by this class. -var _range_overlays: Array[Polygon2D] = [] -var _path_overlays: Array[Polygon2D] = [] -var _selection_outline: Line2D = null - -## Reachable tiles for the currently selected unit. -var _reachable_tiles: Array[Vector2i] = [] - -## Pending combat pair — populated when waiting for preview confirmation. -var _pending_attacker: RefCounted = null -var _pending_defender: RefCounted = null - -var _combat_resolver: CombatResolverScript = null - - -func initialize( - game_map: RefCounted, - unit_renderer: Node, - movement_animator: Node, - selection_layer: Node2D, - overlay_layer: Node2D, -) -> void: - ## Wire dependencies. Called by WorldMap after all nodes are ready. - _game_map = game_map - _unit_renderer = unit_renderer - _movement_animator = movement_animator - _selection_layer = selection_layer - _overlay_layer = overlay_layer - _combat_resolver = CombatResolverScript.new() - - EventBus.tile_clicked.connect(_on_tile_clicked) - - -func handle_right_click(axial: Vector2i) -> void: - ## Called by WorldMap when a right-click occurs on a valid tile. - ## Routes to attack or movement depending on what occupies the target. - if selected_unit == null or _game_map == null: - return - if _try_attack(axial): - return - _try_move(axial) - - -func _try_attack(axial: Vector2i) -> bool: - ## Attempt to initiate an attack on the target tile. Returns true if combat was initiated. - if not selected_unit is UnitScript: - return false - - var attacker: UnitScript = selected_unit as UnitScript - if not attacker.is_military() or not attacker.can_attack(): - return false - - var player_idx: int = GameState.current_player_index - var target_unit: RefCounted = _find_enemy_unit_at(axial, player_idx) - var target_city: RefCounted = _find_enemy_city_at(axial, player_idx) - - if target_unit == null and target_city == null: - return false - - var defender: RefCounted = target_unit if target_unit != null else target_city - var dist: int = HexUtilsScript.hex_distance(_get_unit_position(attacker), axial) - if dist > attacker.get_range(): - return false - - _initiate_combat(attacker, defender) - return true - - -func _try_move(axial: Vector2i) -> void: - ## Attempt to move the selected unit to the target tile. - if axial not in _reachable_tiles: - return - - var unit_pos: Vector2i = _get_unit_position(selected_unit) - var is_flying: bool = _is_unit_flying(selected_unit) - var budget: int = _get_movement_remaining(selected_unit) - - var pathfinder: RefCounted = PathfinderScript.new(_game_map) - var path: Array[Vector2i] = pathfinder.find_path(unit_pos, axial, budget, is_flying) - - if path.size() >= 2: - _execute_movement(path) - - -func _initiate_combat(attacker: RefCounted, defender: RefCounted) -> void: - ## Show combat preview when available, otherwise resolve immediately. - _pending_attacker = attacker - _pending_defender = defender - - if combat_preview_ui != null and combat_preview_ui.has_method("show_preview"): - var all_units: Array = _get_all_units() - combat_preview_ui.show_preview(attacker, defender, _game_map, all_units) - else: - _execute_combat(attacker, defender) - - -func _execute_combat(attacker: RefCounted, defender: RefCounted) -> void: - ## Run combat resolution and dispatch result signals/UI. - var all_units: Array = _get_all_units() - var result: Dictionary = _combat_resolver.resolve(attacker, defender, _game_map, all_units) - - EventBus.combat_resolved.emit(attacker, defender, result) - - if attacker is UnitScript: - var a: UnitScript = attacker as UnitScript - if not a.has_keyword("vigilance"): - a.has_attacked = true - a.movement_remaining = 0 - if a.is_alive() and attacker == selected_unit: - show_movement_range(attacker) - - if combat_result_ui != null and combat_result_ui.has_method("show_result"): - combat_result_ui.show_result(attacker, defender, result) - - if attacker is UnitScript: - var a: UnitScript = attacker as UnitScript - var picker_ready: bool = ( - promotion_picker_ui != null and promotion_picker_ui.has_method("show_picker") - ) - if a.can_promote() and picker_ready: - promotion_picker_ui.show_picker(a) - - if defender is UnitScript: - var d: UnitScript = defender as UnitScript - var picker_ready_d: bool = ( - promotion_picker_ui != null and promotion_picker_ui.has_method("show_picker") - ) - if d.is_alive() and d.can_promote() and picker_ready_d: - promotion_picker_ui.show_picker(d) - - _pending_attacker = null - _pending_defender = null - - -func show_movement_range(unit: RefCounted) -> void: - ## Highlight all reachable tiles for the given unit. - _clear_range_overlays() - - if unit == null or _game_map == null: - return - - var unit_pos: Vector2i = _get_unit_position(unit) - var is_flying: bool = _is_unit_flying(unit) - var budget: int = _get_movement_remaining(unit) - - if budget <= 0: - _reachable_tiles = [] - return - - var pathfinder: RefCounted = PathfinderScript.new(_game_map) - _reachable_tiles = pathfinder.get_reachable_tiles(unit_pos, budget, is_flying) - _reachable_tiles.erase(unit_pos) - - for tile_pos: Vector2i in _reachable_tiles: - var overlay: Polygon2D = _create_hex_overlay(tile_pos, MOVE_RANGE_COLOR) - if _overlay_layer != null: - _overlay_layer.add_child(overlay) - else: - add_child(overlay) - _range_overlays.append(overlay) - - -func show_path_preview(from: Vector2i, to: Vector2i) -> void: - ## Show highlighted path tiles from a source to hover target. - _clear_path_overlays() - - if _game_map == null or selected_unit == null: - return - - var is_flying: bool = _is_unit_flying(selected_unit) - var budget: int = _get_movement_remaining(selected_unit) - - var pathfinder: RefCounted = PathfinderScript.new(_game_map) - var path: Array[Vector2i] = pathfinder.find_path(from, to, budget, is_flying) - - if path.size() < 2: - return - - for i: int in range(1, path.size()): - var overlay: Polygon2D = _create_hex_overlay(path[i], PATH_PREVIEW_COLOR) - if _overlay_layer != null: - _overlay_layer.add_child(overlay) - else: - add_child(overlay) - _path_overlays.append(overlay) - - -func clear_selection() -> void: - ## Deselect the current unit and clear all overlays. - if selected_unit != null: - selected_unit = null - EventBus.unit_deselected.emit() - - _clear_range_overlays() - _clear_path_overlays() - _clear_selection_outline() - _reachable_tiles = [] - - -func select_unit(unit: RefCounted) -> void: - ## Select a unit and show its movement range overlay. - if unit == selected_unit: - return - - clear_selection() - selected_unit = unit - - if unit == null: - return - - EventBus.unit_selected.emit(unit) - _show_selection_outline(_get_unit_position(unit)) - show_movement_range(unit) - - -func update_hover(axial: Vector2i) -> void: - ## Called when the hovered tile changes. Refreshes path preview. - if axial == hovered_tile: - return - hovered_tile = axial - - _clear_path_overlays() - - if selected_unit == null or axial not in _reachable_tiles: - return - - var unit_pos: Vector2i = _get_unit_position(selected_unit) - show_path_preview(unit_pos, axial) - - -# -- EventBus listeners -- - - -func _on_tile_clicked(axial: Vector2i) -> void: - var unit_on_tile: RefCounted = _find_player_unit_at(axial) - if unit_on_tile != null: - select_unit(unit_on_tile) - else: - clear_selection() - - -# -- Movement execution -- - - -func _execute_movement(path: Array[Vector2i]) -> void: - if selected_unit == null or _unit_renderer == null: - return - - var unit: RefCounted = selected_unit - - if not _unit_renderer.has_method("get_unit_node"): - push_error("SelectionManager: unit_renderer missing get_unit_node method") - return - var unit_node: Node2D = _unit_renderer.get_unit_node(unit) - if unit_node == null: - return - - var old_pos: Vector2i = _get_unit_position(unit) - var is_flying: bool = _is_unit_flying(unit) - var pathfinder: RefCounted = PathfinderScript.new(_game_map) - var cost: int = pathfinder.get_path_cost(path, is_flying) - if cost >= 0: - _set_movement_remaining(unit, maxi(0, _get_movement_remaining(unit) - cost)) - - var dest: Vector2i = path[-1] - _set_unit_position(unit, dest) - - _clear_range_overlays() - _clear_path_overlays() - _clear_selection_outline() - - if _movement_animator != null and _movement_animator.has_method("animate_movement"): - _movement_animator.animate_movement( - unit_node, path, _on_movement_complete.bind(unit, old_pos, dest) - ) - else: - if _unit_renderer.has_method("update_unit_position"): - _unit_renderer.update_unit_position(unit) - _on_movement_complete(unit, old_pos, dest) - - -func _on_movement_complete(unit: RefCounted, from: Vector2i, to: Vector2i) -> void: - EventBus.unit_moved.emit(unit, from, to) - if unit == selected_unit: - _show_selection_outline(to) - show_movement_range(unit) - - -# -- Entity search helpers -- - - -func _find_enemy_unit_at(axial: Vector2i, player_idx: int) -> RefCounted: - var primary_layer: Dictionary = GameState.get_primary_layer() - for unit: UnitScript in primary_layer.get("units", []): - if unit.owner != player_idx and unit.position == axial and unit.is_military(): - return unit - return null - - -func _find_enemy_city_at(axial: Vector2i, player_idx: int) -> RefCounted: - for player: PlayerScript in GameState.players: - if player.index == player_idx: - continue - for city: CityScript in player.cities: - if city.position == axial: - return city - return null - - -func _get_all_units() -> Array: - var primary_layer: Dictionary = GameState.get_primary_layer() - return primary_layer.get("units", []).duplicate() - - -func _find_player_unit_at(axial: Vector2i) -> RefCounted: - var current_idx: int = GameState.current_player_index - - for player: PlayerScript in GameState.players: - if player.index != current_idx: - continue - for unit: UnitScript in player.units: - if unit.position == axial: - return unit - - var primary_layer: Dictionary = GameState.get_primary_layer() - for unit: UnitScript in primary_layer.get("units", []): - if unit.owner == current_idx and unit.position == axial: - return unit - - return null - - -# -- Overlay rendering -- - - -func _show_selection_outline(axial: Vector2i) -> void: - _clear_selection_outline() - - var pixel_pos: Vector2 = HexUtilsScript.axial_to_pixel(axial) - - var glow: Line2D = Line2D.new() - glow.name = "SelectionGlow" - glow.position = pixel_pos - glow.points = HexUtilsScript.hex_polygon - glow.closed = true - glow.width = SELECTION_GLOW_WIDTH - glow.default_color = SELECTION_GLOW_COLOR - - _selection_outline = Line2D.new() - _selection_outline.name = "SelectionOutline" - _selection_outline.position = pixel_pos - _selection_outline.points = HexUtilsScript.hex_polygon - _selection_outline.closed = true - _selection_outline.width = SELECTION_WIDTH - _selection_outline.default_color = SELECTION_COLOR - - if _selection_layer != null: - _selection_layer.add_child(glow) - _selection_layer.add_child(_selection_outline) - else: - add_child(glow) - add_child(_selection_outline) - - -func _create_hex_overlay(axial: Vector2i, color: Color) -> Polygon2D: - var overlay: Polygon2D = Polygon2D.new() - overlay.polygon = HexUtilsScript.hex_polygon - overlay.color = color - overlay.position = HexUtilsScript.axial_to_pixel(axial) - return overlay - - -func _clear_range_overlays() -> void: - for overlay: Polygon2D in _range_overlays: - if is_instance_valid(overlay): - overlay.queue_free() - _range_overlays.clear() - - -func _clear_path_overlays() -> void: - for overlay: Polygon2D in _path_overlays: - if is_instance_valid(overlay): - overlay.queue_free() - _path_overlays.clear() - - -func _clear_selection_outline() -> void: - if _selection_outline != null and is_instance_valid(_selection_outline): - var parent: Node = _selection_outline.get_parent() - if parent != null: - var glow: Node = parent.get_node_or_null("SelectionGlow") - if glow != null: - glow.queue_free() - _selection_outline.queue_free() - _selection_outline = null - - -# -- Unit property accessors (handles both UnitScript and Dictionary-backed units) -- - - -func _get_unit_position(unit: RefCounted) -> Vector2i: - if unit is UnitScript: - return (unit as UnitScript).position - if unit is Dictionary: - var pos_raw: Dictionary = unit as Dictionary - if pos_raw.has("position") and pos_raw["position"] is Vector2i: - return pos_raw["position"] as Vector2i - return Vector2i.ZERO - - -func _set_unit_position(unit: RefCounted, pos: Vector2i) -> void: - if unit is UnitScript: - (unit as UnitScript).position = pos - elif unit is Dictionary: - (unit as Dictionary)["position"] = pos - - -func _is_unit_flying(unit: RefCounted) -> bool: - if unit is UnitScript: - return (unit as UnitScript).is_flying() - if unit is Dictionary: - var d: Dictionary = unit as Dictionary - return d.has("combat_type") and d["combat_type"] == "flying" - return false - - -func _get_movement_remaining(unit: RefCounted) -> int: - if unit is UnitScript: - return (unit as UnitScript).movement_remaining - if unit is Dictionary: - var d: Dictionary = unit as Dictionary - return d.get("movement_remaining", 0) as int - return 0 - - -func _set_movement_remaining(unit: RefCounted, value: int) -> void: - if unit is UnitScript: - (unit as UnitScript).movement_remaining = value - elif unit is Dictionary: - (unit as Dictionary)["movement_remaining"] = value diff --git a/src/game/engine/src/modules/climate/atmosphere_chemistry.gd b/src/game/engine/src/modules/climate/atmosphere_chemistry.gd deleted file mode 100644 index ece3b83e..00000000 --- a/src/game/engine/src/modules/climate/atmosphere_chemistry.gd +++ /dev/null @@ -1,254 +0,0 @@ -extends RefCounted -## Global atmospheric chemistry step — O2/CO2/CH4 mass balance, greenhouse forcing, -## and ecological collapse threshold logic. -## All functions are static and operate on a climate_state (ClimateBase subclass) -## plus the game_map and spec. Called by Climate._step_atmospheric_chemistry(). - - -static func process_step( - climate_state: Object, game_map: RefCounted, spec: Dictionary -) -> void: - ## Update global O2/CO2/CH4 concentrations from tile-level photosynthesis, - ## respiration, decomposition, and volcanic outgassing. - ## Stores greenhouse temperature bias in climate_state.global_temp_bias. - ## Checks ecological collapse thresholds and updates photosynthesis_multiplier. - var chem: Dictionary = spec.get("atmospheric_chemistry", {}) - var photo: Dictionary = chem.get("photosynthesis", {}) - var resp: Dictionary = chem.get("respiration", {}) - var greenhouse: Dictionary = chem.get("greenhouse", {}) - - var land_canopy_rate: float = photo.get("land_canopy_rate", 0.00004) - var ocean_phyto_rate: float = photo.get("ocean_phyto_rate", 0.00003) - var aerosol_suppression: float = photo.get("aerosol_suppression", 0.6) - var fauna_resp_rate: float = resp.get("fauna_rate", 0.000015) - var decomp_co2_rate: float = resp.get("decomp_co2_rate", 0.000008) - var decomp_ch4_rate: float = resp.get("decomp_ch4_rate", 0.000002) - var wetland_ch4_rate: float = greenhouse.get("wetland_ch4_rate", 0.000003) - var ch4_decay: float = greenhouse.get("ch4_decay_rate", 0.02) - - # Aerosol suppresses photosynthesis - var aerosol_factor: float = maxf( - 0.0, 1.0 - climate_state.global_aerosol * aerosol_suppression - ) - - # Accumulate O2/CO2/CH4 fluxes across all tiles - var total_o2_production: float = 0.0 - var total_o2_consumption: float = 0.0 - var total_co2_production: float = 0.0 - var total_ch4_production: float = 0.0 - var tile_count: int = 0 - - # Determine ocean O2 multiplier once before the tile loop — honours - # suspended turns set by ecological events (e.g. oil-spill, anoxic bloom). - var ocean_o2_mult: float = _ocean_o2_multiplier(climate_state, chem) - - for tile: Variant in game_map.tiles.values(): - tile_count += 1 - var biome_photo_rate: float = tile.get("photosynthesis_rate", land_canopy_rate) - var biome_resp_rate: float = tile.get("respiration_rate", fauna_resp_rate) - var canopy: float = tile.get("canopy_cover", 0.0) - var fauna: float = float(tile.get("fauna_population", 0)) / 25.0 - var fungi: float = tile.get("fungi_cover", 0.0) - var is_wetland: bool = tile.get("substrate", "") in ["wetland", "lake_bed"] - var is_water: bool = tile.get("is_water", false) - - # O2 production (photosynthesis) - if is_water: - var water_flora: float = tile.get("water_flora", tile.get("reef_health", 0.5)) - total_o2_production += water_flora * ocean_phyto_rate * aerosol_factor * ocean_o2_mult - else: - total_o2_production += ( - canopy - * biome_photo_rate - * aerosol_factor - * climate_state.photosynthesis_multiplier - ) - - # O2 consumption (respiration) - total_o2_consumption += fauna * biome_resp_rate - - # CO2 from decomposition (all biomes) - total_co2_production += fungi * decomp_co2_rate * 1000.0 # scale to ppm - - # CH4 from decomposition: aerobic baseline everywhere, enhanced for wetland anaerobic - var tile_ch4_rate: float = decomp_ch4_rate - if is_wetland: - tile_ch4_rate += wetland_ch4_rate - total_ch4_production += fungi * tile_ch4_rate * 1000000.0 # scale to ppb - - # Normalize by tile count — per-tile averages scaled to a 1000-tile reference map - if tile_count > 0: - var scale: float = float(tile_count) / 1000.0 - climate_state.o2_fraction += (total_o2_production - total_o2_consumption) * scale - climate_state.co2_ppm += total_co2_production * scale - climate_state.ch4_ppb += total_ch4_production * scale - - # Volcanic CO2 injection (global_aerosol proxies volcanic activity level) - var volcanic_co2: float = chem.get("volcanic", {}).get("co2_per_aerosol_unit", 120.0) - climate_state.co2_ppm += climate_state.global_aerosol * volcanic_co2 - - # CH4 atmospheric decay (oxidation by OH radical) - climate_state.ch4_ppb *= (1.0 - ch4_decay) - - # Tick down and apply any pending delayed effects from ecological events - var still_pending: Array = [] - for effect: Dictionary in climate_state.pending_atmo_effects: - effect.turns_remaining -= 1 - if effect.turns_remaining <= 0: - climate_state.o2_fraction += effect.get("o2_delta", 0.0) - climate_state.co2_ppm += effect.get("co2_gain", 0.0) - climate_state.ch4_ppb += effect.get("ch4_pulse", 0.0) - else: - still_pending.append(effect) - climate_state.pending_atmo_effects = still_pending - - # CO2 + CH4 greenhouse temperature forcing - climate_state.global_temp_bias = _compute_co2_temp_bias( - climate_state.co2_ppm, greenhouse - ) - climate_state.global_temp_bias += ( - climate_state.ch4_ppb * greenhouse.get("ch4_temp_multiplier", 0.00000005) - ) - - # Ocean biosphere: fish stock, cascade phases, ocean toxicity - _step_ocean_biosphere(climate_state, game_map, chem) - - # Clamp to physical bounds - climate_state.o2_fraction = clampf(climate_state.o2_fraction, 0.0, 0.35) - climate_state.co2_ppm = clampf(climate_state.co2_ppm, 0.0, 1000000.0) - climate_state.ch4_ppb = clampf(climate_state.ch4_ppb, 0.0, 10000000.0) - - # Ecological collapse check — sustained O2 below critical threshold - var collapse_cfg: Dictionary = chem.get("ecological_collapse", {}) - var o2_trigger: float = collapse_cfg.get("o2_trigger", 0.05) - var sustained_turns: int = int(collapse_cfg.get("o2_sustained_turns", 10)) - var tipping_mult: float = collapse_cfg.get("photosynthesis_tipping_multiplier", 0.10) - var recovery_rate: float = collapse_cfg.get("recovery_rate_per_turn", 0.0001) - - if climate_state.o2_fraction < o2_trigger: - climate_state.o2_collapse_turn_count += 1 - if climate_state.o2_collapse_turn_count >= sustained_turns: - climate_state.ecological_collapse = true - climate_state.photosynthesis_multiplier = minf( - climate_state.photosynthesis_multiplier, tipping_mult - ) - else: - climate_state.o2_collapse_turn_count = 0 - if not climate_state.ecological_collapse: - climate_state.photosynthesis_multiplier = minf( - 1.0, climate_state.photosynthesis_multiplier + recovery_rate - ) - - -static func _compute_co2_temp_bias(co2_ppm_val: float, greenhouse: Dictionary) -> float: - ## Return temperature bias (0–1 scale) for the given CO2 concentration. - ## Reads piecewise bands from spec; returns 0.30 when above all defined ranges. - var bands: Array = greenhouse.get("co2_temp_bias", []) - for band: Dictionary in bands: - var band_min: float = band.get("co2_ppm_min", 0.0) - var band_max: float = band.get("co2_ppm_max", 999999.0) - if co2_ppm_val >= band_min and co2_ppm_val < band_max: - return band.get("temp_bias", 0.0) - return 0.30 # above all defined bands - - -static func _ocean_o2_multiplier(climate_state: Object, chem: Dictionary) -> float: - ## Return the current ocean photosynthesis multiplier based on ocean biosphere state. - ## Checks suspended-turns counter first (set by ecological events), then steady-state flags, - ## then fish-stock thresholds from spec. - if climate_state.ocean_o2_suspended_turns > 0: - climate_state.ocean_o2_suspended_turns -= 1 - return 0.0 - var ocean_states: Dictionary = chem.get("ocean_states", {}) - var fish: float = climate_state.global_fish_stock - var mult: float = 1.0 - if climate_state.dead_ocean or climate_state.canfield_ocean: - mult = 0.0 - elif climate_state.ocean_toxic: - mult = ocean_states.get("toxic", {}).get("o2_contribution", 0.0) - elif climate_state.ocean_anoxic or fish < ocean_states.get("anoxic", {}).get("fish_floor", 0.05): - mult = ocean_states.get("anoxic", {}).get("o2_contribution", 0.20) - elif fish < ocean_states.get("stressed", {}).get("fish_floor", 0.15): - mult = ocean_states.get("stressed", {}).get("o2_contribution", 0.70) - climate_state.ocean_o2_contribution = mult - return mult - - -static func _step_ocean_biosphere( - climate_state: Object, game_map: RefCounted, chem: Dictionary -) -> void: - ## Update global fish stock, tick trophic cascade phases, and check for new collapses. - - # --- Compute global average fish stock from water tiles --- - var total_fish: float = 0.0 - var water_tile_count: int = 0 - for tile: Variant in game_map.tiles.values(): - if tile.get("is_water", false): - total_fish += float(tile.get("fish_stock", 50)) / 100.0 - water_tile_count += 1 - climate_state.global_fish_stock = total_fish / maxf(1.0, float(water_tile_count)) - - var fish_spec: Dictionary = chem.get("fish_collapse", {}) - - # --- Tick active trophic cascade --- - if climate_state.trophic_cascade_active: - climate_state.trophic_cascade_turns_remaining -= 1 - match climate_state.trophic_cascade_phase: - 1: # Bloom phase — brief O2 spike, CO2 drawdown - climate_state.o2_fraction += 0.0005 - climate_state.co2_ppm -= 50.0 - if climate_state.trophic_cascade_turns_remaining <= 0: - climate_state.trophic_cascade_phase = 2 - climate_state.trophic_cascade_turns_remaining = int( - fish_spec.get("anoxic_phase_duration", 15) - ) - 2: # Anoxic phase — O2 crash, CO2/CH4 spike - climate_state.o2_fraction -= 0.0012 - climate_state.co2_ppm += 200.0 - climate_state.ch4_ppb += 500.0 - if climate_state.trophic_cascade_turns_remaining <= 0: - climate_state.trophic_cascade_phase = 3 - climate_state.trophic_cascade_turns_remaining = int( - fish_spec.get("toxification_phase_duration", 30) - ) - climate_state.ocean_toxic = true - 3: # Toxic phase — sustained O2 drain, heavy outgassing - climate_state.o2_fraction -= fish_spec.get("o2_loss_while_toxic", 0.0008) - climate_state.co2_ppm += 500.0 - climate_state.ch4_ppb += 1500.0 - if climate_state.trophic_cascade_turns_remaining <= 0: - climate_state.trophic_cascade_active = false - climate_state.trophic_cascade_phase = 0 - # Ocean toxicity persists — decays in the block below - - # --- Ocean toxicity decay / accumulation --- - if climate_state.ocean_toxic: - var recovery_threshold: float = fish_spec.get("recovery_fish_threshold", 0.25) - if climate_state.global_fish_stock > recovery_threshold: - climate_state.ocean_toxicity = maxf( - 0.0, - climate_state.ocean_toxicity - fish_spec.get("ocean_toxicity_decay", 0.005) - ) - if climate_state.ocean_toxicity < 0.01: - climate_state.ocean_toxic = false - else: - climate_state.ocean_toxicity = minf( - 1.0, climate_state.ocean_toxicity + 0.01 - ) - - # --- Fish collapse check (fires every N turns) --- - var check_interval: int = int(fish_spec.get("global_fish_check_interval", 5)) - climate_state.fish_collapse_check_timer += 1 - if climate_state.fish_collapse_check_timer >= check_interval: - climate_state.fish_collapse_check_timer = 0 - var critical: float = fish_spec.get("collapse_threshold", 0.05) - if ( - climate_state.global_fish_stock < critical - and not climate_state.trophic_cascade_active - and not climate_state.dead_ocean - ): - climate_state.trophic_cascade_active = true - climate_state.trophic_cascade_phase = 1 - climate_state.trophic_cascade_turns_remaining = int( - fish_spec.get("bloom_phase_duration", 8) - ) diff --git a/src/game/engine/src/modules/climate/weather_events.gd b/src/game/engine/src/modules/climate/weather_events.gd deleted file mode 100644 index c07219e9..00000000 --- a/src/game/engine/src/modules/climate/weather_events.gd +++ /dev/null @@ -1,474 +0,0 @@ -class_name WeatherEvents -extends RefCounted -## Atmosphere-driven weather event detection. Scans tile state after atmosphere -## processing and emits weather events when extreme conditions are met. -## -## Unlike ecological events (stochastic PCG32 rolls), weather events fire -## deterministically from physics state: if the atmosphere produces the conditions, -## the event fires. The atmosphere IS the randomness. -## -## Processing order: called by Climate.process_turn() after atmosphere steps -## (pressure, anomalies, wind, humidity, CAPE) have written to tiles, but before -## ecological events. Weather events inject magic_heat_delta / magic_moisture_delta -## that downstream climate steps propagate via wind physics. -## -## Five weather categories: -## hurricane — tropical low anomaly + warm ocean + wind convergence -## thunderstorm — high CAPE + high humidity -## blizzard — cold high anomaly + cold + humidity -## heat_wave — persistent thermal low + high temperature -## monsoon_burst — saturated RH + CAPE + wind convergence - -const HexUtilsScript: GDScript = preload("res://engine/src/map/hex_utils.gd") -const EcoUtils: GDScript = preload( - "res://engine/src/modules/climate/ecological_event_utils.gd" -) - -## Turns a location is blocked from re-spawning weather after one expires. -const COOLDOWN_TURNS: int = 50 - -## Persistence tracking: pos -> turns_condition_met for each weather type. -var _hurricane_persist: Dictionary = {} -var _blizzard_persist: Dictionary = {} -var _heat_wave_persist: Dictionary = {} - -## Active weather phenomena: pos -> { type, tier, remaining_turns, radius, effects } -var _active_weather: Dictionary = {} - -## Cooldown after weather expires: pos -> turns_remaining before re-detection. -var _cooldown: Dictionary = {} - -## Config loaded from DataLoader -var _cfg: Dictionary = {} -var _cfg_loaded: bool = false - - -func process_turn( - game_map: RefCounted, turn: int, events: Array -) -> void: - ## Scan atmosphere state and emit weather events for this turn. - ## Called from Climate.process_turn() after atmosphere steps. - ## Appends to the provided events array. - _ensure_cfg() - - # Apply ongoing weather effects from previous turns - _tick_active_weather(game_map, turn, events) - - # Detect new weather events from current atmosphere state - _detect_hurricanes(game_map, turn, events) - _detect_thunderstorms(game_map, turn, events) - _detect_blizzards(game_map, turn, events) - _detect_heat_waves(game_map, turn, events) - _detect_monsoon_bursts(game_map, turn, events) - - -# ----------------------------------------------------------------------- -# Active weather ticking -# ----------------------------------------------------------------------- - - -func _tick_active_weather(game_map: RefCounted, turn: int, events: Array) -> void: - ## Decrement active weather timers, apply ongoing effects, remove expired. - ## Also tick cooldowns from previously expired weather. - var expired: Array[Vector2i] = [] - for pos: Vector2i in _active_weather: - var w: Dictionary = _active_weather[pos] - w["remaining_turns"] = w["remaining_turns"] - 1 - if w["remaining_turns"] <= 0: - expired.append(pos) - continue - var heat: float = w.get("heat_delta", 0.0) - var moisture: float = w.get("moisture_delta", 0.0) - var radius: int = w.get("radius", 1) - _inject_deltas(game_map, pos, radius, heat, moisture) - for pos: Vector2i in expired: - var w: Dictionary = _active_weather[pos] - events.append(EcoUtils.make_event( - turn, w["type"] + "_dissipated", - game_map.tiles[pos] if game_map.tiles.has(pos) else null, - "%s dissipated" % w.get("name", w["type"]) - )) - # Start cooldown — no re-detection at this location for N turns. - _cooldown[pos] = COOLDOWN_TURNS - _active_weather.erase(pos) - # Tick cooldowns - var cooldown_done: Array[Vector2i] = [] - for pos: Vector2i in _cooldown: - _cooldown[pos] = _cooldown[pos] - 1 - if _cooldown[pos] <= 0: - cooldown_done.append(pos) - for pos: Vector2i in cooldown_done: - _cooldown.erase(pos) - - -# ----------------------------------------------------------------------- -# Hurricane detection -# ----------------------------------------------------------------------- - - -func _detect_hurricanes(game_map: RefCounted, turn: int, events: Array) -> void: - var cat_cfg: Dictionary = _cfg.get("hurricane", {}) - if cat_cfg.is_empty(): - return - var det: Dictionary = cat_cfg.get("detection", {}) - var anomaly_max: float = det.get("pressure_anomaly_max", -30.0) - var ocean_temp: float = det.get("ocean_temp_min", 0.6) - var need_conv: bool = det.get("requires_wind_convergence", true) - var persist_req: int = int(det.get("persist_turns", 2)) - var tiers: Dictionary = cat_cfg.get("tiers", {}) - - # Scan for hurricane candidates - var candidates: Dictionary = {} - for tile: Variant in game_map.tiles.values(): - if not tile.is_water(): - continue - if tile.temperature < ocean_temp: - continue - if tile.pressure_anomaly > anomaly_max: - continue - if need_conv and not _has_wind_convergence(tile.position, game_map): - continue - candidates[tile.position] = tile - - # Update persistence - var to_erase: Array[Vector2i] = [] - for pos: Vector2i in _hurricane_persist: - if not candidates.has(pos): - to_erase.append(pos) - for pos: Vector2i in to_erase: - _hurricane_persist.erase(pos) - for pos: Vector2i in candidates: - _hurricane_persist[pos] = _hurricane_persist.get(pos, 0) + 1 - - # Fire events where persistence met and no cooldown active - for pos: Vector2i in _hurricane_persist: - if _hurricane_persist[pos] < persist_req: - continue - if _active_weather.has(pos): - continue - if _cooldown.has(pos): - continue - var tile: Variant = game_map.tiles[pos] - var anomaly_val: float = absf(tile.pressure_anomaly) - var tier: int = _resolve_tier(tiers, "anomaly_threshold", anomaly_val) - var tier_cfg: Dictionary = tiers.get(str(tier), {}) - if tier_cfg.is_empty(): - continue - var radius: int = tier_cfg.get("radius", 2) - var moisture: float = tier_cfg.get("moisture_delta", 0.08) - var wind_boost: float = tier_cfg.get("wind_speed_boost", 0.3) - var name: String = tier_cfg.get("name", "Hurricane") - _inject_deltas(game_map, pos, radius, 0.0, moisture) - _boost_wind_speed(game_map, pos, radius, wind_boost) - _active_weather[pos] = { - "type": "hurricane", "name": name, "tier": tier, - "remaining_turns": 3, "radius": radius, - "heat_delta": 0.0, "moisture_delta": moisture, - } - events.append(EcoUtils.make_event( - turn, "hurricane", tile, - "T%d %s forms over warm ocean (anomaly %.0f hPa, radius %d)" % [ - tier, name, tile.pressure_anomaly, radius - ] - )) - _hurricane_persist.erase(pos) - break # One hurricane per turn globally - - -# ----------------------------------------------------------------------- -# Thunderstorm detection -# ----------------------------------------------------------------------- - - -func _detect_thunderstorms(game_map: RefCounted, turn: int, events: Array) -> void: - var cat_cfg: Dictionary = _cfg.get("thunderstorm", {}) - if cat_cfg.is_empty(): - return - var det: Dictionary = cat_cfg.get("detection", {}) - var cape_min: float = det.get("cape_min", 0.7) - var hum_min: float = det.get("humidity_min", 0.8) - var tiers: Dictionary = cat_cfg.get("tiers", {}) - - for tile: Variant in game_map.tiles.values(): - if tile.is_water(): - continue - if tile.cape < cape_min or tile.humidity < hum_min: - continue - if _active_weather.has(tile.position): - continue - var tier: int = _resolve_tier(tiers, "cape_threshold", tile.cape) - var tier_cfg: Dictionary = tiers.get(str(tier), {}) - if tier_cfg.is_empty(): - continue - var radius: int = tier_cfg.get("radius", 1) - var heat: float = tier_cfg.get("heat_delta", -0.01) - var precip: float = tier_cfg.get("precipitation_burst", 0.1) - var name: String = tier_cfg.get("name", "Thunderstorm") - _inject_deltas(game_map, tile.position, radius, heat, precip) - events.append(EcoUtils.make_event( - turn, "thunderstorm", tile, - "T%d %s (CAPE %.2f, radius %d)" % [tier, name, tile.cape, radius] - )) - - -# ----------------------------------------------------------------------- -# Blizzard detection -# ----------------------------------------------------------------------- - - -func _detect_blizzards(game_map: RefCounted, turn: int, events: Array) -> void: - var cat_cfg: Dictionary = _cfg.get("blizzard", {}) - if cat_cfg.is_empty(): - return - var det: Dictionary = cat_cfg.get("detection", {}) - var anomaly_min: float = det.get("pressure_anomaly_min", 30.0) - var temp_max: float = det.get("temp_max", 0.2) - var hum_min: float = det.get("humidity_min", 0.4) - var persist_req: int = int(det.get("persist_turns", 3)) - var tiers: Dictionary = cat_cfg.get("tiers", {}) - - var candidates: Dictionary = {} - for tile: Variant in game_map.tiles.values(): - if tile.is_water(): - continue - if tile.temperature > temp_max: - continue - if tile.pressure_anomaly < anomaly_min: - continue - if tile.humidity < hum_min: - continue - candidates[tile.position] = tile - - var to_erase: Array[Vector2i] = [] - for pos: Vector2i in _blizzard_persist: - if not candidates.has(pos): - to_erase.append(pos) - for pos: Vector2i in to_erase: - _blizzard_persist.erase(pos) - for pos: Vector2i in candidates: - _blizzard_persist[pos] = _blizzard_persist.get(pos, 0) + 1 - - for pos: Vector2i in _blizzard_persist: - if _blizzard_persist[pos] < persist_req: - continue - if _active_weather.has(pos): - continue - if _cooldown.has(pos): - continue - var tile: Variant = game_map.tiles[pos] - var anomaly_val: float = tile.pressure_anomaly - var tier: int = _resolve_tier(tiers, "anomaly_threshold", anomaly_val) - var tier_cfg: Dictionary = tiers.get(str(tier), {}) - if tier_cfg.is_empty(): - continue - var radius: int = tier_cfg.get("radius", 2) - var heat: float = tier_cfg.get("heat_delta", -0.02) - var moisture: float = tier_cfg.get("moisture_delta", -0.03) - var name: String = tier_cfg.get("name", "Blizzard") - _inject_deltas(game_map, pos, radius, heat, moisture) - _active_weather[pos] = { - "type": "blizzard", "name": name, "tier": tier, - "remaining_turns": 2, "radius": radius, - "heat_delta": heat, "moisture_delta": moisture, - } - events.append(EcoUtils.make_event( - turn, "blizzard", tile, - "T%d %s (anomaly +%.0f hPa, temp %.2f, radius %d)" % [ - tier, name, anomaly_val, tile.temperature, radius - ] - )) - _blizzard_persist.erase(pos) - - -# ----------------------------------------------------------------------- -# Heat wave detection -# ----------------------------------------------------------------------- - - -func _detect_heat_waves(game_map: RefCounted, turn: int, events: Array) -> void: - var cat_cfg: Dictionary = _cfg.get("heat_wave", {}) - if cat_cfg.is_empty(): - return - var det: Dictionary = cat_cfg.get("detection", {}) - var temp_min: float = det.get("temp_min", 0.7) - var anomaly_max: float = det.get("pressure_anomaly_max", -10.0) - var persist_req: int = int(det.get("persist_turns", 5)) - var tiers: Dictionary = cat_cfg.get("tiers", {}) - - var candidates: Dictionary = {} - for tile: Variant in game_map.tiles.values(): - if tile.is_water(): - continue - if tile.temperature < temp_min: - continue - if tile.pressure_anomaly > anomaly_max: - continue - candidates[tile.position] = tile - - var to_erase: Array[Vector2i] = [] - for pos: Vector2i in _heat_wave_persist: - if not candidates.has(pos): - to_erase.append(pos) - for pos: Vector2i in to_erase: - _heat_wave_persist.erase(pos) - for pos: Vector2i in candidates: - _heat_wave_persist[pos] = _heat_wave_persist.get(pos, 0) + 1 - - for pos: Vector2i in _heat_wave_persist: - if _heat_wave_persist[pos] < persist_req: - continue - if _active_weather.has(pos): - continue - if _cooldown.has(pos): - continue - var tile: Variant = game_map.tiles[pos] - var persist_count: int = _heat_wave_persist[pos] - var tier: int = _resolve_tier(tiers, "persist_threshold", float(persist_count)) - var tier_cfg: Dictionary = tiers.get(str(tier), {}) - if tier_cfg.is_empty(): - continue - var radius: int = tier_cfg.get("radius", 3) - var heat: float = tier_cfg.get("heat_delta", 0.02) - var moisture: float = tier_cfg.get("moisture_delta", -0.03) - var name: String = tier_cfg.get("name", "Heat Wave") - _inject_deltas(game_map, pos, radius, heat, moisture) - _active_weather[pos] = { - "type": "heat_wave", "name": name, "tier": tier, - "remaining_turns": 4, "radius": radius, - "heat_delta": heat, "moisture_delta": moisture, - } - events.append(EcoUtils.make_event( - turn, "heat_wave", tile, - "T%d %s (%d turns persistent heat, temp %.2f, radius %d)" % [ - tier, name, persist_count, tile.temperature, radius - ] - )) - _heat_wave_persist.erase(pos) - - -# ----------------------------------------------------------------------- -# Monsoon burst detection -# ----------------------------------------------------------------------- - - -func _detect_monsoon_bursts(game_map: RefCounted, turn: int, events: Array) -> void: - var cat_cfg: Dictionary = _cfg.get("monsoon_burst", {}) - if cat_cfg.is_empty(): - return - var det: Dictionary = cat_cfg.get("detection", {}) - var rh_min: float = det.get("relative_humidity_min", 0.95) - var cape_min: float = det.get("cape_min", 0.5) - var need_conv: bool = det.get("requires_wind_convergence", true) - var tiers: Dictionary = cat_cfg.get("tiers", {}) - - for tile: Variant in game_map.tiles.values(): - if tile.relative_humidity < rh_min: - continue - if tile.cape < cape_min: - continue - if need_conv and not _has_wind_convergence(tile.position, game_map): - continue - if _active_weather.has(tile.position): - continue - # Tier by how far above threshold - var excess: float = tile.relative_humidity - rh_min + (tile.cape - cape_min) - var tier: int = clampi(1 + int(excess * 10.0), 1, GameState.get_max_event_tier()) - var tier_cfg: Dictionary = tiers.get(str(tier), {}) - if tier_cfg.is_empty(): - tier_cfg = tiers.get("1", {}) - tier = 1 - if tier_cfg.is_empty(): - continue - var radius: int = tier_cfg.get("radius", 2) - var moisture: float = tier_cfg.get("moisture_delta", 0.1) - var precip: float = tier_cfg.get("precipitation_burst", 0.1) - var name: String = tier_cfg.get("name", "Monsoon Burst") - _inject_deltas(game_map, tile.position, radius, 0.0, moisture + precip) - events.append(EcoUtils.make_event( - turn, "monsoon_burst", tile, - "T%d %s (RH %.2f, CAPE %.2f, radius %d)" % [ - tier, name, tile.relative_humidity, tile.cape, radius - ] - )) - - -# ----------------------------------------------------------------------- -# Tier resolution helpers -# ----------------------------------------------------------------------- - - -static func _resolve_tier(tiers: Dictionary, key: String, val: float) -> int: - ## Find highest tier whose threshold key is met, capped by era max_event_tier. - var cap: int = GameState.get_max_event_tier() - var best: int = 1 - for tier_str: String in tiers: - var tier_num: int = int(tier_str) - if tier_num > cap: - continue - if val >= float(tiers[tier_str].get(key, 999.0)) and tier_num > best: - best = tier_num - return best - - -# ----------------------------------------------------------------------- -# Effect application -# ----------------------------------------------------------------------- - - -func _inject_deltas( - game_map: RefCounted, center: Vector2i, radius: int, - heat_delta: float, moisture_delta: float -) -> void: - ## Write magic_heat_delta and magic_moisture_delta to tiles in radius. - ## Climate physics propagates these via wind naturally. - var ring: Array[Vector2i] = HexUtilsScript.hex_spiral(center, radius) - for pos: Vector2i in ring: - var tile: Variant = game_map.tiles.get(pos) - if tile == null: - continue - tile.magic_heat_delta += heat_delta - tile.magic_moisture_delta += moisture_delta - - -func _boost_wind_speed( - game_map: RefCounted, center: Vector2i, radius: int, boost: float -) -> void: - ## Increase wind speed in radius (hurricane intensification). - var ring: Array[Vector2i] = HexUtilsScript.hex_spiral(center, radius) - for pos: Vector2i in ring: - var tile: Variant = game_map.tiles.get(pos) - if tile == null: - continue - tile.wind_speed = clampf(tile.wind_speed + boost, 0.0, 1.0) - - -# ----------------------------------------------------------------------- -# Wind convergence (shared with AtmosphereAnomalies) -# ----------------------------------------------------------------------- - - -func _has_wind_convergence(pos: Vector2i, game_map: RefCounted) -> bool: - ## Two or more neighboring tiles have wind blowing toward pos. - var converging: int = 0 - var neighbors: Array[Vector2i] = HexUtilsScript.get_neighbors(pos) - for i: int in neighbors.size(): - var nb: Variant = game_map.get_tile(neighbors[i]) - if nb == null: - continue - var inward_dir: int = (i + 3) % 6 - if nb.wind_direction == inward_dir: - converging += 1 - return converging >= 2 - - -# ----------------------------------------------------------------------- -# Config loading -# ----------------------------------------------------------------------- - - -func _ensure_cfg() -> void: - if _cfg_loaded: - return - var all_events: Dictionary = DataLoader.get_ecological_events() - _cfg = all_events.get("weather", {}) - _cfg_loaded = true diff --git a/src/game/engine/src/modules/ecology/ecosystem_simplified.gd b/src/game/engine/src/modules/ecology/ecosystem_simplified.gd deleted file mode 100644 index d9240188..00000000 --- a/src/game/engine/src/modules/ecology/ecosystem_simplified.gd +++ /dev/null @@ -1,292 +0,0 @@ -class_name EcosystemSimplified -extends RefCounted -## Guide-compatible tile quality (Q1-Q5) and global health computation. -## -## Transpiler-friendly: static functions accept flat tile arrays + plain -## Dictionary params. No DataLoader, EventBus, preload, or BiomeClassifier -## calls inside loops. Biome data pre-resolved by orchestrator. - -# Quality component weights (sum to 1.0) -const W_FLORA: float = 0.30 -const W_FAUNA: float = 0.25 -const W_STABILITY: float = 0.25 -const W_BALANCE: float = 0.20 - -# Quality tier thresholds -const Q2_THRESHOLD: float = 0.2 -const Q3_THRESHOLD: float = 0.4 -const Q4_THRESHOLD: float = 0.6 -const Q5_THRESHOLD: float = 0.8 - -# Biome reclassification deltas -const BIOME_CANOPY_DELTA: float = 0.05 -const BIOME_TEMP_DELTA: float = 0.02 -const BIOME_MOISTURE_DELTA: float = 0.03 - - -# ========================================================================= -# Transpilable functions -# ========================================================================= - - -static func compute_tile_quality(tiles: Array, biome_data: Dictionary, w: int, h: int) -> void: - ## Per-tile ecology composite -> Q1-Q5. - ## biome_data: biome_id -> {canopy, undergrowth, fungi, quality_min, quality_max, - ## temp_min, temp_max, moist_min, moist_max} - ## Land tiles: flora_health x 0.30 + fauna_diversity x 0.25 - ## + biome_stability x 0.25 + population_balance x 0.20 - ## Water tiles: fish_stock ratio used for population_balance. - - for tile: Variant in tiles: - var bd: Dictionary = biome_data.get(tile.biome_id, {}) - - var flora_score: float = 0.0 - var fauna_score: float = 0.0 - var stability_score: float = 0.0 - var balance_score: float = 0.5 - - if BiomeRegistry.has_tag(tile.biome_id, "is_water"): - # Water tiles: quality from fish stock and reef health - balance_score = _water_balance(tile) - stability_score = _water_stability(tile) - fauna_score = balance_score - flora_score = tile.reef_health if "reef_health" in tile else 0.0 - else: - flora_score = _flora_health(tile, bd) - fauna_score = _fauna_proxy(tile) - stability_score = _biome_stability(tile, bd) - balance_score = _land_balance(tile) - - var score: float = ( - flora_score * W_FLORA - + fauna_score * W_FAUNA - + stability_score * W_STABILITY - + balance_score * W_BALANCE - ) - - var new_q: int = _score_to_tier(score) - - # Cap by biome quality range - var q_min: int = bd.get("quality_min", 1) - var q_max: int = bd.get("quality_max", 5) - new_q = clampi(new_q, q_min, q_max) - - tile.quality = new_q - - -static func compute_global_health(tiles: Array) -> float: - ## Average of tile qualities / 5.0 across all tiles. - var total: float = 0.0 - var count: int = 0 - for tile: Variant in tiles: - total += float(tile.quality) / 5.0 - count += 1 - if count == 0: - return 0.0 - return total / float(count) - - -# ========================================================================= -# Scoring helpers -# ========================================================================= - - -static func _flora_health(tile: Variant, bd: Dictionary) -> float: - ## Average of canopy/undergrowth/fungi vs biome climax values. - var canopy_max: float = maxf(bd.get("canopy", 0.0), 0.001) - var ug_max: float = maxf(bd.get("undergrowth", 0.0), 0.001) - var fungi_max: float = maxf(bd.get("fungi", 0.0), 0.001) - - var c: float = clampf(tile.canopy_cover / canopy_max, 0.0, 1.0) - var u: float = clampf(tile.undergrowth / ug_max, 0.0, 1.0) - var f: float = clampf(tile.fungi_network / fungi_max, 0.0, 1.0) - return (c + u + f) / 3.0 - - -static func _fauna_proxy(tile: Variant) -> float: - ## Use habitat_suitability as fauna diversity proxy (no creature DB). - if "habitat_suitability" in tile: - return clampf(tile.habitat_suitability, 0.0, 1.0) - return 0.3 - - -static func _biome_stability(tile: Variant, bd: Dictionary) -> float: - ## Score how well the tile's climate matches its assigned biome range. - ## 1.0 = perfect match, 0.5 = edge, 0.0 = far outside. - if bd.is_empty(): - return 0.5 - - var temp: float = tile.temperature - var moist: float = tile.moisture - var t_min: float = bd.get("temp_min", 0.0) - var t_max: float = bd.get("temp_max", 1.0) - var m_min: float = bd.get("moist_min", 0.0) - var m_max: float = bd.get("moist_max", 1.0) - - var temp_ok: bool = temp >= t_min and temp <= t_max - var moist_ok: bool = moist >= m_min and moist <= m_max - - if temp_ok and moist_ok: - return 1.0 - - # Partial credit for edge cases - var temp_edge: bool = temp >= t_min - 0.1 and temp <= t_max + 0.1 - var moist_edge: bool = moist >= m_min - 0.1 and moist <= m_max + 0.1 - - if temp_edge and moist_edge: - return 0.5 - - return 0.2 - - -static func _land_balance(tile: Variant) -> float: - ## Land population balance proxy: high habitat + moderate flora = balanced. - var hab: float = 0.0 - if "habitat_suitability" in tile: - hab = tile.habitat_suitability - # Well-vegetated tiles with good habitat are balanced - var veg: float = (tile.undergrowth + tile.canopy_cover) * 0.5 - return clampf((hab + veg) * 0.5, 0.0, 1.0) - - -static func _water_balance(tile: Variant) -> float: - ## Water population balance: fish stock ratio vs nominal capacity. - var stock: float = float(tile.fish_stock) if "fish_stock" in tile else 0.0 - var cap: float = 100.0 - if stock <= 0.0: - return 0.1 - var ratio: float = clampf(stock / cap, 0.0, 1.0) - # Score peaks near 60-80% capacity (not overfished, not overcrowded) - if ratio > 0.8: - return 0.8 - if ratio > 0.4: - return 1.0 - return ratio / 0.4 - - -static func _water_stability(tile: Variant) -> float: - ## Water stability from reef health and temperature range. - var reef: float = tile.reef_health if "reef_health" in tile else 0.0 - var temp: float = tile.temperature if "temperature" in tile else 0.5 - # Tropical/temperate water is more stable - var temp_score: float = 0.5 - if temp > 0.25 and temp < 0.75: - temp_score = 1.0 - elif temp > 0.15: - temp_score = 0.7 - return (reef * 0.5 + temp_score * 0.5) - - -static func _score_to_tier(score: float) -> int: - ## Map [0,1] score to Q1-Q5 tier. - if score >= Q5_THRESHOLD: - return 5 - if score >= Q4_THRESHOLD: - return 4 - if score >= Q3_THRESHOLD: - return 3 - if score >= Q2_THRESHOLD: - return 2 - return 1 - - -static func recompute_biomes( - tiles: Array, w: int, h: int, - last_canopy: PackedFloat32Array, last_temp: PackedFloat32Array, - last_moisture: PackedFloat32Array, -) -> void: - ## Reclassify biomes where canopy/temp/moisture changed significantly. - ## Updates last_* arrays in-place for next turn's comparison. - var n: int = tiles.size() - if last_canopy.size() != n: - last_canopy.resize(n) - last_temp.resize(n) - last_moisture.resize(n) - for i: int in n: - last_canopy[i] = tiles[i].canopy_cover - last_temp[i] = tiles[i].temperature - last_moisture[i] = tiles[i].moisture - return - - for i: int in n: - var tile: Variant = tiles[i] - if BiomeRegistry.has_tag(tile.biome_id, "is_water"): - continue - var d_canopy: float = absf(tile.canopy_cover - last_canopy[i]) - var d_temp: float = absf(tile.temperature - last_temp[i]) - var d_moisture: float = absf(tile.moisture - last_moisture[i]) - last_canopy[i] = tile.canopy_cover - last_temp[i] = tile.temperature - last_moisture[i] = tile.moisture - if ( - d_canopy > BIOME_CANOPY_DELTA - or d_temp > BIOME_TEMP_DELTA - or d_moisture > BIOME_MOISTURE_DELTA - ): - # Inline classifier call — will be transpiled to classifyBiome(tile) - var new_biome: String = _classify_biome(tile) - if new_biome != tile.biome_id: - tile.biome_id = new_biome - - -static func _classify_biome(tile: Variant) -> String: - ## Minimal inline classifier for recomputation. Mirrors biome_classifier.gd logic. - if BiomeRegistry.has_tag(tile.biome_id, "is_water"): - return tile.biome_id - var temp: float = tile.temperature - var moist: float = tile.moisture - var elev: float = tile.elevation - var canopy: float = tile.canopy_cover - if moist > 0.7 and elev < 0.4 and canopy > 0: - if temp > 0.4: - return "swamp" - return "bog" - if elev > 0.85: - if temp < 0.1: - return "glacial" - return "alpine_tundra" - if elev > 0.70: - if canopy > 0 and moist > 0.3: - return "alpine_meadow" - return "alpine_tundra" - if elev > 0.55: - if canopy > 0.4: - return "montane_forest" - if canopy > 0 and moist > 0.7 and temp > 0.3: - return "cloud_forest" - if canopy > 0 and moist > 0.3: - return "alpine_meadow" - return "alpine_tundra" - if temp > 0.55: - if moist > 0.7 and canopy > 0.6: - return "tropical_rainforest" - if canopy > 0: - if moist > 0.4: - return "tropical_dry_forest" - if moist > 0.2: - return "savanna" - return "desert" - if temp > 0.25: - if canopy > 0.5: - return "temperate_forest" - if moist > 0.3 and canopy > 0: - return "temperate_grassland" - return "chaparral" - if temp > 0.1: - if canopy > 0.3: - return "boreal_forest" - if canopy > 0: - return "tundra" - return "polar_desert" - return "polar_desert" - - -static func get_ecology_food_modifier(tile: Variant) -> float: - ## Food yield modifier based on ecology quality tier. - var mult: Dictionary = {1: 0.5, 2: 1.0, 3: 1.5, 4: 2.0, 5: 2.5} - var base: float = mult.get(tile.quality, 1.0) - if not BiomeRegistry.has_tag(tile.biome_id, "is_water"): - base *= 0.8 + 0.4 * tile.undergrowth - return base - - diff --git a/src/game/engine/src/modules/ecology/fauna_simplified.gd b/src/game/engine/src/modules/ecology/fauna_simplified.gd deleted file mode 100644 index e4b597bb..00000000 --- a/src/game/engine/src/modules/ecology/fauna_simplified.gd +++ /dev/null @@ -1,124 +0,0 @@ -class_name FaunaSimplified -extends RefCounted -## Tile-level fauna approximation for the guide (no individual creatures, no SQLite). -## -## Transpiler-friendly: static tick functions accept flat tile arrays + plain -## Dictionary params. No DataLoader, EventBus, preload, or HexUtils calls -## inside tick loops. Tiles accessed by col/row (not position Vector2i). - - -# ========================================================================= -# Transpilable tick functions -# ========================================================================= - - -static func tick_fish_stock(tiles: Array, marine_params: Dictionary) -> void: - ## Logistic fish reproduction on water tiles. - ## Requires existing population — no spontaneous generation. - var repro_rate: float = marine_params.get("reproduction_rate", 0.05) - var cap_base: float = marine_params.get("fish_capacity", 100.0) - var reef_bonus: float = marine_params.get("reef_bonus", 0.5) - var reef_penalty: float = marine_params.get("reef_penalty", -0.5) - - for tile: Variant in tiles: - if not BiomeRegistry.has_tag(tile.biome_id, "is_water"): - continue - - var temp_mult: float = _temp_mult(tile.temperature) - var cap: float = cap_base - if tile.reef_health > 0.5: - cap *= (1.0 + reef_bonus) - elif tile.reef_health < 0.1: - cap *= maxf(0.1, 1.0 + reef_penalty) - - var stock: float = float(tile.fish_stock) - - # Population gate: no spontaneous generation - if stock <= 0.0: - continue - - var growth: float = repro_rate * temp_mult * stock * (1.0 - stock / cap) - tile.fish_stock = clampi(int(stock + growth), 0, int(cap)) - - -static func tick_habitat_suitability(tiles: Array, w: int, h: int) -> void: - ## Per land tile: average flora in radius-1 neighbors. - ## undergrowth x 0.6 + canopy x 0.2 + fungi x 0.2. - for i: int in tiles.size(): - var tile: Variant = tiles[i] - if BiomeRegistry.has_tag(tile.biome_id, "is_water"): - continue - - var col: int = tile.col - var row: int = tile.row - var total_ug: float = tile.undergrowth - var total_ca: float = tile.canopy_cover - var total_fn: float = tile.fungi_network - var count: int = 1 - - # Radius-1 neighbors via even-q offset - var nb_offsets: Array = _get_neighbor_offsets(col) - for off: Variant in nb_offsets: - var nc: int = col + off[0] - var nr: int = row + off[1] - if nc < 0 or nc >= w or nr < 0 or nr >= h: - continue - var ni: int = nr * w + nc - if ni < 0 or ni >= tiles.size(): - continue - var ntile: Variant = tiles[ni] - if BiomeRegistry.has_tag(ntile.biome_id, "is_water"): - continue - total_ug += ntile.undergrowth - total_ca += ntile.canopy_cover - total_fn += ntile.fungi_network - count += 1 - - if count > 0: - var avg_ug: float = total_ug / float(count) - var avg_ca: float = total_ca / float(count) - var avg_fn: float = total_fn / float(count) - tile.habitat_suitability = avg_ug * 0.6 + avg_ca * 0.2 + avg_fn * 0.2 - else: - tile.habitat_suitability = 0.0 - - -static func tick_reef_health(tiles: Array, marine_params: Dictionary) -> void: - ## Reef growth in ideal temperature range. - ## Requires existing population — reef can't grow from nothing. - var growth_rate: float = marine_params.get("reef_growth_rate", 0.02) - var ideal_min: float = marine_params.get("reef_ideal_min", 0.55) - var ideal_max: float = marine_params.get("reef_ideal_max", 0.75) - - for tile: Variant in tiles: - if not BiomeRegistry.has_tag(tile.biome_id, "is_water"): - continue - # Population gate: reef can't grow from nothing - if tile.reef_health <= 0.0: - continue - if tile.temperature >= ideal_min and tile.temperature <= ideal_max: - tile.reef_health = minf(1.0, tile.reef_health + growth_rate) - - -# ========================================================================= -# Pure static helpers -# ========================================================================= - - -static func _temp_mult(temperature: float) -> float: - ## Temperature multiplier for fish: tropical=1.0, temperate=0.8, polar=0.5. - if temperature > 0.55: - return 1.0 - if temperature > 0.25: - return 0.8 - return 0.5 - - - -static func _get_neighbor_offsets(col: int) -> Array: - ## Even-q offset hex neighbor deltas as [dc, dr] arrays. - var parity: int = col & 1 - if parity == 0: - return [[1, 0], [1, -1], [0, -1], [-1, -1], [-1, 0], [0, 1]] - else: - return [[1, 1], [1, 0], [0, -1], [-1, 0], [-1, 1], [0, 1]] diff --git a/src/game/engine/src/modules/ecology/water_body_finder.gd b/src/game/engine/src/modules/ecology/water_body_finder.gd deleted file mode 100644 index bb28db22..00000000 --- a/src/game/engine/src/modules/ecology/water_body_finder.gd +++ /dev/null @@ -1,197 +0,0 @@ -class_name WaterBodyFinder -extends RefCounted -## Identifies connected water bodies via flood-fill and computes depth_from_coast -## via BFS from all land tiles outward into water. - -const HexUtilsScript = preload("res://engine/src/map/hex_utils.gd") -const WaterBodyScript = preload("res://engine/src/models/world/water_body.gd") - - -## Flood-fill all connected water tiles into water bodies, classify by size, -## detect ocean (edge-connected), compute depth_from_coast, and store results -## into ecology_db and onto tile fields. -## Returns Array[WaterBody] for convenience. -static func identify_water_bodies( - game_map: Variant, - ecology_db: Variant, -) -> Array: - var water_bodies: Array = [] - var visited: Dictionary = {} # Vector2i → true - var next_id: int = 0 - - # Collect all water tile positions - var water_positions: Array[Vector2i] = [] - for pos: Vector2i in game_map.tiles: - var tile: Variant = game_map.tiles[pos] - if tile.is_water(): - water_positions.append(pos) - - # Precompute map edge positions for ocean detection - var edge_cols: Dictionary = {0: true} - edge_cols[game_map.width - 1] = true - var edge_rows: Dictionary = {0: true} - edge_rows[game_map.height - 1] = true - - # Flood-fill each unvisited water tile - for start_pos: Vector2i in water_positions: - if start_pos in visited: - continue - - var body := WaterBodyScript.new() - body.id = next_id - next_id += 1 - - var touches_edge: bool = false - var queue: Array[Vector2i] = [start_pos] - visited[start_pos] = true - - while not queue.is_empty(): - var current: Vector2i = queue.pop_front() - body.tiles.append(current) - - # Check if this tile touches the map edge (ocean detection) - var offset: Vector2i = HexUtilsScript.axial_to_offset(current) - if offset.x in edge_cols or offset.y in edge_rows: - touches_edge = true - - # BFS to neighbors - var neighbors: Array[Vector2i] = game_map.get_valid_neighbor_positions(current) - for nb: Vector2i in neighbors: - if nb in visited: - continue - var nb_tile: Variant = game_map.tiles.get(nb) - if nb_tile == null or not nb_tile.is_water(): - continue - visited[nb] = true - queue.append(nb) - - # Classify by size - body.classify_type() - if touches_edge: - body.type = "ocean" - - water_bodies.append(body) - - # Store in ecology_db - ecology_db.add_water_body(body.id, body.type, body.size, body.tiles.size()) - - # Set water_body_id and water_body_type on each tile - for tile_pos: Vector2i in body.tiles: - var tile: Variant = game_map.tiles.get(tile_pos) - if tile != null: - tile.water_body_id = body.id - tile.water_body_type = body.type - - # Compute depth_from_coast for all water tiles - _compute_depth_from_coast(game_map, water_bodies, ecology_db) - - # Detect river mouths: ocean tiles at depth <= 1 adjacent to river tiles - _detect_river_mouths(game_map, water_bodies) - - return water_bodies - - -## BFS from ALL land tiles outward into water. Each water tile gets -## depth_from_coast = BFS distance from nearest land. -static func _compute_depth_from_coast( - game_map: Variant, - water_bodies: Array, - ecology_db: Variant, -) -> void: - # Seed the BFS frontier with all water tiles adjacent to land (depth = 1) - var depth_map: Dictionary = {} # Vector2i → int - var frontier: Array[Vector2i] = [] - - # Build set of all water positions for quick lookup - var water_set: Dictionary = {} - for wb: Variant in water_bodies: - for pos: Vector2i in wb.tiles: - water_set[pos] = wb.id - - # Find water tiles adjacent to at least one land tile - for pos: Vector2i in water_set: - var neighbors: Array[Vector2i] = game_map.get_valid_neighbor_positions(pos) - for nb: Vector2i in neighbors: - if nb not in water_set: - # nb is land (or at least not water), so pos is coastal water - depth_map[pos] = 1 - frontier.append(pos) - break - - # BFS outward from depth-1 tiles - var idx: int = 0 - while idx < frontier.size(): - var current: Vector2i = frontier[idx] - idx += 1 - var current_depth: int = depth_map[current] - - var neighbors: Array[Vector2i] = game_map.get_valid_neighbor_positions(current) - for nb: Vector2i in neighbors: - if nb not in water_set: - continue - if nb in depth_map: - continue - depth_map[nb] = current_depth + 1 - frontier.append(nb) - - # Index water bodies by id for O(1) lookup - var wb_by_id: Dictionary = {} - for wb: Variant in water_bodies: - wb_by_id[wb.id] = wb - - # Write results to tiles, water body models, and ecology_db - for pos: Vector2i in depth_map: - var depth: int = depth_map[pos] - var body_id: int = water_set.get(pos, -1) - - # Update tile field - var tile: Variant = game_map.tiles.get(pos) - if tile != null: - tile.depth_from_coast = depth - - # Update WaterBody model cache - var wb: Variant = wb_by_id.get(body_id) - if wb != null: - wb.set_depth_from_coast(pos, depth) - - # Store in ecology_db - ecology_db.add_water_body_tile(body_id, pos.x, pos.y, depth) - - -## Detect river mouths: ocean/sea tiles at depth <= 1 with adjacent river tiles. -## Sets is_river_mouth = true on qualifying tiles. -static func _detect_river_mouths( - game_map: Variant, - water_bodies: Array, -) -> void: - # Build set of river tile positions - var river_positions: Dictionary = {} - for wb: Variant in water_bodies: - if wb.type == "river": - for pos: Vector2i in wb.tiles: - river_positions[pos] = true - - # Also consider land tiles with river_edges as river-adjacent - for pos: Vector2i in game_map.tiles: - var tile: Variant = game_map.tiles[pos] - if not tile.river_edges.is_empty(): - river_positions[pos] = true - - if river_positions.is_empty(): - return - - # Check ocean tiles at depth <= 1 for river adjacency - for wb: Variant in water_bodies: - if wb.type != "ocean": - continue - for pos: Vector2i in wb.tiles: - var tile: Variant = game_map.tiles.get(pos) - if tile == null or tile.depth_from_coast > 1: - continue - var nbs: Array[Vector2i] = ( - game_map.get_valid_neighbor_positions(pos) - ) - for nb: Vector2i in nbs: - if nb in river_positions: - tile.is_river_mouth = true - break diff --git a/src/game/engine/src/rendering/hex_overlay_renderer.gd b/src/game/engine/src/rendering/hex_overlay_renderer.gd deleted file mode 100644 index 5ba13fa3..00000000 --- a/src/game/engine/src/rendering/hex_overlay_renderer.gd +++ /dev/null @@ -1,475 +0,0 @@ -extends RefCounted -## Renders per-tile debug and lens overlays onto hex tile container nodes. -## -## Overlays are identified by the Overlay enum. Enable/disable via `active_overlay`. -## At most one heatmap overlay is shown at a time; the wind arrow is independent. -## -## Adding a new overlay: -## 1. Add a value to the Overlay enum. -## 2. Add a branch in render_overlays() and implement a dedicated render method. -## 3. Wire the new mode string in world_map._on_map_overlay_changed(). -## -## Heatmap color convention: -## Temperature: lerp(TEMP_COLD_COLOR, TEMP_HOT_COLOR, t) -## Moisture: lerp(MOIST_DRY_COLOR, MOIST_WET_COLOR, m) - -## Overlay modes. NONE disables all heatmap overlays. -enum Overlay { - NONE, TEMPERATURE, MOISTURE, WEATHER, - LAND_VALUE, WIND_HEATMAP, WATER, ELEVATION, CORRUPTION, - PRESSURE, HUMIDITY, NATURAL_EVENT, - # LEY_LINE — Game 3+ -} - -const HexUtilsScript: GDScript = preload("res://engine/src/map/hex_utils.gd") -const TileScript: GDScript = preload("res://engine/src/map/tile.gd") - -## Wind arrow angles (radians) indexed by hex edge direction (flat-top). -## 0=E=0°, 1=NE=-60°, 2=NW=-120°, 3=W=180°, 4=SW=120°, 5=SE=60° -const WIND_ANGLES: Array[float] = [0.0, -1.0472, -2.0944, 3.14159, 2.0944, 1.0472] -const WIND_ARROW_BASE_LEN: float = 10.0 -const WIND_ARROW_SPEED_SCALE: float = 8.0 -const WIND_ARROW_TAIL_RATIO: float = 0.3 -const WIND_ARROW_HEAD_LEN: float = 4.0 -const WIND_ARROW_HEAD_ANGLE: float = 0.45 -const WIND_ARROW_WIDTH: float = 1.5 -const WIND_ARROW_COLOR: Color = Color(0.2, 0.6, 1.0, 0.85) - -## Temperature heatmap: blue (cold) → green (temperate) → red (hot) -const TEMP_COLD_COLOR: Color = Color(0.0, 0.2, 1.0, 0.35) -const TEMP_HOT_COLOR: Color = Color(1.0, 0.1, 0.0, 0.35) - -## Moisture heatmap: sandy brown (dry) → teal (wet) -const MOIST_DRY_COLOR: Color = Color(0.76, 0.60, 0.30, 0.35) -const MOIST_WET_COLOR: Color = Color(0.0, 0.45, 0.8, 0.35) - -## Color map for weather event types (non-magic climate events only). -const WEATHER_COLORS: Dictionary = { - "rainstorm": Color(0.1, 0.4, 0.95, 0.45), - "drought": Color(0.9, 0.45, 0.1, 0.45), - "blizzard": Color(0.85, 0.92, 1.0, 0.5), - "monsoon": Color(0.0, 0.55, 0.85, 0.5), - "sandstorm": Color(0.85, 0.72, 0.3, 0.45), - "jet_stream": Color(0.5, 0.8, 1.0, 0.4), - "default": Color(0.6, 0.3, 0.8, 0.4), -} - -const FLOOD_WARNING_COLOR: Color = Color(0.95, 0.15, 0.15, 0.55) - -## Land value lens: three-stop gradient red → yellow → green -const LAND_LOW_COLOR: Color = Color(0.7, 0.1, 0.1, 0.65) -const LAND_MID_COLOR: Color = Color(0.85, 0.75, 0.15, 0.6) -const LAND_HIGH_COLOR: Color = Color(0.1, 0.8, 0.2, 0.65) -const LAND_WATER_COLOR: Color = Color(0.03, 0.05, 0.2, 0.7) -const LAND_EXCLUSION_COLOR: Color = Color(0.15, 0.15, 0.15, 0.7) -const LAND_VALUE_MAX: float = 24.0 -const CITY_MIN_DISTANCE: int = 4 - -## Wind heatmap: calm blue → white → strong orange/red -const WIND_CALM_COLOR: Color = Color(0.15, 0.3, 0.7, 0.6) -const WIND_MID_COLOR: Color = Color(0.85, 0.85, 0.9, 0.55) -const WIND_STRONG_COLOR: Color = Color(0.9, 0.35, 0.1, 0.65) - -## Water lens colors -const FLOW_LOW_COLOR: Color = Color(0.2, 0.4, 0.7, 0.4) -const FLOW_HIGH_COLOR: Color = Color(0.1, 0.3, 0.95, 0.65) -const LAKE_HIGHLIGHT_COLOR: Color = Color(0.1, 0.7, 0.85, 0.6) -const RIVER_LINE_COLOR: Color = Color(0.15, 0.4, 0.95, 0.9) -const RIVER_MIN_WIDTH: float = 1.5 -const RIVER_MAX_WIDTH: float = 5.0 -const FLOW_NORMALIZE: float = 120.0 - -## River source marker colors -const SOURCE_COLORS: Dictionary = { - "snowmelt": Color(0.9, 0.95, 1.0, 0.9), - "spring": Color(0.2, 0.8, 0.3, 0.9), - "hot_spring": Color(0.9, 0.5, 0.15, 0.9), - "glacial": Color(0.5, 0.8, 1.0, 0.9), -} -const SOURCE_MARKER_RADIUS: float = 6.0 - -## Elevation lens: green (low) → brown (mid) → white (peaks) -const ELEV_LOW_COLOR: Color = Color(0.15, 0.45, 0.15, 0.6) -const ELEV_MID_COLOR: Color = Color(0.55, 0.4, 0.2, 0.6) -const ELEV_HIGH_COLOR: Color = Color(0.92, 0.92, 0.95, 0.65) -const ELEV_WATER_SHALLOW: Color = Color(0.15, 0.3, 0.55, 0.55) -const ELEV_WATER_DEEP: Color = Color(0.05, 0.1, 0.35, 0.65) - -## Corruption lens: green (pure) → purple (threatened) → dark red/black (corrupted) -const CORRUPT_PURE_COLOR: Color = Color(0.15, 0.55, 0.2, 0.4) -const CORRUPT_MID_COLOR: Color = Color(0.5, 0.15, 0.6, 0.6) -const CORRUPT_HIGH_COLOR: Color = Color(0.35, 0.05, 0.08, 0.7) -const CORRUPT_LAND_COLOR: Color = Color(0.4, 0.08, 0.45, 0.75) - -## Pressure lens: blue (low/storms ~990 hPa) → green (normal ~1013) → red (high/blocking ~1035 hPa) -const PRESSURE_LOW_COLOR: Color = Color(0.1, 0.2, 0.9, 0.6) -const PRESSURE_NORMAL_COLOR: Color = Color(0.15, 0.75, 0.2, 0.55) -const PRESSURE_HIGH_COLOR: Color = Color(0.85, 0.1, 0.1, 0.6) -const PRESSURE_MIN: float = 990.0 -const PRESSURE_MID: float = 1013.0 -const PRESSURE_MAX: float = 1035.0 - -## Humidity lens: brown (dry) → deep blue (saturated) -const HUMIDITY_DRY_COLOR: Color = Color(0.6, 0.38, 0.12, 0.6) -const HUMIDITY_WET_COLOR: Color = Color(0.05, 0.15, 0.75, 0.65) - -## Natural event overlay colors keyed by event type -const NATURAL_EVENT_COLORS: Dictionary = { - "hurricane": Color(0.7, 0.05, 0.7, 0.6), - "tornado": Color(0.85, 0.65, 0.05, 0.65), - "drought": Color(0.85, 0.35, 0.05, 0.55), - "flood": Color(0.1, 0.35, 0.9, 0.6), - "monsoon": Color(0.0, 0.5, 0.8, 0.55), - "blizzard": Color(0.8, 0.88, 1.0, 0.55), - "heat_wave": Color(0.95, 0.4, 0.05, 0.6), - "default": Color(0.7, 0.4, 0.9, 0.5), -} - -## Currently active heatmap overlay. Change this and call refresh_map() to switch. -var active_overlay: Overlay = Overlay.NONE - -## Whether to draw wind direction arrows (independent of heatmap). -var show_wind: bool = false - -## spell_id → Array[Vector2i] footprint; refreshed by update_weather_footprints(). -var weather_footprints: Dictionary = {} - -## Axial positions currently showing a flood warning icon. -var flood_warning_tiles: Array[Vector2i] = [] - -## axial → event_type string; refreshed by update_natural_events(). -var natural_event_tiles: Dictionary = {} - -## Cached set of city positions for land value exclusion zone checks. -var _city_positions: Array[Vector2i] = [] - - -func render_overlays(container: Node2D, tile: TileScript) -> void: - ## Renders all active overlays onto a tile container node. - ## Called once per tile during map render / tile update. - if show_wind: - _render_wind_arrow(container, tile) - var axial: Vector2i = tile.position - match active_overlay: - Overlay.TEMPERATURE: - _render_heatmap(container, tile.temperature, TEMP_COLD_COLOR, TEMP_HOT_COLOR) - Overlay.MOISTURE: - _render_heatmap(container, tile.moisture, MOIST_DRY_COLOR, MOIST_WET_COLOR) - Overlay.WEATHER: - _render_weather_footprint(container, axial) - Overlay.LAND_VALUE: - _render_land_value(container, tile) - Overlay.WIND_HEATMAP: - _render_wind_heatmap(container, tile) - Overlay.WATER: - _render_water(container, tile) - Overlay.ELEVATION: - _render_elevation(container, tile) - Overlay.CORRUPTION: - _render_corruption(container, tile) - Overlay.PRESSURE: - _render_pressure(container, tile) - Overlay.HUMIDITY: - _render_humidity_overlay(container, tile) - Overlay.NATURAL_EVENT: - _render_natural_event(container, axial) - # Flood warnings render regardless of active overlay - if axial in flood_warning_tiles: - _render_heatmap(container, 1.0, FLOOD_WARNING_COLOR, FLOOD_WARNING_COLOR) - - -func refresh_city_positions() -> void: - ## Rebuild cached city positions from GameState. Call before rendering land value lens. - _city_positions.clear() - for player in GameState.players: - if player == null: - continue - var cities: Array = player.get("cities") if "cities" in player else [] - for city in cities: - if city != null and "position" in city: - _city_positions.append(city.position as Vector2i) - - -# -- Land Value Lens -- - - -func _render_land_value(container: Node2D, tile: TileScript) -> void: - ## Heatmap by weighted yield score: food×3 + production×2 + trade×1. - ## Water tiles render as dark blue. Tiles near existing cities render grayed. - if tile.is_water(): - _render_heatmap(container, 1.0, LAND_WATER_COLOR, LAND_WATER_COLOR) - return - if _is_near_existing_city(tile.position): - _render_heatmap(container, 1.0, LAND_EXCLUSION_COLOR, LAND_EXCLUSION_COLOR) - return - var yields: Dictionary = tile.get_quality_yields() - var score: float = ( - yields.get("food", 0) * 3.0 - + yields.get("production", 0) * 2.0 - + yields.get("trade", 0) * 1.0 - ) - var t: float = clampf(score / LAND_VALUE_MAX, 0.0, 1.0) - if t < 0.5: - _render_heatmap(container, t * 2.0, LAND_LOW_COLOR, LAND_MID_COLOR) - else: - _render_heatmap(container, (t - 0.5) * 2.0, LAND_MID_COLOR, LAND_HIGH_COLOR) - - -func _is_near_existing_city(axial: Vector2i) -> bool: - for city_pos: Vector2i in _city_positions: - if HexUtilsScript.hex_distance(axial, city_pos) < CITY_MIN_DISTANCE: - return true - return false - - -# -- Wind Heatmap Lens -- - - -func _render_wind_heatmap(container: Node2D, tile: TileScript) -> void: - ## Speed heatmap (blue → white → orange) with wind arrows on top. - var speed: float = tile.wind_speed if "wind_speed" in tile else 0.0 - if speed < 0.5: - _render_heatmap(container, speed * 2.0, WIND_CALM_COLOR, WIND_MID_COLOR) - else: - _render_heatmap(container, (speed - 0.5) * 2.0, WIND_MID_COLOR, WIND_STRONG_COLOR) - _render_wind_arrow(container, tile) - - -# -- Water Lens -- - - -func _render_water(container: Node2D, tile: TileScript) -> void: - ## Flow accumulation heatmap + river edge lines + lake highlights + source markers. - # Flow accumulation heatmap on land - if tile.is_land() and tile.flow_accumulation > 0.0: - var t: float = clampf(tile.flow_accumulation / FLOW_NORMALIZE, 0.0, 1.0) - _render_heatmap(container, t, FLOW_LOW_COLOR, FLOW_HIGH_COLOR) - # Lake / freshwater highlight - if tile.lake_id >= 0 or tile.terrain_id in ["lake", "inland_sea"]: - _render_heatmap(container, 1.0, LAKE_HIGHLIGHT_COLOR, LAKE_HIGHLIGHT_COLOR) - # River edge lines - for edge_idx: int in tile.river_edges: - var flow_val: float = absf(tile.river_flow.get(edge_idx, 1.0) as float) - _render_river_edge(container, edge_idx, flow_val) - # Source markers - if tile.river_source_type != "": - _render_source_marker(container, tile.river_source_type) - - -func _render_river_edge(container: Node2D, edge_idx: int, flow: float) -> void: - ## Draw a blue line along the hex edge, thickness scaled by flow accumulation. - ## edge_idx is an AXIAL direction index (0-5), not a polygon vertex index. - var vertices: PackedVector2Array = HexUtilsScript.hex_polygon - if edge_idx < 0 or edge_idx > 5: - return - var pe: int = HexUtilsScript.DIR_TO_POLY_EDGE[edge_idx] - var p1: Vector2 = vertices[pe] - var p2: Vector2 = vertices[(pe + 1) % 6] - var t: float = clampf(flow / FLOW_NORMALIZE, 0.0, 1.0) - var width: float = lerpf(RIVER_MIN_WIDTH, RIVER_MAX_WIDTH, t) - var line: Line2D = Line2D.new() - line.points = PackedVector2Array([p1, p2]) - line.width = width - line.default_color = RIVER_LINE_COLOR - container.add_child(line) - - -func _render_source_marker(container: Node2D, source_type: String) -> void: - ## Draw a small colored circle at hex center for river source tiles. - var color: Color = SOURCE_COLORS.get(source_type, Color.WHITE) as Color - var center: Vector2 = HexUtilsScript.hex_center - var circle: Polygon2D = Polygon2D.new() - var points: PackedVector2Array = PackedVector2Array() - for i: int in 12: - var angle: float = TAU * i / 12.0 - points.append(center + Vector2(cos(angle), sin(angle)) * SOURCE_MARKER_RADIUS) - circle.polygon = points - circle.color = color - container.add_child(circle) - - -# -- Elevation Lens -- - - -func _render_elevation(container: Node2D, tile: TileScript) -> void: - ## Heatmap by tile.elevation: green (low) → brown (mid) → white (peaks). - ## Water tiles shade by depth (coast lighter, ocean darker). - if tile.is_water(): - var depth: float = 1.0 if tile.terrain_id == "ocean" else 0.3 - _render_heatmap(container, depth, ELEV_WATER_SHALLOW, ELEV_WATER_DEEP) - return - var elev: float = tile.elevation - if elev < 0.5: - _render_heatmap(container, elev * 2.0, ELEV_LOW_COLOR, ELEV_MID_COLOR) - else: - _render_heatmap(container, (elev - 0.5) * 2.0, ELEV_MID_COLOR, ELEV_HIGH_COLOR) - - -# -- Corruption Lens -- - - -func _render_corruption(container: Node2D, tile: TileScript) -> void: - ## Heatmap by tile.corruption_pressure: green (pure) → purple → dark red. - ## Corrupted land tiles render as solid dark purple. - if tile.terrain_id == "corrupted_land": - _render_heatmap(container, 1.0, CORRUPT_LAND_COLOR, CORRUPT_LAND_COLOR) - return - var pressure: float = tile.corruption_pressure - if pressure <= 0.001: - return - if pressure < 0.5: - _render_heatmap(container, pressure * 2.0, CORRUPT_PURE_COLOR, CORRUPT_MID_COLOR) - else: - _render_heatmap(container, (pressure - 0.5) * 2.0, CORRUPT_MID_COLOR, CORRUPT_HIGH_COLOR) - - -# -- Pressure Lens -- - - -func _render_pressure(container: Node2D, tile: TileScript) -> void: - ## Three-stop gradient blue→green→red by tile.pressure (range 990–1035 hPa). - var p: float = tile.pressure if "pressure" in tile else PRESSURE_MID - if p <= PRESSURE_MID: - var t: float = clampf((p - PRESSURE_MIN) / (PRESSURE_MID - PRESSURE_MIN), 0.0, 1.0) - _render_heatmap(container, t, PRESSURE_LOW_COLOR, PRESSURE_NORMAL_COLOR) - else: - var t: float = clampf((p - PRESSURE_MID) / (PRESSURE_MAX - PRESSURE_MID), 0.0, 1.0) - _render_heatmap(container, t, PRESSURE_NORMAL_COLOR, PRESSURE_HIGH_COLOR) - - -# -- Humidity Lens -- - - -func _render_humidity_overlay(container: Node2D, tile: TileScript) -> void: - ## Heatmap brown→deep blue by tile.humidity [0,1]. - var h: float = tile.humidity if "humidity" in tile else 0.5 - _render_heatmap(container, clampf(h, 0.0, 1.0), HUMIDITY_DRY_COLOR, HUMIDITY_WET_COLOR) - - -# magic — Game 3+ -# _render_ley_line(), _render_ley_edge_line(), _render_nexus_starburst(), -# _render_anchor_marker_if_present() are all ley/magic features not present in Game 1. - - -# -- Natural Event Overlay -- - - -func _render_natural_event(container: Node2D, axial: Vector2i) -> void: - ## Colored overlay on tiles with active natural events (hurricane, tornado, etc.). - if not natural_event_tiles.has(axial): - return - var event_type: String = natural_event_tiles[axial] as String - var color: Color = NATURAL_EVENT_COLORS.get(event_type, NATURAL_EVENT_COLORS["default"]) as Color - var poly: Polygon2D = Polygon2D.new() - poly.polygon = HexUtilsScript.hex_polygon - poly.color = color - container.add_child(poly) - var border: Line2D = Line2D.new() - var border_color: Color = color - border_color.a = minf(color.a + 0.2, 1.0) - border.points = HexUtilsScript.hex_polygon - border.closed = true - border.width = 1.5 - border.default_color = border_color - container.add_child(border) - - -func update_natural_events(events: Array[Dictionary]) -> void: - ## Refresh natural_event_tiles from a list of {axial: Vector2i, event_type: String} dicts. - natural_event_tiles.clear() - for entry: Dictionary in events: - if not entry.has("axial") or not entry.has("event_type"): - continue - if not entry["axial"] is Vector2i: - continue - var axial: Vector2i = entry["axial"] as Vector2i - var event_type: String = entry["event_type"] as String - if not event_type.is_empty(): - natural_event_tiles[axial] = event_type - - -func update_weather_footprints(effects: Array[Dictionary]) -> void: - ## Refresh weather_footprints from the list returned by weather.get_active_effects(). - weather_footprints.clear() - for effect: Dictionary in effects: - var spell_id: String = effect.get("spell_id", "") - var footprint: Array = effect.get("footprint", []) as Array - if spell_id.is_empty() or footprint.is_empty(): - continue - # Merge footprints if the same spell_id appears multiple times - if weather_footprints.has(spell_id): - for pos in footprint: - if pos is Vector2i and pos not in weather_footprints[spell_id]: - (weather_footprints[spell_id] as Array).append(pos) - else: - weather_footprints[spell_id] = footprint.duplicate() - - -func set_flood_warnings(axials: Array[Vector2i]) -> void: - ## Replace the flood warning tile set. - flood_warning_tiles.assign(axials) - - -# -- Shared rendering helpers -- - - -func _render_wind_arrow(container: Node2D, tile: TileScript) -> void: - ## Draws a direction arrow at tile center. Shaft length scales with wind_speed. - var center: Vector2 = HexUtilsScript.hex_center - var angle: float = WIND_ANGLES[tile.wind_direction % 6] - var arrow_len: float = WIND_ARROW_BASE_LEN + tile.wind_speed * WIND_ARROW_SPEED_SCALE - var dir: Vector2 = Vector2(cos(angle), sin(angle)) - var tip: Vector2 = center + dir * arrow_len - var tail: Vector2 = center - dir * (arrow_len * WIND_ARROW_TAIL_RATIO) - - var shaft: Line2D = Line2D.new() - shaft.points = PackedVector2Array([tail, tip]) - shaft.width = WIND_ARROW_WIDTH - shaft.default_color = WIND_ARROW_COLOR - container.add_child(shaft) - - for sign: float in [1.0, -1.0]: - var barb: Line2D = Line2D.new() - barb.points = PackedVector2Array([ - tip, - tip - Vector2( - cos(angle + sign * WIND_ARROW_HEAD_ANGLE), - sin(angle + sign * WIND_ARROW_HEAD_ANGLE) - ) * WIND_ARROW_HEAD_LEN - ]) - barb.width = WIND_ARROW_WIDTH - barb.default_color = WIND_ARROW_COLOR - container.add_child(barb) - - -func _render_heatmap( - container: Node2D, value: float, cold_color: Color, hot_color: Color -) -> void: - ## Draws a semi-transparent heatmap polygon over the hex tile. - ## value [0,1]: 0 → cold_color, 1 → hot_color (linear interpolation). - var poly: Polygon2D = Polygon2D.new() - poly.polygon = HexUtilsScript.hex_polygon - poly.color = cold_color.lerp(hot_color, clampf(value, 0.0, 1.0)) - container.add_child(poly) - - -func _render_weather_footprint(container: Node2D, axial: Vector2i) -> void: - ## Draws a colored overlay on tiles within any active weather event footprint. - for spell_id: String in weather_footprints: - var footprint: Array = weather_footprints[spell_id] as Array - if axial in footprint: - var color: Color = WEATHER_COLORS.get(spell_id, WEATHER_COLORS["default"]) as Color - var poly: Polygon2D = Polygon2D.new() - poly.polygon = HexUtilsScript.hex_polygon - poly.color = color - container.add_child(poly) - # Draw border for clarity - var border: Line2D = Line2D.new() - var border_color: Color = color - border_color.a = minf(color.a + 0.25, 1.0) - border.points = HexUtilsScript.hex_polygon - border.closed = true - border.width = 1.5 - border.default_color = border_color - container.add_child(border) - break # Only one weather color per tile (first match wins) diff --git a/src/game/engine/src/rendering/indicator_renderer.gd b/src/game/engine/src/rendering/indicator_renderer.gd deleted file mode 100644 index 83f19bca..00000000 --- a/src/game/engine/src/rendering/indicator_renderer.gd +++ /dev/null @@ -1,295 +0,0 @@ -class_name IndicatorRenderer -extends Node2D -## Renders resource, village, and lair indicators as Sprite2D overlays. -## Sits above the river overlay layer so indicators are never occluded by rivers. -## -## Fog of war rules: -## visibility == 0 (unexplored): no indicator shown -## visibility == 1 (fog): show if previously seen (static last-seen state) -## visibility == 2 (visible): always show -## -## Resource visibility: respects tile.is_resource_visible_to(player_index) -## so tech-gated resources remain hidden until the player researches the tech. -## -## Sprite paths (theme-relative, 64x64 PNG icons): -## sprites/indicators/village.png -## sprites/indicators/lair_.png (e.g. lair_beast_den.png) -## sprites/indicators/lair_default.png (fallback for unknown lair types) -## -## Geometric fallback: if a sprite is missing, a small colored Polygon2D is -## rendered instead. Missing sprites do NOT push_error — indicators are optional -## cosmetic overlays, not terrain. - -const HexUtilsScript: GDScript = preload("res://engine/src/map/hex_utils.gd") -const GameMapScript: GDScript = preload("res://engine/src/map/game_map.gd") -const TileScript: GDScript = preload("res://engine/src/map/tile.gd") - -## Offset of village icon from hex center (top-left quadrant) -const VILLAGE_OFFSET: Vector2 = Vector2(-12.0, -10.0) -## Offset of lair icon from hex center (bottom-center) -const LAIR_OFFSET: Vector2 = Vector2(0.0, 8.0) -## Offset of resource icon from hex center (top-right quadrant) -const RESOURCE_OFFSET: Vector2 = Vector2(12.0, -10.0) -## Scale applied to 96x96 source icons so they fit on a hex tile -const ICON_SCALE: Vector2 = Vector2(0.8, 0.8) -## Offset of loot indicator from hex center (bottom-left quadrant) -const LOOT_OFFSET: Vector2 = Vector2(-12.0, 8.0) -## Number of village sprite variants (village_0.png through village_N.png) -const VILLAGE_VARIANT_COUNT: int = 4 - -## Glyph-cluster → tint color for the geometric lair fallback diamond. -## Predators = warm reds, herbivores = greens, aerial = light blue, -## aquatic = deep blue, invertebrates/undead = purple, mythic = orange. -const CLUSTER_COLORS: Dictionary = { - "canines": Color(0.85, 0.20, 0.15, 0.9), - "ursids": Color(0.70, 0.35, 0.10, 0.9), - "cervids": Color(0.30, 0.75, 0.25, 0.9), - "bovids": Color(0.40, 0.70, 0.20, 0.9), - "felids": Color(0.90, 0.50, 0.10, 0.9), - "raptors": Color(0.55, 0.78, 0.95, 0.9), - "waterfowl": Color(0.40, 0.65, 0.90, 0.9), - "fish": Color(0.20, 0.45, 0.85, 0.9), - "reptiles": Color(0.75, 0.30, 0.10, 0.9), - "mythic": Color(0.90, 0.55, 0.15, 0.9), - "invertebrates": Color(0.60, 0.20, 0.75, 0.9), - "marine_mammals": Color(0.25, 0.55, 0.80, 0.9), - "generic": Color(0.85, 0.15, 0.15, 0.9), -} - -var _texture_cache: Dictionary = {} -var _indicator_nodes: Dictionary = {} # axial Vector2i → Array[Node2D] -var _player_index: int = 0 -var _fog_disabled: bool = false -## Cached lair_type → glyph_cluster from wilds config (built on first use). -var _lair_cluster_cache: Dictionary = {} - - -func initialize(player_index: int) -> void: - _player_index = player_index - _fog_disabled = EnvConfig.get_bool("FORCE_DISABLE_FOGOFWAR") - - -func render_indicators(game_map: GameMapScript) -> void: - _clear_all() - for axial: Vector2i in game_map.tiles: - var tile: TileScript = game_map.tiles[axial] as TileScript - if tile == null: - continue - _render_tile_indicators(axial, tile) - - -func update_visibility(game_map: GameMapScript, player_index: int) -> void: - _player_index = player_index - render_indicators(game_map) - - -func _render_tile_indicators(axial: Vector2i, tile: TileScript) -> void: - var vis: int = tile.get_visibility(_player_index) if not _fog_disabled else 2 - if vis == 0: - return # Unexplored — show nothing - - var origin: Vector2 = HexUtilsScript.axial_to_pixel(axial) - var center: Vector2 = HexUtilsScript.hex_center - var z: int = HexUtilsScript.axial_to_offset(axial).y + 2 # above rivers (+1) - - var nodes: Array[Node2D] = [] - - # Resource indicator (top-right corner) - var resource_id: String = tile.resource_id - if resource_id != "" and tile.is_resource_visible_to(_player_index): - var res_node: Node2D = _make_resource_indicator(resource_id, origin, center, z) - if res_node != null: - add_child(res_node) - nodes.append(res_node) - - # Village overlay — full hex tile size, composited on terrain - if tile.village: - var vi: int = (axial.x * 7 + axial.y * 13) % VILLAGE_VARIANT_COUNT - var vil_path: String = "sprites/indicators/village_%d.png" % vi - var vil_node: Node2D = _make_tile_overlay(vil_path, origin, z) - if vil_node == null: - vil_node = _make_tile_overlay( - "sprites/indicators/village.png", origin, z - ) - if vil_node != null: - add_child(vil_node) - nodes.append(vil_node) - - # Lair indicator (bottom-center) - var lair_type: String = tile.lair_type - if lair_type != "": - var lair_node: Node2D = _make_lair_indicator(lair_type, origin, center, z) - add_child(lair_node) - nodes.append(lair_node) - - # Ground loot indicator (bottom-left) - if not tile.ground_items.is_empty(): - var loot_node: Node2D = _make_loot_indicator(tile.ground_items.size(), origin, center, z) - add_child(loot_node) - nodes.append(loot_node) - - if not nodes.is_empty(): - _indicator_nodes[axial] = nodes - - -func _make_resource_indicator( - resource_id: String, origin: Vector2, center: Vector2, z: int -) -> Node2D: - var res_data: Dictionary = DataLoader.get_resource(resource_id) - # Try a theme sprite first: sprites/indicators/resource_.png - var sprite_path: String = "sprites/indicators/resource_%s.png" % resource_id - var tex: Texture2D = _load_texture(sprite_path) - if tex != null: - return _make_sprite_indicator(sprite_path, origin, center + RESOURCE_OFFSET, z) - - # Geometric fallback: small colored square - var is_luxury: bool = res_data.get("category", "") == "luxury" - var sq: Polygon2D = Polygon2D.new() - var h: float = 5.0 - sq.polygon = PackedVector2Array([ - Vector2(-h, -h), Vector2(h, -h), Vector2(h, h), Vector2(-h, h) - ]) - sq.color = Color(1.0, 0.82, 0.1, 0.9) if is_luxury else Color(0.85, 0.85, 0.85, 0.9) - sq.position = origin + center + RESOURCE_OFFSET - sq.z_index = z - var border: Line2D = Line2D.new() - border.points = sq.polygon - border.closed = true - border.width = 1.0 - border.default_color = Color(0.0, 0.0, 0.0, 0.8) - sq.add_child(border) - return sq - - -func _make_lair_indicator(lair_type: String, origin: Vector2, center: Vector2, z: int) -> Node2D: - var typed_path: String = "sprites/indicators/lair_%s.png" % lair_type - var tex: Texture2D = _load_texture(typed_path) - if tex != null: - return _make_sprite_indicator(typed_path, origin, center + LAIR_OFFSET, z) - - # Fall back to the generic lair sprite - var default_path: String = "sprites/indicators/lair_default.png" - tex = _load_texture(default_path) - if tex != null: - return _make_sprite_indicator(default_path, origin, center + LAIR_OFFSET, z) - - # Geometric fallback: cluster-tinted diamond silhouette - var cluster: String = _get_lair_cluster(lair_type) - var dia_color: Color = CLUSTER_COLORS.get(cluster, CLUSTER_COLORS["generic"]) as Color - return _make_cluster_diamond(origin + center + LAIR_OFFSET, dia_color, z) - - -func _get_lair_cluster(lair_type: String) -> String: - ## Return glyph_cluster for lair_type, building the lookup on first call. - if _lair_cluster_cache.is_empty(): - var cfg: Dictionary = DataLoader.get_wilds_config() - var lair_types: Array = cfg.get("lair_types", []) - for entry: Variant in lair_types: - if entry is Dictionary: - var id: String = String(entry.get("id", "")) - var cluster: String = String(entry.get("glyph_cluster", "generic")) - if not id.is_empty(): - _lair_cluster_cache[id] = cluster - return String(_lair_cluster_cache.get(lair_type, "generic")) - - -func _make_cluster_diamond(world_pos: Vector2, color: Color, z: int) -> Node2D: - ## Draw a small colored diamond silhouette for lair types without sprite art. - var dia: Polygon2D = Polygon2D.new() - var r: float = 5.0 - dia.polygon = PackedVector2Array([ - Vector2(0.0, -r), Vector2(r * 0.7, 0.0), Vector2(0.0, r), Vector2(-r * 0.7, 0.0) - ]) - dia.color = color - dia.position = world_pos - dia.z_index = z - var border: Line2D = Line2D.new() - border.points = dia.polygon - border.closed = true - border.width = 1.0 - border.default_color = Color(0.0, 0.0, 0.0, 0.8) - dia.add_child(border) - return dia - - -func _make_sprite_indicator( - theme_path: String, origin: Vector2, world_offset: Vector2, z: int -) -> Node2D: - var tex: Texture2D = _load_texture(theme_path) - if tex == null: - # Return a tiny white square as last-resort fallback (no push_error for indicators) - var sq: Polygon2D = Polygon2D.new() - sq.polygon = PackedVector2Array([ - Vector2(-4, -4), Vector2(4, -4), Vector2(4, 4), Vector2(-4, 4) - ]) - sq.color = Color(1, 1, 1, 0.5) - sq.position = origin + world_offset - sq.z_index = z - return sq - - var sprite: Sprite2D = Sprite2D.new() - sprite.texture = tex - sprite.centered = true - sprite.scale = ICON_SCALE - sprite.position = origin + world_offset - sprite.z_index = z - return sprite - - -func _make_loot_indicator( - item_count: int, origin: Vector2, center: Vector2, z: int, -) -> Node2D: - ## Small yellow-green diamond indicating dropped items on a tile. - var world_offset: Vector2 = center + LOOT_OFFSET - var poly: Polygon2D = Polygon2D.new() - var sz: float = 6.0 - poly.polygon = PackedVector2Array([ - Vector2(0, -sz), Vector2(sz, 0), Vector2(0, sz), Vector2(-sz, 0), - ]) - poly.color = Color(0.85, 0.9, 0.2, 0.9) - poly.position = origin + world_offset - poly.z_index = z + 1 - if item_count > 1: - var label: Label = Label.new() - label.text = str(item_count) - label.add_theme_font_size_override("font_size", 9) - label.add_theme_color_override("font_color", Color.WHITE) - label.position = origin + world_offset + Vector2(6, -8) - label.z_index = z + 2 - var wrapper: Node2D = Node2D.new() - wrapper.add_child(poly) - wrapper.add_child(label) - return wrapper - return poly - - -func _make_tile_overlay( - theme_path: String, origin: Vector2, z: int, -) -> Node2D: - ## Full hex tile overlay (384×332) — composited on terrain like rivers. - var tex: Texture2D = _load_texture(theme_path) - if tex == null: - return null - var poly: Polygon2D = Polygon2D.new() - poly.polygon = HexUtilsScript.hex_polygon - poly.texture = tex - poly.uv = HexUtilsScript.hex_polygon - poly.position = origin - poly.z_index = z - return poly - - -func _clear_all() -> void: - for nodes in _indicator_nodes.values(): - for node: Node2D in (nodes as Array): - if is_instance_valid(node): - node.queue_free() - _indicator_nodes.clear() - - -func _load_texture(relative_path: String) -> Texture2D: - if _texture_cache.has(relative_path): - return _texture_cache[relative_path] as Texture2D - var tex: Texture2D = ThemeAssets.load_sprite(relative_path) - _texture_cache[relative_path] = tex - return tex diff --git a/src/game/engine/src/rendering/movement_animator.gd b/src/game/engine/src/rendering/movement_animator.gd deleted file mode 100644 index 9d62f2ed..00000000 --- a/src/game/engine/src/rendering/movement_animator.gd +++ /dev/null @@ -1,67 +0,0 @@ -extends Node -## Animates unit movement along a hex path using Tweens. -## Lerps position from tile to tile at a configurable speed. -## Optionally makes the camera follow the moving unit. - -const HexUtilsScript: GDScript = preload("res://engine/src/map/hex_utils.gd") - -const MOVE_DURATION_PER_TILE: float = 0.2 -const CAMERA_FOLLOW_OFFSET: Vector2 = Vector2(48.0, 24.0) - -## Whether the camera should follow animated units -var camera_follow: bool = true - -## Currently animating — prevents overlapping animations -var _is_animating: bool = false - -## Reference to the world map camera for following -var _camera: Camera2D = null - - -func initialize(camera: Camera2D) -> void: - _camera = camera - - -func is_animating() -> bool: - return _is_animating - - -func animate_movement( - unit_node: Node2D, path: Array[Vector2i], on_complete: Callable, -) -> void: - ## Animate a unit node along a path of axial positions. - ## Calls on_complete when the animation finishes. - if path.size() < 2: - on_complete.call() - return - - if _is_animating: - # Queue silently — skip animation and jump to end - unit_node.position = HexUtilsScript.axial_to_pixel(path[-1]) + HexUtilsScript.hex_center - on_complete.call() - return - - _is_animating = true - - var tween: Tween = create_tween() - tween.set_ease(Tween.EASE_IN_OUT) - tween.set_trans(Tween.TRANS_SINE) - - # Skip first position (unit is already there) - for i: int in range(1, path.size()): - var target_pixel: Vector2 = HexUtilsScript.axial_to_pixel(path[i]) + HexUtilsScript.hex_center - tween.tween_property( - unit_node, "position", target_pixel, MOVE_DURATION_PER_TILE - ) - - if camera_follow and _camera != null: - tween.parallel().tween_callback( - _camera.center_on.bind(target_pixel) - ) - - tween.tween_callback(_on_animation_complete.bind(on_complete)) - - -func _on_animation_complete(on_complete: Callable) -> void: - _is_animating = false - on_complete.call() diff --git a/src/game/engine/src/rendering/river_renderer.gd b/src/game/engine/src/rendering/river_renderer.gd deleted file mode 100644 index b53c4ce0..00000000 --- a/src/game/engine/src/rendering/river_renderer.gd +++ /dev/null @@ -1,121 +0,0 @@ -class_name RiverRenderer -extends Node2D -## Renders rivers as tile-center overlays with channels connecting to adjacent tiles. -## Each river tile gets a single sprite showing water flowing from center to each -## connected edge — like a resource overlay with directional connectivity. -## -## Sprite naming: sprites/terrain/rivers/river___.png -## state: "flowing" or "frozen" -## width: "stream" (<2), "river" (2-8), "major" (8-20), "great" (20+) -## bitmask: 6-bit int, bit i = edge i connected (e.g. 3 = edges 0+1) - -const HexUtilsScript: GDScript = preload("res://engine/src/map/hex_utils.gd") -const GameMapScript: GDScript = preload("res://engine/src/map/game_map.gd") -const TileScript: GDScript = preload("res://engine/src/map/tile.gd") - -## Width class thresholds -const WIDTH_CLASSES: Array = [ - [2.0, "stream"], - [8.0, "river"], - [20.0, "major"], -] -const WIDTH_DEFAULT: String = "great" -## Ordered width classes for desert downgrade (wadi effect: one step narrower). -const WIDTH_CLASS_ORDER: Array[String] = ["stream", "river", "major", "great"] - -var _texture_cache: Dictionary = {} -var _player_index: int = 0 -var _fog_disabled: bool = false -var _river_nodes: Dictionary = {} - - -func initialize(player_index: int) -> void: - _player_index = player_index - _fog_disabled = EnvConfig.get_bool("FORCE_DISABLE_FOGOFWAR") - - -func render_rivers(game_map: GameMapScript) -> void: - ## Place one river overlay sprite per tile with river edges. - _clear_rivers() - - for axial: Vector2i in game_map.tiles: - var tile: TileScript = game_map.tiles[axial] as TileScript - if tile == null or tile.river_edges.is_empty(): - continue - # Rivers don't render on water tiles (ocean, coast, lake, inland_sea) - if tile.is_water(): - continue - if not _fog_disabled and tile.get_visibility(_player_index) < 1: - continue - - # Build bitmask from river_edges - var bitmask: int = 0 - var max_flow: float = 0.0 - var has_frozen: bool = false - for edge: int in tile.river_edges: - if edge < 0 or edge > 5: - continue - bitmask |= (1 << edge) - var flow: float = float(tile.river_flow.get(edge, 1.0)) - if flow < 0.0: - has_frozen = true - max_flow = maxf(max_flow, absf(flow)) - - if bitmask == 0: - continue - - var state: String = "frozen" if has_frozen else "flowing" - var width_class: String = _get_width_class(max_flow, tile.terrain_id) - var sprite_path: String = "sprites/terrain/rivers/river_%s_%s_%d.png" % [ - state, width_class, bitmask - ] - - var tex: Texture2D = _load_texture(sprite_path) - if tex == null: - continue - - var origin: Vector2 = HexUtilsScript.axial_to_pixel(axial) - var poly: Polygon2D = Polygon2D.new() - poly.polygon = HexUtilsScript.hex_polygon - poly.texture = tex - poly.uv = HexUtilsScript.hex_polygon - poly.position = origin - var offset_pos: Vector2i = HexUtilsScript.axial_to_offset(axial) - poly.z_index = offset_pos.y + 1 - - poly.modulate = Color(0.6, 0.75, 0.85, 0.6) - add_child(poly) - _river_nodes[axial] = poly - - -func update_visibility(game_map: GameMapScript, player_index: int) -> void: - _player_index = player_index - render_rivers(game_map) - - -func _clear_rivers() -> void: - for node: Node2D in _river_nodes.values(): - node.queue_free() - _river_nodes.clear() - - -func _load_texture(sprite_path: String) -> Texture2D: - if _texture_cache.has(sprite_path): - return _texture_cache[sprite_path] as Texture2D - var tex: Texture2D = ThemeAssets.load_sprite(sprite_path) - _texture_cache[sprite_path] = tex - return tex - - -static func _get_width_class(abs_flow: float, terrain_id: String = "") -> String: - var base: String = WIDTH_DEFAULT - for pair: Array in WIDTH_CLASSES: - if abs_flow < float(pair[0]): - base = pair[1] as String - break - # Desert rivers are visually narrowed by one width class (wadi effect). - if terrain_id == "desert": - var idx: int = WIDTH_CLASS_ORDER.find(base) - if idx > 0: - return WIDTH_CLASS_ORDER[idx - 1] - return base diff --git a/src/game/engine/src/rendering/road_renderer.gd b/src/game/engine/src/rendering/road_renderer.gd deleted file mode 100644 index 173e5a40..00000000 --- a/src/game/engine/src/rendering/road_renderer.gd +++ /dev/null @@ -1,88 +0,0 @@ -class_name RoadRenderer -extends Node2D -## Renders road improvement connections as sprite overlays on hex edges. -## A road edge is drawn between two adjacent tiles that both have improvement == "road". -## -## Sprite path: sprites/terrain/roads/road_.png -## dir: 0-5 (E, NE, NW, W, SW, SE) - -const HexUtilsScript: GDScript = preload("res://engine/src/map/hex_utils.gd") -const GameMapScript: GDScript = preload("res://engine/src/map/game_map.gd") -const TileScript: GDScript = preload("res://engine/src/map/tile.gd") - -var _texture_cache: Dictionary = {} -var _player_index: int = 0 -var _fog_disabled: bool = false -var _road_nodes: Dictionary = {} - - -func initialize(player_index: int) -> void: - _player_index = player_index - _fog_disabled = EnvConfig.get_bool("FORCE_DISABLE_FOGOFWAR") - - -func render_roads(game_map: GameMapScript) -> void: - ## Build road sprite overlays for all connected road tiles. - _clear_roads() - - for axial: Vector2i in game_map.tiles: - var tile: TileScript = game_map.tiles[axial] as TileScript - if tile == null or tile.improvement != "road": - continue - if not _fog_disabled and tile.get_visibility(_player_index) < 1: - continue - - var origin: Vector2 = HexUtilsScript.axial_to_pixel(axial) - var container: Node2D = Node2D.new() - container.position = origin - var offset_pos: Vector2i = HexUtilsScript.axial_to_offset(axial) - container.z_index = offset_pos.y + 1 - - var has_edges: bool = false - for dir_idx: int in 6: - var neighbor_pos: Vector2i = axial + HexUtilsScript.AXIAL_DIRECTIONS[dir_idx] - var neighbor: TileScript = game_map.tiles.get(neighbor_pos) as TileScript - if neighbor == null: - continue - if neighbor.improvement != "road": - continue - - var sprite_path: String = "sprites/terrain/roads/road_%d.png" % dir_idx - var tex: Texture2D = _load_texture(sprite_path) - if tex == null: - # Try direction 0 as fallback - tex = _load_texture("sprites/terrain/roads/road_0.png") - if tex == null: - continue - - var poly: Polygon2D = Polygon2D.new() - poly.polygon = HexUtilsScript.hex_polygon - poly.texture = tex - poly.uv = HexUtilsScript.hex_polygon - container.add_child(poly) - has_edges = true - - if has_edges: - add_child(container) - _road_nodes[axial] = container - else: - container.queue_free() - - -func update_visibility(game_map: GameMapScript, player_index: int) -> void: - _player_index = player_index - render_roads(game_map) - - -func _clear_roads() -> void: - for node: Node2D in _road_nodes.values(): - node.queue_free() - _road_nodes.clear() - - -func _load_texture(sprite_path: String) -> Texture2D: - if _texture_cache.has(sprite_path): - return _texture_cache[sprite_path] as Texture2D - var tex: Texture2D = ThemeAssets.load_sprite(sprite_path) - _texture_cache[sprite_path] = tex - return tex