feat(events): ✨ Introduce event handlers for marine harvest actions with validation, triggers, and optimizations
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
ddf41217ca
commit
1a8ce185ff
1 changed files with 371 additions and 0 deletions
|
|
@ -1,2 +1,373 @@
|
|||
class_name MarineHarvest
|
||||
extends RefCounted
|
||||
## Marine ecology system: fish stock depletion/recovery, coral reef health,
|
||||
## iron seeding bloom propagation, corrupted creature DoT, ocean dead fraction.
|
||||
##
|
||||
## Called once per full game turn by TurnManager._process_climate(), BEFORE
|
||||
## climate.process_turn() so that ocean_dead_fraction is ready for evaporation scaling.
|
||||
|
||||
const HexUtilsScript = preload("res://engine/src/map/hex_utils.gd")
|
||||
const UnitScript = preload("res://engine/src/entities/unit.gd")
|
||||
|
||||
## Marine resource IDs that track fish stock depletion.
|
||||
const FISHING_RESOURCE_IDS: Array[String] = [
|
||||
"coral_reef", "merfolk_shallows", "sea_serpent_shoals", "leviathan_grounds",
|
||||
]
|
||||
|
||||
## All marine resource IDs (includes non-fish ones like pearl_beds, kraken_ink_vent).
|
||||
const MARINE_RESOURCE_IDS: Array[String] = [
|
||||
"coral_reef", "merfolk_shallows", "sea_serpent_shoals",
|
||||
"pearl_beds", "leviathan_grounds", "kraken_ink_vent",
|
||||
]
|
||||
|
||||
## Water biome IDs where fishing spots can migrate to.
|
||||
const WATER_BIOMES: Array[String] = ["coast", "ocean", "lake", "inland_sea"]
|
||||
|
||||
const STOCK_MAX: int = 100
|
||||
const STOCK_RECOVERY: int = 1 # +1 stock/turn when no boats present
|
||||
const STOCK_DEPLETION_PER_BOAT: int = 3 # -3 stock/turn per fishing unit on tile
|
||||
const STOCK_ABUNDANT_MIN: int = 70 # above this: full yield
|
||||
const STOCK_HEALTHY_MIN: int = 40 # above this: normal yield
|
||||
const STOCK_DEPLETED_MIN: int = 15 # above this: reduced yield (-1 food)
|
||||
|
||||
const REEF_BLEACH_TEMP: float = 0.75 # tile.temperature above this → reef degrades
|
||||
const REEF_RECOVER_TEMP: float = 0.65 # tile.temperature below this → reef can recover
|
||||
const REEF_DEGRADE_RATE: float = 0.05 # health lost per turn when bleaching
|
||||
const REEF_RECOVER_RATE: float = 0.02 # health gained per turn when recovering
|
||||
const REEF_BLEACH_THRESHOLD: float = 0.4 # below this: partial yield; bleached signal fires
|
||||
const REEF_DEAD_THRESHOLD: float = 0.0 # at this: resource removed, dead coast
|
||||
|
||||
const OCEAN_WARN_YELLOW: float = 0.25 # first warning threshold
|
||||
const OCEAN_WARN_ORANGE: float = 0.50 # second warning threshold
|
||||
|
||||
## School affiliation of each corrupted creature type.
|
||||
const CREATURE_SCHOOL: Dictionary = {
|
||||
"wraith_eel": "death",
|
||||
"bone_shark": "death",
|
||||
"drowned_leviathan": "death",
|
||||
"death_kelp": "death",
|
||||
"lava_eel": "chaos",
|
||||
"pyroclastic_manta": "chaos",
|
||||
"infernal_serpent": "chaos",
|
||||
"volcanic_jellyfish": "chaos",
|
||||
}
|
||||
|
||||
## Base DoT dealt per turn to non-aligned naval units.
|
||||
const CREATURE_DOT: Dictionary = {
|
||||
"wraith_eel": 3,
|
||||
"bone_shark": 6,
|
||||
"drowned_leviathan": 12,
|
||||
"death_kelp": 2,
|
||||
"lava_eel": 4,
|
||||
"pyroclastic_manta": 8,
|
||||
"infernal_serpent": 15,
|
||||
"volcanic_jellyfish": 2,
|
||||
}
|
||||
|
||||
## Fraction of coast tiles permanently dead — updated each turn, read by climate.gd.
|
||||
var ocean_dead_fraction: float = 0.0
|
||||
|
||||
## Previous warning level, used to avoid repeating the same warning every turn.
|
||||
var _prev_warn_level: String = "green"
|
||||
|
||||
|
||||
func process_turn(game_map: RefCounted, players: Array) -> void:
|
||||
## Run the full marine ecology tick for one game turn.
|
||||
_tick_fish_stocks(game_map, players)
|
||||
_tick_reef_health(game_map)
|
||||
_tick_marine_blooms(game_map)
|
||||
_tick_marine_creatures(game_map, players)
|
||||
_compute_ocean_dead_fraction(game_map)
|
||||
_check_ocean_warnings()
|
||||
|
||||
|
||||
# -- Fish stock depletion / recovery / migration --
|
||||
|
||||
|
||||
func _tick_fish_stocks(game_map: RefCounted, players: Array) -> void:
|
||||
for axial: Vector2i in game_map.tiles:
|
||||
var tile: Variant = game_map.tiles[axial]
|
||||
if tile.resource_id not in FISHING_RESOURCE_IDS:
|
||||
continue
|
||||
|
||||
# Initialize stock on first encounter (newly spawned resource tile)
|
||||
if tile.fish_stock < 0:
|
||||
tile.fish_stock = STOCK_MAX
|
||||
|
||||
# Count fishing units on this tile across all players
|
||||
var boat_count: int = 0
|
||||
for player: Variant in players:
|
||||
for unit: Variant in player.units:
|
||||
if unit is UnitScript and (unit as UnitScript).position == axial:
|
||||
var unit_data: Dictionary = DataLoader.get_unit((unit as UnitScript).type_id)
|
||||
if unit_data.get("fishing_capacity", 0) > 0:
|
||||
boat_count += 1
|
||||
|
||||
# Marine creatures also drain stock
|
||||
if tile.marine_creature != "":
|
||||
boat_count += 1 # creature counts as persistent pressure source
|
||||
|
||||
if boat_count > 0:
|
||||
tile.fish_stock = maxi(0, tile.fish_stock - STOCK_DEPLETION_PER_BOAT * boat_count)
|
||||
else:
|
||||
tile.fish_stock = mini(STOCK_MAX, tile.fish_stock + STOCK_RECOVERY)
|
||||
|
||||
# Migrate if stock hits zero
|
||||
if tile.fish_stock == 0:
|
||||
_migrate_fishing_spot(game_map, axial, tile)
|
||||
|
||||
|
||||
func _migrate_fishing_spot(game_map: RefCounted, axial: Vector2i, tile: Variant) -> void:
|
||||
## Move the fishing resource to an adjacent water tile with no existing resource.
|
||||
## Permanent extinction if no valid tile found.
|
||||
var candidates: Array[Vector2i] = []
|
||||
for nb_pos: Vector2i in HexUtilsScript.get_neighbors(axial):
|
||||
var nb: Variant = game_map.tiles.get(nb_pos)
|
||||
if nb == null:
|
||||
continue
|
||||
if nb.biome_id not in WATER_BIOMES:
|
||||
continue
|
||||
if nb.resource_id != "":
|
||||
continue
|
||||
if nb.fish_stock == 0:
|
||||
continue # also a dead tile
|
||||
candidates.append(nb_pos)
|
||||
|
||||
var old_resource: String = tile.resource_id
|
||||
tile.resource_id = ""
|
||||
# fish_stock stays 0 — marks this tile as a dead zone for ocean_dead_fraction
|
||||
|
||||
if candidates.is_empty():
|
||||
EventBus.fishing_spot_exhausted.emit(axial)
|
||||
return
|
||||
|
||||
# Pick the candidate with the highest stock (healthiest water)
|
||||
var best_pos: Vector2i = candidates[0]
|
||||
var best_stock: int = -1
|
||||
for pos: Vector2i in candidates:
|
||||
var nb: Variant = game_map.tiles[pos]
|
||||
var nb_stock: int = nb.fish_stock if nb.fish_stock >= 0 else STOCK_MAX
|
||||
if nb_stock > best_stock:
|
||||
best_stock = nb_stock
|
||||
best_pos = pos
|
||||
|
||||
var dest: Variant = game_map.tiles[best_pos]
|
||||
dest.resource_id = old_resource
|
||||
dest.fish_stock = STOCK_MAX / 2 # migrated stock starts at half capacity
|
||||
EventBus.fishing_spot_migrated.emit(axial, best_pos)
|
||||
|
||||
|
||||
# -- Coral reef health --
|
||||
|
||||
|
||||
func _tick_reef_health(game_map: RefCounted) -> void:
|
||||
for axial: Vector2i in game_map.tiles:
|
||||
var tile: Variant = game_map.tiles[axial]
|
||||
if tile.resource_id != "coral_reef":
|
||||
continue
|
||||
|
||||
var health_before: float = tile.reef_health
|
||||
|
||||
# Determine whether conditions favor bleaching or recovery
|
||||
var bleaching: bool = _is_bleaching(game_map, axial, tile)
|
||||
|
||||
if bleaching:
|
||||
tile.reef_health = maxf(0.0, tile.reef_health - REEF_DEGRADE_RATE)
|
||||
elif tile.temperature < REEF_RECOVER_TEMP:
|
||||
tile.reef_health = minf(1.0, tile.reef_health + REEF_RECOVER_RATE)
|
||||
|
||||
# Bleached threshold crossing — signal fires once per crossing
|
||||
if health_before >= REEF_BLEACH_THRESHOLD and tile.reef_health < REEF_BLEACH_THRESHOLD:
|
||||
EventBus.reef_bleached.emit(axial)
|
||||
|
||||
# Reef death
|
||||
if tile.reef_health <= REEF_DEAD_THRESHOLD:
|
||||
tile.resource_id = ""
|
||||
tile.fish_stock = 0 # mark as dead coast for ocean_dead_fraction
|
||||
EventBus.reef_died.emit(axial)
|
||||
|
||||
|
||||
func _is_bleaching(_game_map: RefCounted, _axial: Vector2i, tile: Variant) -> bool:
|
||||
## True if temperature is above bleaching threshold.
|
||||
return tile.temperature > REEF_BLEACH_TEMP
|
||||
|
||||
|
||||
# -- Iron seeding bloom propagation --
|
||||
|
||||
|
||||
func _tick_marine_blooms(game_map: RefCounted) -> void:
|
||||
## Decrement bloom counters. The +2 food bonus is applied directly in tile.get_quality_yields().
|
||||
for axial: Vector2i in game_map.tiles:
|
||||
var tile: Variant = game_map.tiles[axial]
|
||||
if tile.marine_bloom_turns <= 0:
|
||||
continue
|
||||
tile.marine_bloom_turns -= 1
|
||||
if tile.marine_bloom_turns == 0 and tile.fish_stock >= 0:
|
||||
# Bloom expiry restores some stock on the host tile
|
||||
tile.fish_stock = mini(STOCK_MAX, tile.fish_stock + 20)
|
||||
|
||||
|
||||
func apply_iron_seeding(game_map: RefCounted, origin: Vector2i, current_dir: int) -> void:
|
||||
## Trigger a bloom propagating downstream along ocean currents.
|
||||
## Call from the Marine Engineer's Iron Seeding action.
|
||||
## current_dir: hex edge index (0-5) representing the ocean current direction.
|
||||
## The bloom spreads 1 tile per turn for 6 turns, staying within water tiles.
|
||||
var pos: Vector2i = origin
|
||||
for _step: int in 6:
|
||||
var nb_pos: Vector2i = pos + HexUtilsScript.AXIAL_DIRECTIONS[current_dir]
|
||||
var nb: Variant = game_map.tiles.get(nb_pos)
|
||||
if nb == null or nb.biome_id not in WATER_BIOMES:
|
||||
break
|
||||
nb.marine_bloom_turns = maxi(nb.marine_bloom_turns, 6 - _step)
|
||||
if nb.fish_stock >= 0:
|
||||
nb.fish_stock = mini(STOCK_MAX, nb.fish_stock + 5)
|
||||
pos = nb_pos
|
||||
|
||||
|
||||
# -- Corrupted marine creature DoT --
|
||||
|
||||
|
||||
func _tick_marine_creatures(game_map: RefCounted, players: Array) -> void:
|
||||
for axial: Vector2i in game_map.tiles:
|
||||
var tile: Variant = game_map.tiles[axial]
|
||||
if tile.marine_creature == "":
|
||||
continue
|
||||
var creature: String = tile.marine_creature
|
||||
var creature_school: String = CREATURE_SCHOOL.get(creature, "")
|
||||
var base_dot: int = CREATURE_DOT.get(creature, 0)
|
||||
|
||||
# Drain stock from the infested tile
|
||||
if tile.fish_stock >= 0:
|
||||
tile.fish_stock = maxi(0, tile.fish_stock - 2)
|
||||
if tile.fish_stock == 0 and tile.resource_id != "":
|
||||
# Infested spots extinguish rather than migrate — directly kills the spot
|
||||
tile.resource_id = ""
|
||||
EventBus.fishing_spot_exhausted.emit(axial)
|
||||
|
||||
# Apply DoT to non-aligned naval units on this tile
|
||||
for player: Variant in players:
|
||||
var player_schools: Array = player.get("schools", [])
|
||||
var immune: bool = _is_immune(player_schools, creature_school)
|
||||
|
||||
for unit: Variant in player.units:
|
||||
if not unit is UnitScript:
|
||||
continue
|
||||
if (unit as UnitScript).position != axial:
|
||||
continue
|
||||
if not _is_naval_unit(unit as UnitScript):
|
||||
continue
|
||||
if immune:
|
||||
continue
|
||||
|
||||
var dot: int = _compute_dot(base_dot, player_schools, creature_school)
|
||||
(unit as UnitScript).take_damage(dot)
|
||||
EventBus.climate_damage_applied.emit(unit, "marine_creature", dot)
|
||||
|
||||
|
||||
func _is_immune(player_schools: Array, creature_school: String) -> bool:
|
||||
## Death and Chaos players are immune to each other's (and their own) creatures.
|
||||
if creature_school == "death" or creature_school == "chaos":
|
||||
return "death" in player_schools or "chaos" in player_schools
|
||||
return false
|
||||
|
||||
|
||||
func _compute_dot(base_dot: int, player_schools: Array, creature_school: String) -> int:
|
||||
## Life players take 50% more damage from death creatures.
|
||||
## Nature players take 50% more damage from chaos creatures.
|
||||
if creature_school == "death" and "life" in player_schools:
|
||||
return roundi(base_dot * 1.5)
|
||||
if creature_school == "chaos" and "nature" in player_schools:
|
||||
return roundi(base_dot * 1.5)
|
||||
return base_dot
|
||||
|
||||
|
||||
func _is_naval_unit(unit: UnitScript) -> bool:
|
||||
var unit_data: Dictionary = DataLoader.get_unit(unit.type_id)
|
||||
return "naval_only" in unit_data.get("flags", [])
|
||||
|
||||
|
||||
# -- Ocean dead fraction --
|
||||
|
||||
|
||||
func _compute_ocean_dead_fraction(game_map: RefCounted) -> void:
|
||||
## dead_coast_tiles: coast/ocean tiles where fish_stock == 0 and resource_id == ""
|
||||
## (meaning a resource once lived there and was permanently destroyed).
|
||||
## Divided by total coast tile count.
|
||||
var total_coast: int = 0
|
||||
var dead_coast: int = 0
|
||||
for axial: Vector2i in game_map.tiles:
|
||||
var tile: Variant = game_map.tiles[axial]
|
||||
if tile.biome_id != "coast":
|
||||
continue
|
||||
total_coast += 1
|
||||
if tile.fish_stock == 0 and tile.resource_id == "":
|
||||
dead_coast += 1
|
||||
if total_coast == 0:
|
||||
ocean_dead_fraction = 0.0
|
||||
else:
|
||||
ocean_dead_fraction = float(dead_coast) / float(total_coast)
|
||||
|
||||
|
||||
func _check_ocean_warnings() -> void:
|
||||
var level: String
|
||||
if ocean_dead_fraction >= OCEAN_WARN_ORANGE:
|
||||
level = "orange"
|
||||
elif ocean_dead_fraction >= OCEAN_WARN_YELLOW:
|
||||
level = "yellow"
|
||||
else:
|
||||
level = "green"
|
||||
|
||||
if level != _prev_warn_level and level != "green":
|
||||
EventBus.ocean_health_warning.emit(ocean_dead_fraction, level)
|
||||
_prev_warn_level = level
|
||||
|
||||
|
||||
# -- Spell / action entry points --
|
||||
|
||||
|
||||
func apply_reef_restore(tile: Variant, amount: float) -> void:
|
||||
## Life spell: Coral Tendril / Sanctify Waters. Directly boost reef health.
|
||||
if tile.resource_id == "coral_reef":
|
||||
tile.reef_health = minf(1.0, tile.reef_health + amount)
|
||||
|
||||
|
||||
func apply_stock_restore(tile: Variant, amount: int) -> void:
|
||||
## Life: Rejuvenate Fishing Spot. Aether: Upwelling. Nature: Phytoplankton Bloom.
|
||||
if tile.fish_stock < 0 and tile.resource_id in FISHING_RESOURCE_IDS:
|
||||
tile.fish_stock = STOCK_MAX / 2
|
||||
tile.fish_stock = mini(STOCK_MAX, tile.fish_stock + amount)
|
||||
|
||||
|
||||
func apply_necro_bloom(game_map: RefCounted, axial: Vector2i) -> void:
|
||||
## Death spell: restore a permanently extinct fishing spot (the only way to do so).
|
||||
var tile: Variant = game_map.tiles.get(axial)
|
||||
if tile == null:
|
||||
return
|
||||
if tile.fish_stock != 0 or tile.resource_id != "":
|
||||
return # not a dead spot
|
||||
|
||||
# Resurrect as merfolk_shallows (closest generic coastal resource)
|
||||
tile.resource_id = "merfolk_shallows" if tile.biome_id == "coast" else "leviathan_grounds"
|
||||
tile.fish_stock = 50
|
||||
EventBus.fishing_spot_migrated.emit(axial, axial) # "migrated" back to same tile
|
||||
|
||||
|
||||
func spawn_marine_creature(game_map: RefCounted, axial: Vector2i, creature: String) -> void:
|
||||
## Death/Chaos spell entry point: place a corrupted creature on a water tile.
|
||||
var tile: Variant = game_map.tiles.get(axial)
|
||||
if tile == null or tile.biome_id not in WATER_BIOMES:
|
||||
return
|
||||
if tile.marine_creature != "":
|
||||
return # already occupied
|
||||
tile.marine_creature = creature
|
||||
EventBus.marine_creature_spawned.emit(axial, creature)
|
||||
|
||||
|
||||
func clear_marine_creature(game_map: RefCounted, axial: Vector2i) -> void:
|
||||
## Remove a corrupted creature (Life: Sanctify Waters, Aether: Tidal Surge, combat).
|
||||
var tile: Variant = game_map.tiles.get(axial)
|
||||
if tile == null or tile.marine_creature == "":
|
||||
return
|
||||
var creature: String = tile.marine_creature
|
||||
tile.marine_creature = ""
|
||||
EventBus.marine_creature_cleared.emit(axial, creature)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue