magicciv/src/game/engine/src/map/pathfinder.gd
Natalie 09a9d6dc89 feat(@projects/@magic-civilization): add race gate home assets & movement system
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-04-18 03:45:02 -07:00

312 lines
11 KiB
GDScript

class_name Pathfinder
extends RefCounted
## A* pathfinding and movement range queries for hex grids.
##
## All methods are static — no instance state needed.
## Terrain movement costs come from Tile.get_movement_cost() which reads
## DataLoader terrain data and applies improvement modifiers (roads, etc.).
## Impassable terrain (mountains, water for land units) is handled via
## terrain flags from the JSON data.
const HexUtilsScript = preload("res://engine/src/map/hex_utils.gd")
const GameMapScript = preload("res://engine/src/map/game_map.gd")
const TileScript = preload("res://engine/src/map/tile.gd")
## Terrain flags that block land unit movement.
const LAND_IMPASSABLE_FLAGS: Array[String] = ["water", "naval_only", "impassable"]
## Terrain flags that block line of sight.
const LOS_BLOCKING_FLAGS: Array[String] = ["blocks_los"]
## Biome IDs that block line of sight (fallback when flags are missing).
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
) -> 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).
## Returns empty array if the goal is unreachable within the movement budget.
## unit_type: "land", "flying", "naval" — controls which tiles are passable.
if start == goal:
return [] as Array[Vector2i]
var goal_tile: Resource = map.get_tile(goal)
if goal_tile == null:
return [] as Array[Vector2i]
if not _is_passable(goal_tile, unit_type):
return [] as Array[Vector2i]
# Open set: Array of [f_score, g_score, position] sorted by f_score ascending.
# Using Array + sort since Godot has no built-in priority queue.
var open: Array = []
# g_score: best known cost to reach each position
var g_scores: Dictionary = {}
# came_from: parent position for path reconstruction
var came_from: Dictionary = {}
var start_norm: Vector2i = HexUtilsScript.normalize_position(
start, map.width, map.height, map.wrap_mode
)
var goal_norm: Vector2i = HexUtilsScript.normalize_position(
goal, map.width, map.height, map.wrap_mode
)
var h_start: int = HexUtilsScript.hex_distance(start_norm, goal_norm)
g_scores[start_norm] = 0
open.append([h_start, 0, start_norm])
while not open.is_empty():
# Pop the node with lowest f_score
open.sort_custom(_compare_open_entries)
var current_entry: Array = open.pop_front()
var current_g: int = current_entry[1]
var current: Vector2i = current_entry[2]
if current == goal_norm:
return _reconstruct_path(came_from, current, start_norm)
# Skip if we already found a better path to this node
if current_g > g_scores.get(current, current_g + 1):
continue
var neighbors: Array[Vector2i] = map.get_valid_neighbor_positions(current)
for neighbor: Vector2i in neighbors:
var neighbor_tile: Resource = map.get_tile(neighbor)
if neighbor_tile == null:
continue
if not _is_passable(neighbor_tile, unit_type):
continue
var move_cost: int = _get_effective_cost(neighbor_tile, unit_type)
var tentative_g: int = current_g + move_cost
if tentative_g > movement_budget:
continue
var prev_g: int = g_scores.get(neighbor, movement_budget + 1)
if tentative_g < prev_g:
g_scores[neighbor] = tentative_g
came_from[neighbor] = current
var h: int = HexUtilsScript.hex_distance(neighbor, goal_norm)
open.append([tentative_g + h, tentative_g, neighbor])
return [] as Array[Vector2i]
static func movement_range(
map: RefCounted, start: Vector2i, movement_budget: int, unit_type: String
) -> Dictionary:
## Dijkstra flood-fill: all hexes reachable within movement_budget.
## Returns Dictionary mapping Vector2i (axial) -> int (cost spent to reach).
## The start position is included with cost 0.
var start_norm: Vector2i = HexUtilsScript.normalize_position(
start, map.width, map.height, map.wrap_mode
)
# cost_so_far: position -> best known cost
var cost_so_far: Dictionary = {start_norm: 0}
# Frontier: Array of [cost, position] sorted by cost ascending
var frontier: Array = [[0, start_norm]]
while not frontier.is_empty():
frontier.sort_custom(_compare_frontier_entries)
var entry: Array = frontier.pop_front()
var current_cost: int = entry[0]
var current: Vector2i = entry[1]
# Skip if we already processed a better path
if current_cost > cost_so_far.get(current, current_cost + 1):
continue
var neighbors: Array[Vector2i] = map.get_valid_neighbor_positions(current)
for neighbor: Vector2i in neighbors:
var neighbor_tile: Resource = map.get_tile(neighbor)
if neighbor_tile == null:
continue
if not _is_passable(neighbor_tile, unit_type):
continue
var move_cost: int = _get_effective_cost(neighbor_tile, unit_type)
var new_cost: int = current_cost + move_cost
if new_cost > movement_budget:
continue
var prev_cost: int = cost_so_far.get(neighbor, movement_budget + 1)
if new_cost < prev_cost:
cost_so_far[neighbor] = new_cost
frontier.append([new_cost, neighbor])
return cost_so_far
static func find_path_with_fog(
map: RefCounted,
start: Vector2i,
goal: Vector2i,
movement_budget: int,
unit_type: String,
scouted: Dictionary,
) -> Array[Vector2i]:
## p0-35: fog-aware path preview. A* through tiles the player has scouted
## (`scouted[axial] == true`), straight `HexUtilsScript.hex_line()` through
## unscouted fog so the player isn't shown information they don't have.
## When the goal is fully reachable through scouted territory we return
## the regular A* path (and `movement_budget` may legitimately be exceeded
## by the multi-turn renderer; pass a generous budget when previewing).
## When the goal sits in fog we A* to the last scouted hex on the line and
## append a straight hex_line continuation to the goal — the renderer can
## label that suffix differently.
if start == goal:
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)
var line: Array[Vector2i] = HexUtilsScript.hex_line(start, goal)
## Walk the straight line backwards from goal until we hit a scouted hex.
## That hex becomes the A* sub-goal; the remainder is appended verbatim.
var pivot_idx: int = -1
for i: int in range(line.size() - 1, -1, -1):
if bool(scouted.get(line[i], false)):
pivot_idx = i
break
if pivot_idx <= 0:
## No scouted intermediary: emit the straight hex_line minus the start
## tile. This matches the spec "straight line through unscouted fog".
var fog_only: Array[Vector2i] = []
for j: int in range(1, line.size()):
fog_only.append(line[j])
return fog_only
var pivot: Vector2i = line[pivot_idx]
var scouted_path: Array[Vector2i] = find_path(map, start, pivot, movement_budget, unit_type)
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.
var fallback: Array[Vector2i] = []
for k: int in range(1, line.size()):
fallback.append(line[k])
return fallback
var combined: Array[Vector2i] = []
for p: Vector2i in scouted_path:
combined.append(p)
for k: int in range(pivot_idx + 1, line.size()):
combined.append(line[k])
return combined
static func visible_hexes(map: RefCounted, center: Vector2i, vision_radius: int) -> Array[Vector2i]:
## Return all hex positions visible from center within vision_radius.
## A hex is visible if it is within range AND has_line_of_sight from center.
## The center tile is always included.
var center_norm: Vector2i = HexUtilsScript.normalize_position(
center, map.width, map.height, map.wrap_mode
)
var candidates: Array[Vector2i] = HexUtilsScript.hex_spiral(center_norm, vision_radius)
var result: Array[Vector2i] = []
for pos: Vector2i in candidates:
var norm: Vector2i = HexUtilsScript.normalize_position(
pos, map.width, map.height, map.wrap_mode
)
if map.get_tile(norm) == null:
continue
if norm == center_norm or has_line_of_sight(map, center_norm, norm):
result.append(norm)
return result
static func has_line_of_sight(map: RefCounted, start: Vector2i, goal: Vector2i) -> bool:
## Check line of sight between two hex positions.
## Uses hex line drawing; any intermediate tile with blocking terrain
## (mountains, dense forest) breaks LoS. Start and goal tiles are not checked.
var line: Array[Vector2i] = HexUtilsScript.hex_line(start, goal)
# Skip first (start) and last (goal) — only intermediate tiles block
for i: int in range(1, line.size() - 1):
var pos: Vector2i = HexUtilsScript.normalize_position(
line[i], map.width, map.height, map.wrap_mode
)
var tile: Resource = map.get_tile(pos)
if tile == null:
return false
if _blocks_los(tile):
return false
return true
# -- Private helpers --
static func _is_passable(tile: Resource, unit_type: String) -> bool:
## Check if a tile is passable for the given unit type.
var flags: Array = tile.get_terrain_flags()
match unit_type:
"flying":
# Flying units can cross anything except truly impassable tiles
return "impassable" not in flags
"naval":
# 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
return true
static func _get_effective_cost(tile: Resource, unit_type: String) -> int:
## Return the movement cost for entering a tile.
## Flying units always pay 1 regardless of terrain.
if unit_type == "flying":
return 1
return tile.get_movement_cost()
static func _blocks_los(tile: Resource) -> bool:
## Check if a tile blocks line of sight.
var flags: Array = tile.get_terrain_flags()
for flag: String in LOS_BLOCKING_FLAGS:
if flag in flags:
return true
# Fallback: check biome_id directly for common blockers
return tile.biome_id in LOS_BLOCKING_BIOMES
static func _reconstruct_path(
came_from: Dictionary, current: Vector2i, start: Vector2i
) -> Array[Vector2i]:
## Walk the came_from chain backwards from goal to start.
## Returns path excluding start, including goal.
var path: Array[Vector2i] = []
var pos: Vector2i = current
while pos != start:
path.append(pos)
pos = came_from[pos]
path.reverse()
return path
static func _compare_open_entries(a: Array, b: Array) -> bool:
## Sort comparator for A* open set: lower f_score first, with
## (g_score, x, y) tiebreakers so ties resolve deterministically
## across processes (sort_custom is unstable).
if a[0] != b[0]: return a[0] < b[0]
if a[1] != b[1]: return a[1] < b[1]
var pa: Vector2i = a[2]
var pb: Vector2i = b[2]
return pa.x < pb.x if pa.x != pb.x else pa.y < pb.y
static func _compare_frontier_entries(a: Array, b: Array) -> bool:
## Sort comparator for Dijkstra frontier: lower cost first, with
## (x, y) tiebreakers for deterministic tie resolution.
if a[0] != b[0]: return a[0] < b[0]
var pa: Vector2i = a[1]
var pb: Vector2i = b[1]
return pa.x < pb.x if pa.x != pb.x else pa.y < pb.y