magicciv/engine/src/map/tile.gd
Claude Code 2e1e38a133 feat(map-specific): Optimize tile rendering pipeline for smoother map performance
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-03-26 00:29:34 -07:00

547 lines
22 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

class_name Tile
extends Resource
## Single hex tile data. Holds terrain, overlays, ownership, and per-player visibility.
## Resource class (not Node) — lightweight, serializable, no scene tree dependency.
const ImprovementScript: GDScript = preload("res://engine/src/entities/improvement.gd")
var biome_id: String = "" # biome classification result
var substrate_id: String = "" # geological substrate from elevation
var water_body_id: int = -1 # water body index (-1 if land)
var water_body_type: String = "" # pond/river/lake/large_lake/ocean
var depth_from_coast: int = -1 # BFS distance from land (-1 if land)
var soil_type: String = "" # rocky/sandy/clay/loam/peat/volcanic_ash/permafrost
var is_river_mouth: bool = false # true if water tile adjacent to both river and ocean
var has_cave: bool = false # true if cave system present (mountain/highland with geology marker)
## Axial coordinates (q, r) — primary position key
var position: Vector2i = Vector2i.ZERO
## Overlay resource ID from resources.json (e.g. "mithril_vein"), empty if none
var resource_id: String = ""
## Tile improvement type (e.g. "farm", "mine", "road"), empty if none
var improvement: String = ""
## Owning player index, -1 = unclaimed
var owner: int = -1
## Lair type ID from wilds.json (e.g. "beast_den"), empty if no lair
var lair_type: String = ""
## Per-player visibility state: player_index → int
## 0 = unexplored, 1 = fog of war (previously seen), 2 = currently visible
var visibility: Dictionary = {}
## Render metadata — populated by MapGenerator, used by tileset renderer
## Texture variant selector (deterministic from position + seed)
var variation_index: int = 0
## Raw noise values [0,1] for gradient coloring in the renderer
var elevation: float = 0.0
var moisture: float = 0.0
var temperature: float = 0.0
## Hex edge indices (0-5) that have rivers flowing along them
var river_edges: Array[int] = []
## River flow per edge: edge_index → accumulation value (0.0 = no river, <0 = frozen)
var river_flow: Dictionary = {}
## Total upstream drainage accumulation at this tile
var flow_accumulation: float = 0.0
## Lake group ID, -1 = not part of a lake
var lake_id: int = -1
## River source type: "", "snowmelt", "spring", "hot_spring", "glacial"
var river_source_type: String = ""
## True if this land tile is adjacent to water (for beach/shore transitions)
var is_coastal: bool = false
## Tile quality tier: 1=nascent, 2=standard, 3=mature
var quality: int = 2
## Ticks accumulated toward the next quality change
var quality_progress: int = 0
## Wind speed at this tile [0,1]
var wind_speed: float = 0.5
## Wind direction as hex edge index (0=E, 1=NE, 2=NW, 3=W, 4=SW, 5=SE)
var wind_direction: int = 0
## Cultural pressure accumulated from enemy cities (for ownership flip).
## Resets to 0 after a flip. Not bounded — flip_threshold determines ownership change.
var culture_pressure: float = 0.0
## Per-turn heat forcing from active spells (transient, not saved)
var magic_heat_delta: float = 0.0
## Per-turn moisture forcing from active spells (transient, not saved)
var magic_moisture_delta: float = 0.0
## City mitigation: aerosol cooling/drying scaled down by this fraction [0, 1].
## Written each turn by economy.gd from protection building effects. Not saved.
var aerosol_mitigation: float = 0.0
## Ley line mana density [0,1]. 0.0 = dead zone, 1.0 = nexus.
var mana_density: float = 0.0
## Number of ley line edges passing through or adjacent to this tile.
var ley_line_count: int = 0
## Dominant school of ley lines at this tile ("" = none/neutral).
var ley_school: String = ""
## True when a Death-school spell has corrupted this ley segment.
var ley_corrupted: bool = false
## Residue school left by a spell cast on this tile ("" = none).
var ley_residue_school: String = ""
## Residue strength [0,1] — decays each turn.
var ley_residue_strength: float = 0.0
## Turns remaining for residue decay. 0 = no residue.
var ley_residue_turns: int = 0
## Fish stock for marine resource tiles [0, 100]. -1 = not a marine resource tile.
## 0 with resource_id == "" = permanently dead (exhausted or reef-killed).
var fish_stock: int = -1
## Coral reef health [0.0, 1.0]. Only meaningful when resource_id == "coral_reef".
var reef_health: float = 1.0
## Active marine bloom turns remaining (iron seeding or spell effect). 0 = no bloom.
var marine_bloom_turns: int = 0
## Corrupted marine creature occupying this tile ("" = none, e.g. "wraith_eel").
var marine_creature: String = ""
## -- Flora fields (Layer 3) --
## Tree canopy density [0-1]. Drives biome classification, shade, lumber yield.
var canopy_cover: float = 0.0
## Ground-level vegetation [0-1]. Drives food yield, movement cost, habitat quality.
var undergrowth: float = 0.0
## Mycorrhizal network [0-1]. Drives ecosystem resilience, regrowth speed.
var fungi_network: float = 0.0
## -- Flora tracking --
## Consecutive turns with moisture < desertification_threshold.
var drought_counter: int = 0
## Turns at high canopy toward succession.
var succession_progress: int = 0
## Regrowth stage: -1=none, 0=barren, 1=scrub, 2=young_forest, 3=forest.
var regrowth_stage: int = -1
## Turns spent in current regrowth stage.
var regrowth_turns: int = 0
## -- Fauna tracking --
## Per-turn computed habitat suitability [0-1]. NOT serialized (recalculated each turn).
var habitat_suitability: float = 0.0
## Consecutive turns with habitat_suitability < abandon_threshold.
var habitat_low_turns: int = 0
## Landmark name set when tile crosses Q4 quality threshold.
var landmark_name: String = ""
## Ground loot: items dropped on this tile from dead units.
## Array of { "item_id": String, "charges_remaining": int, "turns_remaining": int }
var ground_items: Array = []
## Ley anchor strength injected by a geological ecological event [0.0, 5.0].
## 0.0 = no anchor present.
var wonder_anchor_strength: float = 0.0
## LEGACY single school — kept for anchor_decay compat during transition.
var wonder_anchor_school: String = ""
## Multi-school affinities for ley resonance network (e.g. ["death", "chaos"]).
var wonder_anchor_schools: Array[String] = []
## Wonder tier 1-5 — aligned with 5 eras. Separate from terrain quality (1-3).
## Tier = strength for ley resonance calculations.
var wonder_tier: int = 0
## Stratospheric sulfate aerosol [0, 1]. Injected by volcanic/impact events.
## Applied each turn to magic_heat_delta (cooling) and magic_moisture_delta (drying).
## Spreads downwind and decays per turn. Persistent — saved to disk.
var sulfate_aerosol: float = 0.0
## Accumulated solar forcing from solar cycle events [1, 1].
## Positive = solar maximum (warming), negative = solar minimum (cooling).
## Written by _process_solar, read by climate.gd each turn. Persistent — saved to disk.
var solar_forcing: float = 0.0
## Accumulated glacial forcing from glacial/ice events [1, 1].
## Positive = warm runaway melt, negative = glacial advance.
## Written by _process_glacial, read by climate.gd each turn. Persistent — saved to disk.
var glacial_forcing: float = 0.0
## Barometric pressure (hPa). Sea level baseline ~1013.
var pressure: float = 1013.0
## Atmospheric water vapor [0,1] — persistent, saved to disk
var humidity: float = 0.5
## Derived: humidity / saturation_capacity(temperature) — recalculated each turn, not saved
var relative_humidity: float = 0.5
## Temperature at which condensation occurs [0,1 scale] — recalculated each turn, not saved
var dew_point: float = 0.4
## Convective Available Potential Energy [0,1 normalized] — recalculated each turn, not saved
## Real scale: 0=0 J/kg, 1.0=5000+ J/kg
var cape: float = 0.0
## Dynamic deviation from baseline pressure (-50 to +50 hPa) — persistent, saved to disk
var pressure_anomaly: float = 0.0
func _init(
p_position: Vector2i = Vector2i.ZERO,
p_biome_id: String = "",
) -> void:
position = p_position
biome_id = p_biome_id
func get_movement_cost() -> int:
## Look up movement cost from terrain data via DataLoader.
## Roads reduce cost by 1 (minimum 1).
var terrain: Dictionary = DataLoader.get_terrain(biome_id)
var base: int = terrain.get("movement_cost", 1) if not terrain.is_empty() else 1
if improvement != "":
var modifier: int = int(ImprovementScript.get_movement_cost_modifier(improvement))
base = maxi(1, base + modifier)
return base
func get_defense_bonus() -> int:
## Look up defense bonus from terrain data via DataLoader.
var terrain: Dictionary = DataLoader.get_terrain(biome_id)
if terrain.is_empty():
return 0
return terrain.get("defense_bonus", 0)
func get_yields(for_player: int = -1) -> Dictionary:
## Return terrain yields factoring quality tier and resource bonuses.
## Pass for_player to exclude tech-gated resources the player hasn't discovered.
return get_quality_yields(for_player)
func get_quality_yields(for_player: int = -1) -> Dictionary:
## Return base terrain yields modified by quality tier (15), plus resource bonuses.
## Q1 (Degraded): base - 2 (min 0) for food/production/trade; no mana.
## Q2 (Sparse): base - 1 (min 0) for food/production/trade; 0.5× mana.
## Q3 (Healthy): base yields; 1× mana. ← baseline
## Q4 (Thriving): base + quality_bonuses × 1; 1.5× mana.
## Q5 (Pristine): base + quality_bonuses × 2; 2× mana.
## culture is never penalized, only boosted at Q4/Q5.
## When for_player >= 0, tech-gated resources are excluded if player lacks the tech.
## mana: Dictionary[String, float] keyed by school name.
var terrain: Dictionary = DataLoader.get_terrain(biome_id)
var food: int = terrain.get("food", 0)
var production: int = terrain.get("production", 0)
var trade: int = terrain.get("trade", 0)
var culture: int = terrain.get("culture", 0)
if quality < 3:
var penalty: int = 3 - quality # Q1 → 2, Q2 → 1
food = maxi(0, food - penalty)
production = maxi(0, production - penalty)
trade = maxi(0, trade - penalty)
elif quality > 3:
var bonus_scale: int = quality - 3 # Q4 → 1, Q5 → 2
var bonuses: Dictionary = terrain.get("quality_bonuses", {})
food += bonuses.get("food", 0) * bonus_scale
production += bonuses.get("production", 0) * bonus_scale
trade += bonuses.get("trade", 0) * bonus_scale
culture += bonuses.get("culture", 0) * bonus_scale
# Mana from terrain: major + minor schools
var mana: Dictionary = {}
var mana_scale: float = (quality - 1) * 0.5 # Q1=0, Q2=0.5, Q3=1.0, Q4=1.5, Q5=2.0
var mana_major: Variant = terrain.get("mana_major", null)
if mana_major is Dictionary and mana_major.get("school", "") != "":
var school: String = mana_major["school"]
var amount: float = float(mana_major.get("amount", 0)) * mana_scale
if amount > 0.0:
if school == "all":
for s: String in ["life", "death", "chaos", "nature", "aether"]:
mana[s] = mana.get(s, 0.0) + amount
else:
mana[school] = mana.get(school, 0.0) + amount
var mana_minor: Variant = terrain.get("mana_minor", [])
if mana_minor is Array:
for entry: Variant in mana_minor:
if not entry is Dictionary:
continue
var school: String = entry.get("school", "")
if school == "":
continue
var amount: float = float(entry.get("amount", 0)) * mana_scale
if amount > 0.0:
mana[school] = mana.get(school, 0.0) + amount
if resource_id != "":
var include_resource: bool = for_player < 0 or is_resource_visible_to(for_player)
if include_resource:
var res: Dictionary = DataLoader.get_resource(resource_id)
food += res.get("food_bonus", 0)
production += res.get("production_bonus", 0)
trade += res.get("trade_bonus", 0)
culture += res.get("culture_bonus", 0)
# Resource mana bonuses
var res_mana: Variant = res.get("mana_bonus", null)
if res_mana is Dictionary:
for school: String in res_mana:
mana[school] = mana.get(school, 0.0) + float(res_mana[school])
if improvement != "":
var imp_yields: Dictionary = ImprovementScript.get_yield_bonus(improvement)
food += imp_yields.get("food", 0)
production += imp_yields.get("production", 0)
trade += imp_yields.get("gold", 0)
# Scale mana output by ley-line mana density field.
# Dead zones (density == 0.0) mean the ley field has not been computed yet — pass through.
# Dense zones (density == 1.0) double mana via 2x multiplier.
# density 0.05 = fringe (5%), 0.2 = 2-hex from edge, 0.4 = adjacent, 0.7 = edge, 1.0 = nexus
if mana_density > 0.0 and not mana.is_empty():
var density_mult: float = mana_density * 2.0 # 0.05→0.1, 0.4→0.8, 0.7→1.4, 1.0→2.0
for school: String in mana:
mana[school] = mana[school] * density_mult
# Active marine bloom grants +2 food to this tile
if marine_bloom_turns > 0:
food += 2
return {
"food": food, "production": production, "trade": trade,
"culture": culture, "mana": mana,
}
func is_resource_visible_to(player_index: int) -> bool:
## Check if this tile's resource is visible to the given player.
## Resources without revealed_by_tech are always visible.
## FORCE_UNLIMITED_RESEARCH debug flag reveals all resources.
if resource_id == "":
return false
if EnvConfig.get_bool("FORCE_UNLIMITED_RESEARCH"):
return true
var res_data: Dictionary = DataLoader.get_resource(resource_id)
var required_tech: String = res_data.get("revealed_by_tech", "")
if required_tech == "":
return true
var player: Variant = GameState.get_player(player_index)
if player == null:
return false
return player.has_tech(required_tech)
func get_quality_defense() -> int:
## Return defense bonus scaled by quality tier (15).
## Q1: 0%, Q2: 50%, Q3: 100% (base), Q4: 150%, Q5: 200%.
var base: int = get_defense_bonus()
match quality:
1: return 0
2: return base / 2
4: return base + base / 2
5: return base * 2
_: return base # Q3 = base
func get_terrain_flags() -> Array:
## Return terrain flags (e.g. ["water", "naval_only"]).
var terrain: Dictionary = DataLoader.get_terrain(biome_id)
return terrain.get("flags", [])
func is_water() -> bool:
return "water" in get_terrain_flags()
func is_natural_wonder() -> bool:
return "natural_wonder" in get_terrain_flags()
func is_land() -> bool:
return not is_water()
func has_flag(flag: String) -> bool:
return flag in get_terrain_flags()
func get_visibility(player_index: int) -> int:
## Return visibility state for a player. 0 = unexplored (default).
return visibility.get(player_index, 0)
func set_visibility(player_index: int, state: int) -> void:
visibility[player_index] = state
func to_dict() -> Dictionary:
## Serialize tile state for save files.
var data: Dictionary = {
"position": [position.x, position.y],
"biome_id": biome_id,
}
if substrate_id != "":
data["substrate_id"] = substrate_id
if water_body_id != -1:
data["water_body_id"] = water_body_id
if water_body_type != "":
data["water_body_type"] = water_body_type
if depth_from_coast != -1:
data["depth_from_coast"] = depth_from_coast
if soil_type != "":
data["soil_type"] = soil_type
if is_river_mouth:
data["is_river_mouth"] = true
if has_cave:
data["has_cave"] = true
if resource_id != "":
data["resource_id"] = resource_id
if improvement != "":
data["improvement"] = improvement
if owner != -1:
data["owner"] = owner
if lair_type != "":
data["lair_type"] = lair_type
if not visibility.is_empty():
data["visibility"] = visibility
if variation_index != 0:
data["variation_index"] = variation_index
if elevation != 0.0:
data["elevation"] = elevation
if moisture != 0.0:
data["moisture"] = moisture
if temperature != 0.0:
data["temperature"] = temperature
if not river_edges.is_empty():
data["river_edges"] = river_edges
if not river_flow.is_empty():
data["river_flow"] = river_flow
if flow_accumulation != 0.0:
data["flow_accumulation"] = flow_accumulation
if lake_id != -1:
data["lake_id"] = lake_id
if river_source_type != "":
data["river_source_type"] = river_source_type
if is_coastal:
data["is_coastal"] = true
if quality != 2:
data["quality"] = quality
if quality_progress != 0:
data["quality_progress"] = quality_progress
if wind_speed != 0.5:
data["wind_speed"] = wind_speed
if culture_pressure != 0.0:
data["culture_pressure"] = culture_pressure
# magic_heat_delta and magic_moisture_delta are per-turn transients — not saved
# relative_humidity, dew_point, cape are recalculated each turn — not saved
# ley fields
if mana_density != 0.0:
data["mana_density"] = mana_density
if ley_line_count != 0:
data["ley_line_count"] = ley_line_count
if ley_school != "":
data["ley_school"] = ley_school
if ley_corrupted:
data["ley_corrupted"] = true
if ley_residue_school != "":
data["ley_residue_school"] = ley_residue_school
if ley_residue_strength != 0.0:
data["ley_residue_strength"] = ley_residue_strength
if ley_residue_turns != 0:
data["ley_residue_turns"] = ley_residue_turns
if humidity != 0.5:
data["humidity"] = humidity
if pressure_anomaly != 0.0:
data["pressure_anomaly"] = pressure_anomaly
if fish_stock != -1:
data["fish_stock"] = fish_stock
if reef_health != 1.0:
data["reef_health"] = reef_health
# marine_bloom_turns is transient (spell effect) — not saved
if marine_creature != "":
data["marine_creature"] = marine_creature
# Flora fields
if canopy_cover != 0.0:
data["canopy_cover"] = canopy_cover
if undergrowth != 0.0:
data["undergrowth"] = undergrowth
if fungi_network != 0.0:
data["fungi_network"] = fungi_network
if drought_counter != 0:
data["drought_counter"] = drought_counter
if succession_progress != 0:
data["succession_progress"] = succession_progress
if regrowth_stage != -1:
data["regrowth_stage"] = regrowth_stage
if regrowth_turns != 0:
data["regrowth_turns"] = regrowth_turns
# Fauna tracking (habitat_suitability is NOT serialized — recalculated per turn)
if habitat_low_turns != 0:
data["habitat_low_turns"] = habitat_low_turns
if landmark_name != "":
data["landmark_name"] = landmark_name
if not ground_items.is_empty():
data["ground_items"] = ground_items.duplicate(true)
if wonder_anchor_strength != 0.0:
data["wonder_anchor_strength"] = wonder_anchor_strength
if wonder_anchor_school != "":
data["wonder_anchor_school"] = wonder_anchor_school
if not wonder_anchor_schools.is_empty():
data["wonder_anchor_schools"] = wonder_anchor_schools.duplicate()
if wonder_tier != 0:
data["wonder_tier"] = wonder_tier
if sulfate_aerosol != 0.0:
data["sulfate_aerosol"] = sulfate_aerosol
if solar_forcing != 0.0:
data["solar_forcing"] = solar_forcing
if glacial_forcing != 0.0:
data["glacial_forcing"] = glacial_forcing
return data
static func from_dict(data: Dictionary) -> Resource: # Tile
## Deserialize tile state from save files.
var pos_arr: Array = data.get("position", [0, 0])
var pos: Vector2i = Vector2i(pos_arr[0], pos_arr[1])
var SelfScript: GDScript = load("res://engine/src/map/tile.gd")
var tile: Resource = SelfScript.new(pos, data.get("biome_id", ""))
tile.substrate_id = data.get("substrate_id", "")
tile.water_body_id = data.get("water_body_id", -1)
tile.water_body_type = data.get("water_body_type", "")
tile.depth_from_coast = data.get("depth_from_coast", -1)
tile.soil_type = data.get("soil_type", "")
tile.is_river_mouth = data.get("is_river_mouth", false)
tile.has_cave = data.get("has_cave", false)
tile.resource_id = data.get("resource_id", "")
tile.improvement = data.get("improvement", "")
tile.owner = data.get("owner", -1)
tile.lair_type = data.get("lair_type", "")
tile.visibility = data.get("visibility", {})
tile.variation_index = data.get("variation_index", 0)
tile.elevation = data.get("elevation", 0.0)
tile.moisture = data.get("moisture", 0.0)
tile.temperature = data.get("temperature", 0.0)
var edges: Array = data.get("river_edges", [])
for edge: Variant in edges:
tile.river_edges.append(int(edge))
tile.river_flow = data.get("river_flow", {})
tile.flow_accumulation = data.get("flow_accumulation", 0.0)
tile.lake_id = data.get("lake_id", -1)
tile.river_source_type = data.get("river_source_type", "")
tile.is_coastal = data.get("is_coastal", false)
tile.quality = data.get("quality", 2)
tile.quality_progress = data.get("quality_progress", 0)
tile.wind_speed = data.get("wind_speed", 0.5)
tile.culture_pressure = data.get("culture_pressure", 0.0)
tile.mana_density = data.get("mana_density", 0.0)
tile.ley_line_count = data.get("ley_line_count", 0)
tile.ley_school = data.get("ley_school", "")
tile.ley_corrupted = data.get("ley_corrupted", false)
tile.ley_residue_school = data.get("ley_residue_school", "")
tile.ley_residue_strength = data.get("ley_residue_strength", 0.0)
tile.ley_residue_turns = data.get("ley_residue_turns", 0)
tile.humidity = data.get("humidity", 0.5)
tile.pressure_anomaly = data.get("pressure_anomaly", 0.0)
tile.fish_stock = data.get("fish_stock", -1)
tile.reef_health = data.get("reef_health", 1.0)
tile.marine_creature = data.get("marine_creature", "")
tile.canopy_cover = data.get("canopy_cover", 0.0)
tile.undergrowth = data.get("undergrowth", 0.0)
tile.fungi_network = data.get("fungi_network", 0.0)
tile.drought_counter = data.get("drought_counter", 0)
tile.succession_progress = data.get("succession_progress", 0)
tile.regrowth_stage = data.get("regrowth_stage", -1)
tile.regrowth_turns = data.get("regrowth_turns", 0)
tile.habitat_low_turns = data.get("habitat_low_turns", 0)
tile.landmark_name = data.get("landmark_name", "")
tile.ground_items = data.get("ground_items", [])
tile.wonder_anchor_strength = data.get("wonder_anchor_strength", 0.0)
tile.wonder_anchor_school = data.get("wonder_anchor_school", "")
var schools_raw: Array = data.get("wonder_anchor_schools", [])
for s: Variant in schools_raw:
tile.wonder_anchor_schools.append(str(s))
tile.wonder_tier = data.get("wonder_tier", 0)
tile.sulfate_aerosol = data.get("sulfate_aerosol", 0.0)
tile.solar_forcing = data.get("solar_forcing", 0.0)
tile.glacial_forcing = data.get("glacial_forcing", 0.0)
return tile