refactor(@projects/@magic-civilization): 🔥 cull 13 dead .gd files (~3,135 LOC) — orphaned during atomic rebuild

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) <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-06-19 18:19:59 -05:00
parent 42ac86e7ec
commit 7dd049df13
13 changed files with 0 additions and 3135 deletions

View file

@ -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,
}

View file

@ -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

View file

@ -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

View file

@ -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 (01 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)
)

View file

@ -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

View file

@ -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

View file

@ -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]]

View file

@ -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

View file

@ -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 9901035 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)

View file

@ -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_<lair_type>.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_<id>.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

View file

@ -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()

View file

@ -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_<state>_<width>_<bitmask>.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

View file

@ -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_<dir>.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