Files
rthythm_archor_game/tests/test_rhythm_ui_layout.gd
T
2026-07-06 22:59:47 -07:00

232 lines
13 KiB
GDScript

extends SceneTree
var failures: Array[String] = []
func _init() -> void:
_run.call_deferred()
func _run() -> void:
root.size = Vector2i(1152, 648)
await _check_main_ui_authored_layout()
await _check_stage_x1_camera_composition()
_finish()
func _check_main_ui_authored_layout() -> void:
var scene: PackedScene = load("res://scenes/ui/rhythm_track.tscn")
if scene == null:
failures.append("Could not load rhythm_track.tscn")
return
# Movers glide with the live rhythm clock; freeze it so this layout check
# reads the authored start positions deterministically.
var rhythm := root.get_node_or_null("RhythmManager")
if rhythm != null and rhythm.has_method("stop_manager"):
rhythm.call("stop_manager")
var track := scene.instantiate() as Control
root.add_child(track)
await process_frame
_expect_float(track.anchor_left, 0.5, "RhythmTrack should stay centered")
_expect_float(track.anchor_right, 0.5, "RhythmTrack should stay centered")
_expect_bool(track.has_node("JudgementLabel"), true, "RhythmTrack should own its judgement label")
var track_rect := _control_rect(track)
_expect_float(track_rect.size.x, 864.0, "RhythmTrack should keep the authored scene width")
_expect_float(track_rect.size.y, 240.0, "RhythmTrack should keep the x1 authored scene height")
_expect_float(track_rect.position.y, 54.0, "RhythmTrack should keep the x1 authored scene y")
var readout_row := track.get_node_or_null("HudReadoutRow") as Control
var buff_row := track.get_node_or_null("BuffPipRow") as Control
var judgement_art := track.get_node_or_null("JudgementArt") as Control
_expect_bool(readout_row != null, true, "RhythmTrack should own the combo / ATK readout row")
_expect_bool(buff_row != null, true, "RhythmTrack should own the seven-slot buff row")
_expect_bool(judgement_art != null, true, "RhythmTrack should own the judgement art node")
if readout_row != null:
_expect_float(_control_rect(readout_row).position.y, 97.2, "Combo and ATK readouts should use the x1 responsive layout")
if buff_row != null:
_expect_float(_control_rect(buff_row).position.y, 95.04, "Buff pips should use the x1 responsive layout")
if judgement_art != null:
_expect_float(_control_rect(judgement_art).position.y, 108.48, "Judgement art should use the x1 responsive layout")
var line := track.get_node("TrackLine") as Control
var center_base := track.get_node("CenterBase") as Control
var center_flash := track.get_node("CenterFlash") as Control
var left_mover := track.get_node("LeftMover") as Control
var right_mover := track.get_node("RightMover") as Control
var line_center_y := _control_rect(line).get_center().y
_expect_float((_control_rect(center_base).get_center()).y, line_center_y, "Center anchor should sit on the authored rhythm track horizontal axis")
_expect_float((_control_rect(left_mover).get_center()).y, line_center_y, "Left rhythm point should start on the authored horizontal axis")
_expect_float((_control_rect(right_mover).get_center()).y, line_center_y, "Right rhythm point should start on the authored horizontal axis")
_expect_float((_control_rect(left_mover).get_center()).x, 174.41, "Left rhythm point should start at the x1 responsive position")
_expect_float((_control_rect(right_mover).get_center()).x, 689.59, "Right rhythm point should start at the x1 responsive position")
_expect_float((track.get("track_center") as Vector2).y, line_center_y, "Rhythm point convergence should use the authored track horizontal axis")
_expect_bool(center_flash.z_index > left_mover.z_index, true, "Center hit effect should render above moving rhythm points")
_expect_bool(center_base.z_index > center_flash.z_index, true, "Center icon should render above the hit effect")
_expect_bool(center_base.z_index > left_mover.z_index, true, "Center icon should cover rhythm points when they reach the middle")
track.free()
var ui_scene: PackedScene = load("res://scenes/ui/main_ui.tscn")
if ui_scene == null:
failures.append("Could not load main_ui.tscn")
return
var ui := ui_scene.instantiate()
root.add_child(ui)
await process_frame
_expect_bool(ui is Control, true, "MainUI should stay as the x1 Control scene hosted by Main/UILayer")
var status_node := ui.get_node("StatusBars") as Control
var rhythm_node := ui.get_node("RhythmTrack") as Control
var boss_node := ui.get_node("BossStatus") as Control
var combo_node := ui.get_node("ComboWindow") as Control
_expect_vector(status_node.scale, Vector2(1.125, 1.125), "Player status HUD runtime scale should match x1")
_expect_vector(status_node.position, Vector2(30.6, 25.2), "Player status HUD runtime position should match x1")
_expect_vector(rhythm_node.scale, Vector2(1.0, 1.0), "Rhythm HUD runtime scale should match x1")
_expect_vector(rhythm_node.pivot_offset, Vector2(432.0, 0.0), "Rhythm HUD should keep its top-center pivot")
_expect_vector(boss_node.scale, Vector2(1.062, 1.062), "Boss status HUD runtime scale should match x1")
_expect_vector(boss_node.pivot_offset, Vector2.ZERO, "Boss status HUD should keep the x1 top-left pivot")
_expect_vector(combo_node.scale, Vector2(1.152, 1.152), "Combo slot HUD runtime scale should match x1")
_expect_vector(combo_node.position, Vector2(37.8, 369.0), "Combo slots runtime position should match x1")
_expect_bool(not bool(ui.get_node("TimePhaseHud").visible), true, "TimePhaseHud should use the authored hidden default")
_expect_bool(not bool(ui.get_node("DebugPanel").visible), true, "DebugPanel should use the authored hidden default")
ui.free()
func _check_stage_x1_camera_composition() -> void:
var scene: PackedScene = load("res://scenes/stage/stage.tscn")
if scene == null:
failures.append("Could not load stage.tscn")
return
var stage := scene.instantiate()
var initial_player_position := (stage.get_node("ActorsContainer/Player") as Node2D).position
var initial_melee_position := (stage.get_node("ActorsContainer/JinZhanMinion") as Node2D).position
var initial_boss_position := (stage.get_node("ActorsContainer/Boss") as Node2D).position
root.add_child(stage)
await process_frame
_expect_bool(bool(stage.get("use_authored_camera_view")), false, "Stage should use the x1 follow-camera baseline")
var player := stage.get_node("ActorsContainer/Player") as Node2D
var melee := stage.get_node("ActorsContainer/JinZhanMinion") as Node2D
var boss := stage.get_node("ActorsContainer/Boss") as Node2D
var camera := stage.get_node("Camera2D") as Camera2D
var gate := stage.get_node("BossRoomGate")
_expect_vector(camera.global_position, Vector2(1180, 395), "Camera2D should start over the left-spawn player (new2)")
_expect_vector(camera.zoom, Vector2(1.25, 1.25), "Camera2D should keep the x1 reference zoom")
_expect_vector(initial_player_position, Vector2(1180, 560), "Player should spawn at the map's left side (new2)")
_expect_vector(initial_melee_position, Vector2(2200, 560), "Initial minion should use the left-shifted safe authored anchor")
_expect_vector(initial_boss_position, Vector2(2520, 560), "Boss should keep the x1 authored anchor")
_expect_float(player.global_position.y, melee.global_position.y, "Player and minion anchors should share one horizontal line")
_expect_float(player.global_position.y, boss.global_position.y, "Player and Boss anchors should share one horizontal line")
player.global_position = Vector2(float(gate.get("gate_x")), player.global_position.y)
stage.call("_update_camera_follow")
var boss_entry_x := float(gate.get("gate_x")) + float(gate.get("wall_thickness"))
var half_view_width := float(root.size.x) / (2.0 * camera.zoom.x)
var sealed_camera_max_x := boss_entry_x - half_view_width
_expect_float(camera.global_position.x, sealed_camera_max_x, "Sealed Boss room should clamp camera before the boss-room entry line")
_expect_float(camera.global_position.x + half_view_width, boss_entry_x, "Sealed camera view should not reveal the boss-room entry area")
gate.call("unseal")
await process_frame
stage.call("_update_camera_follow")
_expect_vector(camera.global_position, player.global_position + Vector2(0.0, -165.0), "Camera2D should follow freely after the Boss room path unlocks")
var projectile_screen_y := _world_to_screen(boss.call("projectile_spawn_position", null), camera.global_position, camera.zoom, Vector2(1152, 648)).y
var boss_bounds := _visible_sprite_bounds_global(boss.get_node("Visual/CharacterSprite") as Sprite2D)
var boss_top_screen_y := _world_to_screen(Vector2(boss.global_position.x, boss_bounds.position.y), camera.global_position, camera.zoom, Vector2(1152, 648)).y
var boss_bottom_screen_y := _world_to_screen(Vector2(boss.global_position.x, boss_bounds.end.y), camera.global_position, camera.zoom, Vector2(1152, 648)).y
var boss_height_screen := boss_bottom_screen_y - boss_top_screen_y
_expect_ratio(projectile_screen_y - boss_top_screen_y, boss_height_screen, 0.30, 0.62, "Boss projectile should spawn from the upper body, not below the feet")
_expect_float(absf(float(player.get_node("Visual").scale.x)), 1.0, "Player visual scale should match x1")
_expect_float(float(player.get_node("Visual").scale.y), 1.0, "Player visual scale should match x1")
_expect_float(absf(float(melee.get_node("Visual").scale.x)), 2.0, "Minion visual scale should match x1")
_expect_float(float(melee.get_node("Visual").scale.y), 2.0, "Minion visual scale should match x1")
_expect_float(absf(float(boss.get_node("Visual").scale.x)), 2.0, "Boss visual scale should match x1")
_expect_float(float(boss.get_node("Visual").scale.y), 2.0, "Boss visual scale should match x1")
for sprite_path: String in [
"Ground/ArtLayer/PastBackground",
"Ground/ArtLayer/FutureBackground",
"ActorsContainer/Player/Visual/CharacterSprite",
"ActorsContainer/Player/Visual/FxOverlay",
"ActorsContainer/Boss/Visual/CharacterSprite",
"ActorsContainer/Boss/Visual/FxOverlay",
]:
var sprite := stage.get_node_or_null(sprite_path) as Sprite2D
_expect_bool(sprite != null, true, "%s should exist for texture filtering" % sprite_path)
if sprite != null:
_expect_bool(sprite.texture_filter == CanvasItem.TEXTURE_FILTER_LINEAR, true, "%s should use linear texture interpolation when scaled" % sprite_path)
stage.free()
func _control_rect(control: Control) -> Rect2:
return Rect2(
Vector2(control.offset_left, control.offset_top),
Vector2(control.offset_right - control.offset_left, control.offset_bottom - control.offset_top)
)
func _world_to_screen(world_position: Vector2, camera_position: Vector2, zoom: Vector2, viewport_size: Vector2) -> Vector2:
return (world_position - camera_position) * zoom + viewport_size * 0.5
func _visible_sprite_bounds_global(sprite: Sprite2D) -> Rect2:
if sprite == null or sprite.texture == null:
return Rect2()
var image := sprite.texture.get_image()
if image == null or image.is_empty():
return Rect2()
var hframes := maxi(1, sprite.hframes)
var vframes := maxi(1, sprite.vframes)
var frame_width := image.get_width() / hframes
var frame_height := image.get_height() / vframes
var frame_index := clampi(sprite.frame, 0, hframes * vframes - 1)
var column := frame_index % hframes
var row := int(frame_index / hframes)
var left := frame_width
var right := -1
var top := frame_height
var bottom := -1
for y: int in range(frame_height):
for x: int in range(frame_width):
var pixel := image.get_pixel(column * frame_width + x, row * frame_height + y)
if pixel.a > 0.01:
left = mini(left, x)
right = maxi(right, x)
top = mini(top, y)
bottom = maxi(bottom, y)
if right < left or bottom < top:
return Rect2()
var top_left := sprite.to_global(sprite.offset + Vector2(left, top))
var bottom_right := sprite.to_global(sprite.offset + Vector2(right, bottom))
return Rect2(
Vector2(minf(top_left.x, bottom_right.x), minf(top_left.y, bottom_right.y)),
Vector2(absf(bottom_right.x - top_left.x), absf(bottom_right.y - top_left.y))
)
func _expect_ratio(actual: float, basis: float, minimum: float, maximum: float, label: String) -> void:
var ratio := actual / maxf(1.0, basis)
if ratio < minimum or ratio > maximum:
failures.append("%s: expected ratio %.3f..%.3f, got %.3f" % [label, minimum, maximum, ratio])
func _expect_float(actual: float, expected: float, label: String) -> void:
if absf(actual - expected) > 0.02:
failures.append("%s: expected %.3f, got %.3f" % [label, expected, actual])
func _expect_vector(actual: Vector2, expected: Vector2, label: String) -> void:
if actual.distance_to(expected) > 0.03:
failures.append("%s: expected %s, got %s" % [label, expected, actual])
func _expect_bool(actual: bool, expected: bool, label: String) -> void:
if actual != expected:
failures.append("%s: expected %s, got %s" % [label, expected, actual])
func _finish() -> void:
if failures.is_empty():
print("PASS rhythm ui layout")
quit(0)
else:
for failure: String in failures:
push_error(failure)
quit(1)