Files
Papay-Shooter/debug/walkability_probe.gd
2026-08-02 02:20:02 -04:00

386 lines
14 KiB
GDScript

extends SceneTree
## Is every part of a map actually reachable on foot?
##
## godot --headless --path . -s res://debug/walkability_probe.gd \
## -- res://scenes/maps/sakura_crossing/sakura_crossing.tscn 80 60
##
## Builds a 2.5-D walkability graph of the level and flood-fills it from the
## spawn points, then reports what it could not reach.
##
## ── Why a probe and not an eyeball ───────────────────────────────────────────
##
## A stair that climbs away from its landing, a pavement that stops at a wall, a
## roof with no way onto it — these are all invisible from the one camera angle
## you happen to photograph, and all obvious the moment you try to WALK there.
## Screenshots verify how a map looks; only a traversal check verifies that it
## works. Sakura Crossing shipped with three staircases ending in mid-air and
## not one of the captures showed it.
##
## ── How it works ─────────────────────────────────────────────────────────────
##
## For every cell of a horizontal grid the probe rays downward repeatedly,
## collecting EVERY up-facing surface in that column, not just the topmost one.
## That matters here: a single downward ray over the service alley hits the
## footbridge deck six metres above it, and the alley — the thing you actually
## want to check — never appears in the graph at all.
##
## Edges are DIRECTED. You may always fall, but you may only climb `STEP_UP`,
## the same 0.95 m the ground state uses for its step assist. So the forward
## fill answers "can a player get here from spawn", and the reverse fill answers
## "having got here, can they get back" — a surface that fails only the second
## test is a pit, which is its own kind of bug.
## ── Sample spacing, and why it is this small ─────────────────────────────────
##
## Two separate false failures came out of sampling too coarsely, and both of
## them looked exactly like a broken map:
##
## 2.0 m missed the 1.3 m-wide fire-escape flights altogether — the rays
## landed beside the stairs — so every roof they served was reported
## unreachable.
## 1.0 m found the stairs but could not WALK them. A flight with a 0.34 m
## going climbs about 0.96 m per metre travelled, which is just over
## the 0.95 m step limit, so the probe declared a perfectly good
## staircase impassable at every other sample.
##
## The rule: CELL must be small enough that one cell of travel is at most one
## or two treads of climb. At 0.5 m the steepest flight in the map gains 0.64 m
## per cell, comfortably inside the limit.
const CELL := 0.5
const STEP_UP := 0.95 # matches max_step_height in state_ground.gd
const WALKABLE_NORMAL := 0.7 # cos of the steepest floor we call ground
const RAY_TOP := 60.0
const RAY_BOTTOM := -6.0
const MAX_LEVELS := 8
const MAX_FALL := 12.0
## Islands smaller than this are single props — a bollard top, a lantern cap —
## and reporting them is noise rather than signal.
const MIN_ISLAND := 12
const DIRS: Array[Vector2i] = [
Vector2i(1, 0), Vector2i(-1, 0), Vector2i(0, 1), Vector2i(0, -1),
]
## ── Named routes ─────────────────────────────────────────────────────────────
##
## The reachability percentage is a summary, and a summary can hide the one
## thing you actually care about: 96% reachable is a pass mark that would still
## be reported if the entire railway were sealed off. These are the places a
## player must be able to stand and walk between, named, so a failure says
## "the alley is cut off" instead of "4% unreachable".
const ROUTES := {
"sakura_crossing": [
["railway west end", Vector3(-108, 0.4, 0)],
["railway at the crossing", Vector3(0, 0.4, 0)],
["railway before the curve", Vector3(58, 0.4, 0)],
["railway through the curve", Vector3(95, 0.4, -12)],
["main street, north end", Vector3(0, 0.1, -84)],
["main street, south end", Vector3(0, 0.1, 84)],
["shopping street, west", Vector3(-108, 0.1, -30)],
["shopping street, east", Vector3(46, 0.1, -30)],
["south street, west", Vector3(-108, 0.1, 20)],
["south street, east", Vector3(108, 0.1, 20)],
["service alley, west", Vector3(-108, 0.2, -11)],
["service alley, east", Vector3(46, 0.2, -11)],
["north fields lane, west", Vector3(-90, 0.1, -69)],
["north fields lane, east", Vector3(90, 0.1, -69)],
["footbridge deck", Vector3(-40, 6.2, 0)],
["footbridge north stair foot", Vector3(-47, 0.2, -10)],
["footbridge south stair foot", Vector3(-33, 0.2, 10)],
["footbridge approach street", Vector3(-40, 0.1, -18)],
["station platform", Vector3(42, 1.1, 9)],
["station canopy roof", Vector3(40, 4.6, 9)],
["shrine approach, at the street", Vector3(-22, 0.1, 29)],
["shrine steps", Vector3(-22, 0.1, 48)],
["east district, north-south street", Vector3(64, 0.1, 40)],
["east district, east-west street", Vector3(30, 0.1, 54)],
["east district, far corner", Vector3(100, 0.1, 74)],
# A two-storey shophouse roof on the railway row. The exact unit matters:
# the row is laid out from a seeded RNG, so a waypoint has to sit on a
# roof that is actually there rather than in the joint between two.
["a shop roof", Vector3(27, 6.8, -19)],
],
}
var _scene := "res://scenes/maps/sakura_crossing/sakura_crossing.tscn"
var _half_x := 80.0
var _half_z := 60.0
var _nx := 0
var _nz := 0
# Flat arrays keyed by cell*MAX_LEVELS + level. String-keyed dictionaries are
# unusable at this resolution — 77 000 columns is a quarter of a million
# lookups per fill, and GDScript hashes every one of those strings.
var _height := PackedFloat32Array()
var _count := PackedByteArray()
func _initialize() -> void:
var args := OS.get_cmdline_user_args()
if args.size() > 0:
_scene = args[0]
if args.size() > 2:
_half_x = float(args[1])
_half_z = float(args[2])
_run()
func _run() -> void:
await process_frame
var packed: PackedScene = load(_scene)
if packed == null:
printerr("walkability: cannot load ", _scene)
quit(1)
return
var level: Node = packed.instantiate()
root.add_child(level)
for _i in 90:
await process_frame
for _i in 8:
await physics_frame
var space: PhysicsDirectSpaceState3D = \
level.get_viewport().find_world_3d().direct_space_state
_nx = int(_half_x * 2.0 / CELL)
_nz = int(_half_z * 2.0 / CELL)
var cells := _nx * _nz
_height.resize(cells * MAX_LEVELS)
_count.resize(cells)
var total := 0
for ix in range(_nx):
for iz in range(_nz):
var x := -_half_x + (float(ix) + 0.5) * CELL
var z := -_half_z + (float(iz) + 0.5) * CELL
total += _column(space, x, z, (ix * _nz + iz))
var seeds := PackedInt32Array()
for s in level.find_children("*", "Marker3D", true, false):
if s.is_in_group("spawn_points"):
var n := _nearest(s.global_position)
if n >= 0:
seeds.append(n)
if seeds.is_empty():
var n := _nearest(Vector3(0, 1.0, 0))
if n >= 0:
seeds.append(n)
var reached := _fill(seeds, true)
var can_return := _fill(seeds, false)
var reach_n := 0
for v in reached:
if v:
reach_n += 1
print("WALK: %s" % _scene)
print("WALK: %d columns, %d walkable surfaces, %d spawn seeds"
% [cells, total, seeds.size()])
print("WALK: reachable from spawn: %d / %d (%.1f%%)"
% [reach_n, total, 100.0 * float(reach_n) / maxf(1.0, float(total))])
var stranded := _islands(reached)
var pits := 0
for c in range(cells):
for li in range(_count[c]):
var id := c * MAX_LEVELS + li
if reached[id] and not can_return[id]:
pits += 1
# ── The named routes ─────────────────────────────────────────────────────
var route_fails := 0
var key := _scene.get_file().get_basename()
if ROUTES.has(key):
print("WALK: named routes —")
for entry in ROUTES[key]:
var label: String = entry[0]
var want: Vector3 = entry[1]
var id := _nearest_loose(want)
if id < 0:
print("WALK: MISSING %s — no walkable ground near (%.0f, %.1f, %.0f)"
% [label, want.x, want.y, want.z])
route_fails += 1
elif not reached[id]:
print("WALK: CUT OFF %s (found ground at y=%.2f)"
% [label, _height[id]])
route_fails += 1
elif not can_return[id]:
print("WALK: ONE-WAY %s — reachable but cannot get back"
% label)
route_fails += 1
else:
print("WALK: ok %s" % label)
if route_fails == 0:
print("WALK: all %d named routes connect" % ROUTES[key].size())
if stranded.is_empty():
print("WALK: PASS — every walkable surface is reachable from a spawn")
else:
print("WALK: %d unreachable island(s) of %d+ surfaces:"
% [stranded.size(), MIN_ISLAND])
for isl in stranded:
print("WALK: %5d x[%.0f..%.0f] z[%.0f..%.0f] y[%.2f..%.2f]"
% [isl.count, isl.bb_min.x, isl.bb_max.x, isl.bb_min.z,
isl.bb_max.z, isl.bb_min.y, isl.bb_max.y])
if pits > 0:
print("WALK: %d surface(s) reachable but not escapable (one-way pits)" % pits)
level.queue_free()
for _i in 4:
await process_frame
quit(0 if route_fails == 0 else 2)
## Every up-facing surface in one column, top to bottom.
func _column(space: PhysicsDirectSpaceState3D, x: float, z: float, cell: int) -> int:
var n := 0
var y := RAY_TOP
var ex: Array[RID] = []
for _i in MAX_LEVELS * 2:
if n >= MAX_LEVELS:
break
var q := PhysicsRayQueryParameters3D.create(
Vector3(x, y, z), Vector3(x, RAY_BOTTOM, z), 1)
q.exclude = ex
var hit: Dictionary = space.intersect_ray(q)
if hit.is_empty():
break
var pos: Vector3 = hit["position"]
var nrm: Vector3 = hit["normal"]
if nrm.y >= WALKABLE_NORMAL:
_height[cell * MAX_LEVELS + n] = pos.y
n += 1
ex.append(hit["rid"])
y = pos.y - 0.001
if y <= RAY_BOTTOM:
break
_count[cell] = n
return n
func _nearest(pos: Vector3) -> int:
var ix := int((pos.x + _half_x) / CELL)
var iz := int((pos.z + _half_z) / CELL)
if ix < 0 or iz < 0 or ix >= _nx or iz >= _nz:
return -1
var cell := ix * _nz + iz
var best := -1
var best_d := 3.0
for li in range(_count[cell]):
var d: float = absf(_height[cell * MAX_LEVELS + li] - pos.y)
if d < best_d:
best_d = d
best = cell * MAX_LEVELS + li
return best
## As `_nearest`, but tolerant on height and willing to search the neighbouring
## cells. Route waypoints are written by hand from the builder's constants, so
## their Y is approximate and their X/Z can land on a kerb or a paint stripe.
func _nearest_loose(pos: Vector3) -> int:
var best := -1
var best_d := 3.0
for ox in range(-2, 3):
for oz in range(-2, 3):
var ix := int((pos.x + _half_x) / CELL) + ox
var iz := int((pos.z + _half_z) / CELL) + oz
if ix < 0 or iz < 0 or ix >= _nx or iz >= _nz:
continue
var cell := ix * _nz + iz
for li in range(_count[cell]):
var d: float = absf(_height[cell * MAX_LEVELS + li] - pos.y)
if d < best_d:
best_d = d
best = cell * MAX_LEVELS + li
return best
## Directed flood fill. `forward` climbs at most STEP_UP and falls freely;
## reversed, it finds what can get BACK to the seeds.
func _fill(seeds: PackedInt32Array, forward: bool) -> Array:
var seen := []
seen.resize(_nx * _nz * MAX_LEVELS)
seen.fill(false)
var queue := PackedInt32Array()
for s in seeds:
if not seen[s]:
seen[s] = true
queue.append(s)
var head := 0
while head < queue.size():
var id: int = queue[head]
head += 1
var cell := id / MAX_LEVELS
var y := _height[id]
var ix := cell / _nz
var iz := cell % _nz
for d in DIRS:
var jx := ix + d.x
var jz := iz + d.y
if jx < 0 or jz < 0 or jx >= _nx or jz >= _nz:
continue
var ncell := jx * _nz + jz
for lj in range(_count[ncell]):
var nid := ncell * MAX_LEVELS + lj
if seen[nid]:
continue
var ny := _height[nid]
# Climbing is capped; falling is not. A fall of more than a
# storey is still a legal move, just not a reversible one.
var rise: float = (ny - y) if forward else (y - ny)
if rise > STEP_UP or rise < -MAX_FALL:
continue
seen[nid] = true
queue.append(nid)
return seen
## Group everything the fill missed into contiguous islands.
func _islands(reached: Array) -> Array:
var seen := []
seen.resize(_nx * _nz * MAX_LEVELS)
seen.fill(false)
var out: Array = []
for cell in range(_nx * _nz):
for li in range(_count[cell]):
var start := cell * MAX_LEVELS + li
if reached[start] or seen[start]:
continue
seen[start] = true
var queue := PackedInt32Array([start])
var head := 0
var n := 0
var lo := Vector3.INF
var hi := -Vector3.INF
while head < queue.size():
var id: int = queue[head]
head += 1
n += 1
var c := id / MAX_LEVELS
var ix := c / _nz
var iz := c % _nz
var y := _height[id]
var p := Vector3(-_half_x + (float(ix) + 0.5) * CELL, y,
-_half_z + (float(iz) + 0.5) * CELL)
lo = lo.min(p)
hi = hi.max(p)
for d in DIRS:
var jx := ix + d.x
var jz := iz + d.y
if jx < 0 or jz < 0 or jx >= _nx or jz >= _nz:
continue
var ncell := jx * _nz + jz
for lj in range(_count[ncell]):
var nid := ncell * MAX_LEVELS + lj
if seen[nid] or reached[nid]:
continue
if absf(_height[nid] - y) > STEP_UP:
continue
seen[nid] = true
queue.append(nid)
if n >= MIN_ISLAND:
out.append({ "count": n, "bb_min": lo, "bb_max": hi })
out.sort_custom(func(a, b): return a.count > b.count)
return out