big
This commit is contained in:
@@ -0,0 +1,385 @@
|
||||
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
|
||||
Reference in New Issue
Block a user