ajhahn.de
← Theria
GDScript 321 lines
class_name NavGrid
extends RefCounted
## Deterministic grid pathfinding over the arena's obstacles — the routing behind click-to-move
## and the bots' approach, so a unit threads the jungle walls and rounds the towers instead of
## walking into them.
##
## A coarse occupancy grid is baked once from MapData.obstacles() (the map is static), a cell
## marked blocked when a unit centred there would overlap an obstacle. `find_path` runs an
## 8-connected A* with integer costs and an index tie-break, then a line-of-sight string-pull, so
## the same call yields a byte-identical path on every machine. Pure GDScript — no engine, render,
## or NavigationServer coupling — so it lives in the sim layer beside the rest of the deterministic
## core: a bot's route is part of the replayable simulation, and a client predicts its own with the
## same math. Built around MapData and SimCore.UNIT_RADIUS, the same footprint the collision uses,
## so a routed path and the movement resolve agree.

## Grid cell size in world units. Fine enough to thread the wall gaps (~490 between the rocks that
## bound a gank opening), coarse enough that a full-map search stays cheap.
const CELL := 128.0

## Integer step costs (orthogonal / diagonal ≈ 1 : √2) — integers keep A* fully deterministic.
const COST_ORTHO := 10
const COST_DIAG := 14

## A stand-in for an unreached g-score; larger than any real path cost on this grid.
const INF_COST := 1 << 30

## The eight grid neighbours as (dcol, drow, step-cost) — precomputed so the A* inner loop allocates
## nothing per cell.
const NEIGHBOURS: Array[Vector3i] = [
	Vector3i(-1, -1, COST_DIAG),
	Vector3i(0, -1, COST_ORTHO),
	Vector3i(1, -1, COST_DIAG),
	Vector3i(-1, 0, COST_ORTHO),
	Vector3i(1, 0, COST_ORTHO),
	Vector3i(-1, 1, COST_DIAG),
	Vector3i(0, 1, COST_ORTHO),
	Vector3i(1, 1, COST_DIAG),
]

static var _shared: NavGrid = null

var _cols: int = 0
var _rows: int = 0
var _origin: Vector2 = Vector2.ZERO
var _blocked: PackedByteArray = PackedByteArray()

# Scratch reused across an A* run, sized to the grid in `_init`.
var _g: PackedInt32Array = PackedInt32Array()
var _came: PackedInt32Array = PackedInt32Array()
var _closed: PackedByteArray = PackedByteArray()
var _heap_f: PackedInt32Array = PackedInt32Array()
var _heap_i: PackedInt32Array = PackedInt32Array()


func _init() -> void:
	var bounds := MapData.BOUNDS
	_origin = bounds.position
	_cols = int(ceil(bounds.size.x / CELL))
	_rows = int(ceil(bounds.size.y / CELL))
	_bake()


## The lazily-baked shared grid. The layout is a pure function of MapData's static geometry, so one
## bake serves the whole process and stays deterministic.
static func shared() -> NavGrid:
	if _shared == null:
		_shared = NavGrid.new()
	return _shared


## Marks every cell a unit could not stand in (its centre, inflated by the body radius, overlaps an
## obstacle). Stamps each obstacle's bounding box rather than testing every cell against every
## obstacle, so the bake is a few thousand checks instead of millions. The grid uses the same
## SimCore.UNIT_RADIUS the collision does, so a free cell is a spot the resolve also accepts.
func _bake() -> void:
	var total := _cols * _rows
	_blocked.resize(total)
	_blocked.fill(0)
	for o in MapData.obstacles():
		var center: Vector2 = o["center"]
		var reach: float = o["radius"] + SimCore.UNIT_RADIUS
		var min_col := clampi(int((center.x - reach - _origin.x) / CELL), 0, _cols - 1)
		var max_col := clampi(int((center.x + reach - _origin.x) / CELL), 0, _cols - 1)
		var min_row := clampi(int((center.y - reach - _origin.y) / CELL), 0, _rows - 1)
		var max_row := clampi(int((center.y + reach - _origin.y) / CELL), 0, _rows - 1)
		for row in range(min_row, max_row + 1):
			for col in range(min_col, max_col + 1):
				var i := row * _cols + col
				if _blocked[i] == 0 and _cell_center(i).distance_to(center) < reach:
					_blocked[i] = 1


## A path of world waypoints from `from` to `to` that avoids the obstacles, or an empty array when
## the goal is unreachable (the caller then falls back to a direct move). The returned points lead
## from the first turn to the destination — the hero, already at `from`, walks toward waypoint 0.
## A clear straight line short-circuits to the single point `to`; otherwise A* routes the grid and a
## line-of-sight pass pulls the staircase taut. If `to` sits in an obstacle the route ends at the
## nearest free cell, but a clear final leg still lands on the real `to`.
func find_path(from: Vector2, to: Vector2) -> PackedVector2Array:
	if segment_clear(from, to):
		var direct := PackedVector2Array()
		direct.append(to)
		return direct
	var start_i := _nearest_free(_cell_of(from))
	var goal_i := _nearest_free(_cell_of(to))
	if start_i < 0 or goal_i < 0:
		return PackedVector2Array()
	if not _astar(start_i, goal_i):
		return PackedVector2Array()
	var cells := _reconstruct(start_i, goal_i)
	# Turn the cell chain (minus the start cell the unit already occupies) into world points,
	# landing the last leg on the real `to` when its cell was free rather than the cell centre.
	var raw := PackedVector2Array()
	for k in range(1, cells.size()):
		raw.append(_cell_center(cells[k]))
	if raw.size() > 0 and _cell_of(to) == goal_i:
		raw[raw.size() - 1] = to
	return _smooth(from, raw)


# --- A* ------------------------------------------------------------------------------------

## Runs the search from `start_i` to `goal_i`, filling `_came`. Returns whether the goal was
## reached. Lazy heap with a `_closed` set: the first time a cell is popped it has its lowest f
## (the octile heuristic is consistent), so it is finalised once; later stale heap entries are
## skipped. Costs and the heuristic are integers and the heap breaks ties by cell index, so the
## search is deterministic.
func _astar(start_i: int, goal_i: int) -> bool:
	var total := _cols * _rows
	_g = PackedInt32Array()
	_g.resize(total)
	_g.fill(INF_COST)
	_came = PackedInt32Array()
	_came.resize(total)
	_came.fill(-1)
	_closed = PackedByteArray()
	_closed.resize(total)
	_heap_f = PackedInt32Array()
	_heap_i = PackedInt32Array()
	_g[start_i] = 0
	_heap_push(_heuristic(start_i, goal_i), start_i)
	while _heap_i.size() > 0:
		var cur := _heap_pop()
		if cur == goal_i:
			return true
		if _closed[cur] == 1:
			continue
		_closed[cur] = 1
		var col := cur % _cols
		var row := cur / _cols
		for nb in NEIGHBOURS:
			var nc := col + nb.x
			var nr := row + nb.y
			if nc < 0 or nc >= _cols or nr < 0 or nr >= _rows:
				continue
			var n := nr * _cols + nc
			if _blocked[n] == 1 or _closed[n] == 1:
				continue
			if nb.x != 0 and nb.y != 0:
				# No corner-cutting: a diagonal needs both flanking orthogonals open, so a unit
				# never squeezes through a one-cell diagonal slit in a wall.
				if _blocked[row * _cols + nc] == 1 or _blocked[nr * _cols + col] == 1:
					continue
			var tentative := _g[cur] + nb.z
			if tentative < _g[n]:
				_g[n] = tentative
				_came[n] = cur
				_heap_push(tentative + _heuristic(n, goal_i), n)
	return false


## The octile heuristic between two cells, in the same integer units as the step costs and
## admissible/consistent for 8-connected movement — `10·(dx+dy) − 6·min(dx,dy)`.
func _heuristic(a: int, b: int) -> int:
	var dx: int = absi(a % _cols - b % _cols)
	var dy: int = absi(a / _cols - b / _cols)
	return COST_ORTHO * (dx + dy) - (2 * COST_ORTHO - COST_DIAG) * mini(dx, dy)


## Walks `_came` back from the goal to the start, returning the cell chain start→goal.
func _reconstruct(start_i: int, goal_i: int) -> PackedInt32Array:
	var rev := PackedInt32Array()
	var cur := goal_i
	while cur != -1:
		rev.append(cur)
		if cur == start_i:
			break
		cur = _came[cur]
	var out := PackedInt32Array()
	for k in range(rev.size() - 1, -1, -1):
		out.append(rev[k])
	return out


# --- min-heap (f, index) -------------------------------------------------------------------
# A binary min-heap ordered by f-score, breaking ties by the lower cell index so the search is
# deterministic. Kept as two parallel packed arrays to avoid per-entry allocation.

func _heap_push(f: int, idx: int) -> void:
	_heap_f.append(f)
	_heap_i.append(idx)
	var c := _heap_f.size() - 1
	while c > 0:
		var p := (c - 1) >> 1
		if _heap_less(c, p):
			_heap_swap(c, p)
			c = p
		else:
			break


func _heap_pop() -> int:
	var top := _heap_i[0]
	var last := _heap_f.size() - 1
	_heap_f[0] = _heap_f[last]
	_heap_i[0] = _heap_i[last]
	_heap_f.remove_at(last)
	_heap_i.remove_at(last)
	var n := _heap_f.size()
	var c := 0
	while true:
		var l := 2 * c + 1
		var r := 2 * c + 2
		var smallest := c
		if l < n and _heap_less(l, smallest):
			smallest = l
		if r < n and _heap_less(r, smallest):
			smallest = r
		if smallest == c:
			break
		_heap_swap(c, smallest)
		c = smallest
	return top


## Whether heap entry `a` orders before `b`: lower f first, then the lower cell index — the
## deterministic tie-break that pins one path among equal-cost routes.
func _heap_less(a: int, b: int) -> bool:
	if _heap_f[a] != _heap_f[b]:
		return _heap_f[a] < _heap_f[b]
	return _heap_i[a] < _heap_i[b]


func _heap_swap(a: int, b: int) -> void:
	var tf := _heap_f[a]
	_heap_f[a] = _heap_f[b]
	_heap_f[b] = tf
	var ti := _heap_i[a]
	_heap_i[a] = _heap_i[b]
	_heap_i[b] = ti


# --- geometry helpers ----------------------------------------------------------------------

## The cell index containing a world point, clamped into the grid so an out-of-bounds point maps to
## the nearest edge cell.
func _cell_of(p: Vector2) -> int:
	var col := clampi(int((p.x - _origin.x) / CELL), 0, _cols - 1)
	var row := clampi(int((p.y - _origin.y) / CELL), 0, _rows - 1)
	return row * _cols + col


func _cell_center(i: int) -> Vector2:
	var col := i % _cols
	var row := i / _cols
	return _origin + Vector2((float(col) + 0.5) * CELL, (float(row) + 0.5) * CELL)


## The nearest free cell to `i`, searched in rings of growing Chebyshev radius (deterministic
## row-then-column order within a ring). Returns `i` itself when already free, or -1 if the whole
## grid is blocked (it never is).
func _nearest_free(i: int) -> int:
	if _blocked[i] == 0:
		return i
	var col := i % _cols
	var row := i / _cols
	var max_r := maxi(_cols, _rows)
	for r in range(1, max_r + 1):
		for dr in range(-r, r + 1):
			for dc in range(-r, r + 1):
				if absi(dr) != r and absi(dc) != r:
					continue  # only the ring's perimeter
				var nc := col + dc
				var nr := row + dr
				if nc < 0 or nc >= _cols or nr < 0 or nr >= _rows:
					continue
				if _blocked[nr * _cols + nc] == 0:
					return nr * _cols + nc
	return -1


## A line-of-sight string-pull over the raw waypoints: from the unit's position, greedily keep the
## farthest point still reachable in a clear straight line, so the cell-stepped path is pulled into
## a few long legs instead of a staircase.
func _smooth(from: Vector2, pts: PackedVector2Array) -> PackedVector2Array:
	var out := PackedVector2Array()
	var anchor := from
	var i := 0
	while i < pts.size():
		var j := i
		var k := i
		while k < pts.size() and segment_clear(anchor, pts[k]):
			j = k
			k += 1
		out.append(pts[j])
		anchor = pts[j]
		i = j + 1
	return out


## Whether the straight segment a→b stays on free cells, sampled at half-cell steps (obstacles span
## several cells, so nothing slips between samples). Reads the baked grid — an O(1) lookup per
## sample instead of testing every obstacle — so it is cheap enough to call each tick.
func segment_clear(a: Vector2, b: Vector2) -> bool:
	var delta := b - a
	var steps := maxi(1, int(ceil(delta.length() / (CELL * 0.5))))
	for s in steps + 1:
		if _blocked[_cell_of(a + delta * (float(s) / float(steps)))] == 1:
			return false
	return true