312 lines
11 KiB
GDScript
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
|