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