diff --git a/src/game/engine/scenes/world_map/world_map.gd b/src/game/engine/scenes/world_map/world_map.gd index dd54895a..52b63381 100644 --- a/src/game/engine/scenes/world_map/world_map.gd +++ b/src/game/engine/scenes/world_map/world_map.gd @@ -1028,12 +1028,26 @@ func _deselect_unit() -> void: EventBus.unit_deselected.emit() +## p3-18 — the active player's embarkation level (0 none / 1 coast / 2 ocean) for +## movement gating. Computed in Rust (GdTechWeb.embark_level, data-driven); the +## GDScript pathfinder uses it to allow land units onto water when teched. +## Defensive: returns 0 (no embark) if the tech web or player is unavailable. +func _current_embark_level() -> int: + var tw: RefCounted = TurnManager.get_tech_web() + if tw == null or not tw.has_method(&"embark_level"): + return 0 + var player: RefCounted = GameState.get_current_player() + if player == null: + return 0 + return int(tw.embark_level(PackedStringArray(player.researched_techs))) + + func _compute_movement_range(unit: RefCounted) -> void: var game_map: RefCounted = GameState.get_game_map() if game_map == null: return _reachable_hexes = PathfinderScript.movement_range( - game_map, unit.position, unit.movement_remaining, unit.unit_type + game_map, unit.position, unit.movement_remaining, unit.unit_type, _current_embark_level() ) _unit_renderer.show_movement_range(_reachable_hexes) @@ -1060,7 +1074,8 @@ func _move_unit_to(unit: RefCounted, target: Vector2i) -> void: if game_map == null: return var path: Array[Vector2i] = PathfinderScript.find_path( - game_map, unit.position, target, unit.movement_remaining, unit.unit_type + game_map, unit.position, target, unit.movement_remaining, unit.unit_type, + _current_embark_level() ) if path.is_empty(): return @@ -1787,6 +1802,7 @@ func update_path_preview(hovered_axial: Vector2i) -> void: preview_budget, _selected_unit.unit_type, scouted, + _current_embark_level(), ) if path.is_empty(): _unit_renderer.clear_path_preview() @@ -1833,6 +1849,7 @@ func _path_preview_for_target(target: Vector2i) -> Array[Vector2i]: 9999, _selected_unit.unit_type, scouted, + _current_embark_level(), ) diff --git a/src/game/engine/src/map/pathfinder.gd b/src/game/engine/src/map/pathfinder.gd index 4bd0bed9..e7924a78 100644 --- a/src/game/engine/src/map/pathfinder.gd +++ b/src/game/engine/src/map/pathfinder.gd @@ -23,7 +23,12 @@ const LOS_BLOCKING_BIOMES: Array[String] = ["mountain", "dense_forest"] static func find_path( - map: RefCounted, start: Vector2i, goal: Vector2i, movement_budget: int, unit_type: String + map: RefCounted, + start: Vector2i, + goal: Vector2i, + movement_budget: int, + unit_type: String, + embark_level: int = 0, ) -> Array[Vector2i]: ## A* pathfinding from start to goal on the hex grid. ## Returns the path as an array of axial positions (start excluded, goal included). @@ -35,7 +40,7 @@ static func find_path( var goal_tile: Resource = map.get_tile(goal) if goal_tile == null: return [] as Array[Vector2i] - if not _is_passable(goal_tile, unit_type): + if not _is_passable(goal_tile, unit_type, embark_level): return [] as Array[Vector2i] # Open set: Array of [f_score, g_score, position] sorted by f_score ascending. @@ -76,7 +81,7 @@ static func find_path( var neighbor_tile: Resource = map.get_tile(neighbor) if neighbor_tile == null: continue - if not _is_passable(neighbor_tile, unit_type): + if not _is_passable(neighbor_tile, unit_type, embark_level): continue var move_cost: int = _get_effective_cost(neighbor_tile, unit_type) @@ -96,7 +101,11 @@ static func find_path( static func movement_range( - map: RefCounted, start: Vector2i, movement_budget: int, unit_type: String + map: RefCounted, + start: Vector2i, + movement_budget: int, + unit_type: String, + embark_level: int = 0, ) -> Dictionary: ## Dijkstra flood-fill: all hexes reachable within movement_budget. ## Returns Dictionary mapping Vector2i (axial) -> int (cost spent to reach). @@ -125,7 +134,7 @@ static func movement_range( var neighbor_tile: Resource = map.get_tile(neighbor) if neighbor_tile == null: continue - if not _is_passable(neighbor_tile, unit_type): + if not _is_passable(neighbor_tile, unit_type, embark_level): continue var move_cost: int = _get_effective_cost(neighbor_tile, unit_type) @@ -149,6 +158,7 @@ static func find_path_with_fog( movement_budget: int, unit_type: String, scouted: Dictionary, + embark_level: int = 0, ) -> Array[Vector2i]: ## p0-35: fog-aware path preview. A* through tiles the player has scouted ## (`scouted[axial] == true`), straight `HexUtilsScript.hex_line()` through @@ -163,7 +173,7 @@ static func find_path_with_fog( return [] as Array[Vector2i] var goal_scouted: bool = bool(scouted.get(goal, false)) if goal_scouted: - return find_path(map, start, goal, movement_budget, unit_type) + return find_path(map, start, goal, movement_budget, unit_type, embark_level) var line: Array[Vector2i] = HexUtilsScript.hex_line(start, goal) ## Walk the straight line backwards from goal until we hit a scouted hex. @@ -182,7 +192,9 @@ static func find_path_with_fog( return fog_only var pivot: Vector2i = line[pivot_idx] - var scouted_path: Array[Vector2i] = find_path(map, start, pivot, movement_budget, unit_type) + var scouted_path: Array[Vector2i] = find_path( + map, start, pivot, movement_budget, unit_type, embark_level + ) if scouted_path.is_empty() and start != pivot: ## A* failed in scouted territory (impassable goal-tile, etc.); fall ## back to the straight line so the renderer at least shows intent. @@ -242,8 +254,13 @@ static func has_line_of_sight(map: RefCounted, start: Vector2i, goal: Vector2i) # -- Private helpers -- -static func _is_passable(tile: Resource, unit_type: String) -> bool: +static func _is_passable(tile: Resource, unit_type: String, embark_level: int = 0) -> bool: ## Check if a tile is passable for the given unit type. + ## `embark_level` (p3-18): the owner's embarkation capability, computed in Rust + ## (`GdTechWeb.embark_level`) — 0 none / 1 coast / 2 ocean. When > 0 a land unit + ## may enter water (it embarks). The precise coast-vs-ocean tier is enforced + ## authoritatively by the Rust sim; this rendered gate is single-tier (any + ## embark tech opens water) to avoid hardcoding the biome→tier map in GDScript. var flags: Array = tile.get_terrain_flags() match unit_type: "flying": @@ -253,10 +270,13 @@ static func _is_passable(tile: Resource, unit_type: String) -> bool: # Naval units require water return "water" in flags _: - # Land units: blocked by water, naval_only, impassable - for flag: String in LAND_IMPASSABLE_FLAGS: - if flag in flags: - return false + # Land units: never cross truly-impassable terrain. + if "impassable" in flags: + return false + # Water / naval_only blocks land units UNLESS they can embark (p3-18). + var is_water: bool = "water" in flags or "naval_only" in flags + if is_water: + return embark_level > 0 return true diff --git a/src/game/engine/src/modules/tech/knowledge_web.gd b/src/game/engine/src/modules/tech/knowledge_web.gd index 44f5668c..3ce86c68 100644 --- a/src/game/engine/src/modules/tech/knowledge_web.gd +++ b/src/game/engine/src/modules/tech/knowledge_web.gd @@ -95,6 +95,15 @@ func is_researched(node_id: String, player_index: int) -> bool: return researched.has(node_id) +## p3-18 — embarkation level (0 none / 1 coast / 2 ocean) granted by the given +## researched tech ids, computed in Rust (`GdTechWeb.embark_level`, data-driven). +## `0` when the bridge is not built. +func embark_level(researched: PackedStringArray) -> int: + if _bridge == null: + return 0 + return int(_bridge.embark_level(researched)) + + func can_research(node_id: String, player_index: int) -> bool: if _bridge == null or node_id.is_empty(): return false diff --git a/src/game/engine/src/modules/tech/tech_web.gd b/src/game/engine/src/modules/tech/tech_web.gd index 6072d6ff..2c0002a2 100644 --- a/src/game/engine/src/modules/tech/tech_web.gd +++ b/src/game/engine/src/modules/tech/tech_web.gd @@ -52,6 +52,13 @@ func process_research(player_json: String, yield_json: String, sci_modifier: flo return _web.process_research(player_json, yield_json, sci_modifier) +## p3-18 — embarkation level (0 none / 1 coast / 2 ocean) granted by a set of +## researched tech ids. Delegates to the Rust GdTechWeb (data-driven mapping); +## consumed by world_map to make rendered movement embark-aware. +func embark_level(researched: PackedStringArray) -> int: + return _web.embark_level(researched) + + func apply_heritage_tech(player: RefCounted) -> void: ## Grant the player's race origin tech (cost 0 by convention). if player == null: