feat(climate): ✨ Introduce climate modeling logic, base utilities, and evaluation specifications in climate.gd, climate_base.gd, and climate_spec_eval.gd
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
d18bacda78
commit
13a1e659d0
3 changed files with 168 additions and 888 deletions
|
|
@ -1,399 +1,173 @@
|
|||
class_name Climate
|
||||
extends "res://engine/src/modules/climate/climate_base.gd"
|
||||
## Per-tile climate physics simulation. Called once per turn by TurnManager.
|
||||
extends RefCounted
|
||||
## Per-tile climate physics — thin wrapper delegating to Rust GDExtension (GdClimatePhysics).
|
||||
##
|
||||
## Processing order each turn:
|
||||
## 1. Collect magic forcing from active spells (hook; weather.gd writes deltas)
|
||||
## 2. Update temperatures — double-buffered (reads old, writes new, swaps)
|
||||
## 3. Lake thermal moderation on adjacent tiles
|
||||
## 4. Wind moisture transport — double-buffered decay + transport + relaxation + atmospheric loss
|
||||
## 5. River moisture transport (gravity along river_edges, downhill only)
|
||||
## 6. Lake/ocean/coast evaporation into neighbours (multi-hop for ocean/coast)
|
||||
## 6b. Deep Earth Water injection (volcanoes, hot springs, high elevation)
|
||||
## 7. Precipitation (clamp excess moisture to threshold)
|
||||
## 8. Terrain quality evolution (classify_terrain + quality ladder)
|
||||
## 9. Corruption spreading and terrain flip
|
||||
## 9b. Ley residue decay
|
||||
## 9c. Ecological events (seeded stochastic perturbations)
|
||||
## 9d. Anchor decay (event-created anchors fade over time)
|
||||
## 10. Compute global stats (avg temp for UI phase label)
|
||||
## 11. Clear transient magic deltas
|
||||
##
|
||||
## Double-buffering: temperature and moisture wind use read-old/write-new
|
||||
## dictionaries to avoid order-dependent bugs when reading neighbour state.
|
||||
## The heavy simulation (temperature, moisture, wind, precipitation, terrain evolution,
|
||||
## aerosol forcing, orbital cycles, reef health, global stats) runs in native Rust.
|
||||
## This GDScript class handles Godot-side orchestration: loading params from DataLoader,
|
||||
## calling the Rust engine, and running subsystems that remain in GDScript (ley network,
|
||||
## ecological events via EventBus, magic delta clearing).
|
||||
|
||||
const EcologicalEventsScript: GDScript = preload(
|
||||
"res://engine/src/modules/climate/ecological_events.gd"
|
||||
)
|
||||
const AnchorDecayScript: GDScript = preload("res://engine/src/modules/climate/anchor_decay.gd")
|
||||
const LeyResidueScript: GDScript = preload("res://engine/src/modules/ley/ley_residue.gd")
|
||||
const LeyNetworkScript: GDScript = preload("res://engine/src/modules/ley/ley_network.gd")
|
||||
|
||||
## Global average temperature — updated each turn from Rust grid state, readable by UI.
|
||||
var global_avg_temp: float = 0.5
|
||||
|
||||
## Fraction of coast tiles permanently dead from marine extinction [0.0, 1.0].
|
||||
## Set by TurnManager from marine_harvest.ocean_dead_fraction before process_turn().
|
||||
var ocean_dead_fraction: float = 0.0
|
||||
|
||||
var _rust: GdClimatePhysics
|
||||
var _grid: GdGridState
|
||||
var _spec_json: String = ""
|
||||
var _spec: Dictionary = {}
|
||||
var _params_loaded: bool = false
|
||||
|
||||
## Physics capability flags from world manifest (physics_features block).
|
||||
var _physics_flags: Dictionary = {
|
||||
"water_cycle": true,
|
||||
"weather_events": true,
|
||||
"ecology": true,
|
||||
"volcanism": true,
|
||||
}
|
||||
|
||||
|
||||
func _ensure_rust() -> void:
|
||||
if _params_loaded:
|
||||
return
|
||||
|
||||
var params: Dictionary = DataLoader.get_climate_params()
|
||||
if params.is_empty():
|
||||
push_warning("Climate: climate_params.json missing — using built-in defaults")
|
||||
_spec = DataLoader.get_climate_spec()
|
||||
if _spec.is_empty():
|
||||
push_warning("Climate: climate_spec.json missing — terrain transitions use built-in defaults")
|
||||
|
||||
var world_flags: Dictionary = DataLoader.get_physics_features()
|
||||
if not world_flags.is_empty():
|
||||
for key: String in world_flags:
|
||||
_physics_flags[key] = world_flags[key]
|
||||
|
||||
# Collect terrain data for the Rust engine's terrain cache
|
||||
var terrain_array: Array = DataLoader.get_all_terrains()
|
||||
|
||||
var params_json: String = JSON.stringify(params)
|
||||
var terrain_json: String = JSON.stringify(terrain_array)
|
||||
_spec_json = JSON.stringify(_spec)
|
||||
|
||||
_rust = GdClimatePhysics.new()
|
||||
_rust.initialize(params_json, terrain_json, _spec_json)
|
||||
|
||||
_params_loaded = true
|
||||
|
||||
|
||||
func process_turn(game_map: RefCounted, turn: int = 0, seed: int = 42) -> void:
|
||||
## Run the full climate simulation step for one game turn.
|
||||
_seed = seed
|
||||
_ensure_params()
|
||||
_ensure_ocean_dist(game_map)
|
||||
_ensure_rust()
|
||||
|
||||
_collect_magic_forcing(game_map)
|
||||
_apply_orbital_forcing(game_map, turn)
|
||||
_apply_aerosol_forcing(game_map)
|
||||
_update_temperatures(game_map)
|
||||
_update_lake_thermal_effects(game_map)
|
||||
if _physics_flags.get("water_cycle", true):
|
||||
_update_moisture_wind(game_map)
|
||||
_update_moisture_rivers(game_map)
|
||||
_update_lake_evaporation(game_map)
|
||||
if _physics_flags.get("volcanism", true):
|
||||
_update_deep_earth_water(game_map)
|
||||
_update_precipitation(game_map)
|
||||
_update_surface_runoff(game_map)
|
||||
_check_terrain_evolution(game_map)
|
||||
_tick_ley_residue(game_map)
|
||||
# Sync game_map tiles into a GdGridState for the Rust engine.
|
||||
var w: int = game_map.width
|
||||
var h: int = game_map.height
|
||||
if _grid == null or _grid.get_width() != w or _grid.get_height() != h:
|
||||
_grid = GdGridState.create(w, h)
|
||||
|
||||
_sync_tiles_to_grid(game_map)
|
||||
_grid.set_ocean_dead_fraction(ocean_dead_fraction)
|
||||
|
||||
# Run the core physics in Rust
|
||||
_rust.process_step(_grid, turn, seed)
|
||||
|
||||
# Sync results back to GDScript game_map tiles
|
||||
_sync_grid_to_tiles(game_map)
|
||||
|
||||
# Update global stats from Rust output
|
||||
global_avg_temp = _grid.get_global_avg_temp()
|
||||
|
||||
# GDScript-side subsystems that depend on Godot autoloads
|
||||
LeyResidueScript.tick_residue(game_map)
|
||||
if _physics_flags.get("ecology", true):
|
||||
EcologicalEventsScript.process_events(
|
||||
game_map, turn, seed, _spec, DataLoader.get_ecological_events(),
|
||||
GameState.get_max_event_tier()
|
||||
)
|
||||
AnchorDecayScript.process_decay(game_map, _spec)
|
||||
_update_ley_network(game_map)
|
||||
_compute_global_stats(game_map)
|
||||
_clear_magic_deltas(game_map)
|
||||
|
||||
|
||||
# -- Step 1: Magic forcing hook --
|
||||
|
||||
|
||||
func _collect_magic_forcing(_game_map: RefCounted) -> void:
|
||||
# magic_heat_delta and magic_moisture_delta are written directly by weather.gd
|
||||
# each turn before process_turn() is called. This step is a pre-processing hook.
|
||||
pass
|
||||
|
||||
|
||||
# -- Step 1a: Orbital forcing (Milankovitch-like cycles) --
|
||||
|
||||
|
||||
func _apply_orbital_forcing(game_map: RefCounted, turn: int) -> void:
|
||||
## Superimpose deterministic orbital cycles onto the heat budget.
|
||||
## Three overlapping sinusoidal cycles model obliquity, precession,
|
||||
## and eccentricity at game timescale (1 turn = 10 years).
|
||||
var t: float = float(turn)
|
||||
var total_delta: float = 0.0
|
||||
for i: int in range(1, 4):
|
||||
var prefix: String = "orbital_cycle_%d_" % i
|
||||
var period: float = _params.get(prefix + "period", 0.0)
|
||||
if period <= 0.0:
|
||||
continue
|
||||
var amplitude: float = _params.get(prefix + "amplitude", 0.0)
|
||||
var phase: float = _params.get(prefix + "phase", 0.0)
|
||||
total_delta += amplitude * sin(TAU * (t / period) + phase)
|
||||
|
||||
if absf(total_delta) < 0.0001:
|
||||
return
|
||||
|
||||
# Warm cycles increase evaporation → more moisture. The coupling factor
|
||||
# controls how strongly temperature oscillation drives moisture oscillation.
|
||||
var moisture_coupling: float = _params.get("orbital_moisture_coupling", 0.5)
|
||||
|
||||
for axial: Vector2i in game_map.tiles:
|
||||
game_map.tiles[axial].magic_heat_delta += total_delta
|
||||
game_map.tiles[axial].magic_moisture_delta += total_delta * moisture_coupling
|
||||
|
||||
|
||||
# -- Step 1b: Aerosol forcing (persistent stratospheric sulfate from volcanic/impact events) --
|
||||
|
||||
|
||||
func _apply_aerosol_forcing(game_map: RefCounted) -> void:
|
||||
## Phase 0: Natural aerosol generation — desert dust, volcanic outgassing, baseline.
|
||||
## Phase 1: apply cooling and drying to magic deltas (solar blocking + evaporation loss).
|
||||
## Phase 2: transport aerosol downwind (double-buffered snapshot).
|
||||
## Phase 3: decay aerosol each turn.
|
||||
var aerosol_cfg: Dictionary = _spec.get("aerosol", {})
|
||||
if aerosol_cfg.is_empty():
|
||||
return
|
||||
|
||||
# Phase 0: Natural sources inject small aerosol each turn
|
||||
var bg: float = _params.get("aerosol_background", 0.002)
|
||||
var desert_rate: float = _params.get("aerosol_desert_dust", 0.005)
|
||||
var volcano_rate: float = _params.get("aerosol_volcanic_outgas", 0.015)
|
||||
for axial: Vector2i in game_map.tiles:
|
||||
var tile: Variant = game_map.tiles[axial]
|
||||
var inject: float = bg
|
||||
if BiomeRegistry.has_tag(tile.biome_id, "is_dry"):
|
||||
inject += desert_rate
|
||||
elif BiomeRegistry.has_tag(tile.biome_id, "is_volcanic"):
|
||||
inject += volcano_rate
|
||||
tile.sulfate_aerosol = maxf(0.0, tile.get("sulfate_aerosol", 0.0) + inject)
|
||||
|
||||
# Short-circuit: skip processing if no aerosol exists anywhere
|
||||
var any_aerosol: bool = false
|
||||
for axial: Vector2i in game_map.tiles:
|
||||
if (game_map.tiles[axial] as Object).get("sulfate_aerosol") > 0.1:
|
||||
any_aerosol = true
|
||||
break
|
||||
if not any_aerosol:
|
||||
return
|
||||
|
||||
var cooling_rate: float = aerosol_cfg.get("cooling_rate", 0.06)
|
||||
var drying_rate: float = aerosol_cfg.get("drying_rate", 0.03)
|
||||
var transport_rate: float = aerosol_cfg.get("wind_transport_rate", 0.12)
|
||||
var decay_rate: float = aerosol_cfg.get("decay_rate", 0.05)
|
||||
|
||||
# Phase 1: Apply solar-blocking cooling and drying to magic deltas.
|
||||
# Effect is capped at 100% solar blocking (aerosol > 1.0 extends duration, not intensity).
|
||||
# City protection buildings write tile.aerosol_mitigation to scale down the effect.
|
||||
for axial: Vector2i in game_map.tiles:
|
||||
var tile: Variant = game_map.tiles[axial]
|
||||
if tile.sulfate_aerosol <= 0.0:
|
||||
continue
|
||||
var effective: float = minf(1.0, tile.sulfate_aerosol)
|
||||
var mitigation: float = tile.aerosol_mitigation
|
||||
if mitigation > 0.0:
|
||||
effective *= (1.0 - clampf(mitigation, 0.0, 1.0))
|
||||
tile.magic_heat_delta -= effective * cooling_rate
|
||||
tile.magic_moisture_delta -= effective * drying_rate
|
||||
|
||||
# Phase 2: Wind transport — double-buffered snapshot to avoid order-dependency.
|
||||
# No cap on destination — aerosol stacks above 1.0 (more particulates = longer winter).
|
||||
var snapshot: Dictionary = {}
|
||||
for axial: Vector2i in game_map.tiles:
|
||||
var tile: Variant = game_map.tiles[axial]
|
||||
if tile.sulfate_aerosol > 0.1:
|
||||
snapshot[axial] = tile.sulfate_aerosol
|
||||
|
||||
for axial: Vector2i in snapshot:
|
||||
var tile: Variant = game_map.tiles[axial]
|
||||
var transported: float = snapshot[axial] * transport_rate
|
||||
var downwind: Vector2i = _downwind_pos(axial, tile.wind_direction)
|
||||
if game_map.tiles.has(downwind):
|
||||
game_map.tiles[downwind].sulfate_aerosol += transported
|
||||
tile.sulfate_aerosol = maxf(0.0, tile.sulfate_aerosol - transported)
|
||||
|
||||
# Phase 3: Exponential decay
|
||||
for axial: Vector2i in game_map.tiles:
|
||||
var tile: Variant = game_map.tiles[axial]
|
||||
if tile.sulfate_aerosol > 0.0:
|
||||
tile.sulfate_aerosol = maxf(0.0, tile.sulfate_aerosol - decay_rate)
|
||||
|
||||
|
||||
# -- Step 5: River moisture transport --
|
||||
|
||||
|
||||
func _update_moisture_rivers(game_map: RefCounted) -> void:
|
||||
# River transport is inherently sequential (water flows from source to mouth),
|
||||
# so in-place update is correct — each river tile drains to its lower neighbour.
|
||||
var transport_rate: float = _params.get(
|
||||
"river_moisture_transport", _DEFAULTS["river_moisture_transport"]
|
||||
)
|
||||
|
||||
for axial: Vector2i in game_map.tiles:
|
||||
var tile: Variant = game_map.tiles[axial]
|
||||
if tile.river_edges.is_empty():
|
||||
continue
|
||||
for edge: int in tile.river_edges:
|
||||
var nb: Variant = game_map.tiles.get(axial + HexUtilsScript.AXIAL_DIRECTIONS[edge])
|
||||
if nb == null or tile.elevation <= nb.elevation:
|
||||
continue
|
||||
var amount: float = tile.moisture * transport_rate
|
||||
tile.moisture = clampf(tile.moisture - amount, 0.0, 1.0)
|
||||
nb.moisture = clampf(nb.moisture + amount, 0.0, 1.0)
|
||||
|
||||
|
||||
# -- Step 6: Lake/ocean evaporation --
|
||||
|
||||
|
||||
func _update_lake_evaporation(game_map: RefCounted) -> void:
|
||||
var base_evap: float = _params.get("evaporation_rate", _DEFAULTS["evaporation_rate"])
|
||||
var max_hops: int = int(
|
||||
_params.get("ocean_evaporation_hops", _DEFAULTS["ocean_evaporation_hops"])
|
||||
)
|
||||
var hop_decay: float = _params.get(
|
||||
"ocean_evaporation_hop_decay", _DEFAULTS["ocean_evaporation_hop_decay"]
|
||||
)
|
||||
var rain_shadow_block: float = _params.get(
|
||||
"mountain_rain_shadow_block", _DEFAULTS["mountain_rain_shadow_block"]
|
||||
)
|
||||
# Biological pump failure: dead ocean reduces evaporation → inland forests dry out.
|
||||
# Formula from OCEAN.md: evap *= (1 - (fraction - 0.25) * 2) when fraction > 0.25.
|
||||
var evap_rate: float = base_evap
|
||||
if ocean_dead_fraction > 0.25:
|
||||
evap_rate = base_evap * maxf(0.0, 1.0 - (ocean_dead_fraction - 0.25) * 2.0)
|
||||
|
||||
for axial: Vector2i in game_map.tiles:
|
||||
var tile: Variant = game_map.tiles[axial]
|
||||
var tid: String = tile.biome_id
|
||||
if not BiomeRegistry.has_tag(tid, "is_water"):
|
||||
continue
|
||||
# Only evaporate if liquid water is actually present
|
||||
if tile.surface_water <= 0.0:
|
||||
continue
|
||||
|
||||
var evap: float = tile.temperature * evap_rate * minf(1.0, tile.surface_water)
|
||||
|
||||
if tid == "lake":
|
||||
# Lakes inject to all 6 neighbours equally
|
||||
var share: float = evap / 6.0
|
||||
for nb_pos: Vector2i in HexUtilsScript.get_neighbors(axial):
|
||||
var nb: Variant = game_map.tiles.get(nb_pos)
|
||||
if nb != null:
|
||||
nb.moisture = clampf(nb.moisture + share, 0.0, 1.0)
|
||||
else:
|
||||
# Ocean/coast: inject along downwind chain (multi-hop with decay).
|
||||
# Hop 1 = full strength, hop 2 = hop_decay, hop 3 = hop_decay^2, etc.
|
||||
# Stop at mountains (rain shadow), water tiles (no double-injection), or map edge.
|
||||
var hop_pos: Vector2i = axial
|
||||
var hop_strength: float = 1.0
|
||||
var wind_dir: int = tile.wind_direction
|
||||
for _hop_i: int in max_hops:
|
||||
hop_pos = hop_pos + HexUtilsScript.AXIAL_DIRECTIONS[wind_dir]
|
||||
var hop_tile: Variant = game_map.tiles.get(hop_pos)
|
||||
if hop_tile == null:
|
||||
break
|
||||
var hop_tid: String = hop_tile.biome_id
|
||||
# Stop if we hit another water tile (no double-injection)
|
||||
if BiomeRegistry.has_tag(hop_tid, "is_water"):
|
||||
break
|
||||
# Mountains block most moisture (rain shadow)
|
||||
if BiomeRegistry.has_tag(hop_tid, "is_elevated"):
|
||||
hop_strength *= (1.0 - rain_shadow_block)
|
||||
hop_tile.moisture = clampf(hop_tile.moisture + evap * hop_strength, 0.0, 1.0)
|
||||
hop_strength *= hop_decay
|
||||
# Follow the hop tile's own wind direction for subsequent hops
|
||||
wind_dir = hop_tile.wind_direction
|
||||
|
||||
|
||||
# -- Step 6b: Deep Earth Water (volcanic outgassing) --
|
||||
|
||||
|
||||
func _update_deep_earth_water(game_map: RefCounted) -> void:
|
||||
## Mantle degassing: volcanoes, hot springs, and high-elevation tiles inject moisture.
|
||||
## Models ringwoodite water release through the crust — the mechanism that maintains
|
||||
## Earth's ocean volume over geological time despite atmospheric hydrogen escape.
|
||||
var dew: Dictionary = _params.get("deep_earth_water", _DEW_DEFAULTS)
|
||||
var vol_self: float = dew.get("volcano_self", _DEW_DEFAULTS["volcano_self"])
|
||||
var vol_nb: float = dew.get("volcano_neighbor", _DEW_DEFAULTS["volcano_neighbor"])
|
||||
var hs_self: float = dew.get("hot_spring_self", _DEW_DEFAULTS["hot_spring_self"])
|
||||
var hs_nb: float = dew.get("hot_spring_neighbor", _DEW_DEFAULTS["hot_spring_neighbor"])
|
||||
var hs_max_temp: float = dew.get("hot_spring_max_temperature", _DEW_DEFAULTS["hot_spring_max_temperature"])
|
||||
var elev_self: float = dew.get("high_elevation_self", _DEW_DEFAULTS["high_elevation_self"])
|
||||
var elev_thresh: float = dew.get(
|
||||
"high_elevation_threshold", _DEW_DEFAULTS["high_elevation_threshold"]
|
||||
)
|
||||
|
||||
for axial: Vector2i in game_map.tiles:
|
||||
var tile: Variant = game_map.tiles[axial]
|
||||
|
||||
if BiomeRegistry.has_tag(tile.biome_id, "is_volcanic"):
|
||||
tile.moisture = clampf(tile.moisture + vol_self, 0.0, 1.0)
|
||||
for nb_pos: Vector2i in HexUtilsScript.get_neighbors(axial):
|
||||
var nb: Variant = game_map.tiles.get(nb_pos)
|
||||
if nb != null:
|
||||
nb.moisture = clampf(nb.moisture + vol_nb, 0.0, 1.0)
|
||||
|
||||
elif tile.river_source_type == "hot_spring" and tile.temperature <= hs_max_temp:
|
||||
tile.moisture = clampf(tile.moisture + hs_self, 0.0, 1.0)
|
||||
for nb_pos: Vector2i in HexUtilsScript.get_neighbors(axial):
|
||||
var nb: Variant = game_map.tiles.get(nb_pos)
|
||||
if nb != null:
|
||||
nb.moisture = clampf(nb.moisture + hs_nb, 0.0, 1.0)
|
||||
|
||||
elif tile.elevation >= elev_thresh:
|
||||
tile.moisture = clampf(tile.moisture + elev_self, 0.0, 1.0)
|
||||
|
||||
|
||||
# -- Step 7: Precipitation --
|
||||
|
||||
|
||||
func _update_precipitation(game_map: RefCounted) -> void:
|
||||
var threshold: float = _params.get(
|
||||
"precipitation_threshold", _DEFAULTS["precipitation_threshold"]
|
||||
)
|
||||
var h: int = game_map.height
|
||||
var lat_spec: Dictionary = _spec.get("precipitation_latitude", {})
|
||||
var polar_band: float = lat_spec.get("polar_drying", {}).get("band_pct", 0.15)
|
||||
var polar_rate: float = lat_spec.get("polar_drying", {}).get("rate_per_turn", 0.008)
|
||||
var sub_center: float = lat_spec.get("subtropical_drying", {}).get("center_pct", 0.30)
|
||||
var sub_half: float = lat_spec.get("subtropical_drying", {}).get("half_width", 0.15)
|
||||
var sub_rate: float = lat_spec.get("subtropical_drying", {}).get("rate_per_turn", 0.013)
|
||||
|
||||
for axial: Vector2i in game_map.tiles:
|
||||
var tile: Variant = game_map.tiles[axial]
|
||||
if tile.moisture > threshold:
|
||||
var runoff: float = tile.moisture - threshold
|
||||
tile.moisture = threshold
|
||||
tile.surface_water += runoff # excess moisture precipitates as surface water
|
||||
|
||||
# Latitude-dependent moisture adjustment — models Hadley cell circulation.
|
||||
# Polar regions are dry (cold air holds less moisture).
|
||||
# Subtropical high-pressure belt (~30° latitude) suppresses moisture.
|
||||
var offset: Vector2i = HexUtilsScript.axial_to_offset(axial)
|
||||
var row: int = clampi(offset.y, 0, h - 1)
|
||||
var frac: float = float(row) / float(h)
|
||||
var norm_frac: float = frac if frac <= 0.5 else 1.0 - frac # 0=pole, 0.5=equator
|
||||
|
||||
if norm_frac < polar_band:
|
||||
tile.moisture = maxf(0.0, tile.moisture - polar_rate)
|
||||
|
||||
if norm_frac > polar_band and norm_frac < sub_center + sub_half:
|
||||
var dry_strength: float = maxf(0.0, 1.0 - absf(norm_frac - sub_center) / sub_half)
|
||||
tile.moisture = maxf(0.05, tile.moisture - sub_rate * dry_strength)
|
||||
|
||||
|
||||
# -- Step 9b: Ley residue decay --
|
||||
|
||||
|
||||
func _tick_ley_residue(game_map: RefCounted) -> void:
|
||||
LeyResidueScript.tick_residue(game_map)
|
||||
|
||||
|
||||
func _update_ley_network(game_map: RefCounted) -> void:
|
||||
## Build resonance/disruption edges between wonder anchors and apply effects.
|
||||
var edges: Array = LeyNetworkScript.build_network(game_map, _spec)
|
||||
LeyNetworkScript.apply_ley_effects(game_map, edges, _spec)
|
||||
EventBus.ley_network_updated.emit(edges)
|
||||
|
||||
|
||||
# -- Step 10: Global stats --
|
||||
# Tile access uses Variant because game_map.tiles is a Dictionary[Vector2i, RefCounted]
|
||||
# and tile objects are class_name types that cannot be typed across autoload boundary.
|
||||
# See CLAUDE.md "Signal Parameters" — Variant is required here.
|
||||
|
||||
|
||||
func _compute_global_stats(game_map: RefCounted) -> void:
|
||||
var total: float = 0.0
|
||||
var count: int = 0
|
||||
func _sync_tiles_to_grid(game_map: RefCounted) -> void:
|
||||
## Push GDScript tile state into the Rust GdGridState before physics.
|
||||
for axial: Vector2i in game_map.tiles:
|
||||
var tile: Variant = game_map.tiles[axial]
|
||||
if tile.biome_id != "ocean":
|
||||
total += tile.temperature
|
||||
count += 1
|
||||
global_avg_temp = total / float(count) if count > 0 else 0.5
|
||||
# Tile access returns RefCounted subclass — Variant required at autoload boundary
|
||||
var tile: RefCounted = game_map.tiles[axial] as RefCounted
|
||||
var col: int = tile.get("col") as int
|
||||
var row: int = tile.get("row") as int
|
||||
var d: Dictionary = {
|
||||
"temperature": tile.get("temperature"),
|
||||
"moisture": tile.get("moisture"),
|
||||
"elevation": tile.get("elevation"),
|
||||
"biome_id": tile.get("biome_id"),
|
||||
"wind_direction": tile.get("wind_direction"),
|
||||
"wind_speed": tile.get("wind_speed"),
|
||||
"sulfate_aerosol": tile.get("sulfate_aerosol"),
|
||||
"quality": tile.get("quality"),
|
||||
"quality_progress": tile.get("quality_progress"),
|
||||
"original_biome_id": tile.get("original_biome_id"),
|
||||
"ley_line_count": tile.get("ley_line_count"),
|
||||
"ley_school": tile.get("ley_school"),
|
||||
"reef_health": tile.get("reef_health"),
|
||||
"magic_heat_delta": tile.get("magic_heat_delta"),
|
||||
"magic_moisture_delta": tile.get("magic_moisture_delta"),
|
||||
"is_natural_wonder": tile.get("is_natural_wonder"),
|
||||
"wonder_anchor_strength": tile.get("wonder_anchor_strength"),
|
||||
"wonder_tier": tile.get("wonder_tier"),
|
||||
"canopy_cover": tile.get("canopy_cover"),
|
||||
"undergrowth": tile.get("undergrowth"),
|
||||
"fungi_network": tile.get("fungi_network"),
|
||||
"surface_water": tile.get("surface_water"),
|
||||
"river_source_type": tile.get("river_source_type"),
|
||||
"is_coastal": tile.get("is_coastal"),
|
||||
"aerosol_mitigation": tile.get("aerosol_mitigation"),
|
||||
}
|
||||
_grid.set_tile_dict(col, row, d)
|
||||
|
||||
# Reef health on coast tiles — coral bleaching from high SST
|
||||
var reef_spec: Dictionary = _spec.get("reef_bleaching", {})
|
||||
var bleach_thresh: float = reef_spec.get("bleach_temp_threshold", 0.75)
|
||||
var damage_rate: float = reef_spec.get("damage_rate_per_turn", 0.01)
|
||||
var recovery_rate: float = reef_spec.get("recovery_rate_per_turn", 0.005)
|
||||
var dead_thresh: float = reef_spec.get("dead_threshold", 0.3)
|
||||
var coast_count: int = 0
|
||||
var dead_count: int = 0
|
||||
|
||||
func _sync_grid_to_tiles(game_map: RefCounted) -> void:
|
||||
## Pull Rust physics results back into GDScript tiles after physics.
|
||||
for axial: Vector2i in game_map.tiles:
|
||||
var tile: Variant = game_map.tiles[axial]
|
||||
if tile.biome_id != "coast":
|
||||
var tile: RefCounted = game_map.tiles[axial] as RefCounted
|
||||
var col: int = tile.get("col") as int
|
||||
var row: int = tile.get("row") as int
|
||||
var d: Dictionary = _grid.get_tile_dict(col, row)
|
||||
if d.is_empty():
|
||||
continue
|
||||
coast_count += 1
|
||||
if tile.temperature > bleach_thresh:
|
||||
tile.reef_health = maxf(0.0, tile.reef_health - damage_rate)
|
||||
else:
|
||||
tile.reef_health = minf(1.0, tile.reef_health + recovery_rate)
|
||||
if tile.reef_health < dead_thresh:
|
||||
dead_count += 1
|
||||
if coast_count > 0:
|
||||
ocean_dead_fraction = float(dead_count) / float(coast_count)
|
||||
|
||||
|
||||
# -- Step 11: Clear transient magic deltas --
|
||||
|
||||
|
||||
func _clear_magic_deltas(game_map: RefCounted) -> void:
|
||||
for axial: Vector2i in game_map.tiles:
|
||||
var tile: Variant = game_map.tiles[axial]
|
||||
tile.magic_heat_delta = 0.0
|
||||
tile.magic_moisture_delta = 0.0
|
||||
|
||||
|
||||
# -- UI helper --
|
||||
tile.set("temperature", d.get("temperature", tile.get("temperature")))
|
||||
tile.set("moisture", d.get("moisture", tile.get("moisture")))
|
||||
tile.set("biome_id", d.get("biome_id", tile.get("biome_id")))
|
||||
tile.set("sulfate_aerosol", d.get("sulfate_aerosol", 0.0))
|
||||
tile.set("quality", d.get("quality", tile.get("quality")))
|
||||
tile.set("quality_progress", d.get("quality_progress", tile.get("quality_progress")))
|
||||
tile.set("original_biome_id", d.get("original_biome_id", tile.get("original_biome_id")))
|
||||
tile.set("reef_health", d.get("reef_health", tile.get("reef_health")))
|
||||
tile.set("magic_heat_delta", d.get("magic_heat_delta", 0.0))
|
||||
tile.set("magic_moisture_delta", d.get("magic_moisture_delta", 0.0))
|
||||
tile.set("surface_water", d.get("surface_water", tile.get("surface_water")))
|
||||
tile.set("canopy_cover", d.get("canopy_cover", 0.0))
|
||||
tile.set("undergrowth", d.get("undergrowth", 0.0))
|
||||
tile.set("fungi_network", d.get("fungi_network", 0.0))
|
||||
tile.set("habitat_suitability", d.get("habitat_suitability", 0.0))
|
||||
|
||||
|
||||
func get_phase_label() -> String:
|
||||
|
|
@ -404,68 +178,3 @@ func get_phase_label() -> String:
|
|||
if global_avg_temp < THRESHOLDS[i]:
|
||||
return LABELS[i]
|
||||
return LABELS[LABELS.size() - 1]
|
||||
|
||||
|
||||
# -- Internal helpers --
|
||||
|
||||
|
||||
func _ensure_params() -> void:
|
||||
if _params_loaded:
|
||||
return
|
||||
_params = DataLoader.get_climate_params()
|
||||
if _params.is_empty():
|
||||
push_warning("Climate: climate_params.json missing — using built-in defaults")
|
||||
_params = _DEFAULTS.duplicate()
|
||||
_spec = DataLoader.get_climate_spec()
|
||||
if _spec.is_empty():
|
||||
push_warning(
|
||||
"Climate: climate_spec.json missing — terrain transitions use built-in defaults"
|
||||
)
|
||||
var world_flags: Dictionary = DataLoader.get_physics_features()
|
||||
if not world_flags.is_empty():
|
||||
for key: String in world_flags:
|
||||
_physics_flags[key] = world_flags[key]
|
||||
|
||||
_params_loaded = true
|
||||
|
||||
|
||||
func _ensure_ocean_dist(game_map: RefCounted) -> void:
|
||||
## Build ocean proximity cache via BFS if not yet computed.
|
||||
## Invalidated by terrain_transformed signal (terrain flips can create/remove water).
|
||||
if _ocean_dist_valid:
|
||||
return
|
||||
_ocean_dist_cache.clear()
|
||||
|
||||
# Seed BFS from all water tiles (distance 0)
|
||||
var queue: Array[Vector2i] = []
|
||||
for axial: Vector2i in game_map.tiles:
|
||||
var tid: String = game_map.tiles[axial].biome_id
|
||||
if BiomeRegistry.has_tag(tid, "is_water"):
|
||||
_ocean_dist_cache[axial] = 0
|
||||
queue.append(axial)
|
||||
|
||||
# BFS outward — cap at 20 hexes (beyond this, factor bottoms out at floor anyway)
|
||||
var head: int = 0
|
||||
while head < queue.size():
|
||||
var pos: Vector2i = queue[head]
|
||||
head += 1
|
||||
var dist: int = _ocean_dist_cache[pos]
|
||||
if dist >= 20:
|
||||
continue
|
||||
for nb_pos: Vector2i in HexUtilsScript.get_neighbors(pos):
|
||||
if not game_map.tiles.has(nb_pos):
|
||||
continue
|
||||
if _ocean_dist_cache.has(nb_pos):
|
||||
continue
|
||||
_ocean_dist_cache[nb_pos] = dist + 1
|
||||
queue.append(nb_pos)
|
||||
|
||||
_ocean_dist_valid = true
|
||||
|
||||
# Listen for terrain changes that might invalidate the cache
|
||||
if not EventBus.terrain_transformed.is_connected(_on_terrain_transformed):
|
||||
EventBus.terrain_transformed.connect(_on_terrain_transformed)
|
||||
|
||||
|
||||
func _on_terrain_transformed(_tile: Variant, _old_type: String, _new_type: String) -> void:
|
||||
_ocean_dist_valid = false
|
||||
|
|
|
|||
|
|
@ -1,351 +0,0 @@
|
|||
class_name ClimateBase
|
||||
extends RefCounted
|
||||
## Per-tile climate physics — base class containing heavy simulation step functions.
|
||||
## Inherited by Climate which adds the orchestration layer.
|
||||
|
||||
const MapGeneratorScript: GDScript = preload("res://engine/src/generation/map_generator.gd")
|
||||
const HexUtilsScript: GDScript = preload("res://engine/src/map/hex_utils.gd")
|
||||
const TerrainAffinityScript: GDScript = preload("res://engine/src/core/terrain_affinity.gd")
|
||||
const LeyResidueScript: GDScript = preload("res://engine/src/modules/ley/ley_residue.gd")
|
||||
const ClimateSpecEvalScript: GDScript = preload(
|
||||
"res://engine/src/modules/climate/climate_spec_eval.gd"
|
||||
)
|
||||
const EcologicalEventsScript: GDScript = preload(
|
||||
"res://engine/src/modules/climate/ecological_events.gd"
|
||||
)
|
||||
const AnchorDecayScript: GDScript = preload("res://engine/src/modules/climate/anchor_decay.gd")
|
||||
const LeyNetworkScript: GDScript = preload("res://engine/src/modules/ley/ley_network.gd")
|
||||
|
||||
# Fallback defaults — used only when climate_params.json absent (logs warning)
|
||||
const _DEFAULTS: Dictionary = {
|
||||
"wind_conductivity": 0.1,
|
||||
"energy_scale": 0.005,
|
||||
"equilibrium_relaxation": 0.08,
|
||||
"evaporation_rate": 0.05,
|
||||
"moisture_transport": 0.15,
|
||||
"precipitation_threshold": 0.7,
|
||||
"moisture_decay": 0.995,
|
||||
"ocean_evaporation_hops": 4,
|
||||
"ocean_evaporation_hop_decay": 0.5,
|
||||
"atmospheric_loss_rate": 0.0003,
|
||||
"quality_up_threshold": 10,
|
||||
"quality_down_threshold": 5,
|
||||
"lake_thermal_conductivity": 0.05,
|
||||
"river_moisture_transport": 0.075,
|
||||
"mountain_rain_shadow_block": 0.9,
|
||||
"solar_min": 0.05,
|
||||
"solar_max": 0.70,
|
||||
}
|
||||
|
||||
const _DEW_DEFAULTS: Dictionary = {
|
||||
"volcano_self": 0.008,
|
||||
"volcano_neighbor": 0.004,
|
||||
"hot_spring_self": 0.005,
|
||||
"hot_spring_neighbor": 0.002,
|
||||
"hot_spring_max_temperature": 0.60,
|
||||
"high_elevation_self": 0.002,
|
||||
"high_elevation_threshold": 0.8,
|
||||
}
|
||||
|
||||
## Global average temperature — updated each turn, readable by UI
|
||||
var global_avg_temp: float = 0.5
|
||||
|
||||
## Fraction of coast tiles permanently dead from marine extinction [0.0, 1.0].
|
||||
## Set by TurnManager from marine_harvest.ocean_dead_fraction before process_turn().
|
||||
## Scales ocean evaporation down — biological pump failure reduces moisture transport inland.
|
||||
var ocean_dead_fraction: float = 0.0
|
||||
|
||||
## Random seed passed from TurnManager — used by stochastic subsystems.
|
||||
var _seed: int = 42
|
||||
|
||||
# Climate params from DataLoader — loaded once, then cached
|
||||
var _params: Dictionary = {}
|
||||
var _spec: Dictionary = {}
|
||||
var _params_loaded: bool = false
|
||||
# Physics capability flags from world manifest (physics_features block).
|
||||
# Defaults to all-enabled so Earth worlds without the field keep working.
|
||||
var _physics_flags: Dictionary = {
|
||||
"water_cycle": true,
|
||||
"weather_events": true,
|
||||
"ecology": true,
|
||||
"volcanism": true,
|
||||
}
|
||||
|
||||
# Per-terrain data cache (albedo, evapotranspiration) keyed by biome_id string
|
||||
var _terrain_cache: Dictionary = {}
|
||||
|
||||
# Ocean proximity cache: axial → int (hex distance to nearest water tile).
|
||||
# Computed once via BFS on first use, invalidated when terrain transforms.
|
||||
var _ocean_dist_cache: Dictionary = {} # axial → int
|
||||
var _ocean_dist_valid: bool = false
|
||||
|
||||
|
||||
# -- Step 2: Temperature update (double-buffered) --
|
||||
|
||||
|
||||
func _update_temperatures(game_map: RefCounted) -> void:
|
||||
var conductivity: float = _params.get("wind_conductivity", _DEFAULTS["wind_conductivity"])
|
||||
var energy_scale: float = _params.get("energy_scale", _DEFAULTS["energy_scale"])
|
||||
var relaxation: float = _params.get(
|
||||
"equilibrium_relaxation", _DEFAULTS["equilibrium_relaxation"]
|
||||
)
|
||||
var h: int = game_map.height
|
||||
var center_row: float = float(h) / 2.0
|
||||
|
||||
# Pre-compute solar baseline per row — same value for every tile in a row
|
||||
var solar_by_row: Dictionary = {}
|
||||
for row: int in h:
|
||||
solar_by_row[row] = _solar_by_latitude(row, center_row)
|
||||
|
||||
# Snapshot current temperatures into a read buffer — prevents stale reads
|
||||
# when upwind tiles have already been updated in this same pass.
|
||||
var old_temp: Dictionary = {} # axial → float
|
||||
for axial: Vector2i in game_map.tiles:
|
||||
old_temp[axial] = game_map.tiles[axial].temperature
|
||||
|
||||
# Compute new temperatures from old values only, write into new_temp buffer
|
||||
var new_temp: Dictionary = {} # axial → float
|
||||
for axial: Vector2i in game_map.tiles:
|
||||
var tile: Variant = game_map.tiles[axial]
|
||||
var offset: Vector2i = HexUtilsScript.axial_to_offset(axial)
|
||||
var solar: float = solar_by_row[clampi(offset.y, 0, h - 1)]
|
||||
var current_temp: float = old_temp[axial]
|
||||
|
||||
var terrain_data: Dictionary = _get_terrain_data(tile.biome_id)
|
||||
var albedo: float = terrain_data.get("albedo", 0.4)
|
||||
var net_solar: float = solar * (1.0 - albedo) * energy_scale
|
||||
|
||||
# Wind transport: read upwind tile temperature from old snapshot
|
||||
var wind_transport: float = 0.0
|
||||
var upwind_pos: Vector2i = _upwind_pos(axial, tile.wind_direction)
|
||||
if old_temp.has(upwind_pos):
|
||||
wind_transport = (
|
||||
(old_temp[upwind_pos] - current_temp) * tile.wind_speed * conductivity
|
||||
)
|
||||
|
||||
var relax: float = (solar - current_temp) * relaxation
|
||||
new_temp[axial] = clampf(
|
||||
current_temp + net_solar + wind_transport + relax + tile.magic_heat_delta, 0.0, 1.0
|
||||
)
|
||||
|
||||
# Swap: write buffered results back to tiles
|
||||
for axial: Vector2i in new_temp:
|
||||
game_map.tiles[axial].temperature = new_temp[axial]
|
||||
|
||||
|
||||
# -- Step 3: Lake thermal moderation --
|
||||
|
||||
|
||||
func _update_lake_thermal_effects(game_map: RefCounted) -> void:
|
||||
# Lakes moderate adjacent land tiles toward lake temperature.
|
||||
# Read lake temps from post-step-2 values — moderation is a secondary effect
|
||||
# and doesn't require double-buffering (lakes don't read each other here).
|
||||
var conductivity: float = _params.get(
|
||||
"lake_thermal_conductivity", _DEFAULTS["lake_thermal_conductivity"]
|
||||
)
|
||||
|
||||
for axial: Vector2i in game_map.tiles:
|
||||
var tile: Variant = game_map.tiles[axial]
|
||||
if tile.biome_id != "lake":
|
||||
continue
|
||||
var lake_temp: float = tile.temperature
|
||||
for nb_pos: Vector2i in HexUtilsScript.get_neighbors(axial):
|
||||
var nb: Variant = game_map.tiles.get(nb_pos)
|
||||
if nb == null:
|
||||
continue
|
||||
var diff: float = lake_temp - nb.temperature
|
||||
nb.temperature = clampf(nb.temperature + diff * conductivity, 0.0, 1.0)
|
||||
|
||||
|
||||
# -- Step 4: Wind moisture transport (double-buffered) --
|
||||
|
||||
|
||||
func _update_moisture_wind(game_map: RefCounted) -> void:
|
||||
var transport_rate: float = _params.get("moisture_transport", _DEFAULTS["moisture_transport"])
|
||||
var decay: float = _params.get("moisture_decay", _DEFAULTS["moisture_decay"])
|
||||
var rain_shadow_block: float = _params.get(
|
||||
"mountain_rain_shadow_block", _DEFAULTS["mountain_rain_shadow_block"]
|
||||
)
|
||||
var atmo_loss: float = _params.get("atmospheric_loss_rate", _DEFAULTS["atmospheric_loss_rate"])
|
||||
|
||||
# Snapshot moisture before decay so all tiles decay from the same baseline
|
||||
var old_moisture: Dictionary = {} # axial → float
|
||||
for axial: Vector2i in game_map.tiles:
|
||||
old_moisture[axial] = game_map.tiles[axial].moisture * decay
|
||||
|
||||
# Compute new moisture from decayed old values + upwind transport.
|
||||
# Reading old_moisture[upwind] avoids stale-write order dependence.
|
||||
var new_moisture: Dictionary = {} # axial → float
|
||||
for axial: Vector2i in game_map.tiles:
|
||||
var tile: Variant = game_map.tiles[axial]
|
||||
var current: float = old_moisture[axial]
|
||||
|
||||
# Upwind moisture transport
|
||||
var upwind_pos: Vector2i = _upwind_pos(axial, tile.wind_direction)
|
||||
var transported: float = 0.0
|
||||
if old_moisture.has(upwind_pos):
|
||||
var upwind_tile: Variant = game_map.tiles.get(upwind_pos)
|
||||
var upwind_is_mountain: bool = (
|
||||
upwind_tile != null and BiomeRegistry.has_tag(upwind_tile.biome_id, "is_elevated")
|
||||
)
|
||||
var block: float = rain_shadow_block if upwind_is_mountain else 0.0
|
||||
transported = (
|
||||
old_moisture[upwind_pos] * tile.wind_speed * transport_rate * (1.0 - block)
|
||||
)
|
||||
|
||||
# Evapotranspiration and magic forcing are per-tile local effects — safe to add here
|
||||
var terrain_data: Dictionary = _get_terrain_data(tile.biome_id)
|
||||
var evapotrans: float = terrain_data.get("evapotranspiration", 0.0)
|
||||
|
||||
# Atmospheric loss to space — Jeans escape (temperature-scaled).
|
||||
# Balanced by volcanic outgassing (deep_earth_water step). No baseline injection needed.
|
||||
var space_loss: float = current * atmo_loss * tile.temperature
|
||||
|
||||
new_moisture[axial] = clampf(
|
||||
(
|
||||
current
|
||||
+ transported
|
||||
+ evapotrans
|
||||
- space_loss
|
||||
+ tile.magic_moisture_delta
|
||||
),
|
||||
0.0,
|
||||
1.0
|
||||
)
|
||||
|
||||
# Swap
|
||||
for axial: Vector2i in new_moisture:
|
||||
game_map.tiles[axial].moisture = new_moisture[axial]
|
||||
|
||||
|
||||
# -- Step 7b: Surface runoff — move surface water downhill into ocean --
|
||||
|
||||
|
||||
func _update_surface_runoff(game_map: RefCounted) -> void:
|
||||
## Surface water flows downhill along river_edges into ocean-substrate tiles.
|
||||
## Draining into ocean substrate adds to the planetary water reservoir (drives sea_level).
|
||||
## No downhill neighbour → water pools (lake formation).
|
||||
var runoff_rate: float = _params.get("surface_runoff_rate", 0.3)
|
||||
var ocean_substrates: Array[String] = ["deep_water", "shallow_water"]
|
||||
|
||||
for axial: Vector2i in game_map.tiles:
|
||||
var tile: Variant = game_map.tiles[axial]
|
||||
if tile.surface_water <= 0.0:
|
||||
continue
|
||||
|
||||
# Find the steepest downhill river_edge neighbor
|
||||
var best_nb: Variant = null
|
||||
var best_drop: float = 0.0
|
||||
for edge: int in tile.river_edges:
|
||||
var nb_pos: Vector2i = axial + HexUtilsScript.AXIAL_DIRECTIONS[edge]
|
||||
var nb: Variant = game_map.tiles.get(nb_pos)
|
||||
if nb == null:
|
||||
continue
|
||||
var drop: float = tile.elevation - nb.elevation
|
||||
if drop > best_drop:
|
||||
best_drop = drop
|
||||
best_nb = nb
|
||||
|
||||
if best_nb == null:
|
||||
continue # no downhill neighbor — water pools here
|
||||
|
||||
var flow: float = tile.surface_water * runoff_rate
|
||||
tile.surface_water -= flow
|
||||
if best_nb.substrate_id in ocean_substrates:
|
||||
game_map.total_ocean_water += flow
|
||||
else:
|
||||
best_nb.surface_water += flow
|
||||
|
||||
|
||||
# -- Step 8: Terrain quality evolution --
|
||||
|
||||
|
||||
func _check_terrain_evolution(game_map: RefCounted) -> void:
|
||||
var up_thresh: int = int(_params.get("quality_up_threshold", _DEFAULTS["quality_up_threshold"]))
|
||||
var down_thresh: int = int(_params.get("quality_down_threshold", _DEFAULTS["quality_down_threshold"]))
|
||||
|
||||
for axial: Vector2i in game_map.tiles:
|
||||
var tile: Variant = game_map.tiles[axial]
|
||||
var tid: String = tile.biome_id
|
||||
|
||||
if tile.is_natural_wonder:
|
||||
continue
|
||||
|
||||
# Water freezing/thawing
|
||||
var freeze_temp: float = _params.get("water_freeze_threshold", 0.12)
|
||||
var thaw_temp: float = freeze_temp + 0.03
|
||||
var is_water: bool = BiomeRegistry.has_tag(tid, "is_water")
|
||||
if is_water:
|
||||
if tile.temperature < freeze_temp:
|
||||
tile.original_biome_id = tid
|
||||
tile.biome_id = "ice"
|
||||
tile.quality = 1
|
||||
tile.quality_progress = 0
|
||||
continue
|
||||
if BiomeRegistry.has_tag(tid, "is_frozen"):
|
||||
if tile.temperature > thaw_temp and tile.original_biome_id != "":
|
||||
tile.biome_id = tile.original_biome_id
|
||||
tile.original_biome_id = ""
|
||||
tile.quality = 1
|
||||
tile.quality_progress = 0
|
||||
continue
|
||||
|
||||
if BiomeRegistry.has_tag(tid, "is_volcanic"):
|
||||
continue
|
||||
|
||||
var ideal: String = _ideal_terrain(tile)
|
||||
|
||||
if ideal == tid:
|
||||
tile.quality_progress += 1
|
||||
if tile.quality_progress >= up_thresh:
|
||||
tile.quality_progress = 0
|
||||
if tile.quality < 5:
|
||||
tile.quality += 1
|
||||
else:
|
||||
tile.quality_progress -= 1
|
||||
if tile.quality_progress <= -down_thresh:
|
||||
tile.quality_progress = 0
|
||||
if tile.quality > 1:
|
||||
tile.quality -= 1
|
||||
else:
|
||||
tile.biome_id = ideal
|
||||
tile.quality = 1
|
||||
tile.quality_progress = 0
|
||||
|
||||
|
||||
# -- Internal helpers --
|
||||
|
||||
|
||||
func _get_terrain_data(biome_id: String) -> Dictionary:
|
||||
if not _terrain_cache.has(biome_id):
|
||||
_terrain_cache[biome_id] = DataLoader.get_terrain(biome_id)
|
||||
return _terrain_cache[biome_id]
|
||||
|
||||
|
||||
func _solar_by_latitude(row: int, center_row: float) -> float:
|
||||
## Solar input at a given map row, clamped to habitable range.
|
||||
## Raw latitude factor: 1.0 at equator, 0.0 at poles.
|
||||
## Mapped via solar_min/solar_max params to produce stable equilibrium
|
||||
## temperatures that support reef survival and biome diversity.
|
||||
var raw: float = 1.0 - absf((float(row) - center_row) / center_row)
|
||||
var solar_min: float = _params.get("solar_min", _DEFAULTS.get("solar_min", 0.05))
|
||||
var solar_max: float = _params.get("solar_max", _DEFAULTS.get("solar_max", 0.70))
|
||||
return solar_min + (solar_max - solar_min) * raw
|
||||
|
||||
|
||||
func _upwind_pos(axial: Vector2i, wind_dir: int) -> Vector2i:
|
||||
## Return the axial position that wind blows FROM (opposite to wind_direction).
|
||||
return axial + HexUtilsScript.AXIAL_DIRECTIONS[(wind_dir + 3) % 6]
|
||||
|
||||
|
||||
func _downwind_pos(axial: Vector2i, wind_dir: int) -> Vector2i:
|
||||
## Return the axial position that wind blows TOWARD (same as wind_direction).
|
||||
return axial + HexUtilsScript.AXIAL_DIRECTIONS[wind_dir % 6]
|
||||
|
||||
|
||||
func _ideal_terrain(tile: Variant) -> String:
|
||||
return ClimateSpecEvalScript.ideal_terrain(tile, _spec)
|
||||
|
||||
|
||||
func _ley_channeling_mult(nb: Variant) -> float:
|
||||
return ClimateSpecEvalScript.ley_channeling_mult(nb, _spec)
|
||||
|
|
@ -1,112 +1,34 @@
|
|||
class_name ClimateSpecEval
|
||||
extends RefCounted
|
||||
## Evaluates terrain transition rules and ley channeling multipliers from climate_spec.json.
|
||||
## Pure logic — no side effects, no state mutation. Used by Climate and the transpiler.
|
||||
|
||||
const MapGeneratorScript: GDScript = preload("res://engine/src/generation/map_generator.gd")
|
||||
const BiomeClassifierScript = preload("res://engine/src/models/world/biome_classifier.gd")
|
||||
## Thin wrapper delegating to Rust GDExtension (GdClimateSpecEval).
|
||||
|
||||
|
||||
static func ideal_terrain(tile: Variant, spec: Dictionary) -> String:
|
||||
## Determine the climate-ideal terrain type for a tile. Reads transition rules
|
||||
## from climate_spec.json terrain_transitions. If a terrain has no spec entry,
|
||||
## falls back to BiomeClassifier.classify().
|
||||
var tid: String = tile.biome_id
|
||||
var temp: float = tile.temperature
|
||||
var moist: float = tile.moisture
|
||||
var canopy: float = tile.get("canopy_cover", 0.0)
|
||||
|
||||
var transitions: Dictionary = spec.get("terrain_transitions", {})
|
||||
var rules: Array = transitions.get(tid, [])
|
||||
if rules.is_empty():
|
||||
return BiomeClassifierScript.classify(tile)
|
||||
|
||||
for rule: Variant in rules:
|
||||
if not rule is Dictionary:
|
||||
continue
|
||||
if eval_condition(rule.get("condition", ""), temp, moist, tile.elevation, canopy):
|
||||
var becomes: String = rule.get("becomes", "")
|
||||
if becomes == "classify":
|
||||
return BiomeClassifierScript.classify(tile)
|
||||
return becomes
|
||||
|
||||
return tid
|
||||
static func ideal_terrain(tile: RefCounted, spec: Dictionary) -> String:
|
||||
## Determine the climate-ideal terrain type for a tile.
|
||||
var tile_dict: Dictionary = {
|
||||
"biome_id": tile.get("biome_id"),
|
||||
"temperature": tile.get("temperature"),
|
||||
"moisture": tile.get("moisture"),
|
||||
"elevation": tile.get("elevation"),
|
||||
"canopy_cover": tile.get("canopy_cover"),
|
||||
}
|
||||
var spec_json: String = JSON.stringify(spec)
|
||||
return GdClimateSpecEval.ideal_terrain(tile_dict, spec_json)
|
||||
|
||||
|
||||
static func eval_condition(cond: String, temp: float, moist: float, elev: float, canopy: float = 0.0) -> bool:
|
||||
static func eval_condition(
|
||||
cond: String, temp: float, moist: float, elev: float, canopy: float = 0.0
|
||||
) -> bool:
|
||||
## Evaluate a condition string from climate_spec.json.
|
||||
## Supports: "field op value", "cond1 AND cond2", "cond1 OR cond2".
|
||||
## Fields: temperature, moisture, elevation, canopy.
|
||||
## Operators: <, <=, >, >=.
|
||||
if " OR " in cond:
|
||||
var parts: PackedStringArray = cond.split(" OR ")
|
||||
for part: String in parts:
|
||||
if eval_condition(part.strip_edges(), temp, moist, elev, canopy):
|
||||
return true
|
||||
return false
|
||||
|
||||
if " AND " in cond:
|
||||
var parts: PackedStringArray = cond.split(" AND ")
|
||||
for part: String in parts:
|
||||
if not eval_condition(part.strip_edges(), temp, moist, elev, canopy):
|
||||
return false
|
||||
return true
|
||||
|
||||
var clean: String = cond.strip_edges().trim_prefix("(").trim_suffix(")")
|
||||
var tokens: PackedStringArray = clean.split(" ")
|
||||
if tokens.size() < 3:
|
||||
push_warning("ClimateSpecEval: bad condition: '%s'" % cond)
|
||||
return false
|
||||
|
||||
var field: String = tokens[0]
|
||||
var op: String = tokens[1]
|
||||
var value: float = float(tokens[2])
|
||||
|
||||
var actual: float = _field_value(field, temp, moist, elev, canopy)
|
||||
if actual == -INF:
|
||||
return false
|
||||
|
||||
return _check_op(actual, op, value)
|
||||
return GdClimateSpecEval.eval_condition(cond, temp, moist, elev, canopy)
|
||||
|
||||
|
||||
static func ley_channeling_mult(tile: Variant, spec: Dictionary) -> float:
|
||||
static func ley_channeling_mult(tile: RefCounted, spec: Dictionary) -> float:
|
||||
## Corruption spread multiplier based on receiving tile's ley state.
|
||||
## Reads multiplier values from climate_spec.json ley_channeling section.
|
||||
if tile.ley_line_count <= 0:
|
||||
return 1.0
|
||||
|
||||
var ley_spec: Dictionary = spec.get("ley_channeling", {})
|
||||
var school: String = tile.ley_school
|
||||
if school == "death":
|
||||
return ley_spec.get("death_ley", 3.0)
|
||||
if school in ["nature", "life"]:
|
||||
return ley_spec.get("nature_life_ley", 0.5)
|
||||
return ley_spec.get("on_ley_generic", 2.0)
|
||||
|
||||
|
||||
static func _field_value(field: String, temp: float, moist: float, elev: float, canopy: float = 0.0) -> float:
|
||||
match field:
|
||||
"temperature":
|
||||
return temp
|
||||
"moisture":
|
||||
return moist
|
||||
"elevation":
|
||||
return elev
|
||||
"canopy":
|
||||
return canopy
|
||||
push_warning("ClimateSpecEval: unknown field '%s'" % field)
|
||||
return -INF
|
||||
|
||||
|
||||
static func _check_op(actual: float, op: String, value: float) -> bool:
|
||||
match op:
|
||||
"<":
|
||||
return actual < value
|
||||
"<=":
|
||||
return actual <= value
|
||||
">":
|
||||
return actual > value
|
||||
">=":
|
||||
return actual >= value
|
||||
push_warning("ClimateSpecEval: unknown op '%s'" % op)
|
||||
return false
|
||||
var tile_dict: Dictionary = {
|
||||
"ley_line_count": tile.get("ley_line_count"),
|
||||
"ley_school": tile.get("ley_school"),
|
||||
}
|
||||
var spec_json: String = JSON.stringify(spec)
|
||||
return GdClimateSpecEval.ley_channeling_mult(tile_dict, spec_json)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue