feat(@projects/@magic-civilization): ⛵ p3-18 P5b(2) — rendered movement is embark-aware (UI)
Wires the rendered game's GDScript movement to the data-driven embark capability, so a human playing the UI can cross water when teched (previously embark worked headless only): - pathfinder.gd: find_path / movement_range / find_path_with_fog gain a defaulted embark_level param (non-breaking for all existing callers); _is_passable lets a land unit enter water when embark_level > 0. Single-tier UI gate — the precise coast-vs-ocean tier stays authoritative in the Rust sim; the gate avoids hardcoding the biome→tier map in GDScript (Rail 1). - KnowledgeWeb + TechWeb: embark_level(researched) passthroughs to the Rust GdTechWeb.embark_level (the tech→level mapping stays in Rust, data-driven). - world_map.gd: _current_embark_level() (defensive) feeds the active player's level into all four pathfinder call sites (move, range overlay, fog previews). gdlint clean. Verifies via dylib rebuild + GUT (next). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
04a6120fe5
commit
7d40c2ce86
4 changed files with 67 additions and 14 deletions
|
|
@ -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(),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue