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:
parent
42ac86e7ec
commit
7dd049df13
13 changed files with 0 additions and 3135 deletions
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -1,254 +0,0 @@
|
|||
extends RefCounted
|
||||
## Global atmospheric chemistry step — O2/CO2/CH4 mass balance, greenhouse forcing,
|
||||
## and ecological collapse threshold logic.
|
||||
## All functions are static and operate on a climate_state (ClimateBase subclass)
|
||||
## plus the game_map and spec. Called by Climate._step_atmospheric_chemistry().
|
||||
|
||||
|
||||
static func process_step(
|
||||
climate_state: Object, game_map: RefCounted, spec: Dictionary
|
||||
) -> void:
|
||||
## Update global O2/CO2/CH4 concentrations from tile-level photosynthesis,
|
||||
## respiration, decomposition, and volcanic outgassing.
|
||||
## Stores greenhouse temperature bias in climate_state.global_temp_bias.
|
||||
## Checks ecological collapse thresholds and updates photosynthesis_multiplier.
|
||||
var chem: Dictionary = spec.get("atmospheric_chemistry", {})
|
||||
var photo: Dictionary = chem.get("photosynthesis", {})
|
||||
var resp: Dictionary = chem.get("respiration", {})
|
||||
var greenhouse: Dictionary = chem.get("greenhouse", {})
|
||||
|
||||
var land_canopy_rate: float = photo.get("land_canopy_rate", 0.00004)
|
||||
var ocean_phyto_rate: float = photo.get("ocean_phyto_rate", 0.00003)
|
||||
var aerosol_suppression: float = photo.get("aerosol_suppression", 0.6)
|
||||
var fauna_resp_rate: float = resp.get("fauna_rate", 0.000015)
|
||||
var decomp_co2_rate: float = resp.get("decomp_co2_rate", 0.000008)
|
||||
var decomp_ch4_rate: float = resp.get("decomp_ch4_rate", 0.000002)
|
||||
var wetland_ch4_rate: float = greenhouse.get("wetland_ch4_rate", 0.000003)
|
||||
var ch4_decay: float = greenhouse.get("ch4_decay_rate", 0.02)
|
||||
|
||||
# Aerosol suppresses photosynthesis
|
||||
var aerosol_factor: float = maxf(
|
||||
0.0, 1.0 - climate_state.global_aerosol * aerosol_suppression
|
||||
)
|
||||
|
||||
# Accumulate O2/CO2/CH4 fluxes across all tiles
|
||||
var total_o2_production: float = 0.0
|
||||
var total_o2_consumption: float = 0.0
|
||||
var total_co2_production: float = 0.0
|
||||
var total_ch4_production: float = 0.0
|
||||
var tile_count: int = 0
|
||||
|
||||
# Determine ocean O2 multiplier once before the tile loop — honours
|
||||
# suspended turns set by ecological events (e.g. oil-spill, anoxic bloom).
|
||||
var ocean_o2_mult: float = _ocean_o2_multiplier(climate_state, chem)
|
||||
|
||||
for tile: Variant in game_map.tiles.values():
|
||||
tile_count += 1
|
||||
var biome_photo_rate: float = tile.get("photosynthesis_rate", land_canopy_rate)
|
||||
var biome_resp_rate: float = tile.get("respiration_rate", fauna_resp_rate)
|
||||
var canopy: float = tile.get("canopy_cover", 0.0)
|
||||
var fauna: float = float(tile.get("fauna_population", 0)) / 25.0
|
||||
var fungi: float = tile.get("fungi_cover", 0.0)
|
||||
var is_wetland: bool = tile.get("substrate", "") in ["wetland", "lake_bed"]
|
||||
var is_water: bool = tile.get("is_water", false)
|
||||
|
||||
# O2 production (photosynthesis)
|
||||
if is_water:
|
||||
var water_flora: float = tile.get("water_flora", tile.get("reef_health", 0.5))
|
||||
total_o2_production += water_flora * ocean_phyto_rate * aerosol_factor * ocean_o2_mult
|
||||
else:
|
||||
total_o2_production += (
|
||||
canopy
|
||||
* biome_photo_rate
|
||||
* aerosol_factor
|
||||
* climate_state.photosynthesis_multiplier
|
||||
)
|
||||
|
||||
# O2 consumption (respiration)
|
||||
total_o2_consumption += fauna * biome_resp_rate
|
||||
|
||||
# CO2 from decomposition (all biomes)
|
||||
total_co2_production += fungi * decomp_co2_rate * 1000.0 # scale to ppm
|
||||
|
||||
# CH4 from decomposition: aerobic baseline everywhere, enhanced for wetland anaerobic
|
||||
var tile_ch4_rate: float = decomp_ch4_rate
|
||||
if is_wetland:
|
||||
tile_ch4_rate += wetland_ch4_rate
|
||||
total_ch4_production += fungi * tile_ch4_rate * 1000000.0 # scale to ppb
|
||||
|
||||
# Normalize by tile count — per-tile averages scaled to a 1000-tile reference map
|
||||
if tile_count > 0:
|
||||
var scale: float = float(tile_count) / 1000.0
|
||||
climate_state.o2_fraction += (total_o2_production - total_o2_consumption) * scale
|
||||
climate_state.co2_ppm += total_co2_production * scale
|
||||
climate_state.ch4_ppb += total_ch4_production * scale
|
||||
|
||||
# Volcanic CO2 injection (global_aerosol proxies volcanic activity level)
|
||||
var volcanic_co2: float = chem.get("volcanic", {}).get("co2_per_aerosol_unit", 120.0)
|
||||
climate_state.co2_ppm += climate_state.global_aerosol * volcanic_co2
|
||||
|
||||
# CH4 atmospheric decay (oxidation by OH radical)
|
||||
climate_state.ch4_ppb *= (1.0 - ch4_decay)
|
||||
|
||||
# Tick down and apply any pending delayed effects from ecological events
|
||||
var still_pending: Array = []
|
||||
for effect: Dictionary in climate_state.pending_atmo_effects:
|
||||
effect.turns_remaining -= 1
|
||||
if effect.turns_remaining <= 0:
|
||||
climate_state.o2_fraction += effect.get("o2_delta", 0.0)
|
||||
climate_state.co2_ppm += effect.get("co2_gain", 0.0)
|
||||
climate_state.ch4_ppb += effect.get("ch4_pulse", 0.0)
|
||||
else:
|
||||
still_pending.append(effect)
|
||||
climate_state.pending_atmo_effects = still_pending
|
||||
|
||||
# CO2 + CH4 greenhouse temperature forcing
|
||||
climate_state.global_temp_bias = _compute_co2_temp_bias(
|
||||
climate_state.co2_ppm, greenhouse
|
||||
)
|
||||
climate_state.global_temp_bias += (
|
||||
climate_state.ch4_ppb * greenhouse.get("ch4_temp_multiplier", 0.00000005)
|
||||
)
|
||||
|
||||
# Ocean biosphere: fish stock, cascade phases, ocean toxicity
|
||||
_step_ocean_biosphere(climate_state, game_map, chem)
|
||||
|
||||
# Clamp to physical bounds
|
||||
climate_state.o2_fraction = clampf(climate_state.o2_fraction, 0.0, 0.35)
|
||||
climate_state.co2_ppm = clampf(climate_state.co2_ppm, 0.0, 1000000.0)
|
||||
climate_state.ch4_ppb = clampf(climate_state.ch4_ppb, 0.0, 10000000.0)
|
||||
|
||||
# Ecological collapse check — sustained O2 below critical threshold
|
||||
var collapse_cfg: Dictionary = chem.get("ecological_collapse", {})
|
||||
var o2_trigger: float = collapse_cfg.get("o2_trigger", 0.05)
|
||||
var sustained_turns: int = int(collapse_cfg.get("o2_sustained_turns", 10))
|
||||
var tipping_mult: float = collapse_cfg.get("photosynthesis_tipping_multiplier", 0.10)
|
||||
var recovery_rate: float = collapse_cfg.get("recovery_rate_per_turn", 0.0001)
|
||||
|
||||
if climate_state.o2_fraction < o2_trigger:
|
||||
climate_state.o2_collapse_turn_count += 1
|
||||
if climate_state.o2_collapse_turn_count >= sustained_turns:
|
||||
climate_state.ecological_collapse = true
|
||||
climate_state.photosynthesis_multiplier = minf(
|
||||
climate_state.photosynthesis_multiplier, tipping_mult
|
||||
)
|
||||
else:
|
||||
climate_state.o2_collapse_turn_count = 0
|
||||
if not climate_state.ecological_collapse:
|
||||
climate_state.photosynthesis_multiplier = minf(
|
||||
1.0, climate_state.photosynthesis_multiplier + recovery_rate
|
||||
)
|
||||
|
||||
|
||||
static func _compute_co2_temp_bias(co2_ppm_val: float, greenhouse: Dictionary) -> float:
|
||||
## Return temperature bias (0–1 scale) for the given CO2 concentration.
|
||||
## Reads piecewise bands from spec; returns 0.30 when above all defined ranges.
|
||||
var bands: Array = greenhouse.get("co2_temp_bias", [])
|
||||
for band: Dictionary in bands:
|
||||
var band_min: float = band.get("co2_ppm_min", 0.0)
|
||||
var band_max: float = band.get("co2_ppm_max", 999999.0)
|
||||
if co2_ppm_val >= band_min and co2_ppm_val < band_max:
|
||||
return band.get("temp_bias", 0.0)
|
||||
return 0.30 # above all defined bands
|
||||
|
||||
|
||||
static func _ocean_o2_multiplier(climate_state: Object, chem: Dictionary) -> float:
|
||||
## Return the current ocean photosynthesis multiplier based on ocean biosphere state.
|
||||
## Checks suspended-turns counter first (set by ecological events), then steady-state flags,
|
||||
## then fish-stock thresholds from spec.
|
||||
if climate_state.ocean_o2_suspended_turns > 0:
|
||||
climate_state.ocean_o2_suspended_turns -= 1
|
||||
return 0.0
|
||||
var ocean_states: Dictionary = chem.get("ocean_states", {})
|
||||
var fish: float = climate_state.global_fish_stock
|
||||
var mult: float = 1.0
|
||||
if climate_state.dead_ocean or climate_state.canfield_ocean:
|
||||
mult = 0.0
|
||||
elif climate_state.ocean_toxic:
|
||||
mult = ocean_states.get("toxic", {}).get("o2_contribution", 0.0)
|
||||
elif climate_state.ocean_anoxic or fish < ocean_states.get("anoxic", {}).get("fish_floor", 0.05):
|
||||
mult = ocean_states.get("anoxic", {}).get("o2_contribution", 0.20)
|
||||
elif fish < ocean_states.get("stressed", {}).get("fish_floor", 0.15):
|
||||
mult = ocean_states.get("stressed", {}).get("o2_contribution", 0.70)
|
||||
climate_state.ocean_o2_contribution = mult
|
||||
return mult
|
||||
|
||||
|
||||
static func _step_ocean_biosphere(
|
||||
climate_state: Object, game_map: RefCounted, chem: Dictionary
|
||||
) -> void:
|
||||
## Update global fish stock, tick trophic cascade phases, and check for new collapses.
|
||||
|
||||
# --- Compute global average fish stock from water tiles ---
|
||||
var total_fish: float = 0.0
|
||||
var water_tile_count: int = 0
|
||||
for tile: Variant in game_map.tiles.values():
|
||||
if tile.get("is_water", false):
|
||||
total_fish += float(tile.get("fish_stock", 50)) / 100.0
|
||||
water_tile_count += 1
|
||||
climate_state.global_fish_stock = total_fish / maxf(1.0, float(water_tile_count))
|
||||
|
||||
var fish_spec: Dictionary = chem.get("fish_collapse", {})
|
||||
|
||||
# --- Tick active trophic cascade ---
|
||||
if climate_state.trophic_cascade_active:
|
||||
climate_state.trophic_cascade_turns_remaining -= 1
|
||||
match climate_state.trophic_cascade_phase:
|
||||
1: # Bloom phase — brief O2 spike, CO2 drawdown
|
||||
climate_state.o2_fraction += 0.0005
|
||||
climate_state.co2_ppm -= 50.0
|
||||
if climate_state.trophic_cascade_turns_remaining <= 0:
|
||||
climate_state.trophic_cascade_phase = 2
|
||||
climate_state.trophic_cascade_turns_remaining = int(
|
||||
fish_spec.get("anoxic_phase_duration", 15)
|
||||
)
|
||||
2: # Anoxic phase — O2 crash, CO2/CH4 spike
|
||||
climate_state.o2_fraction -= 0.0012
|
||||
climate_state.co2_ppm += 200.0
|
||||
climate_state.ch4_ppb += 500.0
|
||||
if climate_state.trophic_cascade_turns_remaining <= 0:
|
||||
climate_state.trophic_cascade_phase = 3
|
||||
climate_state.trophic_cascade_turns_remaining = int(
|
||||
fish_spec.get("toxification_phase_duration", 30)
|
||||
)
|
||||
climate_state.ocean_toxic = true
|
||||
3: # Toxic phase — sustained O2 drain, heavy outgassing
|
||||
climate_state.o2_fraction -= fish_spec.get("o2_loss_while_toxic", 0.0008)
|
||||
climate_state.co2_ppm += 500.0
|
||||
climate_state.ch4_ppb += 1500.0
|
||||
if climate_state.trophic_cascade_turns_remaining <= 0:
|
||||
climate_state.trophic_cascade_active = false
|
||||
climate_state.trophic_cascade_phase = 0
|
||||
# Ocean toxicity persists — decays in the block below
|
||||
|
||||
# --- Ocean toxicity decay / accumulation ---
|
||||
if climate_state.ocean_toxic:
|
||||
var recovery_threshold: float = fish_spec.get("recovery_fish_threshold", 0.25)
|
||||
if climate_state.global_fish_stock > recovery_threshold:
|
||||
climate_state.ocean_toxicity = maxf(
|
||||
0.0,
|
||||
climate_state.ocean_toxicity - fish_spec.get("ocean_toxicity_decay", 0.005)
|
||||
)
|
||||
if climate_state.ocean_toxicity < 0.01:
|
||||
climate_state.ocean_toxic = false
|
||||
else:
|
||||
climate_state.ocean_toxicity = minf(
|
||||
1.0, climate_state.ocean_toxicity + 0.01
|
||||
)
|
||||
|
||||
# --- Fish collapse check (fires every N turns) ---
|
||||
var check_interval: int = int(fish_spec.get("global_fish_check_interval", 5))
|
||||
climate_state.fish_collapse_check_timer += 1
|
||||
if climate_state.fish_collapse_check_timer >= check_interval:
|
||||
climate_state.fish_collapse_check_timer = 0
|
||||
var critical: float = fish_spec.get("collapse_threshold", 0.05)
|
||||
if (
|
||||
climate_state.global_fish_stock < critical
|
||||
and not climate_state.trophic_cascade_active
|
||||
and not climate_state.dead_ocean
|
||||
):
|
||||
climate_state.trophic_cascade_active = true
|
||||
climate_state.trophic_cascade_phase = 1
|
||||
climate_state.trophic_cascade_turns_remaining = int(
|
||||
fish_spec.get("bloom_phase_duration", 8)
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
@ -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]]
|
||||
|
|
@ -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
|
||||
|
|
@ -1,475 +0,0 @@
|
|||
extends RefCounted
|
||||
## Renders per-tile debug and lens overlays onto hex tile container nodes.
|
||||
##
|
||||
## Overlays are identified by the Overlay enum. Enable/disable via `active_overlay`.
|
||||
## At most one heatmap overlay is shown at a time; the wind arrow is independent.
|
||||
##
|
||||
## Adding a new overlay:
|
||||
## 1. Add a value to the Overlay enum.
|
||||
## 2. Add a branch in render_overlays() and implement a dedicated render method.
|
||||
## 3. Wire the new mode string in world_map._on_map_overlay_changed().
|
||||
##
|
||||
## Heatmap color convention:
|
||||
## Temperature: lerp(TEMP_COLD_COLOR, TEMP_HOT_COLOR, t)
|
||||
## Moisture: lerp(MOIST_DRY_COLOR, MOIST_WET_COLOR, m)
|
||||
|
||||
## Overlay modes. NONE disables all heatmap overlays.
|
||||
enum Overlay {
|
||||
NONE, TEMPERATURE, MOISTURE, WEATHER,
|
||||
LAND_VALUE, WIND_HEATMAP, WATER, ELEVATION, CORRUPTION,
|
||||
PRESSURE, HUMIDITY, NATURAL_EVENT,
|
||||
# LEY_LINE — Game 3+
|
||||
}
|
||||
|
||||
const HexUtilsScript: GDScript = preload("res://engine/src/map/hex_utils.gd")
|
||||
const TileScript: GDScript = preload("res://engine/src/map/tile.gd")
|
||||
|
||||
## Wind arrow angles (radians) indexed by hex edge direction (flat-top).
|
||||
## 0=E=0°, 1=NE=-60°, 2=NW=-120°, 3=W=180°, 4=SW=120°, 5=SE=60°
|
||||
const WIND_ANGLES: Array[float] = [0.0, -1.0472, -2.0944, 3.14159, 2.0944, 1.0472]
|
||||
const WIND_ARROW_BASE_LEN: float = 10.0
|
||||
const WIND_ARROW_SPEED_SCALE: float = 8.0
|
||||
const WIND_ARROW_TAIL_RATIO: float = 0.3
|
||||
const WIND_ARROW_HEAD_LEN: float = 4.0
|
||||
const WIND_ARROW_HEAD_ANGLE: float = 0.45
|
||||
const WIND_ARROW_WIDTH: float = 1.5
|
||||
const WIND_ARROW_COLOR: Color = Color(0.2, 0.6, 1.0, 0.85)
|
||||
|
||||
## Temperature heatmap: blue (cold) → green (temperate) → red (hot)
|
||||
const TEMP_COLD_COLOR: Color = Color(0.0, 0.2, 1.0, 0.35)
|
||||
const TEMP_HOT_COLOR: Color = Color(1.0, 0.1, 0.0, 0.35)
|
||||
|
||||
## Moisture heatmap: sandy brown (dry) → teal (wet)
|
||||
const MOIST_DRY_COLOR: Color = Color(0.76, 0.60, 0.30, 0.35)
|
||||
const MOIST_WET_COLOR: Color = Color(0.0, 0.45, 0.8, 0.35)
|
||||
|
||||
## Color map for weather event types (non-magic climate events only).
|
||||
const WEATHER_COLORS: Dictionary = {
|
||||
"rainstorm": Color(0.1, 0.4, 0.95, 0.45),
|
||||
"drought": Color(0.9, 0.45, 0.1, 0.45),
|
||||
"blizzard": Color(0.85, 0.92, 1.0, 0.5),
|
||||
"monsoon": Color(0.0, 0.55, 0.85, 0.5),
|
||||
"sandstorm": Color(0.85, 0.72, 0.3, 0.45),
|
||||
"jet_stream": Color(0.5, 0.8, 1.0, 0.4),
|
||||
"default": Color(0.6, 0.3, 0.8, 0.4),
|
||||
}
|
||||
|
||||
const FLOOD_WARNING_COLOR: Color = Color(0.95, 0.15, 0.15, 0.55)
|
||||
|
||||
## Land value lens: three-stop gradient red → yellow → green
|
||||
const LAND_LOW_COLOR: Color = Color(0.7, 0.1, 0.1, 0.65)
|
||||
const LAND_MID_COLOR: Color = Color(0.85, 0.75, 0.15, 0.6)
|
||||
const LAND_HIGH_COLOR: Color = Color(0.1, 0.8, 0.2, 0.65)
|
||||
const LAND_WATER_COLOR: Color = Color(0.03, 0.05, 0.2, 0.7)
|
||||
const LAND_EXCLUSION_COLOR: Color = Color(0.15, 0.15, 0.15, 0.7)
|
||||
const LAND_VALUE_MAX: float = 24.0
|
||||
const CITY_MIN_DISTANCE: int = 4
|
||||
|
||||
## Wind heatmap: calm blue → white → strong orange/red
|
||||
const WIND_CALM_COLOR: Color = Color(0.15, 0.3, 0.7, 0.6)
|
||||
const WIND_MID_COLOR: Color = Color(0.85, 0.85, 0.9, 0.55)
|
||||
const WIND_STRONG_COLOR: Color = Color(0.9, 0.35, 0.1, 0.65)
|
||||
|
||||
## Water lens colors
|
||||
const FLOW_LOW_COLOR: Color = Color(0.2, 0.4, 0.7, 0.4)
|
||||
const FLOW_HIGH_COLOR: Color = Color(0.1, 0.3, 0.95, 0.65)
|
||||
const LAKE_HIGHLIGHT_COLOR: Color = Color(0.1, 0.7, 0.85, 0.6)
|
||||
const RIVER_LINE_COLOR: Color = Color(0.15, 0.4, 0.95, 0.9)
|
||||
const RIVER_MIN_WIDTH: float = 1.5
|
||||
const RIVER_MAX_WIDTH: float = 5.0
|
||||
const FLOW_NORMALIZE: float = 120.0
|
||||
|
||||
## River source marker colors
|
||||
const SOURCE_COLORS: Dictionary = {
|
||||
"snowmelt": Color(0.9, 0.95, 1.0, 0.9),
|
||||
"spring": Color(0.2, 0.8, 0.3, 0.9),
|
||||
"hot_spring": Color(0.9, 0.5, 0.15, 0.9),
|
||||
"glacial": Color(0.5, 0.8, 1.0, 0.9),
|
||||
}
|
||||
const SOURCE_MARKER_RADIUS: float = 6.0
|
||||
|
||||
## Elevation lens: green (low) → brown (mid) → white (peaks)
|
||||
const ELEV_LOW_COLOR: Color = Color(0.15, 0.45, 0.15, 0.6)
|
||||
const ELEV_MID_COLOR: Color = Color(0.55, 0.4, 0.2, 0.6)
|
||||
const ELEV_HIGH_COLOR: Color = Color(0.92, 0.92, 0.95, 0.65)
|
||||
const ELEV_WATER_SHALLOW: Color = Color(0.15, 0.3, 0.55, 0.55)
|
||||
const ELEV_WATER_DEEP: Color = Color(0.05, 0.1, 0.35, 0.65)
|
||||
|
||||
## Corruption lens: green (pure) → purple (threatened) → dark red/black (corrupted)
|
||||
const CORRUPT_PURE_COLOR: Color = Color(0.15, 0.55, 0.2, 0.4)
|
||||
const CORRUPT_MID_COLOR: Color = Color(0.5, 0.15, 0.6, 0.6)
|
||||
const CORRUPT_HIGH_COLOR: Color = Color(0.35, 0.05, 0.08, 0.7)
|
||||
const CORRUPT_LAND_COLOR: Color = Color(0.4, 0.08, 0.45, 0.75)
|
||||
|
||||
## Pressure lens: blue (low/storms ~990 hPa) → green (normal ~1013) → red (high/blocking ~1035 hPa)
|
||||
const PRESSURE_LOW_COLOR: Color = Color(0.1, 0.2, 0.9, 0.6)
|
||||
const PRESSURE_NORMAL_COLOR: Color = Color(0.15, 0.75, 0.2, 0.55)
|
||||
const PRESSURE_HIGH_COLOR: Color = Color(0.85, 0.1, 0.1, 0.6)
|
||||
const PRESSURE_MIN: float = 990.0
|
||||
const PRESSURE_MID: float = 1013.0
|
||||
const PRESSURE_MAX: float = 1035.0
|
||||
|
||||
## Humidity lens: brown (dry) → deep blue (saturated)
|
||||
const HUMIDITY_DRY_COLOR: Color = Color(0.6, 0.38, 0.12, 0.6)
|
||||
const HUMIDITY_WET_COLOR: Color = Color(0.05, 0.15, 0.75, 0.65)
|
||||
|
||||
## Natural event overlay colors keyed by event type
|
||||
const NATURAL_EVENT_COLORS: Dictionary = {
|
||||
"hurricane": Color(0.7, 0.05, 0.7, 0.6),
|
||||
"tornado": Color(0.85, 0.65, 0.05, 0.65),
|
||||
"drought": Color(0.85, 0.35, 0.05, 0.55),
|
||||
"flood": Color(0.1, 0.35, 0.9, 0.6),
|
||||
"monsoon": Color(0.0, 0.5, 0.8, 0.55),
|
||||
"blizzard": Color(0.8, 0.88, 1.0, 0.55),
|
||||
"heat_wave": Color(0.95, 0.4, 0.05, 0.6),
|
||||
"default": Color(0.7, 0.4, 0.9, 0.5),
|
||||
}
|
||||
|
||||
## Currently active heatmap overlay. Change this and call refresh_map() to switch.
|
||||
var active_overlay: Overlay = Overlay.NONE
|
||||
|
||||
## Whether to draw wind direction arrows (independent of heatmap).
|
||||
var show_wind: bool = false
|
||||
|
||||
## spell_id → Array[Vector2i] footprint; refreshed by update_weather_footprints().
|
||||
var weather_footprints: Dictionary = {}
|
||||
|
||||
## Axial positions currently showing a flood warning icon.
|
||||
var flood_warning_tiles: Array[Vector2i] = []
|
||||
|
||||
## axial → event_type string; refreshed by update_natural_events().
|
||||
var natural_event_tiles: Dictionary = {}
|
||||
|
||||
## Cached set of city positions for land value exclusion zone checks.
|
||||
var _city_positions: Array[Vector2i] = []
|
||||
|
||||
|
||||
func render_overlays(container: Node2D, tile: TileScript) -> void:
|
||||
## Renders all active overlays onto a tile container node.
|
||||
## Called once per tile during map render / tile update.
|
||||
if show_wind:
|
||||
_render_wind_arrow(container, tile)
|
||||
var axial: Vector2i = tile.position
|
||||
match active_overlay:
|
||||
Overlay.TEMPERATURE:
|
||||
_render_heatmap(container, tile.temperature, TEMP_COLD_COLOR, TEMP_HOT_COLOR)
|
||||
Overlay.MOISTURE:
|
||||
_render_heatmap(container, tile.moisture, MOIST_DRY_COLOR, MOIST_WET_COLOR)
|
||||
Overlay.WEATHER:
|
||||
_render_weather_footprint(container, axial)
|
||||
Overlay.LAND_VALUE:
|
||||
_render_land_value(container, tile)
|
||||
Overlay.WIND_HEATMAP:
|
||||
_render_wind_heatmap(container, tile)
|
||||
Overlay.WATER:
|
||||
_render_water(container, tile)
|
||||
Overlay.ELEVATION:
|
||||
_render_elevation(container, tile)
|
||||
Overlay.CORRUPTION:
|
||||
_render_corruption(container, tile)
|
||||
Overlay.PRESSURE:
|
||||
_render_pressure(container, tile)
|
||||
Overlay.HUMIDITY:
|
||||
_render_humidity_overlay(container, tile)
|
||||
Overlay.NATURAL_EVENT:
|
||||
_render_natural_event(container, axial)
|
||||
# Flood warnings render regardless of active overlay
|
||||
if axial in flood_warning_tiles:
|
||||
_render_heatmap(container, 1.0, FLOOD_WARNING_COLOR, FLOOD_WARNING_COLOR)
|
||||
|
||||
|
||||
func refresh_city_positions() -> void:
|
||||
## Rebuild cached city positions from GameState. Call before rendering land value lens.
|
||||
_city_positions.clear()
|
||||
for player in GameState.players:
|
||||
if player == null:
|
||||
continue
|
||||
var cities: Array = player.get("cities") if "cities" in player else []
|
||||
for city in cities:
|
||||
if city != null and "position" in city:
|
||||
_city_positions.append(city.position as Vector2i)
|
||||
|
||||
|
||||
# -- Land Value Lens --
|
||||
|
||||
|
||||
func _render_land_value(container: Node2D, tile: TileScript) -> void:
|
||||
## Heatmap by weighted yield score: food×3 + production×2 + trade×1.
|
||||
## Water tiles render as dark blue. Tiles near existing cities render grayed.
|
||||
if tile.is_water():
|
||||
_render_heatmap(container, 1.0, LAND_WATER_COLOR, LAND_WATER_COLOR)
|
||||
return
|
||||
if _is_near_existing_city(tile.position):
|
||||
_render_heatmap(container, 1.0, LAND_EXCLUSION_COLOR, LAND_EXCLUSION_COLOR)
|
||||
return
|
||||
var yields: Dictionary = tile.get_quality_yields()
|
||||
var score: float = (
|
||||
yields.get("food", 0) * 3.0
|
||||
+ yields.get("production", 0) * 2.0
|
||||
+ yields.get("trade", 0) * 1.0
|
||||
)
|
||||
var t: float = clampf(score / LAND_VALUE_MAX, 0.0, 1.0)
|
||||
if t < 0.5:
|
||||
_render_heatmap(container, t * 2.0, LAND_LOW_COLOR, LAND_MID_COLOR)
|
||||
else:
|
||||
_render_heatmap(container, (t - 0.5) * 2.0, LAND_MID_COLOR, LAND_HIGH_COLOR)
|
||||
|
||||
|
||||
func _is_near_existing_city(axial: Vector2i) -> bool:
|
||||
for city_pos: Vector2i in _city_positions:
|
||||
if HexUtilsScript.hex_distance(axial, city_pos) < CITY_MIN_DISTANCE:
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
# -- Wind Heatmap Lens --
|
||||
|
||||
|
||||
func _render_wind_heatmap(container: Node2D, tile: TileScript) -> void:
|
||||
## Speed heatmap (blue → white → orange) with wind arrows on top.
|
||||
var speed: float = tile.wind_speed if "wind_speed" in tile else 0.0
|
||||
if speed < 0.5:
|
||||
_render_heatmap(container, speed * 2.0, WIND_CALM_COLOR, WIND_MID_COLOR)
|
||||
else:
|
||||
_render_heatmap(container, (speed - 0.5) * 2.0, WIND_MID_COLOR, WIND_STRONG_COLOR)
|
||||
_render_wind_arrow(container, tile)
|
||||
|
||||
|
||||
# -- Water Lens --
|
||||
|
||||
|
||||
func _render_water(container: Node2D, tile: TileScript) -> void:
|
||||
## Flow accumulation heatmap + river edge lines + lake highlights + source markers.
|
||||
# Flow accumulation heatmap on land
|
||||
if tile.is_land() and tile.flow_accumulation > 0.0:
|
||||
var t: float = clampf(tile.flow_accumulation / FLOW_NORMALIZE, 0.0, 1.0)
|
||||
_render_heatmap(container, t, FLOW_LOW_COLOR, FLOW_HIGH_COLOR)
|
||||
# Lake / freshwater highlight
|
||||
if tile.lake_id >= 0 or tile.terrain_id in ["lake", "inland_sea"]:
|
||||
_render_heatmap(container, 1.0, LAKE_HIGHLIGHT_COLOR, LAKE_HIGHLIGHT_COLOR)
|
||||
# River edge lines
|
||||
for edge_idx: int in tile.river_edges:
|
||||
var flow_val: float = absf(tile.river_flow.get(edge_idx, 1.0) as float)
|
||||
_render_river_edge(container, edge_idx, flow_val)
|
||||
# Source markers
|
||||
if tile.river_source_type != "":
|
||||
_render_source_marker(container, tile.river_source_type)
|
||||
|
||||
|
||||
func _render_river_edge(container: Node2D, edge_idx: int, flow: float) -> void:
|
||||
## Draw a blue line along the hex edge, thickness scaled by flow accumulation.
|
||||
## edge_idx is an AXIAL direction index (0-5), not a polygon vertex index.
|
||||
var vertices: PackedVector2Array = HexUtilsScript.hex_polygon
|
||||
if edge_idx < 0 or edge_idx > 5:
|
||||
return
|
||||
var pe: int = HexUtilsScript.DIR_TO_POLY_EDGE[edge_idx]
|
||||
var p1: Vector2 = vertices[pe]
|
||||
var p2: Vector2 = vertices[(pe + 1) % 6]
|
||||
var t: float = clampf(flow / FLOW_NORMALIZE, 0.0, 1.0)
|
||||
var width: float = lerpf(RIVER_MIN_WIDTH, RIVER_MAX_WIDTH, t)
|
||||
var line: Line2D = Line2D.new()
|
||||
line.points = PackedVector2Array([p1, p2])
|
||||
line.width = width
|
||||
line.default_color = RIVER_LINE_COLOR
|
||||
container.add_child(line)
|
||||
|
||||
|
||||
func _render_source_marker(container: Node2D, source_type: String) -> void:
|
||||
## Draw a small colored circle at hex center for river source tiles.
|
||||
var color: Color = SOURCE_COLORS.get(source_type, Color.WHITE) as Color
|
||||
var center: Vector2 = HexUtilsScript.hex_center
|
||||
var circle: Polygon2D = Polygon2D.new()
|
||||
var points: PackedVector2Array = PackedVector2Array()
|
||||
for i: int in 12:
|
||||
var angle: float = TAU * i / 12.0
|
||||
points.append(center + Vector2(cos(angle), sin(angle)) * SOURCE_MARKER_RADIUS)
|
||||
circle.polygon = points
|
||||
circle.color = color
|
||||
container.add_child(circle)
|
||||
|
||||
|
||||
# -- Elevation Lens --
|
||||
|
||||
|
||||
func _render_elevation(container: Node2D, tile: TileScript) -> void:
|
||||
## Heatmap by tile.elevation: green (low) → brown (mid) → white (peaks).
|
||||
## Water tiles shade by depth (coast lighter, ocean darker).
|
||||
if tile.is_water():
|
||||
var depth: float = 1.0 if tile.terrain_id == "ocean" else 0.3
|
||||
_render_heatmap(container, depth, ELEV_WATER_SHALLOW, ELEV_WATER_DEEP)
|
||||
return
|
||||
var elev: float = tile.elevation
|
||||
if elev < 0.5:
|
||||
_render_heatmap(container, elev * 2.0, ELEV_LOW_COLOR, ELEV_MID_COLOR)
|
||||
else:
|
||||
_render_heatmap(container, (elev - 0.5) * 2.0, ELEV_MID_COLOR, ELEV_HIGH_COLOR)
|
||||
|
||||
|
||||
# -- Corruption Lens --
|
||||
|
||||
|
||||
func _render_corruption(container: Node2D, tile: TileScript) -> void:
|
||||
## Heatmap by tile.corruption_pressure: green (pure) → purple → dark red.
|
||||
## Corrupted land tiles render as solid dark purple.
|
||||
if tile.terrain_id == "corrupted_land":
|
||||
_render_heatmap(container, 1.0, CORRUPT_LAND_COLOR, CORRUPT_LAND_COLOR)
|
||||
return
|
||||
var pressure: float = tile.corruption_pressure
|
||||
if pressure <= 0.001:
|
||||
return
|
||||
if pressure < 0.5:
|
||||
_render_heatmap(container, pressure * 2.0, CORRUPT_PURE_COLOR, CORRUPT_MID_COLOR)
|
||||
else:
|
||||
_render_heatmap(container, (pressure - 0.5) * 2.0, CORRUPT_MID_COLOR, CORRUPT_HIGH_COLOR)
|
||||
|
||||
|
||||
# -- Pressure Lens --
|
||||
|
||||
|
||||
func _render_pressure(container: Node2D, tile: TileScript) -> void:
|
||||
## Three-stop gradient blue→green→red by tile.pressure (range 990–1035 hPa).
|
||||
var p: float = tile.pressure if "pressure" in tile else PRESSURE_MID
|
||||
if p <= PRESSURE_MID:
|
||||
var t: float = clampf((p - PRESSURE_MIN) / (PRESSURE_MID - PRESSURE_MIN), 0.0, 1.0)
|
||||
_render_heatmap(container, t, PRESSURE_LOW_COLOR, PRESSURE_NORMAL_COLOR)
|
||||
else:
|
||||
var t: float = clampf((p - PRESSURE_MID) / (PRESSURE_MAX - PRESSURE_MID), 0.0, 1.0)
|
||||
_render_heatmap(container, t, PRESSURE_NORMAL_COLOR, PRESSURE_HIGH_COLOR)
|
||||
|
||||
|
||||
# -- Humidity Lens --
|
||||
|
||||
|
||||
func _render_humidity_overlay(container: Node2D, tile: TileScript) -> void:
|
||||
## Heatmap brown→deep blue by tile.humidity [0,1].
|
||||
var h: float = tile.humidity if "humidity" in tile else 0.5
|
||||
_render_heatmap(container, clampf(h, 0.0, 1.0), HUMIDITY_DRY_COLOR, HUMIDITY_WET_COLOR)
|
||||
|
||||
|
||||
# magic — Game 3+
|
||||
# _render_ley_line(), _render_ley_edge_line(), _render_nexus_starburst(),
|
||||
# _render_anchor_marker_if_present() are all ley/magic features not present in Game 1.
|
||||
|
||||
|
||||
# -- Natural Event Overlay --
|
||||
|
||||
|
||||
func _render_natural_event(container: Node2D, axial: Vector2i) -> void:
|
||||
## Colored overlay on tiles with active natural events (hurricane, tornado, etc.).
|
||||
if not natural_event_tiles.has(axial):
|
||||
return
|
||||
var event_type: String = natural_event_tiles[axial] as String
|
||||
var color: Color = NATURAL_EVENT_COLORS.get(event_type, NATURAL_EVENT_COLORS["default"]) as Color
|
||||
var poly: Polygon2D = Polygon2D.new()
|
||||
poly.polygon = HexUtilsScript.hex_polygon
|
||||
poly.color = color
|
||||
container.add_child(poly)
|
||||
var border: Line2D = Line2D.new()
|
||||
var border_color: Color = color
|
||||
border_color.a = minf(color.a + 0.2, 1.0)
|
||||
border.points = HexUtilsScript.hex_polygon
|
||||
border.closed = true
|
||||
border.width = 1.5
|
||||
border.default_color = border_color
|
||||
container.add_child(border)
|
||||
|
||||
|
||||
func update_natural_events(events: Array[Dictionary]) -> void:
|
||||
## Refresh natural_event_tiles from a list of {axial: Vector2i, event_type: String} dicts.
|
||||
natural_event_tiles.clear()
|
||||
for entry: Dictionary in events:
|
||||
if not entry.has("axial") or not entry.has("event_type"):
|
||||
continue
|
||||
if not entry["axial"] is Vector2i:
|
||||
continue
|
||||
var axial: Vector2i = entry["axial"] as Vector2i
|
||||
var event_type: String = entry["event_type"] as String
|
||||
if not event_type.is_empty():
|
||||
natural_event_tiles[axial] = event_type
|
||||
|
||||
|
||||
func update_weather_footprints(effects: Array[Dictionary]) -> void:
|
||||
## Refresh weather_footprints from the list returned by weather.get_active_effects().
|
||||
weather_footprints.clear()
|
||||
for effect: Dictionary in effects:
|
||||
var spell_id: String = effect.get("spell_id", "")
|
||||
var footprint: Array = effect.get("footprint", []) as Array
|
||||
if spell_id.is_empty() or footprint.is_empty():
|
||||
continue
|
||||
# Merge footprints if the same spell_id appears multiple times
|
||||
if weather_footprints.has(spell_id):
|
||||
for pos in footprint:
|
||||
if pos is Vector2i and pos not in weather_footprints[spell_id]:
|
||||
(weather_footprints[spell_id] as Array).append(pos)
|
||||
else:
|
||||
weather_footprints[spell_id] = footprint.duplicate()
|
||||
|
||||
|
||||
func set_flood_warnings(axials: Array[Vector2i]) -> void:
|
||||
## Replace the flood warning tile set.
|
||||
flood_warning_tiles.assign(axials)
|
||||
|
||||
|
||||
# -- Shared rendering helpers --
|
||||
|
||||
|
||||
func _render_wind_arrow(container: Node2D, tile: TileScript) -> void:
|
||||
## Draws a direction arrow at tile center. Shaft length scales with wind_speed.
|
||||
var center: Vector2 = HexUtilsScript.hex_center
|
||||
var angle: float = WIND_ANGLES[tile.wind_direction % 6]
|
||||
var arrow_len: float = WIND_ARROW_BASE_LEN + tile.wind_speed * WIND_ARROW_SPEED_SCALE
|
||||
var dir: Vector2 = Vector2(cos(angle), sin(angle))
|
||||
var tip: Vector2 = center + dir * arrow_len
|
||||
var tail: Vector2 = center - dir * (arrow_len * WIND_ARROW_TAIL_RATIO)
|
||||
|
||||
var shaft: Line2D = Line2D.new()
|
||||
shaft.points = PackedVector2Array([tail, tip])
|
||||
shaft.width = WIND_ARROW_WIDTH
|
||||
shaft.default_color = WIND_ARROW_COLOR
|
||||
container.add_child(shaft)
|
||||
|
||||
for sign: float in [1.0, -1.0]:
|
||||
var barb: Line2D = Line2D.new()
|
||||
barb.points = PackedVector2Array([
|
||||
tip,
|
||||
tip - Vector2(
|
||||
cos(angle + sign * WIND_ARROW_HEAD_ANGLE),
|
||||
sin(angle + sign * WIND_ARROW_HEAD_ANGLE)
|
||||
) * WIND_ARROW_HEAD_LEN
|
||||
])
|
||||
barb.width = WIND_ARROW_WIDTH
|
||||
barb.default_color = WIND_ARROW_COLOR
|
||||
container.add_child(barb)
|
||||
|
||||
|
||||
func _render_heatmap(
|
||||
container: Node2D, value: float, cold_color: Color, hot_color: Color
|
||||
) -> void:
|
||||
## Draws a semi-transparent heatmap polygon over the hex tile.
|
||||
## value [0,1]: 0 → cold_color, 1 → hot_color (linear interpolation).
|
||||
var poly: Polygon2D = Polygon2D.new()
|
||||
poly.polygon = HexUtilsScript.hex_polygon
|
||||
poly.color = cold_color.lerp(hot_color, clampf(value, 0.0, 1.0))
|
||||
container.add_child(poly)
|
||||
|
||||
|
||||
func _render_weather_footprint(container: Node2D, axial: Vector2i) -> void:
|
||||
## Draws a colored overlay on tiles within any active weather event footprint.
|
||||
for spell_id: String in weather_footprints:
|
||||
var footprint: Array = weather_footprints[spell_id] as Array
|
||||
if axial in footprint:
|
||||
var color: Color = WEATHER_COLORS.get(spell_id, WEATHER_COLORS["default"]) as Color
|
||||
var poly: Polygon2D = Polygon2D.new()
|
||||
poly.polygon = HexUtilsScript.hex_polygon
|
||||
poly.color = color
|
||||
container.add_child(poly)
|
||||
# Draw border for clarity
|
||||
var border: Line2D = Line2D.new()
|
||||
var border_color: Color = color
|
||||
border_color.a = minf(color.a + 0.25, 1.0)
|
||||
border.points = HexUtilsScript.hex_polygon
|
||||
border.closed = true
|
||||
border.width = 1.5
|
||||
border.default_color = border_color
|
||||
container.add_child(border)
|
||||
break # Only one weather color per tile (first match wins)
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
Loading…
Add table
Reference in a new issue