big
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bmuejgnrchb1g
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://cgjnu7mwndf28
|
||||
@@ -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)),
|
||||
])
|
||||
@@ -0,0 +1 @@
|
||||
uid://o46rju6s0mug
|
||||
@@ -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
@@ -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])
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://c2ob7ppq88shf
|
||||
@@ -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", [])
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://hhe274y22xvp
|
||||
+76
-398
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://coy68t4v8bnk0
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://c183tdow8n6dn
|
||||
@@ -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])
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://d3kckdjoe77uu
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://dryddw6j3udbo
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bhlv24bt2dymi
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://dduxrlh05tup3
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://bl1ks6hnbv6qp
|
||||
+21
-6
@@ -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])
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://p6fiv1r1hhef
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://5hx0k7ohj27h
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://c01r2mnvkv5f7
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://b43ksbou375or
|
||||
+19
-2
@@ -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])
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://00s457y80gpu
|
||||
@@ -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"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user