This commit is contained in:
Nicholas Butzke
2026-08-02 02:20:02 -04:00
parent 61669627db
commit 922983429e
226 changed files with 34032 additions and 18521 deletions
+16
View File
@@ -106,6 +106,22 @@ func _process(_delta: float) -> bool:
_check(Acoustics.classify(crate) == "wood", "name-based classify: crate -> wood")
crate.free()
# An UNKNOWN acoustic_material must degrade to concrete, not throw.
# Sakura Crossing tagged its ballast "gravel" before gravel existed in
# MATERIALS, and every hitscan impact on it took down occlusion() from
# inside the audio path — a level builder's typo must never be able to
# stop the game making sound.
var odd := StaticBody3D.new()
odd.name = "OddSurface"
odd.set_meta("acoustic_material", "unobtainium")
_check(Acoustics.classify(odd) == "concrete",
"unknown acoustic_material falls back to concrete")
odd.free()
# And every material the level builders actually use must resolve.
for m in ["concrete", "wood", "metal", "glass", "brick", "gravel", "grass"]:
_check(Acoustics.MATERIALS.has(m), "MATERIALS defines '%s'" % m)
print("=== Acoustics results: %s ===" % ("ALL PASSED" if _fails == 0 else "%d FAILED" % _fails))
return true
return false
+65
View File
@@ -0,0 +1,65 @@
extends SceneTree
## Close-up visual regression capture for Akiba's repaired assets.
## godot --path . --windowed --resolution 1280x720 \
## -s res://debug/akiba_detail_capture.gd -- <out_dir>
var _frames := 0
var _out_dir := "."
var _camera: Camera3D
func _initialize() -> void:
var args := OS.get_cmdline_user_args()
if not args.is_empty():
_out_dir = args[0]
change_scene_to_file("res://scenes/maps/neon_alley/neon_alley.tscn")
func _process(_delta: float) -> bool:
_frames += 1
if _frames == 60:
_camera = Camera3D.new()
_camera.far = 1200.0
current_scene.add_child(_camera)
_camera.current = true
var poles := root.find_children(
"UtilityPole_*", "StaticBody3D", true, false)
if not poles.is_empty():
var pole := poles[0] as StaticBody3D
_camera.look_at_from_position(
pole.global_position + Vector3(-8.0, 3.2, -9.0),
pole.global_position + Vector3(0.6, 5.8, 0), Vector3.UP)
elif _frames == 90:
_snap("powerlines")
var cars := root.find_children("Car_*", "StaticBody3D", true, false)
if not cars.is_empty():
var car := cars[0] as StaticBody3D
_camera.look_at_from_position(
car.global_position + Vector3(6.5, 3.1, 7.5),
car.global_position + Vector3(0, 1.0, 0), Vector3.UP)
elif _frames == 120:
_snap("parked_car")
var landings := root.find_children(
"EscLanding_*", "StaticBody3D", true, false)
if not landings.is_empty():
var landing := landings[0] as StaticBody3D
_camera.look_at_from_position(
landing.global_position + Vector3(0, 3.2, -10.0),
landing.global_position + Vector3(0, 2.0, 0), Vector3.UP)
elif _frames == 150:
_snap("supported_escape")
unload_current_scene()
elif _frames >= 240:
return true
return false
func _snap(tag: String) -> void:
var image := root.get_viewport().get_texture().get_image()
var path := _out_dir.path_join("akiba_%s.png" % tag)
var error := image.save_png(path)
if error == OK:
print("akiba_detail_capture: saved ", path)
else:
printerr("akiba_detail_capture: save failed ", error)
+1
View File
@@ -0,0 +1 @@
uid://bmuejgnrchb1g
+243
View File
@@ -0,0 +1,243 @@
extends SceneTree
## Geometry/material regression probe for Akiba Crossing.
##
## This deliberately verifies the mechanisms that prove the requested fixes:
## imported art has one transformed triangle collider per rendered mesh (not
## an enclosing AABB), every supported prop sits on its declared plane, every
## utility curve terminates at a pole/building terminal, and cable interiors
## do not intersect static geometry.
var _frames := 0
var _result_ready := false
var _exit_code := 0
func _initialize() -> void:
change_scene_to_file("res://scenes/maps/neon_alley/neon_alley.tscn")
func _process(_delta: float) -> bool:
_frames += 1
if _result_ready:
if _frames >= 45:
quit(_exit_code)
return false
if _frames < 90:
return false
if current_scene == null:
printerr("AKIBA_INTEGRITY no current scene")
quit(1)
return false
var exact_bodies := 0
var exact_meshes := 0
var exact_shapes := 0
var collision_mismatches := 0
var fallbacks := 0
var untextured_cars := 0
var out_of_district := 0
for candidate in root.find_children("*", "StaticBody3D", true, false):
var body := candidate as StaticBody3D
if body.has_meta("exact_collision_fallback"):
fallbacks += 1
if not body.has_meta("mesh_exact_collision"):
continue
exact_bodies += 1
var meshes := body.find_children("*", "MeshInstance3D", true, false)
var shapes := body.find_children(
"ExactMeshCollision_*", "CollisionShape3D", false, false)
exact_meshes += meshes.size()
exact_shapes += shapes.size()
if meshes.size() != shapes.size():
collision_mismatches += 1
_report_collision_issue(body,
"mesh_count=%d shape_count=%d" % [meshes.size(), shapes.size()])
for shape_candidate in shapes:
var shape := shape_candidate as CollisionShape3D
if not shape.shape is ConcavePolygonShape3D \
or (shape.shape as ConcavePolygonShape3D).get_faces().is_empty():
collision_mismatches += 1
_report_collision_issue(body, "%s is not triangle collision" % shape.name)
continue
var source_path: String = str(shape.get_meta("source_mesh_path", ""))
var source := root.get_node_or_null(NodePath(source_path)) as MeshInstance3D
if source == null:
collision_mismatches += 1
_report_collision_issue(body, "%s has no source mesh" % shape.name)
continue
var expected := body.global_transform.affine_inverse() * source.global_transform
if not shape.transform.is_equal_approx(expected):
collision_mismatches += 1
_report_collision_issue(body, "%s transform differs from %s" % [
shape.name, source.name])
if body.name.begins_with("Car_") and not _has_textured_surface(body):
untextured_cars += 1
for child in root.find_children("*", "MeshInstance3D", true, false):
var mesh_instance := child as MeshInstance3D
if mesh_instance.mesh == null:
continue
var global_bounds := _transform_aabb(
mesh_instance.get_aabb(), mesh_instance.global_transform)
var center := global_bounds.get_center()
if absf(center.x) > 336.0 or absf(center.z) > 336.0:
out_of_district += 1
if out_of_district <= 8:
print("AKIBA_OUT_OF_DISTRICT %s center=%s" % [
mesh_instance.get_path(), center])
var support_checked := 0
var unsupported := 0
var road_lamps := 0
var road_intrusions := 0
for candidate in root.find_children("*", "Node3D", true, false):
var node := candidate as Node3D
if node.has_meta("support_fitted"):
support_checked += 1
var plane := float(node.get_meta("support_plane_y"))
var bounds := _global_visual_bounds(node)
if bounds.size == Vector3.ZERO or absf(bounds.position.y - plane) > 0.025:
unsupported += 1
if unsupported <= 8:
print("AKIBA_UNSUPPORTED_PROP %s bottom=%.4f plane=%.4f" % [
node.get_path(), bounds.position.y, plane])
var source_path: String = str(node.get_meta("source_asset_path", ""))
if source_path.contains("light-curved"):
road_lamps += 1
var p := node.global_position
var central_ns := absf(p.x) < 20.0
var central_ew := absf(p.z) < 20.0
if (central_ns and absf(p.x) < 7.0) \
or (central_ew and absf(p.z) < 7.0):
road_intrusions += 1
print("AKIBA_ROAD_LAMP %s position=%s" % [node.get_path(), p])
var terminals: Array[Vector3] = []
for marker in root.find_children("UtilityTerminal_*", "Node3D", true, false):
terminals.append((marker as Node3D).global_position)
var curves: Dictionary = {}
var cable_intersections := 0
var space: PhysicsDirectSpaceState3D = \
current_scene.get_world_3d().direct_space_state
for candidate in root.find_children("PowerCable_*", "MeshInstance3D", true, false):
var cable := candidate as MeshInstance3D
var curve_id := int(cable.get_meta("curve_id", -1))
var segment_index := int(cable.get_meta("segment_index", -1))
var segment_count := int(cable.get_meta("segment_count", 0))
if not curves.has(curve_id):
curves[curve_id] = {
"from": cable.get_meta("cable_from"),
"to": cable.get_meta("cable_to"),
"first": segment_index,
"last": segment_index,
}
else:
var record: Dictionary = curves[curve_id]
if segment_index < int(record["first"]):
record["first"] = segment_index
record["from"] = cable.get_meta("cable_from")
if segment_index > int(record["last"]):
record["last"] = segment_index
record["to"] = cable.get_meta("cable_to")
curves[curve_id] = record
var from: Vector3 = cable.get_meta("cable_from")
var to: Vector3 = cable.get_meta("cable_to")
var query := PhysicsRayQueryParameters3D.create(
from.lerp(to, 0.16), from.lerp(to, 0.84))
query.collide_with_areas = false
var hit: Dictionary = space.intersect_ray(query)
if not hit.is_empty():
cable_intersections += 1
if cable_intersections <= 8:
print("AKIBA_CABLE_INTERSECTION curve=%d segment=%d/%d collider=%s" % [
curve_id, segment_index, segment_count,
(hit["collider"] as Node).get_path()])
var unterminated_curves := 0
for curve_id in curves:
var record: Dictionary = curves[curve_id]
if not _near_terminal(record["from"], terminals) \
or not _near_terminal(record["to"], terminals):
unterminated_curves += 1
if unterminated_curves <= 8:
print("AKIBA_UNTERMINATED_CURVE id=%s from=%s to=%s" % [
curve_id, record["from"], record["to"]])
var power_cables := root.find_children(
"PowerCable_*", "MeshInstance3D", true, false).size()
var utility_poles := root.find_children(
"UtilityPole_*", "StaticBody3D", true, false).size()
var failures := collision_mismatches + fallbacks + untextured_cars \
+ out_of_district + unsupported + road_intrusions \
+ cable_intersections + unterminated_curves
print("AKIBA_INTEGRITY exact_bodies=%d exact_meshes=%d exact_shapes=%d collision_mismatches=%d fallbacks=%d untextured_cars=%d out_of_district=%d support_checked=%d unsupported=%d road_lamps=%d road_intrusions=%d cables=%d curves=%d poles=%d terminals=%d cable_intersections=%d unterminated_curves=%d failures=%d" % [
exact_bodies, exact_meshes, exact_shapes, collision_mismatches,
fallbacks, untextured_cars, out_of_district, support_checked,
unsupported, road_lamps, road_intrusions, power_cables,
curves.size(), utility_poles, terminals.size(), cable_intersections,
unterminated_curves, failures])
if failures > 0:
printerr("AKIBA_INTEGRITY FAILED")
_exit_code = 1
else:
print("AKIBA_INTEGRITY PASSED")
_exit_code = 0
unload_current_scene()
_result_ready = true
_frames = 0
return false
func _report_collision_issue(body: StaticBody3D, detail: String) -> void:
print("AKIBA_COLLISION_MISMATCH %s %s" % [body.get_path(), detail])
func _near_terminal(point: Vector3, terminals: Array[Vector3]) -> bool:
for terminal in terminals:
if point.distance_to(terminal) <= 0.035:
return true
return false
func _global_visual_bounds(node: Node) -> AABB:
var result := AABB()
var has_bounds := false
for child in node.find_children("*", "MeshInstance3D", true, false):
var mesh_instance := child as MeshInstance3D
if mesh_instance.mesh == null:
continue
var transformed := _transform_aabb(
mesh_instance.get_aabb(), mesh_instance.global_transform)
result = result.merge(transformed) if has_bounds else transformed
has_bounds = true
return result if has_bounds else AABB()
func _transform_aabb(bounds: AABB, xform: Transform3D) -> AABB:
var result := AABB(xform * bounds.position, Vector3.ZERO)
for x in 2:
for y in 2:
for z in 2:
var corner := bounds.position + Vector3(
bounds.size.x * float(x),
bounds.size.y * float(y),
bounds.size.z * float(z))
result = result.expand(xform * corner)
return result
func _has_textured_surface(body: Node) -> bool:
for child in body.find_children("*", "MeshInstance3D", true, false):
var mesh_instance := child as MeshInstance3D
if mesh_instance.mesh == null:
continue
for surface in mesh_instance.mesh.get_surface_count():
var material := mesh_instance.get_active_material(surface)
if material is ShaderMaterial \
and (material.get_shader_parameter("has_texture") == true \
or material.get_shader_parameter("use_vertex_color") == true):
return true
if material is BaseMaterial3D and material.albedo_texture != null:
return true
return false
+1
View File
@@ -0,0 +1 @@
uid://cgjnu7mwndf28
+150
View File
@@ -0,0 +1,150 @@
extends SceneTree
## Loads every menu-selectable level through an active single-player match,
## waits for its player/viewmodel to initialize, then advances. This is the
## release gate for scene-load, shader, and teardown errors.
## The map list is DISCOVERED, not written down.
##
## It used to be a hardcoded array, and a newly added level (Sakura Crossing)
## loaded, shipped and never once went through this gate — the run still said
## "PASS: all 5 levels" while there were six. A release gate that has to be
## edited by hand every time it gains something to test is a gate that will be
## wrong exactly when it matters.
##
## `scenes/maps/*/map_meta.cfg` is already the definition of "menu-selectable",
## since that is what the main menu and map_preview_capture both scan.
const MAPS_DIR := "res://scenes/maps/"
var MAPS: Array[String] = []
func _discover_maps() -> Array[String]:
var out: Array[String] = []
var dir := DirAccess.open(MAPS_DIR)
if dir == null:
return out
dir.list_dir_begin()
var folder := dir.get_next()
while folder != "":
if dir.current_is_dir() and not folder.begins_with("."):
var cfg := ConfigFile.new()
if cfg.load(MAPS_DIR + folder + "/map_meta.cfg") == OK:
var scene_path: String = cfg.get_value("map", "scene_path", "")
if scene_path != "" and ResourceLoader.exists(scene_path):
out.append(scene_path)
folder = dir.get_next()
out.sort()
return out
var _mode := "boot"
var _frames := 0
var _map_index := -1
var _failures: Array[String] = []
var _loaded_maps := 0
var _skin_override := ""
func _initialize() -> void:
var args := OS.get_cmdline_user_args()
if not args.is_empty():
_skin_override = args[0]
MAPS = _discover_maps()
print("MAP_SMOKE discovered %d levels: %s" % [MAPS.size(), ", ".join(MAPS)])
change_scene_to_file("res://ui/main_menu/main_menu.tscn")
func _process(_delta: float) -> bool:
_frames += 1
if _mode == "boot":
if _frames == 45:
if not _skin_override.is_empty():
var skin_manager = root.get_node_or_null("SkinManager")
if skin_manager and skin_manager.skins.has(_skin_override):
# Test-only selection: direct assignment avoids overwriting
# the player's saved preference in user://.
skin_manager.active_skin_id = _skin_override
print("MAP_SMOKE skin override: ", _skin_override)
else:
_fail("unknown skin override: " + _skin_override)
var network = root.get_node_or_null("NetworkManager")
if network and network.has_method("start_singleplayer_match"):
network.start_singleplayer_match(GameMode.DEATHMATCH)
_load_next()
return false
if _mode == "map":
if _frames == 150:
var players := root.find_children("*", "CharacterBody3D", true, false)
var local_player = null
for player in players:
if player.has_method("get_visual_model") \
and player.is_multiplayer_authority():
local_player = player
break
if local_player == null:
_fail("missing local player: " + MAPS[_map_index])
else:
var managers: Array[Node] = local_player.find_children(
"WeaponManager", "WeaponManager", true, false)
if managers.is_empty():
_fail("missing WeaponManager: " + MAPS[_map_index])
else:
print("MAP_SMOKE loaded ", MAPS[_map_index])
_loaded_maps += 1
_audit_null_materials()
elif _frames >= 180:
_load_next()
return false
if _mode == "drain":
if _frames >= 180:
if _failures.is_empty() and _loaded_maps == MAPS.size():
print("MAP_SMOKE PASS: all %d levels loaded cleanly" % MAPS.size())
quit(0)
else:
for failure in _failures:
push_error("MAP_SMOKE FAIL: " + failure)
quit(1)
return true
return false
return false
func _load_next() -> void:
_map_index += 1
_frames = 0
if _map_index >= MAPS.size():
_mode = "drain"
unload_current_scene()
return
_mode = "map"
var error := change_scene_to_file(MAPS[_map_index])
if error != OK:
_fail("could not load %s (error %d)" % [MAPS[_map_index], error])
func _fail(message: String) -> void:
_failures.append(message)
printerr("MAP_SMOKE ", message)
func _audit_null_materials() -> void:
var null_surfaces: Array[String] = []
for candidate in root.find_children("*", "MeshInstance3D", true, false):
var mesh_instance := candidate as MeshInstance3D
if mesh_instance.mesh == null:
continue
for surface in mesh_instance.mesh.get_surface_count():
if mesh_instance.get_active_material(surface) == null:
null_surfaces.append(
"%s[%d]" % [mesh_instance.get_path(), surface])
if null_surfaces.is_empty():
print("MAP_SMOKE material audit: 0 null surfaces")
else:
_fail("%d null mesh materials in %s: %s" % [
null_surfaces.size(),
MAPS[_map_index],
", ".join(null_surfaces.slice(0, 12)),
])
+1
View File
@@ -0,0 +1 @@
uid://o46rju6s0mug
+4 -7
View File
@@ -17,13 +17,10 @@ extends SceneTree
## different places in the two poses, and a skeleton-space implementation
## passes check 1 happily.
##
## MEASURED IN THE GUN'S FRAME, and it has to be. The hold BREATHES — there is a
## `sin(_time * 2.2) * 0.012` on the muzzle pitch — so no anchor is ever at the
## same world position twice, and the first version of this check compared
## absolute positions and reported a 3.5 mm error that was just the character
## inhaling. Taking each anchor relative to the one it hangs off and rotating
## into the current gun basis cancels the breathing, the ADS blend and the
## recoil kick exactly, because all three move the basis and the anchor together.
## MEASURED IN THE GUN'S FRAME, and it has to be. Taking each anchor relative to
## the one it hangs off and rotating into the current gun basis cancels the ADS
## blend and recoil kick exactly, because both move the basis and anchor
## together.
##
## godot --headless --path . -s res://debug/anchor_shift_check.gd
+66 -22
View File
@@ -2,30 +2,46 @@ extends SceneTree
## Dev tool: screenshot every animation state on the active GLB skin.
## Run:
## godot --path . --windowed --resolution 1280x720 -s res://debug/anim_capture.gd -- <out_dir> [skin_id]
## godot --path . --windowed --resolution 1280x720 -s res://debug/anim_capture.gd -- <out_dir> [skin_id] [unarmed] [tag,tag,...]
## Saves anim_<state>.png per state plus mid-action shots for reload/throw.
var _frames := 0
var _out_dir := "."
var _skin := "miku"
var _unarmed := false
var _player: Node = null
var _model: Node = null
var _cam: Camera3D = null
# [tag, state, speed, crouch, hold_frames]
# [tag, state, speed, crouch, hold_frames, strafe, forward, emote_index]
var _states := [
["idle", "idle", 0.0, false, 50],
["walk", "ground", 2.2, false, 50],
["run", "ground", 6.5, false, 50],
["sprint", "ground", 12.0, false, 50],
["crouch", "ground", 0.0, true, 50],
["crouchwalk", "ground", 2.0, true, 50],
["fall", "air", 4.0, false, 50],
["slide", "slide", 10.0, true, 50],
["dash", "dash", 14.0, false, 30],
["wallrun", "wall_run", 9.0, false, 50],
["grapple", "grapple", 10.0, false, 50],
["dance", "idle", 0.0, false, 60],
["idle", "idle", 0.0, false, 50, 0.0, 0.0, -1],
["walk", "ground", 0.975, false, 50, 0.0, 1.0, -1],
["run", "ground", 5.26148, false, 50, 0.0, 1.0, -1],
["sprint", "ground", 8.25, false, 50, 0.0, 1.0, -1],
["sprint_game_speed", "ground", 12.0, false, 50, 0.0, 1.0, -1],
["strafe_walk_back", "ground", 3.0, false, 50, 0.0, -1.0, -1],
["strafe_walk_left", "ground", 3.0, false, 50, -1.0, 0.0, -1],
["strafe_walk_right", "ground", 3.0, false, 50, 1.0, 0.0, -1],
["run_back", "ground", 8.25, false, 50, 0.0, -1.0, -1],
["run_left", "ground", 8.25, false, 50, -1.0, 0.0, -1],
["run_right", "ground", 8.25, false, 50, 1.0, 0.0, -1],
["run_left_game_speed", "ground", 11.0, false, 50, -1.0, 0.0, -1],
["run_right_game_speed", "ground", 11.0, false, 50, 1.0, 0.0, -1],
["crouch", "ground", 0.0, true, 50, 0.0, 0.0, -1],
["crouchwalk", "ground", 0.75, true, 50, 0.0, 1.0, -1],
["fall", "air", 4.0, false, 50, 0.0, 1.0, -1],
["slide", "slide", 10.0, true, 50, 0.0, 1.0, -1],
["dash", "dash", 14.0, false, 30, 0.0, 1.0, -1],
["wallrun", "wall_run", 13.0, false, 50, 0.0, 1.0, -1],
["wallcling", "wall_cling", 0.0, false, 50, 0.0, 0.0, -1],
["wallclimb", "wall_climb", 2.0, false, 50, 0.0, 1.0, -1],
["grapple", "grapple", 10.0, false, 50, 0.0, 1.0, -1],
["emote_dance", "idle", 0.0, false, 60, 0.0, 0.0, 0],
["emote_stretch", "idle", 0.0, false, 60, 0.0, 0.0, 1],
["emote_call", "idle", 0.0, false, 60, 0.0, 0.0, 2],
["emote_yes", "idle", 0.0, false, 60, 0.0, 0.0, 3],
["emote_no", "idle", 0.0, false, 60, 0.0, 0.0, 4],
]
var _phase := 0 # index into _states, then actions after
var _phase_frame := 0
@@ -40,6 +56,20 @@ func _initialize() -> void:
_out_dir = args[0]
if args.size() > 1:
_skin = args[1]
if args.size() > 2:
_unarmed = args[2].to_lower() == "unarmed"
if args.size() > 3 and args[3] != "":
var wanted := args[3].split(",", false)
_states = _states.filter(func(state: Array) -> bool:
return String(state[0]) in wanted)
if not _out_dir.is_absolute_path():
_out_dir = ProjectSettings.globalize_path(_out_dir)
var mkdir_error := DirAccess.make_dir_recursive_absolute(_out_dir)
if mkdir_error != OK:
printerr("anim_capture: could not create '%s' (error %d)" % [
_out_dir, mkdir_error])
quit(mkdir_error)
return
change_scene_to_file("res://ui/main_menu/main_menu.tscn")
@@ -75,9 +105,10 @@ func _process(_delta: float) -> bool:
_player.global_position = Vector3(0, 1.2, 14)
_player.rotation = Vector3.ZERO
_model.set_owner_visible(true)
# Give the model a weapon so armed poses read.
# Optional unarmed pass exposes the authored arm silhouettes used by
# idle/dance/emotes instead of covering them with weapon IK.
if _model.has_method("set_weapon"):
_model.set_weapon("res://weapons/ak47.gd")
_model.set_weapon("" if _unarmed else "res://weapons/ak47.gd")
# Camera: repositioned per shot (front + side). Model faces -Z.
_cam = Camera3D.new()
_cam.cull_mask &= ~(1 << 19) # don't render the FP viewmodel layer
@@ -91,8 +122,14 @@ func _process(_delta: float) -> bool:
# hop needs a real rendered frame in between).
if _mode == "states":
var s: Array = _states[_phase]
if s[0] == "dance" and _model.has_method("set_dancing"):
_model.set_dancing(true)
if _model.has_method("set_locomotion"):
_model.set_locomotion(float(s[5]), float(s[6]), 0.0)
if s[0] == "wallrun" and _model.has_method("set_wall_side"):
_model.set_wall_side(-1.0)
if _model.has_method("set_wall_run_motion"):
_model.set_wall_run_motion(Vector3(13.0, 0.0, 0.0))
if int(s[7]) >= 0 and _model.has_method("set_dancing"):
_model.set_dancing(true, int(s[7]))
_model.update_state(s[1], s[2], s[3])
_phase_frame += 1
var hold: int = int(s[4])
@@ -102,7 +139,7 @@ func _process(_delta: float) -> bool:
elif _phase_frame >= hold:
_snap("anim_" + String(s[0]) + "_s")
_cam_to(Vector3(0.4, 0.9, -2.4))
if s[0] == "dance" and _model.has_method("set_dancing"):
if int(s[7]) >= 0 and _model.has_method("set_dancing"):
_model.set_dancing(false)
if s[0] == "idle":
_debug_gun()
@@ -188,7 +225,10 @@ func _process(_delta: float) -> bool:
_model.update_state("grapple", 12.0, false)
if _model.has_method("set_grapple_target"):
# Anchor up and ahead of the player (player faces -Z world).
_model.set_grapple_target(_player.global_position + Vector3(1.5, 7.0, -7.0))
_model.set_grapple_target(
_player.global_position + Vector3(1.5, 7.0, -7.0),
Vector3(14.0, 2.0, -4.0),
)
_phase_frame += 1
if _phase_frame == 48:
_snap("anim_grapple_zip_f")
@@ -222,5 +262,9 @@ func _cam_to(offset: Vector3) -> void:
func _snap(tag: String) -> void:
var img := root.get_viewport().get_texture().get_image()
var path := _out_dir + "/" + tag + ".png"
img.save_png(path)
print("anim_capture: saved ", path)
var save_error := img.save_png(path)
if save_error == OK:
print("anim_capture: saved ", path)
else:
printerr("anim_capture: could not save '%s' (error %d)" % [
path, save_error])
+257
View File
@@ -0,0 +1,257 @@
extends SceneTree
## Runtime release gate for the premium audio pass.
##
## Uses a real Forward+ match so it verifies autoload ordering, imported audio
## resources, a spawned player's movement players, and a live weapon loadout.
var _frames := 0
var _phase := "menu"
var _failures: Array[String] = []
const REQUIRED_EVENTS := [
"ak47_fire", "m4_fire", "mp7_fire", "dmr_fire", "awp_fire",
"shotgun_fire", "nailgun_fire", "plasma_fire", "rocket_fire",
"swarm_fire", "mortar_fire", "knife_swing", "shotgun_reload",
"explosion", "bullet_impact",
"footstep_concrete", "footstep_metal", "footstep_wood", "footstep_glass",
"wind", "slide", "wallrun", "dash", "jump", "double_jump", "vault", "land",
"grapple_launch", "grapple_latch", "grapple_reel",
"ui_hover", "ui_click", "ui_confirm", "ui_error", "ui_equip",
]
func _initialize() -> void:
change_scene_to_file("res://ui/main_menu/main_menu.tscn")
func _process(_delta: float) -> bool:
_frames += 1
if _phase == "menu" and _frames == 50:
_verify_menu_music()
_verify_manager()
var network = root.get_node_or_null("NetworkManager")
if network and network.has_method("start_singleplayer_match"):
network.start_singleplayer_match(GameMode.DEATHMATCH)
else:
_fail("NetworkManager cannot start the runtime audio match")
change_scene_to_file("res://scenes/maps/test_level/test_level.tscn")
_phase = "match"
_frames = 0
elif _phase == "match" and _frames == 180:
_verify_live_player()
_phase = "drain"
_frames = 0
unload_current_scene()
elif _phase == "drain" and _frames >= 90:
_finish()
return false
func _verify_menu_music() -> void:
var found := false
if current_scene:
for candidate in current_scene.find_children("*", "AudioStreamPlayer", true, false):
var player := candidate as AudioStreamPlayer
if player.bus == "Music":
found = true
_check(player.playing, "menu music is playing")
_check(player.stream is AudioStreamOggVorbis,
"menu music is an imported Ogg stream")
if player.stream is AudioStreamOggVorbis:
_check((player.stream as AudioStreamOggVorbis).loop,
"menu music loops")
break
_check(found, "main menu owns a Music-bus player")
func _verify_manager() -> void:
var am = root.get_node_or_null("AudioManager")
_check(am != null, "AudioManager autoload exists")
if am == null:
return
for event in REQUIRED_EVENTS:
_check(am.has_sound(event), "event resolves: %s" % event)
_check(am.weapon_sound_id("Double Barrel") == "shotgun_fire",
"multiplayer shotgun broadcast resolves to its fire family")
var expected_sends := {
"SFX": "Master", "Weapons": "SFX", "Explosions": "SFX",
"Impacts": "SFX", "Footsteps": "SFX", "Movement": "SFX",
"Ambience": "SFX", "UI": "SFX", "Wind": "SFX", "Music": "Master",
}
for bus_name in expected_sends:
var bus := AudioServer.get_bus_index(bus_name)
_check(bus >= 0, "bus exists: %s" % bus_name)
if bus >= 0:
_check(AudioServer.get_bus_send(bus) == expected_sends[bus_name],
"%s routes to %s" % [bus_name, expected_sends[bus_name]])
var master := AudioServer.get_bus_index("Master")
_check(_bus_has_effect(master, "AudioEffectHardLimiter"),
"Master has a hard limiter")
_check(_bus_has_effect(AudioServer.get_bus_index("Weapons"),
"AudioEffectCompressor"), "Weapons has transient compression")
_check(_bus_has_effect(AudioServer.get_bus_index("Explosions"),
"AudioEffectCompressor"), "Explosions has body compression")
var loop_stream: AudioStream = am.stream_for("wind")
_check(loop_stream is AudioStreamRandomizer, "wind resolves through variation stream")
if loop_stream is AudioStreamRandomizer:
var randomizer := loop_stream as AudioStreamRandomizer
_check(randomizer.streams_count >= 1, "wind has a source stream")
var child := randomizer.get_stream(0)
_check(child is AudioStreamWAV, "wind source is WAV")
if child is AudioStreamWAV:
var wav := child as AudioStreamWAV
_check(wav.loop_mode == AudioStreamWAV.LOOP_FORWARD,
"wind source has forward looping enabled")
_check(wav.loop_end > 0, "wind source has an explicit loop end")
var first = am.play_ui("ui_hover")
var suppressed = am.play_ui("ui_hover")
_check(first != null, "UI hover plays")
_check(suppressed == null, "UI hover chatter is rate-limited")
var click = am.play_ui("ui_click")
_check(click != null and click.bus == "UI", "UI click plays on the UI bus")
var explosion = am.play_3d("explosion", Vector3.ZERO)
_check(explosion != null and explosion.bus == "Explosions",
"explosion plays on the Explosions bus")
for material in ["concrete", "metal", "wood", "glass"]:
var surface := Node.new()
surface.set_meta("acoustic_material", material)
_check(Acoustics.classify(surface) == material,
"surface classifier preserves %s" % material)
surface.free()
func _verify_live_player() -> void:
var ambience := current_scene.get_node_or_null("AmbientBed") \
if current_scene else null
_check(ambience == null, "level has no always-on broadband ambience")
var player = null
for candidate in root.find_children("*", "CharacterBody3D", true, false):
if candidate.has_method("get_visual_model") \
and candidate.is_multiplayer_authority():
player = candidate
break
_check(player != null, "single-player match spawned the local player")
if player == null:
return
_check(player.has_method("surface_event"), "player exposes material event lookup")
var foot_event: String = player.surface_event("footstep")
_check(foot_event in [
"footstep_concrete", "footstep_metal", "footstep_wood", "footstep_glass"],
"live floor resolves to a footstep family (%s)" % foot_event)
player.play_footstep(8.0)
_check(player.footstep_player.bus == "Footsteps",
"local footsteps use the Footsteps bus")
_check(player.footstep_player.playing, "local material footstep plays")
player.update_slide_audio(14.0)
_check(player.slide_player.bus == "Movement", "slide uses the Movement bus")
_check(player.slide_player.stream is AudioStreamRandomizer,
"slide uses the mastered looping family")
player.update_wallrun_audio(14.0, Vector3.LEFT)
_check(player.wallrun_player.bus == "Movement", "wall-run uses the Movement bus")
_check(player.wind_player.bus == "Wind", "speed wind uses the independent Wind bus")
_check(player.grapple_shoot_player.bus == "Movement",
"grapple launch uses the Movement bus")
_check(player.grapple_latch_player.bus == "Movement",
"grapple latch uses the Movement bus")
_check(player.grapple_swing_player.bus == "Movement",
"grapple reel uses the Movement bus")
_check(player.jump_player.stream != player.double_jump_player.stream,
"ground jump and powered double jump use distinct masters")
var grapple_streams := [
player.grapple_shoot_player.stream,
player.grapple_latch_player.stream,
player.grapple_swing_player.stream,
]
_check(grapple_streams[0] != grapple_streams[1]
and grapple_streams[0] != grapple_streams[2]
and grapple_streams[1] != grapple_streams[2],
"grapple launch, latch and reel use three distinct masters")
_check(player.jump_player.stream not in grapple_streams
and player.double_jump_player.stream not in grapple_streams
and player.dash_player.stream not in grapple_streams,
"grapple stages do not reuse jump or jet-thrust masters")
_check(_randomizer_has_loop(player.grapple_swing_player.stream),
"grapple reel resolves to an explicitly looping cable stream")
player._on_movement_event("grapple_shoot", {})
_check(player.grapple_shoot_player.playing,
"grapple shoot event plays the cable launcher")
player._on_movement_event("grapple_latch", {})
_check(player.grapple_latch_player.playing,
"grapple latch event plays the hook impact")
_check(player._jet_vfx != null and player._jet_vfx.get_flame_count() == 4,
"live player owns exactly four jet flames")
var jet_before: float = player._jet_vfx._burst_left
player._on_movement_event("jump", {})
_check(is_equal_approx(player._jet_vfx._burst_left, jet_before),
"ordinary jump does not ignite the jets")
player._on_movement_event("double_jump", {})
_check(player._jet_vfx._burst_left > 0.0,
"double jump ignites the four-jet burst")
var managers: Array[Node] = player.find_children(
"WeaponManager", "WeaponManager", true, false)
_check(not managers.is_empty(), "live player has a WeaponManager")
if not managers.is_empty():
var manager = managers[0]
var checked_weapons := 0
for weapon in manager.weapons.values():
if weapon and "fire_sound" in weapon and weapon.fire_sound:
checked_weapons += 1
_check(weapon.fire_sound.bus == "Weapons",
"%s fire routes to Weapons" % weapon.weapon_name)
_check(weapon.fire_sound.stream is AudioStreamRandomizer,
"%s uses the mastered variation family" % weapon.weapon_name)
_check(weapon.fire_sound.max_polyphony >= 4,
"%s shots keep overlapping body/tails" % weapon.weapon_name)
_check(checked_weapons >= 3, "at least three live weapon players verified")
func _randomizer_has_loop(stream: AudioStream) -> bool:
if not stream is AudioStreamRandomizer:
return false
var randomizer := stream as AudioStreamRandomizer
if randomizer.streams_count < 1:
return false
var child := randomizer.get_stream(0)
return child is AudioStreamWAV \
and (child as AudioStreamWAV).loop_mode == AudioStreamWAV.LOOP_FORWARD
func _bus_has_effect(bus: int, effect_class: String) -> bool:
if bus < 0:
return false
for i in AudioServer.get_bus_effect_count(bus):
var effect := AudioServer.get_bus_effect(bus, i)
if effect and effect.is_class(effect_class):
return true
return false
func _check(condition: bool, label: String) -> void:
if condition:
print(" PASS: ", label)
else:
_fail(label)
func _fail(label: String) -> void:
_failures.append(label)
printerr("AUDIO_FIDELITY FAIL: ", label)
func _finish() -> void:
if _failures.is_empty():
print("AUDIO_FIDELITY PASSED")
quit(0)
else:
printerr("AUDIO_FIDELITY failures=", _failures.size())
quit(1)
+1
View File
@@ -0,0 +1 @@
uid://c2ob7ppq88shf
+3 -1
View File
@@ -1,5 +1,7 @@
extends SceneTree
const CLOTH_CLASSES := ["skirt", "cloth", "cape", "coat", "scarf", "ribbon", "sleeve"]
## Dev tool: how much room does the collision solver actually HAVE?
##
## godot --headless --path . -s res://debug/cloth_allow_check.gd -- [skin_glb]
@@ -51,7 +53,7 @@ func _initialize() -> void:
print(" BLIND = limb radius here - the allowance the rest-clearance cap gives\n")
var rows: Array = []
for ch in info.get("chains", []):
if String(ch.get("class", "")) not in SpringBones.DRAPE_CLASSES:
if String(ch.get("class", "")) not in CLOTH_CLASSES:
continue
var names: Array = ch.get("bones", [])
var tips: Array = ch.get("tips", [])
+63
View File
@@ -0,0 +1,63 @@
extends SceneTree
## Regression capture for directional cloud lighting.
##
## Runs the same Akiba street camera twice with the sun rotated 180 degrees.
## The bright cloud rim and colored lee-side band should visibly swap sides.
##
## godot --path . -s res://debug/cloud_sun_response_capture.gd -- <out_dir>
var _frame := 0
var _out_dir := "."
var _sun: DirectionalLight3D
func _initialize() -> void:
var args := OS.get_cmdline_user_args()
if not args.is_empty():
_out_dir = args[0]
if not _out_dir.is_absolute_path():
_out_dir = ProjectSettings.globalize_path(_out_dir)
var mkdir_error := DirAccess.make_dir_recursive_absolute(_out_dir)
if mkdir_error != OK:
printerr("cloud_sun_capture: could not create output directory")
quit(mkdir_error)
return
change_scene_to_file("res://ui/main_menu/main_menu.tscn")
func _process(_delta: float) -> bool:
_frame += 1
if _frame == 60:
var network = root.get_node_or_null("NetworkManager")
if network and network.has_method("start_singleplayer_match"):
network.start_singleplayer_match(GameMode.DEATHMATCH)
change_scene_to_file("res://scenes/maps/neon_alley/neon_alley.tscn")
elif _frame == 240:
_sun = current_scene.get_node_or_null("Sun") as DirectionalLight3D
var camera := root.get_camera_3d()
if _sun == null or camera == null:
printerr("cloud_sun_capture: missing sun or camera")
return true
camera.global_position = Vector3(3, 2.2, 20)
camera.look_at(Vector3(-8, 4, -18), Vector3.UP)
_sun.rotation_degrees = Vector3(-28, -65, 0)
elif _frame == 280:
_shot("sun_west")
_sun.rotation_degrees = Vector3(-28, 115, 0)
elif _frame == 320:
_shot("sun_east")
unload_current_scene()
elif _frame == 440:
return true
return false
func _shot(tag: String) -> void:
var image := root.get_viewport().get_texture().get_image()
var path := _out_dir.path_join(tag + ".png")
var error := image.save_png(path)
if error == OK:
print("cloud_sun_capture: saved ", path)
else:
printerr("cloud_sun_capture: save failed with error ", error)
+1
View File
@@ -0,0 +1 @@
uid://hhe274y22xvp
+76 -398
View File
@@ -1,63 +1,28 @@
extends SceneTree
## Do the five emotes move the character, differ from each other, and OVERLAP?
## Regression gate for the authored emote catalog.
##
## godot --path . -s res://debug/dance_check.gd
##
## Three properties, and the third is the one worth checking. "It moves" and
## "they are different" are easy to satisfy by accident — five sine waves at five
## frequencies would pass both and would still look like programmer animation.
## What separates a dance from an oscillation is that the body moves as a CHAIN:
## the hips lead and the head arrives later. That is measurable, so it is.
##
## Sampled from inside the modifier pass, like every other pose check here.
## Outside it Godot restores the local poses and what gets measured is the
## animation clip alone — every routine would report identical motion whether or
## not the dance layer exists at all.
## The project used to synthesize five routines by adding sine-wave bone
## offsets to one dance clip. That made every emote look like programmer
## animation. The new contract is deliberately simpler: every wheel entry must
## select its own imported clip, the clip must move, and no DanceModifier may
## exist in the live character.
const CAPTURE_BEATS := 4.0
const SAMPLES := 90
const SETTLE_FRAMES := 24
const SAMPLE_FRAMES := 36
const MIN_FRAME_MOTION := 0.01
## A routine has to move the character at least this far, in metres of total
## head travel over the sample window. Below this it is not an emote.
const MIN_TRAVEL := 0.05
## Two routines must differ by at least this, comparing their per-frame pose
## trajectories.
const MIN_DISTINCT := 0.02
var _fails := 0
var _probe: Probe = null
class Probe extends SkeletonModifier3D:
var pose: Array = []
## Each bone's OWN local pose rotation, which is what a phase measurement
## needs. A bone's GLOBAL rotation contains every ancestor's rotation too, so
## the head's global carries the hips' un-lagged swing as a large component
## and correlates with it at a lag of zero no matter how much the head itself
## is delayed. Measuring globals reported Two-Step as having no overlap at
## all when its head is delayed by five links.
var local: Array = []
func _process_modification() -> void:
var skel := get_skeleton()
if skel == null:
return
pose.resize(skel.get_bone_count())
local.resize(skel.get_bone_count())
for i in skel.get_bone_count():
pose[i] = skel.get_bone_global_pose(i)
local[i] = skel.get_bone_pose_rotation(i)
var _failures := 0
func _init() -> void:
await process_frame
var mgr = root.get_node_or_null("SkinManager")
var skin = mgr.get_skin("taila") if mgr else null
var manager = root.get_node_or_null("SkinManager")
var skin = manager.get_skin("taila") if manager else null
if skin == null or skin.model_path == "":
print("dance_check: no rigged skin to test with")
quit(1)
_expect(false, "Taila is available for the authored-emote test")
_done()
return
var model := SkinnedPlayerModel.new()
@@ -68,368 +33,81 @@ func _init() -> void:
model.update_state("idle", 0.0, false)
await process_frame
if model._dance_mod == null:
_expect(false, "the model built a dance layer")
_done()
return
var skel: Skeleton3D = model.skeleton
_probe = Probe.new()
skel.add_child(_probe)
skel.move_child(_probe, skel.get_child_count() - 1)
_expect(model.loaded and model.skeleton != null,
"the authored character and skeleton load")
_expect(DanceRoutines.count() == 5,
"there are five emotes (%d)" % DanceRoutines.count())
"the wheel exposes five authored emotes (%d)" % DanceRoutines.count())
_expect(model.find_children("*", "DanceModifier", true, false).is_empty(),
"no procedural DanceModifier is present")
var tracks := {}
for i in DanceRoutines.count():
tracks[i] = await _sample(model, skel, i)
var resolved_clips: Array[String] = []
for index in DanceRoutines.count():
var canonical := DanceRoutines.clip_of(index)
var resolved: String = String(model._resolved_clips.get(canonical, ""))
_expect(resolved != "",
"%s resolves to an imported clip" % DanceRoutines.name_of(index))
_expect(not resolved_clips.has(resolved),
"%s uses its own authored clip" % DanceRoutines.name_of(index))
resolved_clips.append(resolved)
_report(tracks)
_compare(tracks)
model.set_dancing(true, index)
for _i in SETTLE_FRAMES:
model.update_state("idle", 0.0, false)
await process_frame
_expect(model._current_clip == resolved,
"%s selects %s" % [DanceRoutines.name_of(index), canonical])
var previous := _pose(model.skeleton)
var greatest_motion := 0.0
for _i in SAMPLE_FRAMES:
model.update_state("idle", 0.0, false)
await process_frame
var current := _pose(model.skeleton)
greatest_motion = maxf(greatest_motion, _pose_distance(previous, current))
previous = current
_expect(greatest_motion >= MIN_FRAME_MOTION,
"%s visibly animates the skeleton (%.4f rad/frame)"
% [DanceRoutines.name_of(index), greatest_motion])
model.set_dancing(false)
for _i in 12:
model.update_state("idle", 0.0, false)
await process_frame
model.queue_free()
_done()
## One routine's trajectory: the hips' and head's positions, per frame, in the
## character's own space, plus the elbow angles for the joint-limit check.
func _sample(model, skel: Skeleton3D, index: int) -> Dictionary:
model.set_dancing(true, index)
# Let the blend arrive fully before recording, or the first routine sampled
# reports smaller motion than the rest purely because it was still fading in.
for _i in 40:
model.update_state("idle", 0.0, false)
await process_frame
var mod = model._pose_mod
var hips: int = mod._idx.get("DEF-hips", -1)
var head: int = mod._idx.get("DEF-head", -1)
# Overlap is measured between two links of the SAME chain, not between the
# hips and the head.
#
# Every spine link is driven by the same channels (`spine_roll`, `spine_yaw`,
# `spine_pitch`) at `beat - lag * i`, so the only difference between them IS
# the lag. The hips and the head are driven by DIFFERENT channels, often at
# different periods — Two-Step's hips roll on a two-beat cycle while its head
# bobs on a one-beat one — so correlating those two compares signals that do
# not have a phase relationship to find.
var link_a: int = mod._idx.get("DEF-spine.001", -1)
var link_b: int = mod._idx.get("DEF-spine.003", -1)
var fa_r: int = mod._idx.get("DEF-forearm.R", -1)
var ua_r: int = mod._idx.get("DEF-upper_arm.R", -1)
var hand_r: int = mod._idx.get("DEF-hand.R", -1)
var hip_track: Array = []
var head_track: Array = []
# The SAME quantity at two points in the chain: how far each bone has been
# rotated away from its own rest pose, signed. Correlating the hips' world
# TRANSLATION against the head's position RELATIVE to the hips was comparing
# two different physical quantities driven by different channels at different
# periods, and the peak landed anywhere — it reported the Robot, whose lag is
# zero by construction, as the most overlapped routine in the set.
var hip_rot: Array = []
var head_rot: Array = []
var worst_elbow := 180.0
for _i in SAMPLES:
model.update_state("idle", 0.0, false)
await process_frame
var pose: Array = _probe.pose
if pose.size() <= maxi(hips, head) or hips < 0 or head < 0:
continue
var origin: Vector3 = pose[hips].origin
hip_track.append(origin)
head_track.append(pose[head].origin - origin)
hip_rot.append(_twist(skel, _probe.local, link_a))
head_rot.append(_twist(skel, _probe.local, link_b))
# The elbow must never open past straight. A signed wave on a forearm
# bends it backwards through the joint on half of every cycle, which is
# the single most obvious tell in procedural animation.
if ua_r >= 0 and fa_r >= 0 and hand_r >= 0 and pose.size() > hand_r:
var upper: Vector3 = (pose[ua_r].origin - pose[fa_r].origin).normalized()
var lower: Vector3 = (pose[hand_r].origin - pose[fa_r].origin).normalized()
worst_elbow = minf(worst_elbow, rad_to_deg(acos(clampf(
upper.dot(lower), -1.0, 1.0))))
model.set_dancing(false)
for _i in 30:
model.update_state("idle", 0.0, false)
await process_frame
return {"hips": hip_track, "head": head_track, "elbow": worst_elbow,
"hip_rot": hip_rot, "head_rot": head_rot}
func _pose(skeleton: Skeleton3D) -> Array[Quaternion]:
var result: Array[Quaternion] = []
result.resize(skeleton.get_bone_count())
for bone in skeleton.get_bone_count():
result[bone] = skeleton.get_bone_pose_rotation(bone)
return result
func _report(tracks: Dictionary) -> void:
print("\n=== EMOTES ===")
for i in tracks:
var t: Dictionary = tracks[i]
var travel := _travel(t["head"])
var hip_travel := _travel(t["hips"])
var lag := _lag(t["hip_rot"], t["head_rot"])
print(" %-12s head %.3f m hips %.3f m upper spine lags lower by %d frames min elbow %.0f deg"
% [DanceRoutines.name_of(i), travel, hip_travel, lag, t["elbow"]])
func _compare(tracks: Dictionary) -> void:
for i in tracks:
var t: Dictionary = tracks[i]
var nm := DanceRoutines.name_of(i)
_expect(_travel(t["head"]) >= MIN_TRAVEL,
"%s actually moves the character (%.3f m)" % [nm, _travel(t["head"])])
# 8 degrees of slack: the IK and the idle clip underneath both contribute,
# and an elbow that never quite straightens is correct anyway.
_expect(t["elbow"] >= 8.0,
"%s never hyperextends the elbow (min %.0f deg)" % [nm, t["elbow"]])
var ids: Array = tracks.keys()
for i in ids.size():
for j in range(i + 1, ids.size()):
var d := _difference(tracks[ids[i]]["head"], tracks[ids[j]]["head"])
_expect(d >= MIN_DISTINCT,
"%s and %s are different dances (%.3f)"
% [DanceRoutines.name_of(ids[i]), DanceRoutines.name_of(ids[j]), d])
# OVERLAP. The head must trail the hips, because the body is a chain — this
# is the property that separates a dance from five bones oscillating in
# phase, and it is the whole reason `lag` exists in the routine data.
#
# The robot is exempt and deliberately so: its lag is zero on purpose, which
# is what makes it read as mechanical against the other four.
for i in tracks:
var rid: String = String(DanceRoutines.get_routine(i).get("id", ""))
# Robot: lag zero by construction, which is the point of it.
# Spin: the head SPOTS — it holds its heading against the turn and whips
# round to catch up, so it is deliberately not a delayed copy of the
# hips. Asserting that it follows them would be asserting the opposite of
# the technique.
if rid == "robot" or rid == "spin":
continue
var lag := _lag(tracks[i]["hip_rot"], tracks[i]["head_rot"])
_expect(lag > 0,
"%s moves as a chain — the upper spine trails the lower by %d frames"
% [DanceRoutines.name_of(i), lag])
# The Robot's own property is that its motion is QUANTISED: it holds a pose
# and jumps, where the others move continuously. That is what `steps` in the
# routine data produces and what makes it read as mechanical against the
# other four.
#
# Its LAG is deliberately not asserted. Zero lag ought to correlate perfectly
# at shift 0, but the signal is a staircase with 16-frame plateaus, so many
# shifts score nearly identically and the measured peak wanders — it reported
# 21 frames. Asserting a number the measurement cannot resolve would be
# asserting noise; the hold fraction below is the property that is actually
# there.
var robot := DanceRoutines.index_of("robot")
var robot_step := _step_size(tracks[robot]["head_rot"])
for i in tracks:
if i == robot:
continue
var other := _step_size(tracks[i]["head_rot"])
_expect(robot_step > other * 1.5,
"Robot JUMPS between poses where %s flows (%.2f vs %.2f of range per frame)"
% [DanceRoutines.name_of(i), robot_step, other])
## The largest single-frame change, as a fraction of the track's whole range.
##
## This is what quantised motion looks like from the outside: long flat stretches
## punctuated by one big jump. A smooth wave never moves more than a few percent
## of its range in a frame however punchy its easing.
##
## Measured as a JUMP rather than as time-spent-still, which was the first
## attempt and does not separate them: a shaped wave hangs at its extremes by
## design, so Two-Step scored the same 0.97 "holding" as the Robot did. The
## routines differ in HOW THEY LEAVE a pose, not in how long they sit in one.
func _step_size(rot_track: Array) -> float:
var track := _project(rot_track)
var n := track.size()
if n < 4:
func _pose_distance(a: Array, b: Array) -> float:
var count := mini(a.size(), b.size())
if count == 0:
return 0.0
var lo := 1e30
var hi := -1e30
for v in track:
lo = minf(lo, v)
hi = maxf(hi, v)
var span: float = hi - lo
if span < 0.000001:
return 0.0
var biggest := 0.0
for i in range(1, n):
biggest = maxf(biggest, absf(track[i] - track[i - 1]))
return biggest / span
var greatest := 0.0
for index in count:
var qa: Quaternion = a[index]
var qb: Quaternion = b[index]
greatest = maxf(greatest, qa.angle_to(qb))
return greatest
## Total path length of a track.
func _travel(track: Array) -> float:
var sum := 0.0
for i in range(1, track.size()):
sum += (track[i] as Vector3).distance_to(track[i - 1])
return sum
## Mean per-frame distance between two tracks, after removing each one's own
## average position — otherwise two identical dances at different heights would
## read as different, and two different dances at the same height as the same.
func _difference(a: Array, b: Array) -> float:
var n := mini(a.size(), b.size())
if n == 0:
return 0.0
var ca := Vector3.ZERO
var cb := Vector3.ZERO
for i in n:
ca += a[i]
cb += b[i]
ca /= float(n)
cb /= float(n)
var sum := 0.0
for i in n:
sum += ((a[i] - ca) - (b[i] - cb)).length()
return sum / float(n)
## How many frames the head's rotation trails the hips', by NORMALISED
## cross-correlation.
##
## Both signals are the same quantity — a bone's rotation away from its own rest
## pose — sampled at two ends of the same chain, so the only thing that can
## differ between them is timing. That is the whole point: an unnormalised
## correlation between two DIFFERENT quantities peaks wherever their amplitudes
## happen to line up, which reported the Robot (lag zero by construction) as the
## most overlapped routine in the set.
##
## Pearson, so amplitude cannot influence where the peak falls — a head that
## moves further than the hips must not read as a head that moves later.
func _lag(hips: Array, head: Array) -> int:
# BOTH ends projected onto the HIPS' axis, not each onto its own.
#
# Overlap is "the same motion, later", so the measurement has to be of the
# same motion. Projecting each end onto its own dominant axis compares
# whatever channel happens to dominate at that end, and routines drive
# different channels at the two ends: Two-Step's hips are dominated by a
# two-beat roll while its head is dominated by a one-beat bob, so the
# correlation was between signals of different PERIOD and peaked wherever.
var axis := _dominant_axis(hips)
var a := _centre(_project(hips, axis))
var b := _centre(_project(head, axis))
var n := mini(a.size(), b.size())
if n < 16:
return 0
var best := 0
var best_score := -1e30
# Out to half the window. The correlation of a periodic signal repeats every
# period, so the search must stay inside one; Body Wave has the largest lag
# in the set by design (0.13 s per link over five links, most of a beat at
# 88 bpm) and a short window could not see it at all.
for shift in range(0, n / 2):
var sum := 0.0
var na := 0.0
var nb := 0.0
for i in range(0, n - shift):
sum += a[i] * b[i + shift]
na += a[i] * a[i]
nb += b[i + shift] * b[i + shift]
if na < 0.000001 or nb < 0.000001:
continue
var score: float = sum / sqrt(na * nb)
if score > best_score:
best_score = score
best = shift
return best
## Mean-removed copy of a scalar track.
func _centre(track: Array) -> Array:
var n := track.size()
if n == 0:
return []
var mean := 0.0
for v in track:
mean += v
mean /= float(n)
var out: Array = []
for v in track:
out.append(v - mean)
return out
## How far a bone has been rotated away from its rest pose, as a ROTATION VECTOR
## (axis times angle).
##
## A vector, not a signed scalar. The first version returned `angle * sign of the
## axis's largest component`, and that is discontinuous: as a rocking bone passes
## back through its rest pose the angle goes to zero and the axis FLIPS, so the
## signal jumped the full width of its range in a single frame. Two-Step measured
## a per-frame step of 0.99 of its own range — which looked exactly like the
## quantised motion the Robot is supposed to have exclusively, on a routine that
## is perfectly smooth.
##
## The rotation vector passes through zero and comes out the other side pointing
## the opposite way, which is continuous, and projecting it onto a fixed axis
## afterwards gives the signed wave the analysis actually wants.
func _twist(skel: Skeleton3D, local: Array, idx: int) -> Vector3:
if idx < 0 or idx >= local.size():
return Vector3.ZERO
# The bone's OWN rotation away from its rest — not its global, which carries
# every ancestor's along with it. See Probe.local.
var rest: Quaternion = skel.get_bone_rest(idx).basis.get_rotation_quaternion()
var d := (rest.inverse() * (local[idx] as Quaternion)).normalized()
# Shortest arc, so a rotation just past 180 degrees does not read as one just
# under -180.
if d.w < 0.0:
d = Quaternion(-d.x, -d.y, -d.z, -d.w)
var ang := d.get_angle()
if ang < 0.000001:
return Vector3.ZERO
return d.get_axis() * ang
## The axis a track of rotation vectors varies most about.
func _dominant_axis(track: Array) -> Vector3:
var n := track.size()
if n == 0:
return Vector3.ZERO
var mean := Vector3.ZERO
for v in track:
mean += v
mean /= float(n)
var axis := Vector3.ZERO
var best := 0.0
for v in track:
var d: Vector3 = v - mean
if d.length() > best:
best = d.length()
axis = d
return axis.normalized() if axis.length() > 0.000001 else Vector3.ZERO
## A track of rotation vectors flattened to one signed scalar per frame, along
## `axis` — or along the track's own dominant axis if none is given.
func _project(track: Array, axis: Vector3 = Vector3.ZERO) -> Array:
var n := track.size()
if n == 0:
return []
var use := axis if axis.length() > 0.000001 else _dominant_axis(track)
if use.length() < 0.000001:
return []
var mean := Vector3.ZERO
for v in track:
mean += v
mean /= float(n)
var out: Array = []
for v in track:
out.append((v - mean).dot(use))
return out
func _expect(ok: bool, what: String) -> void:
func _expect(ok: bool, description: String) -> void:
if ok:
print(" OK: ", what)
print(" OK: ", description)
else:
print(" FAIL: ", what)
_fails += 1
printerr(" FAIL: ", description)
_failures += 1
func _done() -> void:
print("\n=== DANCE SUMMARY ===")
print("Failures: %d" % _fails)
quit(1 if _fails > 0 else 0)
print("\n=== AUTHORED EMOTE SUMMARY ===")
print("Failures: %d" % _failures)
quit(1 if _failures > 0 else 0)
+13 -15
View File
@@ -1,5 +1,11 @@
extends TestLevelBuilder
func _build_environment() -> void:
# Dust 2 uses the warm sunset palette. Selecting it through the shared
# environment updates both the sky and the fullscreen ink pass.
LevelEnvironment.add_to(self, "sunset")
func _build_geometry() -> void:
# Build environment setup
_build_environment()
@@ -10,10 +16,9 @@ func _build_geometry() -> void:
# (keep the shared linear tonemap — ACES crushes the cel bands)
if environment.sky and environment.sky.sky_material:
var sky_mat = environment.sky.sky_material
sky_mat.sky_top_color = Color(0.3, 0.5, 0.8)
sky_mat.sky_horizon_color = Color(0.8, 0.7, 0.5)
sky_mat.ground_bottom_color = Color(0.2, 0.15, 0.1)
sky_mat.ground_horizon_color = Color(0.8, 0.7, 0.5)
sky_mat.set_shader_parameter("top_color", Color(0.3, 0.5, 0.8))
sky_mat.set_shader_parameter("horizon_color", Color(0.8, 0.7, 0.5))
sky_mat.set_shader_parameter("ground_color", Color(0.2, 0.15, 0.1))
_build_lighting()
var sun = get_node_or_null("Sun")
@@ -192,17 +197,10 @@ func _build_dust2_layout() -> void:
_build_dust2_dummies()
# Combat Area Bounds
var combat_area = load("res://entities/combat_area.gd").new()
combat_area.name = "CombatArea"
var area_shape = CollisionShape3D.new()
var cbox = BoxShape3D.new()
# Size the combat area to envelop the entire playable space (width and depth of 120, height of 20)
cbox.size = Vector3(120, 20, 120)
area_shape.shape = cbox
combat_area.add_child(area_shape)
# Center it on the map so it fits perfectly around the CSG boundaries
combat_area.position = Vector3(0, 10, 0)
add_child(combat_area)
# Envelops the playable space; centred so it fits around the CSG boundaries.
# Deeper than the old 20 m box, which sat with its floor at y = 0 and so
# never actually contained a player who had fallen below the map.
CombatArea.add_to(self, Vector3(120, 70, 120), Vector3(0, 25, 0))
func _build_dust2_dummies() -> void:
# Do not spawn dummies in real multiplayer matches
+147
View File
@@ -0,0 +1,147 @@
extends SceneTree
## Deterministic set-piece for judging the LOOK, not the level.
##
## visual_capture.gd shoots the real game, which is the right tool for "does
## this still work" and the wrong one for "did that shader change help": the
## player spawns at a random spawn point, weapons idle-sway, and characters
## animate, so two runs of the same build produce different frames. Measuring
## those with tools/levels.py gives numbers that bounce by 15 and hide every
## delta smaller than the noise.
##
## This builds a fixed scene instead — known primitives on a known ground plane,
## a fixed sun angle, a fixed camera — so the ONLY thing that can move a
## statistic is the change under test. The shapes are chosen to exercise each
## part of the cel look:
##
## sphere / cylinder curved terminator (the band-edge anti-aliasing case)
## box flat faces meeting at hard angles (crisp break case)
## the low slab casts across the ground plane (cast-shadow case)
## the overhang deep occlusion right above a surface (contact/AO case)
## the stack object-on-object contact, the "does it float" case
##
## Run:
## godot --path . --windowed --resolution 1280x720 \
## -s res://debug/fidelity_probe.gd -- <out_dir>
##
## Writes probe_key.png (three-quarter hero angle) and probe_graze.png (low
## raking angle, where terminator crawl and outline weight show worst).
var _frames := 0
var _out_dir := "."
var _root: Node3D
var _cam: Camera3D
func _initialize() -> void:
var args := OS.get_cmdline_user_args()
if args.size() > 0:
_out_dir = args[0]
_build()
func _build() -> void:
_root = Node3D.new()
_root.name = "FidelityProbe"
root.add_child(_root)
LevelEnvironment.add_to(_root, "day")
# Pin the sun rather than take the shared default, so a change to the
# shared default cannot silently move the probe's baseline. Raking enough
# that everything throws a long, legible shadow across the plane.
var sun: DirectionalLight3D = _root.get_node("Sun")
sun.rotation_degrees = Vector3(-34, 42, 0)
sun.light_energy = 2.2
sun.directional_shadow_max_distance = 80.0
# Ground: one large plane, deliberately untextured flat cel colour. This is
# the surface the `detail` statistic is measured against — if a surface
# detail law is doing anything, it shows up here first.
var ground := MeshInstance3D.new()
var plane := PlaneMesh.new()
plane.size = Vector2(60, 60)
ground.mesh = plane
ground.material_override = LevelMaterials.flat(Color(0.52, 0.50, 0.58), "ground")
_root.add_child(ground)
var wall_col := Color(0.62, 0.58, 0.66)
var prop_col := Color(0.80, 0.78, 0.84)
# Back wall — a big flat expanse, the "dead%" case.
_box(Vector3(0, 4, -9), Vector3(30, 8, 0.6), wall_col, "wall")
# Low slab: throws a long shadow across open ground.
_box(Vector3(-6, 0.5, 2), Vector3(4, 1, 1.2), prop_col, "trim")
# Overhang: a lid on two legs, deep occlusion directly above the ground.
_box(Vector3(5, 2.6, 0), Vector3(5, 0.4, 4), prop_col, "panel")
_box(Vector3(3.2, 1.3, -1.4), Vector3(0.4, 2.2, 0.4), prop_col)
_box(Vector3(6.8, 1.3, 1.4), Vector3(0.4, 2.2, 0.4), prop_col)
# Stack: object resting on object, the classic "does it float" read.
_box(Vector3(0, 0.6, 3.5), Vector3(2.4, 1.2, 2.4), prop_col, "panel")
_box(Vector3(0, 1.6, 3.5), Vector3(1.2, 0.8, 1.2), Color(0.86, 0.52, 0.34))
# Curved surfaces: the terminator that crawls if the band edge is not
# anti-aliased in screen space.
_shape(SphereMesh.new(), Vector3(-2.5, 1.2, 0.5), prop_col, 1.2)
var cyl := CylinderMesh.new()
cyl.top_radius = 0.5
cyl.bottom_radius = 0.5
cyl.height = 3.4
_shape(cyl, Vector3(-4.5, 1.7, -3.0), Color(0.42, 0.62, 0.72), 1.0)
_cam = Camera3D.new()
_root.add_child(_cam)
_cam.current = true
_aim_key()
func _box(pos: Vector3, size: Vector3, col: Color, law: String = "") -> void:
var mi := MeshInstance3D.new()
var m := BoxMesh.new()
m.size = size
mi.mesh = m
mi.position = pos
mi.material_override = LevelMaterials.flat(col, law)
_root.add_child(mi)
func _shape(mesh: Mesh, pos: Vector3, col: Color, scale: float) -> void:
var mi := MeshInstance3D.new()
if mesh is SphereMesh:
mesh.radius = 1.0
mesh.height = 2.0
mi.mesh = mesh
mi.position = pos
mi.scale = Vector3.ONE * scale
mi.material_override = LevelMaterials.flat(col)
_root.add_child(mi)
## look_at_from_position, not position-then-look_at: _initialize() runs before
## the node is actually in the tree, and look_at needs a global transform.
func _aim_key() -> void:
_cam.look_at_from_position(Vector3(7.5, 5.2, 12.0), Vector3(0.0, 1.0, 0.5), Vector3.UP)
func _aim_graze() -> void:
_cam.look_at_from_position(Vector3(-9.0, 1.05, 7.5), Vector3(2.0, 1.4, -2.0), Vector3.UP)
func _process(_delta: float) -> bool:
_frames += 1
# 90 frames of settle: shaders compile on first use and the first frames
# render with the fallback material, which would show up as a huge and
# entirely fake improvement in every statistic.
if _frames == 90:
_shot("key")
_aim_graze()
elif _frames == 120:
_shot("graze")
return true
return false
func _shot(tag: String) -> void:
var img := root.get_viewport().get_texture().get_image()
var path := _out_dir + "/probe_" + tag + ".png"
img.save_png(path)
print("fidelity_probe: saved ", path)
+1
View File
@@ -0,0 +1 @@
uid://coy68t4v8bnk0
+141
View File
@@ -0,0 +1,141 @@
extends SceneTree
## Visual and structural gate for first-person movement handling.
##
## Run:
## godot --path . --resolution 1280x720 \
## --script res://debug/fp_movement_capture.gd -- <out_dir>
const DT := 1.0 / 60.0
const SETTLE_FRAMES := 42
var _out_dir := "."
var _frames := 0
var _booted := false
var _player: CharacterBody3D = null
var _machine: Node = null
var _manager: Node = null
var _case_index := 0
var _case_frame := 0
var _failures: Array[String] = []
# tag, state, velocity, wall side, crouching
var _cases := [
["idle", "idle", Vector3.ZERO, 0.0, false],
["jump", "air", Vector3(0.0, 9.0, -6.0), 0.0, false],
["fall", "air", Vector3(0.0, -11.0, -7.0), 0.0, false],
["dash", "dash", Vector3(0.0, 0.0, -18.0), 0.0, false],
["slide", "slide", Vector3(0.0, 0.0, -13.0), 0.0, true],
["wallrun_left", "wall_run", Vector3(0.0, 1.0, -11.0), -1.0, false],
["wallrun_right", "wall_run", Vector3(0.0, 1.0, -11.0), 1.0, false],
["wall_cling", "wall_cling", Vector3(0.0, 0.0, -2.0), -1.0, false],
["grapple", "grapple", Vector3(4.0, 5.0, -15.0), 0.0, false],
]
func _initialize() -> void:
var args := OS.get_cmdline_user_args()
if not args.is_empty():
_out_dir = args[0]
if not _out_dir.is_absolute_path():
_out_dir = ProjectSettings.globalize_path(_out_dir)
var mkdir_error := DirAccess.make_dir_recursive_absolute(_out_dir)
if mkdir_error != OK:
printerr("FP_MOVEMENT could not create '%s' (error %d)" % [
_out_dir, mkdir_error])
quit(mkdir_error)
return
change_scene_to_file("res://ui/main_menu/main_menu.tscn")
func _process(_delta: float) -> bool:
_frames += 1
if not _booted and _frames == 40:
_booted = true
var network = root.get_node_or_null("NetworkManager")
if network and network.has_method("start_singleplayer_match"):
network.start_singleplayer_match(GameMode.DEATHMATCH)
change_scene_to_file("res://scenes/maps/test_level/test_level.tscn")
return false
if _frames < 160:
return false
if _player == null:
if not _setup():
_finish()
return true
var entry: Array = _cases[_case_index]
_machine.current_state = entry[1]
_machine.wall_side = entry[3]
_machine.input_crouch = entry[4]
_player.velocity = entry[2]
_manager._process(DT)
_case_frame += 1
if _case_frame >= SETTLE_FRAMES:
_snap("fp_move_" + String(entry[0]))
var pose: Dictionary = _manager.movement_pose_debug()
var position: Vector3 = pose.get("position", Vector3.ZERO)
var rotation: Vector3 = pose.get("rotation", Vector3.ZERO)
if entry[0] != "idle" and position.length() < 0.012 \
and rotation.length() < 0.025:
_failures.append("%s did not produce a readable movement pose" % entry[0])
print("FP_MOVEMENT pose %s position=%s rotation_deg=%s" % [
entry[0], position, rotation * (180.0 / PI)])
_case_index += 1
_case_frame = 0
if _case_index >= _cases.size():
_finish()
return true
return false
func _setup() -> bool:
for candidate in root.find_children("*", "CharacterBody3D", true, false):
if candidate.has_method("get_visual_model") \
and candidate.is_multiplayer_authority():
_player = candidate
break
if _player == null:
_failures.append("local player was not created")
return false
_machine = _player.get_node_or_null("MovementStateMachine")
_manager = _player.get_node_or_null("HeadPivot/Camera3D/WeaponManager")
if _machine == null:
_failures.append("MovementStateMachine is missing")
if _manager == null:
_failures.append("WeaponManager is missing")
if not _failures.is_empty():
return false
_player.set_physics_process(false)
_machine.set_physics_process(false)
_manager.set_process(false)
_player.global_position = Vector3(0.0, 1.2, 14.0)
var obsolete := root.find_children(
"FirstPersonBody", "Node", true, false)
if not obsolete.is_empty():
_failures.append("obsolete first-person body/legs still exist")
if _manager.weapons.is_empty():
_failures.append("viewmodel weapon loadout is empty")
return _failures.is_empty()
func _snap(tag: String) -> void:
var image := root.get_viewport().get_texture().get_image()
var path := _out_dir.path_join(tag + ".png")
var error := image.save_png(path)
if error == OK:
print("FP_MOVEMENT saved ", path)
else:
_failures.append("could not save %s (error %d)" % [path, error])
func _finish() -> void:
if _failures.is_empty() and _case_index >= _cases.size():
print("FP_MOVEMENT PASS: %d authored poses, no first-person legs" \
% _cases.size())
quit(0)
else:
for failure in _failures:
push_error("FP_MOVEMENT FAIL: " + failure)
quit(1)
+1
View File
@@ -0,0 +1 @@
uid://c183tdow8n6dn
+83 -2
View File
@@ -7,6 +7,8 @@ extends SceneTree
var _frames := 0
var _out_dir := "."
var _skin_id := ""
var _quick := false
var _player: Node = null
var _wman: Node = null
var _mode := "boot"
@@ -32,6 +34,10 @@ func _initialize() -> void:
var args := OS.get_cmdline_user_args()
if args.size() > 0:
_out_dir = args[0]
if args.size() > 1:
_skin_id = args[1]
if args.size() > 2:
_quick = args[2] == "quick"
change_scene_to_file("res://ui/main_menu/main_menu.tscn")
@@ -39,6 +45,12 @@ func _process(_delta: float) -> bool:
_frames += 1
if _mode == "boot":
if _frames == 40:
var skin_manager = root.get_node_or_null("SkinManager")
if not _skin_id.is_empty() and skin_manager \
and skin_manager.skins.has(_skin_id):
# Capture-only override: do not persist over the player's actual
# skin selection in user://.
skin_manager.active_skin_id = _skin_id
var nm = root.get_node_or_null("NetworkManager")
if nm and nm.has_method("start_singleplayer_match"):
nm.start_singleplayer_match(GameMode.DEATHMATCH)
@@ -62,7 +74,30 @@ func _process(_delta: float) -> bool:
return true
_player.set_physics_process(false)
_player.global_position = Vector3(0, 1.2, 14)
_mode = "spawn"
_mode = "quick" if _quick else "spawn"
_phase_frame = 0
return false
if _mode == "quick":
_phase_frame += 1
if _phase_frame == 15:
if not _skin_id.is_empty():
_verify_skin(_skin_id)
_snap("fp_quick_idle")
var quick_weapon = _wman.weapons.get(_wman.active_slot)
if quick_weapon and quick_weapon.has_method("_start_reload"):
quick_weapon._start_reload()
elif _phase_frame == 32:
_snap("fp_quick_reload")
elif _phase_frame == 38:
var skin_manager = root.get_node_or_null("SkinManager")
if skin_manager:
skin_manager.skin_changed.emit("aria")
elif _phase_frame == 48:
_verify_skin("aria")
_snap("fp_quick_skin_swap")
elif _phase_frame >= 55:
return true
return false
if _mode == "spawn":
@@ -121,7 +156,9 @@ func _process(_delta: float) -> bool:
if _mode == "knife_swing":
_phase_frame += 1
if _phase_frame == 20:
if _phase_frame == 10:
_snap("fp_knife_guard")
elif _phase_frame == 20:
var knife = _wman.weapons.get(4)
if knife and knife.has_method("_swing"):
knife._swing()
@@ -154,3 +191,47 @@ func _snap(tag: String) -> void:
var path := _out_dir + "/" + tag + ".png"
img.save_png(path)
print("fp_capture: saved ", path)
func _verify_skin(skin_id: String) -> void:
var skin_manager = root.get_node_or_null("SkinManager")
var weapon = _wman.weapons.get(_wman.active_slot) if _wman else null
var sleeve = weapon.get_node_or_null("ArmR/Sleeve") if weapon else null
var hand = weapon.get_node_or_null("ArmR/Hand") if weapon else null
if skin_manager == null or sleeve == null or hand == null:
printerr("fp_capture: skin swap verification missing arm")
return
var material = sleeve.get_active_material(0)
var actual = material.get_shader_parameter("albedo_color") \
if material is ShaderMaterial else null
var expected: Color = skin_manager.get_skin(skin_id).viewmodel_sleeve_color
var selected_skin: PlayerSkin = skin_manager.get_skin(skin_id)
var palm := hand.get_node_or_null("Palm") as MeshInstance3D
var palm_material := palm.get_active_material(0) if palm else null
var palm_actual = palm_material.get_shader_parameter("albedo_color") \
if palm_material is ShaderMaterial else null
var palm_expected: Color
match selected_skin.viewmodel_hand_style:
"bare":
palm_expected = selected_skin.viewmodel_skin_color
"android":
palm_expected = selected_skin.viewmodel_accent_color
_:
palm_expected = selected_skin.viewmodel_glove_color
var finger_segments: int = hand.find_children(
"Finger*", "MeshInstance3D", true, false).size()
var thumb_segments: int = hand.find_children(
"Thumb*", "MeshInstance3D", true, false).size()
if not (actual is Color and actual.is_equal_approx(expected)):
printerr("fp_capture: skin swap mismatch actual=%s expected=%s" % [
actual, expected])
elif not (palm_actual is Color and palm_actual.is_equal_approx(palm_expected)):
printerr("fp_capture: hand palette mismatch actual=%s expected=%s" % [
palm_actual, palm_expected])
elif finger_segments != 8 or thumb_segments != 2:
printerr("fp_capture: incomplete anime hand fingers=%d thumb=%d" % [
finger_segments, thumb_segments])
else:
print("fp_capture: live skin swap verified %s style=%s fingers=%d thumb=%d" % [
skin_id, selected_skin.viewmodel_hand_style,
finger_segments, thumb_segments])
+132
View File
@@ -0,0 +1,132 @@
extends SceneTree
## Rendered QA for the movement-fidelity pass.
## Captures rear views of both thrust modes and side views of reverse/braking.
var _frames := 0
var _out_dir := "."
var _player: Node = null
var _model: Node = null
var _jets: PlayerJetVFX = null
var _camera: Camera3D = null
var _phase := 0
var _phase_frame := 0
func _initialize() -> void:
var args := OS.get_cmdline_user_args()
if not args.is_empty():
_out_dir = args[0]
if not _out_dir.is_absolute_path():
_out_dir = ProjectSettings.globalize_path(_out_dir)
var err := DirAccess.make_dir_recursive_absolute(_out_dir)
if err != OK:
printerr("jet_pose_capture: could not create output directory")
quit(err)
return
change_scene_to_file("res://ui/main_menu/main_menu.tscn")
func _process(_delta: float) -> bool:
_frames += 1
if _frames == 35:
var skins = root.get_node_or_null("SkinManager")
if skins:
skins.set_active_skin("miku")
var network = root.get_node_or_null("NetworkManager")
if network and network.has_method("start_singleplayer_match"):
network.start_singleplayer_match(GameMode.DEATHMATCH)
change_scene_to_file("res://scenes/maps/test_level/test_level.tscn")
return false
if _frames < 145:
return false
if _frames == 145:
for candidate in root.find_children("*", "CharacterBody3D", true, false):
if candidate.has_method("get_visual_model") \
and candidate.is_multiplayer_authority():
_player = candidate
break
if not _player:
printerr("jet_pose_capture: player not found")
return true
_player.set_third_person(true)
_model = _player.get_visual_model()
_jets = _player.get_node_or_null("JetVFX")
if not _model or not _jets:
printerr("jet_pose_capture: model or jet rig not found")
return true
_player.set_physics_process(false)
_player.set_process(false)
_player.global_position = Vector3(0, 1.2, 14)
_player.rotation = Vector3.ZERO
_model.set_owner_visible(true)
_jets.set_render_enabled(true)
_jets.set_visual_model(_model)
_camera = Camera3D.new()
_camera.cull_mask &= ~(1 << 19)
current_scene.add_child(_camera)
_camera.current = true
_side_camera()
return false
_phase_frame += 1
match _phase:
0:
_model.update_state("ground", 8.0, false)
_model.set_locomotion(0.0, -1.0, 0.0)
if _phase_frame == 48:
_snap("backward_lean")
_next_phase()
1:
_model.set_locomotion(0.0, 1.0, 0.0)
_model.update_state("ground", 12.0 if _phase_frame < 12 else 5.0, false)
if _phase_frame == 18:
_snap("brake_pose")
_next_phase()
_rear_camera()
2:
_model.update_state("dash", 14.0, false)
_model.set_locomotion(0.0, 1.0, 0.0)
_jets.set_dash(true, Vector2(0.0, 1.0))
if _phase_frame == 32:
_snap("dash_jets")
_next_phase()
3:
_model.update_state("air", 5.0, false)
_model.set_locomotion(0.0, 1.0, 0.0)
_jets.set_dash(false, Vector2.ZERO)
if _phase_frame == 1:
_jets.burst_double_jump()
if _phase_frame == 7:
_snap("double_jump_jets")
unload_current_scene()
_next_phase()
_:
if _phase_frame > 60:
print("JET_POSE_CAPTURE PASS")
return true
return false
func _next_phase() -> void:
_phase += 1
_phase_frame = 0
func _side_camera() -> void:
_camera.global_position = _player.global_position + Vector3(2.7, 0.9, 0.0)
_camera.look_at(_player.global_position + Vector3(0.0, 0.75, 0.0), Vector3.UP)
func _rear_camera() -> void:
_camera.global_position = _player.global_position + Vector3(2.15, 1.0, 2.8)
_camera.look_at(_player.global_position + Vector3(0.0, 0.72, 0.0), Vector3.UP)
func _snap(tag: String) -> void:
var path := _out_dir.path_join(tag + ".png")
var err := root.get_viewport().get_texture().get_image().save_png(path)
if err == OK:
print("jet_pose_capture: saved ", path)
else:
printerr("jet_pose_capture: failed to save ", path)
+1
View File
@@ -0,0 +1 @@
uid://d3kckdjoe77uu
+56
View File
@@ -0,0 +1,56 @@
extends SceneTree
var _failures: Array[String] = []
func _initialize() -> void:
call_deferred("_run")
func _expect(condition: bool, message: String) -> void:
if not condition:
_failures.append(message)
func _run() -> void:
var host := Node3D.new()
root.add_child(host)
var jets := PlayerJetVFX.new()
host.add_child(jets)
await process_frame
_expect(jets.get_flame_count() == 4, "jet rig must contain exactly four flames")
jets.burst_double_jump()
jets._process(0.016)
var up_exhaust := jets.get_exhaust_direction()
_expect(up_exhaust.y < -0.95, "double-jump exhaust must point mostly down")
_expect(up_exhaust.z > 0.0, "double-jump exhaust needs a slight rear cant")
_expect(jets.visible, "double-jump burst must reveal the flames")
jets.set_dash(true, Vector2(0.0, 1.0))
jets._process(0.016)
var dash_exhaust := jets.get_exhaust_direction()
_expect(dash_exhaust.z > 0.90, "forward dash exhaust must point backward")
_expect(dash_exhaust.y < 0.0, "dash exhaust keeps a slight downward cant")
var humanoid := HumanoidModel.new()
host.add_child(humanoid)
await process_frame
humanoid.set_locomotion(0.0, -1.0, 0.0)
humanoid.update_state("ground", 9.0)
humanoid._process(0.1)
_expect(humanoid.root_pivot.rotation.x > 0.0,
"fallback model must lean backward while backpedalling")
humanoid.update_state("ground", 5.0)
humanoid._process(0.1)
_expect(humanoid._brake_left > 0.0, "deceleration must arm the braking pose")
_expect(absf(humanoid.thigh_r_pivot.rotation.x) > 0.05,
"braking pose must plant a lead foot")
if _failures.is_empty():
print("JET_POSE_TEST PASS: four flames, directional thrust, reverse lean, brake pose")
quit(0)
else:
for failure in _failures:
push_error("JET_POSE_TEST FAIL: " + failure)
quit(1)
+1
View File
@@ -0,0 +1 @@
uid://dryddw6j3udbo
+155
View File
@@ -0,0 +1,155 @@
extends SceneTree
## Deterministic third-person run-to-stop capture.
##
## Run:
## godot --path . --resolution 1280x720 \
## --script res://debug/locomotion_stop_capture.gd -- <out_dir> [skin_id]
const DT := 1.0 / 60.0
const RUN_FRAMES := 90
var _out_dir := "."
var _skin_id := "miku"
var _frames := 0
var _player: CharacterBody3D = null
var _model: SkinnedPlayerModel = null
var _camera: Camera3D = null
var _motion_frame := 0
var _stop_frame := -1
var _worst_run_stride_error := 0.0
var _worst_stop_stride := 0.0
var _failures: Array[String] = []
func _initialize() -> void:
Engine.max_fps = 60
var args := OS.get_cmdline_user_args()
if args.size() > 0:
_out_dir = args[0]
if args.size() > 1:
_skin_id = args[1]
if not _out_dir.is_absolute_path():
_out_dir = ProjectSettings.globalize_path(_out_dir)
var mkdir_error := DirAccess.make_dir_recursive_absolute(_out_dir)
if mkdir_error != OK:
printerr("LOCO_STOP could not create '%s' (error %d)" % [
_out_dir, mkdir_error])
quit(mkdir_error)
return
change_scene_to_file("res://ui/main_menu/main_menu.tscn")
func _process(_delta: float) -> bool:
_frames += 1
if _frames == 40:
var skin_manager = root.get_node_or_null("SkinManager")
if skin_manager:
skin_manager.set_active_skin(_skin_id)
var network = root.get_node_or_null("NetworkManager")
if network and network.has_method("start_singleplayer_match"):
network.start_singleplayer_match(GameMode.DEATHMATCH)
change_scene_to_file("res://scenes/maps/test_level/test_level.tscn")
return false
if _frames < 160:
return false
if _player == null:
if not _setup():
_finish()
return true
_motion_frame += 1
if _motion_frame <= RUN_FRAMES:
_model.set_locomotion(0.0, 1.0, 0.0)
_model.update_state("ground", 9.0, false)
_worst_run_stride_error = maxf(_worst_run_stride_error,
absf(_model.locomotion_effective_speed_debug() - 9.0))
if _motion_frame == RUN_FRAMES - 1:
_snap("loco_stop_00_run")
else:
if _stop_frame < 0:
_stop_frame = 0
else:
_stop_frame += 1
_model.set_locomotion(0.0, 0.0, 0.0)
_model.update_state("ground", 0.0, false)
_worst_stop_stride = maxf(_worst_stop_stride,
_model.locomotion_effective_speed_debug())
_model._process(DT)
if _stop_frame >= 0:
match _stop_frame:
3:
_snap("loco_stop_01_idle_blend")
9:
_snap("loco_stop_02_idle_arrival")
20:
_snap("loco_stop_03_idle")
50:
_snap("loco_stop_04_settled")
if _worst_run_stride_error > 0.02:
_failures.append("run stride diverged from physical speed")
if _worst_stop_stride > 0.02:
_failures.append("authored stride did not stop immediately")
if absf(_model.get_brake_debug()) > 0.001:
_failures.append("procedural brake pose is still active")
_finish()
return true
return false
func _setup() -> bool:
for candidate in root.find_children("*", "CharacterBody3D", true, false):
if candidate.has_method("get_visual_model") \
and candidate.is_multiplayer_authority():
_player = candidate
break
if _player == null:
_failures.append("local player was not created")
return false
_model = _player.get_node_or_null("SkinnedModel")
if _model == null or not _model.loaded:
_failures.append("skinned model '%s' was not ready" % _skin_id)
return false
_player.set_physics_process(false)
_player.set_process(false)
_model.set_process(false)
_player.global_position = Vector3(0.0, 1.2, 14.0)
_player.rotation = Vector3.ZERO
_model.set_owner_visible(true)
if _model.has_method("set_weapon"):
_model.set_weapon("res://weapons/ak47.gd")
# The match HUD and the isolated first-person SubViewport are separate
# CanvasLayers, so a cull mask alone cannot keep them out of this shot.
for layer in root.find_children("*", "CanvasLayer", true, false):
layer.visible = false
_camera = Camera3D.new()
_camera.cull_mask &= ~(1 << 19)
current_scene.add_child(_camera)
_camera.global_position = _player.global_position + Vector3(1.8, 0.85, -1.9)
_camera.look_at(_player.global_position + Vector3(0.0, 0.45, 0.0), Vector3.UP)
_camera.current = true
return true
func _snap(tag: String) -> void:
var image := root.get_viewport().get_texture().get_image()
var path := _out_dir.path_join(tag + ".png")
var error := image.save_png(path)
if error == OK:
print("LOCO_STOP saved ", path)
else:
_failures.append("could not save %s (error %d)" % [path, error])
func _finish() -> void:
if _failures.is_empty() and _stop_frame >= 50:
print("LOCO_STOP PASS: run error %.5f, stop stride %.5f, procedural brake %.5f" % [
_worst_run_stride_error, _worst_stop_stride,
_model.get_brake_debug()])
quit(0)
else:
for failure in _failures:
push_error("LOCO_STOP FAIL: " + failure)
quit(1)
+1
View File
@@ -0,0 +1 @@
uid://bhlv24bt2dymi
+8
View File
@@ -192,6 +192,14 @@ func _bounds(node: Node) -> AABB:
var box := vi.get_aabb()
if box.size.length() > 100000.0 or box.size.length() < 0.001:
continue
# Skip declared BACKDROP: distant hills, ridges and anything else that
# is painted scenery rather than level. Sakura Crossing's far ridge sits
# 380 m out, which inflated the merged bounds to about a kilometre and
# put every fallback vantage in orbit — the map photographed as a
# diorama floating in an empty sky, which is the exact failure this
# tool's framing was rewritten to avoid.
if vi.has_meta("preview_backdrop"):
continue
box = vi.global_transform * box
if not any:
out = box
+153
View File
@@ -0,0 +1,153 @@
extends SceneTree
## Verifies the out-of-bounds contract on a real map, in a real match:
##
## 1. leaving the play volume registers the player and starts the countdown
## 2. the warning UI appears, with a timer
## 3. the red overlay deepens as the timer runs down
## 4. the timer is FIVE seconds
## 5. reaching zero kills the player
## 6. coming back inside before zero cancels it
##
## godot --path . --windowed --resolution 800x600 \
## -s res://debug/out_of_bounds_test.gd
##
## It runs windowed rather than headless because the overlay is a real Control
## and the check on its alpha is the only way to know the screen actually reds
## out rather than merely that a number is counting down somewhere.
## Defaults to Sakura Crossing; pass a scene path to check another map, e.g.
## -s res://debug/out_of_bounds_test.gd -- res://scenes/maps/neon_alley/neon_alley.tscn
var MAP := "res://scenes/maps/sakura_crossing/sakura_crossing.tscn"
## How far outside to teleport. Must clear the widest play volume in the game —
## Neon Alley's is 666 m across.
const FAR := 2000.0
var _frames := 0
var _fails := 0
var _phase := "boot"
var _player: Node3D = null
var _area: CombatArea = null
var _alpha_early := -1.0
var _alpha_late := -1.0
var _t_start := 0.0
## By TYPE, not by node name — the overlay is created in code and its name is
## whatever Godot assigns.
func _ui_layer() -> CanvasLayer:
if _area == null:
return null
for c in _area.get_children():
if c is CanvasLayer:
return c
return null
func _check(ok: bool, msg: String) -> void:
if ok:
print(" OK: ", msg)
else:
_fails += 1
print(" FAIL: ", msg)
func _initialize() -> void:
var args := OS.get_cmdline_user_args()
if args.size() > 0:
MAP = args[0]
print("OOB: testing ", MAP)
change_scene_to_file("res://ui/main_menu/main_menu.tscn")
func _find() -> bool:
if current_scene == null:
return false
var areas := current_scene.find_children("*", "Area3D", true, false)
for a in areas:
if a is CombatArea:
_area = a
for p in current_scene.find_children("*", "CharacterBody3D", true, false):
if str(p.name).is_valid_int():
_player = p
return _area != null and _player != null
func _process(delta: float) -> bool:
_frames += 1
match _phase:
"boot":
if _frames == 60:
var nm = root.get_node_or_null("NetworkManager")
if nm and nm.has_method("start_singleplayer_match"):
nm.start_singleplayer_match(GameMode.DEATHMATCH)
change_scene_to_file(MAP)
_phase = "settle"
"settle":
if _frames > 200:
if not _find():
print("OOB: could not find CombatArea or player")
_fails += 1
return _done()
_check(true, "map has a CombatArea and a player")
# Sideways, not upward. Teleporting straight up puts the player
# back INSIDE the volume a few seconds later as they fall, which
# cancels the countdown and makes the test look like a bug in
# the countdown. Far out on X they stay outside however far they
# drop — which is also the real case being tested.
_player.global_position = Vector3(FAR, 30, 0)
var mp := _area.multiplayer
print("OOB: peer=%s uid=%d authority=%d is_server=%s" % [
mp.has_multiplayer_peer(), mp.get_unique_id(),
_player.get_multiplayer_authority(), mp.is_server()])
_phase = "left"
_t_start = 0.0
"left":
_t_start += delta
if _t_start > 0.4:
_check(_area.out_of_bounds_players.has(_player),
"leaving the volume registers the player")
var t: float = 0.0
if _area.out_of_bounds_players.has(_player):
t = _area.out_of_bounds_players[_player].time_left
_check(t > 4.0 and t <= 5.0,
"countdown starts at five seconds (%.2f left after %.1fs)" % [t, _t_start])
var lay := _ui_layer()
_check(lay != null and lay.visible, "warning UI is on screen")
if lay:
for c in lay.get_children():
if c is Label:
_check(c.text.contains("WARNING"),
"warning text reads '%s'" % c.text.split("\n")[0])
if c is ColorRect:
_alpha_early = c.color.a
_phase = "reddening"
"reddening":
_t_start += delta
if _t_start > 3.6:
var lay := _ui_layer()
if lay:
for c in lay.get_children():
if c is ColorRect:
_alpha_late = c.color.a
_check(_alpha_late > _alpha_early,
"screen reddens as the timer runs down (%.2f -> %.2f)"
% [_alpha_early, _alpha_late])
_phase = "dying"
"dying":
_t_start += delta
if _t_start > 6.5:
var dead: bool = "is_dead" in _player and _player.is_dead
var hurt: bool = "health" in _player and float(_player.health) <= 0.0
_check(dead or hurt,
"player is killed by the environment at zero (dead=%s)" % dead)
_check(not _area.out_of_bounds_players.has(_player),
"player is dropped from the countdown once dead")
return _done()
return false
func _done() -> bool:
print("=== OUT OF BOUNDS: %s ===" %
("ALL PASSED" if _fails == 0 else "%d FAILED" % _fails))
return true
+1
View File
@@ -0,0 +1 @@
uid://dduxrlh05tup3
+169
View File
@@ -0,0 +1,169 @@
extends SceneTree
## Is any piece of street furniture standing somewhere it should not be?
##
## godot --headless --path . -s res://debug/prop_placement_audit.gd
##
## Every "there is a bus stop in the middle of the road" bug in this map came
## from a hand-written coordinate that was right when it was written and wrong
## after something moved. Screenshots catch them one at a time, from whichever
## angle happens to be pointed at them; this checks all of them at once.
##
## It re-derives the corridors from the same constants the builder uses rather
## than reading them out of it, so a prop that has drifted onto a carriageway
## fails here even if the builder thinks it placed it correctly.
const MAP := "res://scenes/maps/sakura_crossing/sakura_crossing.tscn"
# Mirrors of the builder's layout constants.
const ROAD_HALF := 4.5
const PAVE_W := 2.6
const RAIL_HALF := 7.0
const SHOP_ST_Z := -30.0
const SHOP_ST_HALF := 4.5
const SHOP_ST_X1 := 52.0
const SOUTH_ST_Z := 20.0
const SOUTH_ST_HALF := 4.5
const EAST_ST_X := 64.0
const EAST_ST_Z := 54.0
const EAST_ST_HALF := 4.5
const HALF_X := 118.0
const CURVE_START_X := 62.0
const CURVE_RADIUS := 60.0
const CURVE_SWEEP := 55.0
## Bodies whose placement is hand-authored and therefore worth checking.
const WATCHED := ["Van", "Truck", "KeiVan", "KeiCar", "Hedge", "BlockWall", "LinesideHut",
"Hoarding", "Vending", "Cherry", "Mound"]
var _fails := 0
var _checked := 0
var _n := 0
var _lv: Node
func _initialize() -> void:
_lv = (load(MAP) as PackedScene).instantiate()
root.add_child(_lv)
## The running line, as a polyline, recomputed here.
func _rail_points() -> Array:
var pts: Array = []
var x := -HALF_X - 40.0
while x < CURVE_START_X:
pts.append(Vector2(x, 0.0))
x += 2.0
var c := Vector2(CURVE_START_X, -CURVE_RADIUS)
var sweep := deg_to_rad(CURVE_SWEEP)
for i in range(40):
var th := sweep * float(i) / 39.0
pts.append(c + Vector2(sin(th), cos(th)) * CURVE_RADIUS)
var ep: Vector2 = pts[pts.size() - 1]
var et := Vector2(cos(sweep), -sin(sweep))
for i in range(60):
pts.append(ep + et * (float(i) * 2.0))
return pts
## Roads a prop must never stand in.
##
## Every one is BOUNDED along its own length. Treating the main street as an
## infinite strip at |x| < 4.5 flagged a hillside three hundred and fifty metres
## out, where the road has not existed for two hundred of them.
const ROAD_OUT := 170.0 # how far a road runs past the wall
const HALF_Z := 88.0
func _road_hit(p: Vector2) -> String:
if absf(p.x) < ROAD_HALF and absf(p.y) < HALF_Z + ROAD_OUT:
return "main street"
if absf(p.y - SOUTH_ST_Z) < SOUTH_ST_HALF and absf(p.x) < HALF_X + ROAD_OUT:
return "south street"
if p.x < SHOP_ST_X1 and p.x > -HALF_X - ROAD_OUT \
and absf(p.y - SHOP_ST_Z) < SHOP_ST_HALF:
return "shopping street"
if p.y > SOUTH_ST_Z and p.y < 80.0 and absf(p.x - EAST_ST_X) < EAST_ST_HALF:
return "east district n-s street"
if p.x > 10.0 and p.x < 108.0 and absf(p.y - EAST_ST_Z) < EAST_ST_HALF:
return "east district e-w street"
return ""
## Distance from a point on a carriageway to its nearest kerb.
func _kerb_gap(p: Vector2) -> float:
var best := 999.0
if absf(p.x) < ROAD_HALF:
best = minf(best, ROAD_HALF - absf(p.x))
if absf(p.y - SOUTH_ST_Z) < SOUTH_ST_HALF:
best = minf(best, SOUTH_ST_HALF - absf(p.y - SOUTH_ST_Z))
if p.x < SHOP_ST_X1 and absf(p.y - SHOP_ST_Z) < SHOP_ST_HALF:
best = minf(best, SHOP_ST_HALF - absf(p.y - SHOP_ST_Z))
if absf(p.x - EAST_ST_X) < EAST_ST_HALF:
best = minf(best, EAST_ST_HALF - absf(p.x - EAST_ST_X))
if absf(p.y - EAST_ST_Z) < EAST_ST_HALF:
best = minf(best, EAST_ST_HALF - absf(p.y - EAST_ST_Z))
return best
func _rail_hit(p: Vector2, margin: float) -> bool:
for q in _rail_points():
if absf(q.x - p.x) > margin or absf(q.y - p.y) > margin:
continue
if q.distance_to(p) < margin:
return true
return false
func _process(_d: float) -> bool:
_n += 1
if _n < 120:
return false
var seen := {}
for body in _lv.find_children("*", "StaticBody3D", true, false):
# Godot suffixes duplicate node names with digits, so an unstripped
# "Cherry2" matched nothing and most of the map went unchecked.
var base := String(body.name)
while base.length() > 0 and base[base.length() - 1].is_valid_int():
base = base.substr(0, base.length() - 1)
if not WATCHED.has(base):
continue
var g: Vector3 = body.global_position
var p := Vector2(g.x, g.z)
# One report per object, not per collision box.
var key := "%s|%.1f|%.1f" % [base, g.x, g.z]
if seen.has(key):
continue
seen[key] = true
_checked += 1
var road := _road_hit(p)
if road != "":
# Vehicles are SUPPOSED to be on the road — parked against the
# kerb. What is wrong is one stranded out in the running lane.
if base == "Van" or base == "Truck" or base == "KeiVan" or base == "KeiCar":
if _kerb_gap(p) > 2.5:
print("AUDIT: FAIL %-12s at (%.1f, %.1f) is parked %.1f m "
% [base, g.x, g.z, _kerb_gap(p)]
+ "from the kerb of the %s" % road)
_fails += 1
else:
print("AUDIT: FAIL %-12s at (%.1f, %.1f) is in the %s carriageway"
% [base, g.x, g.z, road])
_fails += 1
continue
# Nothing but the railway's own furniture belongs within the four-foot.
# 3.9 m clears the sleeper ends (3.75) without flagging lineside kit.
if base != "LinesideHut" and _rail_hit(p, 3.9):
print("AUDIT: FAIL %-12s at (%.1f, %.1f) is on the running line"
% [base, g.x, g.z])
_fails += 1
elif base == "LinesideHut" and _rail_hit(p, 3.9):
print("AUDIT: FAIL %-12s at (%.1f, %.1f) fouls the running line"
% [base, g.x, g.z])
_fails += 1
print("AUDIT: checked %d placed props" % _checked)
print("=== PROP PLACEMENT: %s ===" %
("ALL CLEAR" if _fails == 0 else "%d MISPLACED" % _fails))
return true
+1
View File
@@ -0,0 +1 @@
uid://bl1ks6hnbv6qp
+21 -6
View File
@@ -28,14 +28,25 @@ func _initialize() -> void:
var args := OS.get_cmdline_user_args()
if args.size() > 0:
_out_dir = args[0]
var nm = root.get_node_or_null("NetworkManager")
if nm and nm.has_method("start_singleplayer_match"):
nm.start_singleplayer_match(GameMode.DEATHMATCH)
change_scene_to_file("res://scenes/maps/test_level/test_level.tscn")
if not _out_dir.is_absolute_path():
_out_dir = ProjectSettings.globalize_path(_out_dir)
var mkdir_error := DirAccess.make_dir_recursive_absolute(_out_dir)
if mkdir_error != OK:
printerr("roster_capture: could not create '%s' (error %d)" % [
_out_dir, mkdir_error])
quit(mkdir_error)
return
change_scene_to_file("res://ui/main_menu/main_menu.tscn")
func _process(_delta: float) -> bool:
_frames += 1
if _frames == 40:
var nm = root.get_node_or_null("NetworkManager")
if nm and nm.has_method("start_singleplayer_match"):
nm.start_singleplayer_match(GameMode.DEATHMATCH)
change_scene_to_file("res://scenes/maps/test_level/test_level.tscn")
return false
if _frames < 150:
return false
if _menu == null:
@@ -80,5 +91,9 @@ func _shoot() -> void:
img = img.get_region(Rect2i(int(w * 0.44), int(h * 0.15),
int(w * 0.32), int(h * 0.68)))
var path := "%s/roster_%s.png" % [_out_dir, id]
img.save_png(path)
print("wrote %s" % path)
var save_error := img.save_png(path)
if save_error == OK:
print("wrote %s" % path)
else:
printerr("roster_capture: could not save '%s' (error %d)" % [
path, save_error])
+81
View File
@@ -0,0 +1,81 @@
extends SceneTree
## Structural checks for the map-cohesion pass.
##
## godot --headless --path . -s res://debug/sakura_boundary_audit.gd
const MAP := "res://scenes/maps/sakura_crossing/sakura_crossing.tscn"
const EXPECTED_AREA := Vector3(236.0, 60.0, 176.0)
var _level: Node
var _frames := 0
var _fails := 0
func _initialize() -> void:
_level = (load(MAP) as PackedScene).instantiate()
root.add_child(_level)
func _check(ok: bool, message: String) -> void:
print("BOUNDARY_AUDIT %s %s" % ["OK" if ok else "FAIL", message])
if not ok:
_fails += 1
func _process(_delta: float) -> bool:
_frames += 1
if _frames < 8:
return false
var area: CombatArea = null
for candidate in _level.find_children("*", "CombatArea", true, false):
area = candidate as CombatArea
break
_check(area != null, "combat area exists")
if area != null:
var collision := area.get_child(0) as CollisionShape3D
var box := collision.shape as BoxShape3D if collision != null else null
_check(box != null and box.size.is_equal_approx(EXPECTED_AREA),
"combat area matches the visible wall rectangle")
var gates: Array[Node] = []
for body in _level.find_children("*", "StaticBody3D", true, false):
if body.has_meta("boundary_gate"):
gates.append(body)
_check(gates.size() == 60, "six road closures have ten beam segments each")
var kei_cars := _level.find_children("KeiCar*", "Node3D", true, false)
_check(not kei_cars.is_empty(), "kei hatchbacks are present")
var house_meshes: Array[MeshInstance3D] = []
for body in _level.find_children("HouseBody*", "StaticBody3D", true, false):
var mesh := body.get_child(1) as MeshInstance3D
if mesh != null:
house_meshes.append(mesh)
var overlaps := 0
for i in range(house_meshes.size()):
for j in range(i + 1, house_meshes.size()):
var overlap := _global_aabb(house_meshes[i]).intersection(_global_aabb(house_meshes[j]))
if overlap.size.x > 0.02 and overlap.size.y > 0.02 and overlap.size.z > 0.02:
overlaps += 1
if overlaps <= 8:
print("BOUNDARY_AUDIT HOUSE_OVERLAP %s %s size=%s" % [
house_meshes[i].get_path(), house_meshes[j].get_path(), overlap.size])
_check(overlaps == 0, "detached house bodies do not overlap")
print("=== SAKURA BOUNDARY AUDIT: %s ===" %
("ALL CLEAR" if _fails == 0 else "%d FAILED" % _fails))
quit(0 if _fails == 0 else 1)
return true
func _global_aabb(mesh: MeshInstance3D) -> AABB:
var local := mesh.get_aabb()
var result := AABB(mesh.global_transform * local.position, Vector3.ZERO)
for x in 2:
for y in 2:
for z in 2:
var corner := local.position + Vector3(
local.size.x * float(x), local.size.y * float(y), local.size.z * float(z))
result = result.expand(mesh.global_transform * corner)
return result
+1
View File
@@ -0,0 +1 @@
uid://p6fiv1r1hhef
+96
View File
@@ -0,0 +1,96 @@
extends SceneTree
## Photograph Sakura Crossing from a fixed set of authored viewpoints.
##
## godot --path . --windowed --resolution 1600x900 \
## -s res://debug/sakura_detail_capture.gd -- <out_dir>
##
## `visual_capture.gd` shoots the real game through a real match, which is the
## right regression tool but the wrong review tool: it spawns the player at
## random, so half its frames are of a rooftop or a wall and no two runs compare.
## Reviewing DETAIL needs the opposite — the same handful of angles every time,
## chosen because each one is where a particular piece of work has to hold up.
const SCENE := "res://scenes/maps/sakura_crossing/sakura_crossing.tscn"
const SETTLE_FRAMES := 120
## [name, eye, look_at, fov]
const VIEWS := [
["crossing", Vector3(0, 1.7, 20.0), Vector3(0, 2.2, -16.0), 70.0],
["shopfront", Vector3(-30.0, 1.7, -29.0), Vector3(-24.0, 3.2, -22.0), 62.0],
["shopping_street", Vector3(-4.0, 1.7, -30.0), Vector3(46.0, 4.0, -30.0), 68.0],
["station", Vector3(30.0, 2.6, 14.0), Vector3(46.0, 2.4, 9.0), 66.0],
["platform", Vector3(52.0, 2.2, 8.8), Vector3(30.0, 2.6, 8.4), 66.0],
["torii", Vector3(-22.0, 1.7, 34.0), Vector3(-22.0, 3.0, 56.0), 68.0],
["alley", Vector3(-52.0, 1.7, -11.2), Vector3(10.0, 2.6, -11.2), 70.0],
["footbridge", Vector3(-40.0, 7.6, 8.0), Vector3(30.0, 3.0, -1.0), 72.0],
["road_paint", Vector3(0.0, 1.7, 16.0), Vector3(0.0, 0.0, 6.0), 66.0],
["houses", Vector3(-8.0, 1.7, 26.0), Vector3(-24.0, 3.0, 33.0), 66.0],
["aerial", Vector3(-30.0, 150.0, 150.0), Vector3(20.0, 0.0, -10.0), 62.0],
["rail_curve", Vector3(40.0, 18.0, 30.0), Vector3(95.0, 2.0, -22.0), 66.0],
["east_district", Vector3(64.0, 1.8, 30.0), Vector3(64.0, 3.0, 78.0), 68.0],
["east_street", Vector3(20.0, 1.8, 54.0), Vector3(104.0, 4.0, 54.0), 66.0],
# Looking OUT from inside the map, over the boundary, which is the only
# angle that tells you whether the world continues past the wall.
["beyond_west", Vector3(-104.0, 3.0, 20.0), Vector3(-200.0, 10.0, 20.0), 70.0],
["beyond_south", Vector3(0.0, 3.0, 74.0), Vector3(0.0, 12.0, 200.0), 70.0],
["catenary", Vector3(20.0, 3.0, 12.0), Vector3(-30.0, 6.5, 0.0), 60.0],
["vehicles", Vector3(-30.0, 2.2, 27.0), Vector3(-8.0, 1.4, 22.0), 55.0],
["rail_east_wall", Vector3(96.0, 9.0, -6.0), Vector3(126.0, 2.0, -38.0), 66.0],
["north_park", Vector3(-30.0, 3.0, -78.0), Vector3(30.0, 4.0, -60.0), 70.0],
["north_park_overview", Vector3(-34.0, 32.0, -45.0), Vector3(-20.0, 0.0, -70.0), 68.0],
["pond_bridge", Vector3(-58.0, 2.5, -64.0), Vector3(-34.0, 1.0, -73.0), 66.0],
["park_road", Vector3(60.0, 6.0, -22.0), Vector3(100.0, 1.0, -60.0), 68.0],
["shrine_precinct", Vector3(-22.0, 2.0, 58.0), Vector3(-22.0, 4.0, 84.0), 70.0],
["shrine_lawn", Vector3(-70.0, 8.0, 42.0), Vector3(-25.0, 2.0, 53.0), 68.0],
["playground", Vector3(-72.0, 2.0, 36.0), Vector3(-72.0, 1.4, 52.0), 70.0],
["playground_reverse", Vector3(-92.0, 5.0, 63.0), Vector3(-72.0, 1.5, 51.0), 68.0],
["shrine_intersection", Vector3(-2.0, 2.0, 54.0), Vector3(20.0, 1.0, 54.0), 66.0],
["sidewalk_corner", Vector3(13.0, 10.0, 7.0), Vector3(5.9, 0.2, 14.1), 52.0],
["street_sign", Vector3(3.5, 1.8, -18.0), Vector3(8.15, 2.0, -21.85), 46.0],
["bikes", Vector3(20.0, 2.0, 8.0), Vector3(32.0, 1.2, 13.0), 55.0],
]
var _out_dir := "."
func _initialize() -> void:
var args := OS.get_cmdline_user_args()
if args.size() > 0:
_out_dir = args[0]
if not _out_dir.is_absolute_path():
_out_dir = ProjectSettings.globalize_path(_out_dir)
DirAccess.make_dir_recursive_absolute(_out_dir)
_run()
func _run() -> void:
await process_frame
var packed: PackedScene = load(SCENE)
var level: Node = packed.instantiate()
root.add_child(level)
for _i in SETTLE_FRAMES:
await process_frame
var cam := Camera3D.new()
cam.far = 1200.0
level.add_child(cam)
cam.current = true
for v in VIEWS:
cam.fov = float(v[3])
cam.global_position = v[1]
cam.look_at(v[2], Vector3.UP)
# Three frames: one for the transform, one for the frame drawn with it,
# and one because the ink pass reads a screen copy of the frame before.
for _i in 3:
await process_frame
var img := root.get_texture().get_image()
var path := "%s/%s.png" % [_out_dir, v[0]]
img.save_png(path)
print("sakura_detail: saved ", path)
level.queue_free()
for _i in 6:
await process_frame
quit(0)
+1
View File
@@ -0,0 +1 @@
uid://5hx0k7ohj27h
+231
View File
@@ -0,0 +1,231 @@
extends SceneTree
## Requirement-level audit for the park/shrine/intersection follow-up pass.
const MAP := "res://scenes/maps/sakura_crossing/sakura_crossing.tscn"
const ROAD_HALF := 4.5
const PAVE_W := 2.6
const SHOP_ST_Z := -30.0
const SOUTH_ST_Z := 20.0
const EAST_ST_X := 64.0
const EAST_ST_Z := 54.0
const STREET_HALF := 4.5
var _level: Node
var _frames := 0
var _fails := 0
func _initialize() -> void:
_level = (load(MAP) as PackedScene).instantiate()
root.add_child(_level)
func _ok(condition: bool, message: String) -> void:
if condition:
print("FOLLOWUP_AUDIT OK ", message)
else:
print("FOLLOWUP_AUDIT FAIL ", message)
_fails += 1
func _all_nodes() -> Array[Node]:
var result: Array[Node] = []
var queue: Array[Node] = [_level]
while not queue.is_empty():
var node: Node = queue.pop_front()
result.append(node)
for child in node.get_children():
queue.append(child)
return result
func _base_name(node: Node) -> String:
var value := String(node.name)
while value.length() > 0 and value[value.length() - 1].is_valid_int():
value = value.substr(0, value.length() - 1)
return value
func _named(name: String) -> Array[Node]:
var result: Array[Node] = []
for node in _all_nodes():
if _base_name(node) == name:
result.append(node)
return result
func _box_size(node: Node) -> Vector3:
var collisions := node.find_children("*", "CollisionShape3D", true, false)
if collisions.is_empty():
return Vector3.ZERO
var collision := collisions[0] as CollisionShape3D
if collision == null or not collision.shape is BoxShape3D:
return Vector3.ZERO
return (collision.shape as BoxShape3D).size
func _on_road_or_pavement(p: Vector2) -> bool:
var paved_half := ROAD_HALF + PAVE_W + 0.30
if absf(p.x) <= paved_half:
return true
if absf(p.y - SOUTH_ST_Z) <= paved_half:
return true
if p.x <= 52.0 and absf(p.y - SHOP_ST_Z) <= paved_half:
return true
if p.x >= ROAD_HALF - 0.2 and p.x <= 108.0 \
and absf(p.y - EAST_ST_Z) <= paved_half:
return true
if p.y >= SOUTH_ST_Z and p.y <= 80.0 \
and absf(p.x - EAST_ST_X) <= paved_half:
return true
return false
func _audit_signs(nodes: Array[Node]) -> void:
var signs := 0
for node in nodes:
if not node.has_meta("ground_sign"):
continue
signs += 1
var g := (node as Node3D).global_position
_ok(not _on_road_or_pavement(Vector2(g.x, g.z)),
"ground sign %s is clear of roads, crosswalks and pavements" % node.name)
_ok(signs == 6, "all six freestanding sign posts are tagged and checked")
func _audit_sidewalk_connectors(nodes: Array[Node]) -> void:
var connectors := 0
var full_size := true
for node in nodes:
if not node.has_meta("sidewalk_connector"):
continue
connectors += 1
var collisions := node.find_children("*", "CollisionShape3D", true, false)
var collision: CollisionShape3D = collisions[0] if not collisions.is_empty() else null
if collision == null or not collision.shape is BoxShape3D:
full_size = false
continue
var size := (collision.shape as BoxShape3D).size
if size.x < PAVE_W + 0.59 or size.z < PAVE_W + 0.59:
full_size = false
_ok(connectors == 10, "eight main-junction and two shrine-side corner connectors exist")
_ok(full_size, "every tactile apron reaches the adjoining pavement on both axes")
func _audit_east_link(nodes: Array[Node]) -> void:
var links: Array[Node] = []
for node in nodes:
if node.has_meta("east_street_link"):
links.append(node)
_ok(links.size() == 1, "the shrine-side neighbourhood road has one continuous link slab")
if links.size() == 1:
var body := links[0] as StaticBody3D
var collisions := body.find_children("*", "CollisionShape3D", true, false)
var collision: CollisionShape3D = collisions[0] if not collisions.is_empty() else null
var size := (collision.shape as BoxShape3D).size
var left_edge := body.global_position.x - size.x * 0.5
_ok(left_edge <= ROAD_HALF, "the neighbourhood branch physically reaches the main road")
var query := PhysicsRayQueryParameters3D.create(
Vector3(6.0, 2.0, EAST_ST_Z), Vector3(6.0, -1.0, EAST_ST_Z))
var hit: Dictionary = _level.get_world_3d().direct_space_state.intersect_ray(query)
var collider: Node = hit.get("collider") as Node
_ok(not hit.is_empty() and collider != null and _base_name(collider) == "EastStreetLink",
"the former gap is road surface, not the old sidewalk barrier")
func _audit_park_and_playground() -> void:
_ok(_named("PondBridge").size() == 1, "north park has a connected pond-loop bridge")
_ok(_named("PicnicTable").size() >= 3, "park system has multiple social/picnic nodes")
_ok(_named("ParkLamp").size() >= 8, "park paths have a consistent lighting rhythm")
_ok(_named("ParkBin").size() >= 4, "park entrances and activity nodes have service assets")
_ok(_named("SwingBeam").size() == 1 and _named("SwingSeat").size() == 2 \
and _named("SwingChain").size() == 4,
"two swing seats align to four chains and one overhead beam")
var beams := _named("SwingBeam")
var seats := _named("SwingSeat")
var chains := _named("SwingChain")
if beams.size() == 1 and seats.size() == 2 and chains.size() == 4:
var beam := beams[0] as Node3D
var beam_size := _box_size(beam)
var beam_left := beam.global_position.x - beam_size.x * 0.5
var beam_right := beam.global_position.x + beam_size.x * 0.5
var chains_under_beam := true
for chain in chains:
var cp := (chain as Node3D).global_position
chains_under_beam = chains_under_beam \
and cp.x >= beam_left and cp.x <= beam_right \
and absf(cp.z - beam.global_position.z) < 0.01 \
and absf((cp.y + 1.55 * 0.5) - (beam.global_position.y - beam_size.y * 0.5)) < 0.08
_ok(chains_under_beam, "every swing chain lands under and touches the common top beam")
var seats_have_pairs := true
for seat in seats:
var sp := (seat as Node3D).global_position
var matching := 0
for chain in chains:
var cp := (chain as Node3D).global_position
if absf(cp.x - sp.x) <= 0.55 and absf(cp.z - sp.z) < 0.01:
matching += 1
seats_have_pairs = seats_have_pairs and matching == 2
_ok(seats_have_pairs, "each swing seat is centered beneath exactly two chains")
_ok(_named("SlidePlatform").size() == 1 and _named("SlideSlope").size() == 1,
"slide platform and slope are both present")
var platforms := _named("SlidePlatform")
var slopes := _named("SlideSlope")
if platforms.size() == 1 and slopes.size() == 1:
var platform := platforms[0] as Node3D
var slope := slopes[0] as Node3D
var platform_size := _box_size(platform)
var slope_size := _box_size(slope)
var high_end := slope.global_transform * Vector3(0.0, 0.0, slope_size.z * 0.5)
var platform_edge := Vector3(platform.global_position.x,
platform.global_position.y, platform.global_position.z - platform_size.z * 0.5)
_ok(Vector2(high_end.y, high_end.z).distance_to(
Vector2(platform_edge.y, platform_edge.z)) < 0.16,
"slide incline meets the platform edge at the same height and z datum")
_ok(_named("Sandpit").size() == 1 and _named("Seesaw").size() == 1 \
and _named("ClimbingFrame").size() == 1,
"playground contains distinct sand, seesaw and climbing zones")
var cherries: Array[Node] = []
var cedars: Array[Node] = []
for node in _named("Cherry"):
var p := (node as Node3D).global_position
if p.x >= -96.0 and p.x <= 92.0 and p.z < -70.0:
cherries.append(node)
for node in _named("Cedar"):
var p := (node as Node3D).global_position
if p.x >= -96.0 and p.x <= 92.0 and p.z <= -78.0 and p.z >= -89.0:
cedars.append(node)
var min_gap := INF
for cherry in cherries:
var cp := (cherry as Node3D).global_position
for cedar in cedars:
var ep := (cedar as Node3D).global_position
min_gap = minf(min_gap, Vector2(cp.x, cp.z).distance_to(Vector2(ep.x, ep.z)))
_ok(not cherries.is_empty() and not cedars.is_empty() and min_gap > 8.0,
"north-park cherry and evergreen planting bands are spatially separated")
func _audit_shrine() -> void:
_ok(_named("Komainu").size() >= 4, "outer and inner shrine thresholds have guardian pairs")
_ok(_named("ShrineBanner").size() >= 4, "shrine approach and precinct have banner rhythm")
_ok(_named("OmikujiRack").size() == 1, "precinct has a dedicated fortune-tying rack")
_ok(_named("SacredTree").size() == 1 and _named("Hokora").size() == 1,
"shrine lawn has a sacred tree and auxiliary shrine destination")
func _process(_delta: float) -> bool:
_frames += 1
if _frames < 120:
return false
var nodes := _all_nodes()
_audit_signs(nodes)
_audit_sidewalk_connectors(nodes)
_audit_east_link(nodes)
_audit_park_and_playground()
_audit_shrine()
print("=== SAKURA FOLLOW-UP AUDIT: %s ===" %
("ALL CLEAR" if _fails == 0 else "%d FAILED" % _fails))
quit(0 if _fails == 0 else 1)
return true
+1
View File
@@ -0,0 +1 @@
uid://c01r2mnvkv5f7
+8
View File
@@ -54,6 +54,14 @@ func _build_geometry() -> void:
_build_speed_corridor()
_build_target_dummy()
_build_lighting()
_build_combat_area()
## The play volume, generous around the 80x80 arena. Subclasses that build a
## bigger map override this — dust2 has its own, and add_to() is a no-op if a
## CombatArea of the same name already exists.
func _build_combat_area() -> void:
CombatArea.add_to(self, Vector3(140, 80, 140), Vector3(0, 30, 0))
# ── Utility ───────────────────────────────────────────────────────────────────
+82 -48
View File
@@ -1,41 +1,36 @@
extends SceneTree
## Dev tool: how abruptly does the model change posture when you start running?
## Regression for the original "slide, then walk, then run" locomotion bug.
##
## godot --headless --path . -s res://debug/transition_check.gd -- [skin_glb]
##
## Accelerates from a standstill the way the movement code does, then stops, and
## logs the clip in play plus the body's forward lean each frame. Reports the
## worst single-frame change in lean and how long the lean took to arrive.
##
## Written for "idle to running snaps the character leaning forward". The lean
## is procedural (ShooterPoseModifier), so it does NOT come from the clip
## crossfade and is not visible in an animation-blend graph — it was driven by a
## normalised input direction that steps 0 -> 1 the instant a key goes down,
## planting a full run posture in ~0.1 s while the Idle->Run crossfade still had
## 0.4 s to run.
## Ground acceleration is the same exponential response as state_ground.gd.
## The test proves that the authored BlendSpace receives that physical speed on
## the same frame, including the first high-acceleration frame and the stop
## frame. It also proves that the removed procedural lean/brake layer stays off.
const ACCEL := 18.0 # m/s^2, roughly the controller's ground acceleration
const TOP := 9.0 # m/s
const RESPONSE := 60.0
const TOP := 11.0
const DT := 1.0 / 60.0
const RUN_FRAMES := 24
var _t := 0.0
var _frames := 0
var _model: SkinnedPlayerModel = null
var _speed := 0.0
var _prev_lean := 0.0
var _worst_step := 0.0
var _clip_changes: Array = []
var _last_clip := ""
var _t_10 := -1.0
var _t_90 := -1.0
var _peak := 0.0
var _first_speed := -1.0
var _first_stride := -1.0
var _worst_error := 0.0
var _stop_stride := -1.0
var _manual_tick := false
var _foot_bone := -1
var _foot_rotation_start := Quaternion.IDENTITY
var _foot_rotation_delta := 0.0
func _initialize() -> void:
var args := OS.get_cmdline_user_args()
var path: String = args[0] if args.size() > 0 \
else "res://assets/characters/skins/taila.glb"
else "res://assets/characters/skins/miku.glb"
var scene := Node3D.new()
root.add_child(scene)
current_scene = scene
@@ -46,36 +41,75 @@ func _initialize() -> void:
func _process(_delta: float) -> bool:
_frames += 1
if _frames < 8:
return false
if not _model.loaded:
if _frames < 8 or not _model.loaded:
return false
if not _manual_tick:
_model.set_process(false)
_manual_tick = true
_foot_bone = _model._role_bone("foot.L",
["foot.L", "LeftFoot", "Left ankle"])
# Accelerate for 1.5 s, then coast at top speed to 2.5 s.
_t += DT
_speed = minf(TOP, _speed + ACCEL * DT) if _t < 2.5 else 0.0
_model.update_state("ground", _speed, false)
var sample := _frames - 8
if sample < RUN_FRAMES:
var response := 1.0 - exp(-RESPONSE * DT)
_speed = lerpf(_speed, TOP, response)
else:
_speed = 0.0
# Same order as the live controller: direction first, then physical speed.
_model.set_locomotion(0.0, 1.0 if _speed > 0.01 else 0.0, 0.0)
_model.update_state("ground", _speed, false)
_model._process(DT)
var lean: float = _model.get_lean_debug()
if _last_clip != _model.current_clip_debug():
_last_clip = _model.current_clip_debug()
_clip_changes.append("%.2fs %s @ %.1f m/s" % [_t, _last_clip, _speed])
if _t < 2.5:
_peak = maxf(_peak, lean)
if _t_10 < 0.0 and lean > 0.1:
_t_10 = _t
if _t_90 < 0.0 and lean > 0.9:
_t_90 = _t
_worst_step = maxf(_worst_step, absf(lean - _prev_lean))
_prev_lean = lean
var stride := _model.locomotion_effective_speed_debug()
_worst_error = maxf(_worst_error, absf(stride - _speed))
if _foot_bone >= 0:
var foot_rotation := _model.skeleton.get_bone_pose_rotation(_foot_bone)
if sample == 1:
_foot_rotation_start = foot_rotation
elif sample > 1 and sample < RUN_FRAMES:
_foot_rotation_delta = maxf(_foot_rotation_delta,
_foot_rotation_start.angle_to(foot_rotation))
if sample == 0:
_first_speed = _speed
_first_stride = stride
if sample == RUN_FRAMES:
_stop_stride = stride
if _t > 3.5:
print("\n=== idle -> run transition ===")
print(" clip changes: " + ", ".join(_clip_changes))
print(" lean reached 10%% at %.2fs, 90%% at %.2fs (peak %.2f)" % [_t_10, _t_90, _peak])
print(" worst single-frame lean change: %.4f (%.2f per second at 60fps)"
% [_worst_step, _worst_step * 60.0])
print(" a snap looks like ~0.10s to 90%%; a blended move is ~0.6s or more\n")
if sample > RUN_FRAMES + 2:
print("\n=== velocity-driven locomotion ===")
print(" first frame: body %.3f m/s, authored stride %.3f m/s" \
% [_first_speed, _first_stride])
print(" worst stride/speed error: %.5f m/s" % _worst_error)
print(" stop-frame stride: %.5f m/s" % _stop_stride)
print(" animated foot rotation delta: %.5f rad" % _foot_rotation_delta)
print(" procedural lean %.5f, brake %.5f" \
% [_model.get_lean_debug(), _model.get_brake_debug()])
var failures: Array[String] = []
if _first_speed < 6.0:
failures.append("test did not reproduce high first-frame acceleration")
if absf(_first_stride - _first_speed) > 0.02:
failures.append("legs did not receive first-frame physical speed")
if _worst_error > 0.02:
failures.append("authored stride diverged from physical speed")
if _stop_stride > 0.02:
failures.append("legs did not reach Idle on the stop frame")
if _foot_bone < 0:
failures.append("could not resolve a foot bone")
elif _foot_rotation_delta < 0.03:
failures.append("runtime foot pose stayed static")
if absf(_model.get_lean_debug()) > 0.02:
failures.append("procedural locomotion lean is still active")
if absf(_model.get_brake_debug()) > 0.02:
failures.append("procedural stop pose is still active")
if failures.is_empty():
print("TRANSITION_CHECK PASS: same-frame, authored locomotion\n")
quit(0)
else:
for failure in failures:
push_error("TRANSITION_CHECK FAIL: " + failure)
quit(1)
return true
return false
+82 -142
View File
@@ -1,162 +1,102 @@
extends SceneTree
## Dev tool: do the LEGS point where the character is going, and does the CHEST
## stay pointed where the player is aiming?
## Regression for authored directional locomotion.
##
## godot --headless --path . -s res://debug/travel_dir_check.gd -- [skin_glb]
##
## The clip library has one forward locomotion cycle and no strafe or backpedal
## clips, so nothing in the animation itself says which way the character is
## travelling. SkinnedPlayerModel._update_travel says it by yawing the hips onto
## the travel direction and unwinding that yaw up the spine.
##
## The number that matters is LEGS OFF BY: how far the stride points from the
## direction the character is actually travelling. A backpedal is achieved by
## running the cycle BACKWARDS with the hips near square, so it is scored on
## where the feet go, not on where the hips face.
##
## CHEST is reported for information only, and is not a pass/fail. Most of it is
## the clip's own torso twist — a run cycle counter-rotates the chest against the
## hips by tens of degrees twice a stride — and averaging over a stride only
## partly removes it. It is also not what keeps the gun on target: the rifle hold
## places the weapon from the SHOULDER and the aim direction and then solves the
## arms onto it, so the aim survives whatever the torso is doing. Read this
## column as "does the upper body still look square-ish", nothing stronger.
## The old test expected one forward run to be procedurally yawed or reversed.
## The runtime now blends separate forward/back/left/right source cycles. This
## gate verifies that physical direction enters that authored BlendSpace, stride
## speed stays calibrated, and the feet actually move in every cardinal case.
## label, strafe, forward, the direction of travel this should read as (degrees
## about the character's up axis, 0 = straight ahead)
## The real controller ground speed, not a convenient library-preview speed.
## This is the failure mode the test exists to catch: the old lateral cycle was
## technically "matched" at 5 m/s but exploded to 6.1x playback in gameplay.
const SPEED := 11.0
const SETTLE_FRAMES := 18
const SAMPLE_FRAMES := 24
const CASES := [
["forward", 0.0, 1.0, 0.0],
["strafe right", 1.0, 0.0, -90.0],
["strafe left", -1.0, 0.0, 90.0],
["fwd-right", 0.7, 0.7, -45.0],
["fwd-left", -0.7, 0.7, 45.0],
["backpedal", 0.0, -1.0, 180.0],
["back-right", 0.7, -0.7, -135.0],
["forward", 0.0, 1.0, "Sprint"],
["backward", 0.0, -1.0, "RunBackward"],
["left", -1.0, 0.0, "RunLeft"],
["right", 1.0, 0.0, "RunRight"],
["forward-right", 1.0, 1.0, ""],
["back-left", -1.0, -1.0, ""],
]
const SETTLE := 70
## Frames averaged at the end of each case — comfortably more than one stride.
const MEASURE_OVER := 40
var _frames := 0
var _model: SkinnedPlayerModel = null
var _probe = null
var _case := 0
var _rows: Array = []
var _hips_sum := 0.0
var _chest_sum := 0.0
var _n := 0.0
var _failures := 0
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
await process_frame
var args := OS.get_cmdline_user_args()
var path: String = args[0] if args.size() > 0 \
var path: String = args[0] if not args.is_empty() \
else "res://assets/characters/skins/taila.glb"
var scene := Node3D.new()
root.add_child(scene)
current_scene = scene
_model = SkinnedPlayerModel.new()
_model.model_path = path
scene.add_child(_model)
var model := SkinnedPlayerModel.new()
model.model_path = path
scene.add_child(model)
for _frame in 60:
await process_frame
if not model.loaded or model.skeleton == null:
_expect(false, "the authored character loads")
_done()
return
var foot := model._role_bone("foot.L", ["foot.L", "LeftFoot", "Left ankle"])
_expect(foot >= 0, "a foot bone resolves")
for test_case in CASES:
var direction := Vector2(float(test_case[1]), float(test_case[2])).normalized()
for _frame in SETTLE_FRAMES:
model.set_locomotion(direction.x, direction.y, 0.0)
model.update_state("ground", SPEED, false)
await process_frame
var blend := model.locomotion_blend_debug()
_expect(blend.length() > 0.01 and blend.normalized().dot(direction) > 0.999,
"%s drives the authored BlendSpace in the physical direction"
% String(test_case[0]))
_expect(absf(model.locomotion_effective_speed_debug() - SPEED) < 0.02,
"%s authored stride matches %.1f m/s"
% [String(test_case[0]), SPEED])
var expected_clip := String(test_case[3])
if not expected_clip.is_empty():
_expect(model.current_clip_debug() == expected_clip,
"%s selects %s" % [String(test_case[0]), expected_clip])
if foot >= 0:
var start := model.skeleton.get_bone_pose_rotation(foot)
var greatest := 0.0
for _frame in SAMPLE_FRAMES:
model.set_locomotion(direction.x, direction.y, 0.0)
model.update_state("ground", SPEED, false)
await process_frame
greatest = maxf(greatest, start.angle_to(
model.skeleton.get_bone_pose_rotation(foot)))
_expect(greatest > 0.03,
"%s uses a moving authored foot cycle (%.3f rad)"
% [String(test_case[0]), greatest])
model.queue_free()
_done()
## Bone poses read from INSIDE the modifier pass — Godot restores them after it,
## so anything read later is the animation alone with the pose layer missing.
class PoseProbe extends SkeletonModifier3D:
var pose: Array = []
func _process_modification() -> void:
var skel := get_skeleton()
if skel == null:
return
pose.resize(skel.get_bone_count())
for i in skel.get_bone_count():
pose[i] = skel.get_bone_global_pose(i)
func _process(_delta: float) -> bool:
_frames += 1
if _frames < 8 or not _model.loaded:
return false
var skel: Skeleton3D = _model.skeleton
if skel == null:
return true
if _probe == null:
_probe = PoseProbe.new()
_probe.name = "TravelProbe"
skel.add_child(_probe)
return false
var c: Array = CASES[_case]
# Run fast enough that the lean, which scales with speed, is fully in.
_model.update_state("ground", 9.0, false)
_model.set_locomotion(c[1], c[2], 0.0)
# AVERAGED over the tail of each case, not sampled at one instant.
#
# A run cycle twists the torso against the hips by tens of degrees twice per
# stride, all of it authored and correct. Reading a single frame therefore
# measures wherever the clip happened to be, and the numbers swing far wider
# than anything the pose layer does — a backpedal read as 50 degrees of chest
# yaw with the hips almost square, which is the clip, not the layer.
var phase: int = (_frames - 9) % SETTLE
if phase >= SETTLE - MEASURE_OVER and _probe.pose.size() == skel.get_bone_count():
_hips_sum += _yaw_of(skel, "hips")
_chest_sum += _yaw_of(skel, "spine_top")
_n += 1.0
if phase != SETTLE - 1:
return false
if _probe.pose.size() == skel.get_bone_count():
_rows.append([c[0], _hips_sum / _n, _chest_sum / _n,
float(c[3]), _model.stride_reversed_debug()])
_hips_sum = 0.0
_chest_sum = 0.0
_n = 0.0
_case += 1
if _case >= CASES.size():
_report()
return true
return false
## Yaw of a bone away from its own rest orientation, in degrees, about the
## character's up axis. Measured against REST rather than against the parent, so
## it reads as "how far round from square" whatever the rig's bone axes are.
func _yaw_of(skel: Skeleton3D, which: String) -> float:
var roles: Dictionary = _model._rig_info.get("roles", {})
var name := ""
if which == "hips":
name = String(roles.get("hips", "DEF-hips"))
func _expect(ok: bool, description: String) -> void:
if ok:
print(" OK: ", description)
else:
var spine: Array = roles.get("spine", [])
# The chest: the last spine bone that is not the neck or the head.
var neck := String(roles.get("neck", ""))
var head := String(roles.get("head", ""))
for n in spine:
if String(n) != neck and String(n) != head:
name = String(n)
var b := skel.find_bone(name)
if b < 0:
return 0.0
var now: Basis = (_probe.pose[b] as Transform3D).basis
var rest: Basis = skel.get_bone_global_rest(b).basis
var delta := rest.inverse() * now
# The character faces +Z; how far that axis has swung in the ground plane.
var f: Vector3 = rest * (delta * (rest.inverse() * Vector3(0, 0, 1)))
return rad_to_deg(atan2(f.x, f.z))
printerr(" FAIL: ", description)
_failures += 1
func _report() -> void:
print("\n=== does the body say which way it is going? ===")
print(" %-13s %9s %9s %9s %s" % [
"input", "hips", "chest", "want legs", "stride"])
for r in _rows:
# A reversed stride points the feet the opposite way to the hips.
var legs: float = r[1] + (180.0 if r[4] else 0.0)
var err: float = absf(wrapf(legs - r[3], -180.0, 180.0))
print(" %-13s %+8.1f° %+8.1f° %+8.1f° %-8s legs off by %5.1f°%s" % [
r[0], r[1], r[2], r[3], "reversed" if r[4] else "forward", err,
" CHEST OFF AIM" if absf(r[2]) > 25.0 else ""])
print("\n 'legs off by' is how far the stride points from the travel")
print(" direction. The hips are capped (see MAX_TRAVEL_YAW), so a pure")
print(" sidestep is expected to fall short — a shooter keeps its aim.\n")
func _done() -> void:
print("\n=== AUTHORED DIRECTION SUMMARY ===")
print("Failures: %d" % _failures)
quit(1 if _failures > 0 else 0)
+232
View File
@@ -0,0 +1,232 @@
extends SceneTree
## Regression gate for authored wall running and physical grapple posing.
##
## Wall running must select side-specific authored performances, never a ground
## run under another name. Grapple must keep its authored airborne base while
## the live arm constraint lands the palm on the cable ray and body alignment
## responds to pendulum energy.
const SETTLE_FRAMES := 90
const SAMPLE_FRAMES := 30
const WALL_RUN_SPEED := 13.0
const WALL_RUN_REFERENCE_SPEED := 8.25
var _failures := 0
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
await process_frame
var args := OS.get_cmdline_user_args()
var path: String = args[0] if not args.is_empty() \
else "res://assets/characters/skins/taila.glb"
var scene := Node3D.new()
root.add_child(scene)
current_scene = scene
var model := SkinnedPlayerModel.new()
model.model_path = path
scene.add_child(model)
for _frame in 60:
await process_frame
if not model.loaded or model.skeleton == null:
_expect(false, "the authored character loads")
_done()
return
var foot := model._role_bone("foot.L", ["foot.L", "LeftFoot", "Left ankle"])
var hips := model._role_bone("hips", ["hips", "Hips", "pelvis"])
var left_hand := model._role_bone("hand.L", ["hand.L", "LeftHand", "hand_l"])
var right_hand := model._role_bone("hand.R", ["hand.R", "RightHand", "hand_r"])
_expect(foot >= 0, "a foot bone resolves")
_expect(hips >= 0 and left_hand >= 0 and right_hand >= 0,
"hips and both hands resolve")
model.set_wall_side(-1.0)
model.set_wall_run_motion(Vector3(13.0, 0.0, 0.0))
await _settle(model, "wall_run", WALL_RUN_SPEED)
_expect(model.current_clip_debug() == "WallRunLeft",
"left wall selects the authored WallRunLeft performance")
var wall_scale := float(model._anim_tree.get("parameters/loco_scale/scale"))
_expect(absf(wall_scale - WALL_RUN_SPEED / WALL_RUN_REFERENCE_SPEED) < 0.02,
"wall-run cadence tracks %.1f m/s (%.3fx)"
% [WALL_RUN_SPEED, wall_scale])
var wall_foot_motion := await _sample_foot_motion(
model, foot, "wall_run", WALL_RUN_SPEED)
_expect(wall_foot_motion > 0.30,
"wall-run has a full wall-stride leg cycle (%.3f rad)"
% wall_foot_motion)
var wall_forward := model.wall_run_forward_debug()
_expect(wall_forward.dot(Vector3.RIGHT) > 0.97,
"wall-run model faces along wall-tangent velocity (dot %.3f)"
% wall_forward.dot(Vector3.RIGHT))
model.set_wall_side(1.0)
await _settle(model, "wall_run", WALL_RUN_SPEED)
_expect(model.current_clip_debug() == "WallRunRight",
"right wall selects the authored WallRunRight performance")
_expect(not model.clip_names_debug().has("WallRun"),
"the relabelled ground-run WallRun clip is absent")
# The imported performance must own the unarmed silhouette. A target rig's
# crossed/T-pose rest arms used to survive every clip because only relative
# bone rotations were copied.
model.set_weapon("")
model.set_locomotion(0.0, 0.0, 0.0)
await _settle(model, "idle", 0.0)
var left_shoulder := model._role_bone(
"shoulder.L", ["shoulder.L", "LeftShoulder", "clavicle_l"])
var right_shoulder := model._role_bone(
"shoulder.R", ["shoulder.R", "RightShoulder", "clavicle_r"])
var idle_drop := minf(
model.skeleton.get_bone_global_pose(left_shoulder).origin.y
- model.skeleton.get_bone_global_pose(left_hand).origin.y,
model.skeleton.get_bone_global_pose(right_shoulder).origin.y
- model.skeleton.get_bone_global_pose(right_hand).origin.y,
)
_expect(idle_drop > 0.22,
"unarmed idle hands hang below the shoulders (drop %.3f m)"
% idle_drop)
# A real backward capture turns/leans the whole body. The old fabricated
# version rotated only hips and legs by 180 degrees, leaving the first
# spine joint facing forward and creating an abdominal corkscrew.
model.set_locomotion(0.0, -1.0, 0.0)
await _settle(model, "ground", 8.25)
var spine_names: Array = model._rig_info.get("roles", {}).get("spine", [])
var first_spine := model.skeleton.find_bone(String(spine_names[0])) \
if not spine_names.is_empty() else -1
_expect(first_spine >= 0, "the first spine bone resolves")
if first_spine >= 0:
var hips_pose := model.skeleton.get_bone_global_pose(hips) \
.basis.get_rotation_quaternion()
var hips_rest := model.skeleton.get_bone_global_rest(hips) \
.basis.get_rotation_quaternion()
var spine_pose := model.skeleton.get_bone_global_pose(first_spine) \
.basis.get_rotation_quaternion()
var spine_rest := model.skeleton.get_bone_global_rest(first_spine) \
.basis.get_rotation_quaternion()
var hips_delta := hips_pose * hips_rest.inverse()
var spine_delta := spine_pose * spine_rest.inverse()
var abdomen_twist := hips_delta.angle_to(spine_delta)
_expect(abdomen_twist < 1.45,
"backward capture keeps hips and abdomen coherent (%.3f rad)"
% abdomen_twist)
var anchor := model.global_position + Vector3(3.0, 7.0, -6.0)
await _settle_grapple(model, anchor, Vector3(1.0, 0.0, 0.0))
_expect(model.current_clip_debug() == "Grapple",
"grapple selects the authored Grapple clip")
var grapple_scale := float(model._anim_tree.get("parameters/loco_scale/scale"))
_expect(absf(grapple_scale - 1.0) < 0.001,
"grapple holds its authored pose at 1.0x")
var grapple_foot_motion := await _sample_grapple_foot_motion(
model, foot, anchor, Vector3(1.0, 0.0, 0.0))
_expect(grapple_foot_motion < 0.06,
"grapple legs stay held instead of swimming (%.3f rad)"
% grapple_foot_motion)
var low: Dictionary = model.grapple_debug()
print(" grapple slow debug: ", low)
_expect(float(low.get("line_error", 1.0)) < 0.035,
"slow grapple palm lies on the cable ray (%.1f mm error)"
% (float(low.get("line_error", 1.0)) * 1000.0))
_expect(float(low.get("alignment", 1.0)) < 0.20,
"slow grapple hangs under gravity (alignment %.3f)"
% float(low.get("alignment", 1.0)))
var low_body: Quaternion = low.get(
"body_rotation", Quaternion.IDENTITY)
await _settle_grapple(model, anchor, Vector3(18.0, 2.0, 4.0))
var medium: Dictionary = model.grapple_debug()
print(" grapple medium debug: ", medium)
_expect(float(medium.get("alignment", 1.0)) < 0.60,
"medium grapple still reads as hanging (alignment %.3f)"
% float(medium.get("alignment", 1.0)))
await _settle_grapple(model, anchor, Vector3(32.0, 4.0, 8.0))
var fast: Dictionary = model.grapple_debug()
print(" grapple high-speed debug: ", fast)
_expect(float(fast.get("line_error", 1.0)) < 0.035,
"high-speed grapple palm stays on the cable ray (%.1f mm error)"
% (float(fast.get("line_error", 1.0)) * 1000.0))
_expect(float(fast.get("alignment", 0.0)) > 0.85,
"only high-speed grapple aligns the body (alignment %.3f)"
% float(fast.get("alignment", 0.0)))
var fast_body: Quaternion = fast.get(
"body_rotation", Quaternion.IDENTITY)
_expect(low_body.angle_to(fast_body) > 0.20,
"body orientation physically changes with swing energy (%.3f rad)"
% low_body.angle_to(fast_body))
model.queue_free()
_done()
func _settle(model: SkinnedPlayerModel, state: String, speed: float) -> void:
for _frame in SETTLE_FRAMES:
model.update_state(state, speed, false)
await process_frame
func _sample_foot_motion(
model: SkinnedPlayerModel,
foot: int,
state: String,
speed: float,
) -> float:
if foot < 0:
return 0.0
var start := model.skeleton.get_bone_pose_rotation(foot)
var greatest := 0.0
for _frame in SAMPLE_FRAMES:
model.update_state(state, speed, false)
await process_frame
greatest = maxf(greatest, start.angle_to(
model.skeleton.get_bone_pose_rotation(foot)))
return greatest
func _settle_grapple(model: SkinnedPlayerModel, anchor: Vector3,
velocity: Vector3) -> void:
for _frame in SETTLE_FRAMES:
model.update_state("grapple", velocity.length(), false)
model.set_grapple_target(anchor, velocity)
await process_frame
func _sample_grapple_foot_motion(
model: SkinnedPlayerModel,
foot: int,
anchor: Vector3,
velocity: Vector3,
) -> float:
if foot < 0:
return 0.0
var start := model.skeleton.get_bone_pose_rotation(foot)
var greatest := 0.0
for _frame in SAMPLE_FRAMES:
model.update_state("grapple", velocity.length(), false)
model.set_grapple_target(anchor, velocity)
await process_frame
greatest = maxf(greatest, start.angle_to(
model.skeleton.get_bone_pose_rotation(foot)))
return greatest
func _expect(ok: bool, description: String) -> void:
if ok:
print(" OK: ", description)
else:
printerr(" FAIL: ", description)
_failures += 1
func _done() -> void:
print("\n=== AUTHORED TRAVERSAL SUMMARY ===")
print("Failures: %d" % _failures)
quit(1 if _failures > 0 else 0)
+1
View File
@@ -0,0 +1 @@
uid://b43ksbou375or
+19 -2
View File
@@ -17,6 +17,14 @@ func _initialize() -> void:
_out_dir = args[0]
if args.size() > 1:
_scene = args[1]
if not _out_dir.is_absolute_path():
_out_dir = ProjectSettings.globalize_path(_out_dir)
var mkdir_error := DirAccess.make_dir_recursive_absolute(_out_dir)
if mkdir_error != OK:
printerr("visual_capture: could not create '%s' (error %d)" % [
_out_dir, mkdir_error])
quit(mkdir_error)
return
change_scene_to_file("res://ui/main_menu/main_menu.tscn")
@@ -61,6 +69,11 @@ func _process(_delta: float) -> bool:
cam.look_at(Vector3(-8, 4, -18), Vector3.UP)
elif _frames == 360:
_shot("street")
# Let the loaded scene and RenderingServer resources drain before the
# process exits. Large CSG maps can still have deferred frees queued on
# the exact frame of the last screenshot.
unload_current_scene()
elif _frames == 480:
return true
return false
@@ -68,5 +81,9 @@ func _process(_delta: float) -> bool:
func _shot(tag: String) -> void:
var img := root.get_viewport().get_texture().get_image()
var path := _out_dir + "/shot_" + tag + ".png"
img.save_png(path)
print("visual_capture: saved ", path)
var save_error := img.save_png(path)
if save_error == OK:
print("visual_capture: saved ", path)
else:
printerr("visual_capture: could not save '%s' (error %d)" % [
path, save_error])
+385
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://00s457y80gpu
+4 -6
View File
@@ -27,12 +27,10 @@ extends SceneTree
##
## ── Measured in the SHOULDER's frame ────────────────────────────────────────
##
## Not in world space, and not even in skeleton space. The hold breathes — a
## `sin(_time * 2.2) * 0.012` on the muzzle pitch — and the idle clip moves the
## whole torso, so no hand is ever at the same place twice and two samples of the
## SAME weapon would differ by more than two different weapons do. Taking each
## hand relative to the right shoulder joint, in the chest's own basis, cancels
## both, because they move the shoulder and the hand together.
## Not in world space, and not even in skeleton space. The authored idle clip
## moves the whole torso, so a world-space sample would mix body motion into the
## hold. Taking each hand relative to the right shoulder joint in the chest's
## own basis cancels that motion.
const LAB := "res://debug/rig_lab.tscn"