357 lines
12 KiB
GDScript
357 lines
12 KiB
GDScript
# gdlint: disable=max-public-methods
|
|
class_name FloraSystem
|
|
extends RefCounted
|
|
## Per-turn flora dynamics: canopy, undergrowth, fungi, succession,
|
|
## desertification, and regrowth.
|
|
##
|
|
## The tick functions are written to be transpiler-friendly:
|
|
## - Iterate flat tile arrays (not game_map.tiles Dictionary)
|
|
## - Accept pre-resolved biome data as plain Dictionaries (not BiomeModel objects)
|
|
## - No DataLoader, EventBus, or preload calls inside tick loops
|
|
## - The orchestrator (process_turn) resolves all external dependencies
|
|
## and passes plain data to the ticks
|
|
##
|
|
## This means `uv run tools/transpile-engine/transpile.py` can auto-generate
|
|
## EcologyPhysics.generated.ts from these functions — single source of truth.
|
|
|
|
const BiomeClassifierScript = preload(
|
|
"res://engine/src/models/world/biome_classifier.gd"
|
|
)
|
|
const FlavorGeneratorScript = preload(
|
|
"res://engine/src/models/world/flavor_generator.gd"
|
|
)
|
|
|
|
# Cached params — loaded once on first process_turn
|
|
var _veg: Dictionary = {}
|
|
var _suc: Dictionary = {}
|
|
var _des: Dictionary = {}
|
|
var _params_loaded: bool = false
|
|
var _regrowth_stages: Array = []
|
|
|
|
# Pre-resolved biome data: biome_id -> {canopy, undergrowth, fungi, temp_min, temp_max, moist_min, moist_max}
|
|
var _biome_flora: Dictionary = {}
|
|
|
|
|
|
func _load_params() -> void:
|
|
_veg = DataLoader.get_vegetation_params()
|
|
_suc = DataLoader.get_succession_params()
|
|
_des = DataLoader.get_desertification_params()
|
|
_regrowth_stages = _suc.get("regrowth_stages", [])
|
|
|
|
# Pre-resolve biome data into plain Dictionary for transpiler-friendly ticks
|
|
_biome_flora = {}
|
|
for biome: Variant in DataLoader.get_all_biomes():
|
|
var fc: Dictionary = biome.flora_climax if biome.flora_climax is Dictionary else {}
|
|
_biome_flora[biome.id] = {
|
|
"canopy": fc.get("canopy", 0.0),
|
|
"undergrowth": fc.get("undergrowth", 0.0),
|
|
"fungi": fc.get("fungi", 0.0),
|
|
"temp_min": biome.temp_range.x if biome.temp_range is Vector2 else 0.0,
|
|
"temp_max": biome.temp_range.y if biome.temp_range is Vector2 else 1.0,
|
|
"moist_min": biome.moisture_range.x if biome.moisture_range is Vector2 else 0.0,
|
|
"moist_max": biome.moisture_range.y if biome.moisture_range is Vector2 else 1.0,
|
|
}
|
|
_params_loaded = true
|
|
|
|
|
|
func process_turn(game_map: RefCounted) -> void:
|
|
## Orchestrator: pre-resolve external data, then call transpilable ticks.
|
|
if not _params_loaded:
|
|
_load_params()
|
|
|
|
# Collect tiles into flat array for transpiler-friendly iteration
|
|
var tiles: Array = game_map.tiles.values()
|
|
|
|
var o2: float = game_map.o2_fraction if "o2_fraction" in game_map else 0.21
|
|
tick_canopy(tiles, _biome_flora, _veg, o2)
|
|
tick_undergrowth(tiles, _biome_flora, _veg, o2)
|
|
tick_fungi(tiles, _biome_flora, _veg)
|
|
tick_succession(tiles, _suc)
|
|
tick_desertification(tiles, _veg, _des)
|
|
tick_regrowth(tiles, _regrowth_stages, _veg)
|
|
|
|
# Post-tick: handle non-transpilable effects (signals, classifier, naming)
|
|
_post_succession(tiles)
|
|
|
|
|
|
# =========================================================================
|
|
# Transpilable tick functions — pure tile array + plain Dictionary params
|
|
# No DataLoader, no EventBus, no preload calls inside these.
|
|
# =========================================================================
|
|
|
|
|
|
static func tick_canopy(tiles: Array, biome_flora: Dictionary, veg: Dictionary, o2_fraction: float = 0.21) -> void:
|
|
## Grow canopy toward biome climax. Decay when outside climate range.
|
|
## Requires existing population (dP/dt = r*P) and sufficient atmospheric O2.
|
|
var growth_rate: float = veg.get("growth_rate", 0.02)
|
|
var decay_rate: float = veg.get("decay_rate", 0.03)
|
|
var o2_mult: float = _o2_growth_mult(o2_fraction)
|
|
|
|
for tile: Variant in tiles:
|
|
if BiomeRegistry.has_tag(tile.biome_id, "is_water"):
|
|
continue
|
|
var bf: Dictionary = biome_flora.get(tile.biome_id, {})
|
|
if bf.is_empty():
|
|
continue
|
|
var climax: float = bf.get("canopy", 0.0)
|
|
|
|
# Population gate: can't grow from nothing
|
|
if tile.canopy_cover <= 0.0:
|
|
continue
|
|
|
|
var match_mult: float = _climate_match_flat(tile, bf)
|
|
var q_mult: float = _quality_mult(tile.quality)
|
|
|
|
if match_mult > 0.0:
|
|
var delta: float = growth_rate * match_mult * q_mult * o2_mult
|
|
tile.canopy_cover = minf(tile.canopy_cover + delta, climax)
|
|
else:
|
|
tile.canopy_cover = maxf(tile.canopy_cover - decay_rate, 0.0)
|
|
|
|
|
|
static func tick_undergrowth(tiles: Array, biome_flora: Dictionary, veg: Dictionary, o2_fraction: float = 0.21) -> void:
|
|
## Grow undergrowth, capped by canopy shade. Decays faster in drought.
|
|
## Requires existing population (dP/dt = r*P) and sufficient atmospheric O2.
|
|
var growth_rate: float = veg.get("growth_rate", 0.02)
|
|
var decay_rate: float = veg.get("decay_rate", 0.03)
|
|
var shade_cap: float = veg.get("shade_cap", 0.7)
|
|
var drought_mult: float = veg.get("drought_decay_multiplier", 1.5)
|
|
var o2_mult: float = _o2_growth_mult(o2_fraction)
|
|
|
|
for tile: Variant in tiles:
|
|
if BiomeRegistry.has_tag(tile.biome_id, "is_water"):
|
|
continue
|
|
var bf: Dictionary = biome_flora.get(tile.biome_id, {})
|
|
if bf.is_empty():
|
|
continue
|
|
var climax: float = bf.get("undergrowth", 0.0)
|
|
|
|
# Population gate: can't grow from nothing
|
|
if tile.undergrowth <= 0.0:
|
|
continue
|
|
|
|
var match_mult: float = _climate_match_flat(tile, bf)
|
|
var q_mult: float = _quality_mult(tile.quality)
|
|
|
|
var effective_cap: float = climax
|
|
if tile.canopy_cover > shade_cap:
|
|
effective_cap = minf(climax, shade_cap)
|
|
|
|
if match_mult > 0.0:
|
|
var delta: float = growth_rate * match_mult * q_mult * o2_mult
|
|
tile.undergrowth = minf(tile.undergrowth + delta, effective_cap)
|
|
else:
|
|
var rate: float = decay_rate
|
|
if tile.drought_counter > 0:
|
|
rate *= drought_mult
|
|
tile.undergrowth = maxf(tile.undergrowth - rate, 0.0)
|
|
|
|
|
|
static func tick_fungi(tiles: Array, biome_flora: Dictionary, veg: Dictionary) -> void:
|
|
## Fungi grows where undergrowth > threshold. Old-growth bonus.
|
|
var growth_rate: float = veg.get("growth_rate", 0.02)
|
|
var decay_rate: float = veg.get("decay_rate", 0.03)
|
|
var ug_threshold: float = veg.get("fungi_undergrowth_threshold", 0.3)
|
|
|
|
for tile: Variant in tiles:
|
|
if BiomeRegistry.has_tag(tile.biome_id, "is_water"):
|
|
continue
|
|
var bf: Dictionary = biome_flora.get(tile.biome_id, {})
|
|
if bf.is_empty():
|
|
continue
|
|
var climax: float = bf.get("fungi", 0.0)
|
|
|
|
if tile.undergrowth < ug_threshold:
|
|
tile.fungi_network = maxf(tile.fungi_network - decay_rate * 0.5, 0.0)
|
|
continue
|
|
|
|
if tile.moisture < 0.15 or tile.temperature < 0.1:
|
|
tile.fungi_network = maxf(tile.fungi_network - decay_rate * 0.5, 0.0)
|
|
continue
|
|
|
|
var ug_factor: float = tile.undergrowth
|
|
var old_growth: float = 1.0
|
|
if (tile.canopy_cover > 0.7
|
|
and tile.undergrowth > 0.5
|
|
and tile.moisture > 0.4):
|
|
old_growth = 1.5
|
|
|
|
var q_mult: float = _quality_mult(tile.quality)
|
|
var delta: float = growth_rate * ug_factor * old_growth * q_mult
|
|
tile.fungi_network = minf(tile.fungi_network + delta, climax)
|
|
|
|
|
|
static func tick_succession(tiles: Array, suc: Dictionary) -> void:
|
|
## Track canopy stability for biome succession.
|
|
## Reclassification and signals handled by _post_succession (non-transpilable).
|
|
var stability_turns: int = suc.get("stability_turns", 50)
|
|
var canopy_threshold: float = suc.get("canopy_threshold", 0.8)
|
|
|
|
for tile: Variant in tiles:
|
|
if BiomeRegistry.has_tag(tile.biome_id, "is_water"):
|
|
continue
|
|
if tile.regrowth_stage >= 0:
|
|
continue
|
|
|
|
if tile.canopy_cover >= canopy_threshold:
|
|
tile.succession_progress += 1
|
|
else:
|
|
tile.succession_progress = 0
|
|
|
|
|
|
static func tick_desertification(tiles: Array, veg: Dictionary, des: Dictionary) -> void:
|
|
## Drought tracking and accelerated flora decay.
|
|
var moisture_thresh: float = des.get("moisture_threshold", 0.2)
|
|
var decay_mult: float = des.get("decay_multiplier", 2.0)
|
|
var recovery_rate: int = des.get("recovery_rate", 1)
|
|
var base_decay: float = veg.get("decay_rate", 0.03)
|
|
|
|
for tile: Variant in tiles:
|
|
if BiomeRegistry.has_tag(tile.biome_id, "is_water"):
|
|
continue
|
|
|
|
if tile.moisture < moisture_thresh:
|
|
tile.drought_counter += 1
|
|
var rate: float = base_decay * decay_mult
|
|
tile.canopy_cover = maxf(tile.canopy_cover - rate, 0.0)
|
|
tile.undergrowth = maxf(tile.undergrowth - rate * 1.5, 0.0)
|
|
tile.fungi_network = maxf(tile.fungi_network - rate, 0.0)
|
|
else:
|
|
tile.drought_counter = maxi(tile.drought_counter - recovery_rate, 0)
|
|
|
|
|
|
static func tick_regrowth(tiles: Array, regrowth_stages: Array, veg: Dictionary) -> void:
|
|
## Advance tiles through regrowth stages. Fungi accelerates (capped).
|
|
var bonus_cap: float = veg.get("fungi_regrowth_bonus_cap", 2.0)
|
|
|
|
for tile: Variant in tiles:
|
|
if tile.regrowth_stage < 0:
|
|
continue
|
|
|
|
tile.regrowth_turns += 1
|
|
|
|
var stage_data: Dictionary = _get_stage(tile.regrowth_stage, regrowth_stages)
|
|
if stage_data.is_empty():
|
|
continue
|
|
|
|
var base_turns: int = stage_data.get("turns_to_advance", 10)
|
|
var fungi_bonus: float = clampf(
|
|
1.0 + tile.fungi_network * bonus_cap, 1.0, bonus_cap
|
|
)
|
|
var effective_turns: int = maxi(
|
|
1, roundi(float(base_turns) / fungi_bonus)
|
|
)
|
|
|
|
if tile.regrowth_turns < effective_turns:
|
|
continue
|
|
|
|
var next_stage: int = tile.regrowth_stage + 1
|
|
var next_data: Dictionary = _get_stage(next_stage, regrowth_stages)
|
|
|
|
if next_data.is_empty() or next_stage > 3:
|
|
tile.regrowth_stage = -1
|
|
tile.regrowth_turns = 0
|
|
continue
|
|
|
|
tile.regrowth_stage = next_stage
|
|
tile.regrowth_turns = 0
|
|
tile.canopy_cover = next_data.get("canopy_target", 0.0)
|
|
tile.undergrowth = next_data.get("undergrowth_target", 0.0)
|
|
tile.fungi_network = next_data.get("fungi_target", 0.0)
|
|
|
|
if next_stage >= 3:
|
|
tile.regrowth_stage = -1
|
|
tile.regrowth_turns = 0
|
|
|
|
|
|
# =========================================================================
|
|
# Non-transpilable post-processing (signals, classifier, naming)
|
|
# =========================================================================
|
|
|
|
|
|
func _post_succession(tiles: Array) -> void:
|
|
## Handle biome reclassification + signals + landmark naming after succession tick.
|
|
var stability_turns: int = _suc.get("stability_turns", 50)
|
|
|
|
for tile: Variant in tiles:
|
|
if BiomeRegistry.has_tag(tile.biome_id, "is_water"):
|
|
continue
|
|
if tile.succession_progress < stability_turns:
|
|
continue
|
|
|
|
# Succession triggered
|
|
var old_biome: String = tile.biome_id
|
|
var new_biome: String = BiomeClassifierScript.classify(tile)
|
|
tile.succession_progress = 0
|
|
|
|
if new_biome != old_biome:
|
|
tile.biome_id = new_biome
|
|
EventBus.biome_changed.emit(tile.position, old_biome, new_biome)
|
|
|
|
# Landmark naming for Q4+ tiles
|
|
if tile.quality >= 4 and tile.landmark_name == "":
|
|
var lname: String = FlavorGeneratorScript.generate_landmark_name(
|
|
tile.biome_id, tile.quality,
|
|
hash(tile.position) + tile.quality,
|
|
)
|
|
tile.landmark_name = lname
|
|
EventBus.landmark_formed.emit(tile.position, lname, tile.quality)
|
|
|
|
|
|
# =========================================================================
|
|
# Pure static helpers — transpilable
|
|
# =========================================================================
|
|
|
|
|
|
static func _get_stage(stage_index: int, stages: Array) -> Dictionary:
|
|
for entry: Variant in stages:
|
|
if entry is Dictionary and entry.get("stage", -1) == stage_index:
|
|
return entry
|
|
return {}
|
|
|
|
|
|
|
|
static func _climate_match_flat(tile: Variant, bf: Dictionary) -> float:
|
|
## Climate match using pre-resolved flat biome data (no BiomeModel).
|
|
var temp: float = tile.temperature
|
|
var moist: float = tile.moisture
|
|
var t_min: float = bf.get("temp_min", 0.0)
|
|
var t_max: float = bf.get("temp_max", 1.0)
|
|
var m_min: float = bf.get("moist_min", 0.0)
|
|
var m_max: float = bf.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
|
|
|
|
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.0
|
|
|
|
|
|
static func _quality_mult(quality: int) -> float:
|
|
## Quality growth scaling: Q1=0.6, Q2=0.8, Q3=1.0, Q4=1.2, Q5=1.4
|
|
match quality:
|
|
1: return 0.6
|
|
2: return 0.8
|
|
4: return 1.2
|
|
5: return 1.4
|
|
_: return 1.0
|
|
|
|
|
|
static func _o2_growth_mult(o2: float) -> float:
|
|
## Complex plant growth scaling by atmospheric O2.
|
|
## Below 2%: no growth. 2-10%: ramp to 0.5. 10-18%: ramp to 1.0. 18%+: full.
|
|
if o2 < 0.02:
|
|
return 0.0
|
|
if o2 < 0.10:
|
|
return (o2 - 0.02) / 0.08 * 0.5
|
|
if o2 < 0.18:
|
|
return 0.5 + (o2 - 0.10) / 0.08 * 0.5
|
|
return 1.0
|