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

739 lines
36 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
extends SceneTree
var failures: Array[String] = []
var started_actions: Array[StringName] = []
var projectile_requests: Array[Dictionary] = []
func _init() -> void:
_run.call_deferred()
func _run() -> void:
await process_frame
_check_boss_assets()
await _check_boss_scene_contract()
_check_boss_action_resources()
await _check_stage_and_main_contract()
await _check_stage_camera_follows_player_with_wider_view()
await _check_visible_ground_alignment()
await _check_boss_behavior_tree_uses_beat_ticks()
await _check_boss_entry_clears_precombat_chart_delay()
await _check_boss_behavior_tree_handles_point_blank_pressure()
await _check_boss_escapes_after_sustained_hit_chain()
await _check_boss_driver_starts_beat_anchored_actions()
await _check_boss_projectile_request_and_visual()
await _check_bidirectional_damage_flow()
await _check_boss_death_state_is_terminal()
_check_boss_script_debt_cleanup()
_finish()
func _check_boss_assets() -> void:
var expected_assets := {
"boss_idle.png": 1,
"boss_run.png": 8,
"boss_dash.png": 6,
"boss_jump_fall.png": 2,
"boss_take_hit.png": 1,
"boss_die.png": 4,
"boss_ground_combo_1.png": 8,
"boss_ground_combo_2.png": 10,
"boss_ground_combo_3.png": 14,
"boss_lunging_stab.png": 15,
"boss_shoot_1.png": 2,
"boss_shoot_2.png": 2,
"boss_2way_shoot_1.png": 2,
"boss_2way_shoot_2.png": 2,
}
for file_name: String in expected_assets:
var texture: Texture2D = load("res://assets/art/characters/boss/%s" % file_name)
_expect(texture != null, "Boss asset %s should load" % file_name)
if texture != null:
_expect_int(texture.get_width(), 80 * int(expected_assets[file_name]), "%s width should match 80px frames" % file_name)
_expect_int(texture.get_height(), 48, "%s height should be 48px" % file_name)
func _check_boss_scene_contract() -> void:
var scene: PackedScene = load("res://scenes/enemies/boss.tscn")
_expect(scene != null, "Boss scene should load")
if scene == null:
return
var boss := scene.instantiate()
root.add_child(boss)
await process_frame
for node_path: String in [
"Visual",
"Visual/CharacterSprite",
"Visual/FxOverlay",
"AnimationPlayer",
"StateMachine",
"MovementMotor",
"ActionResolver",
"ActionExecutor",
"ActionController",
"MotionExecutor",
"EffectContainer",
"HealthComponent",
"DamageReceiver",
"DamageEmitter",
"EnemyActionDriver",
"BossBehaviorTree",
]:
_expect(boss.get_node_or_null(node_path) != null, "Boss should contain %s" % node_path)
_expect_int(int(boss.get_node("HealthComponent").get("maximum")), 28000, "Boss HP baseline should live on HealthComponent")
_expect_float(absf(float(boss.get_node("Visual").scale.x)), 2.0, "Boss Visual should keep the x1 reference scale")
_expect_float(float(boss.get_node("Visual").scale.y), 2.0, "Boss Visual should keep the x1 reference scale")
_expect_float(float(boss.get("visual_ground_offset")), -40.0, "Boss Visual offset should match Rthythm_archor_game")
_expect_float(float(boss.get("projectile_spawn_height")), 74.0, "Boss projectile lane should match Rthythm_archor_game")
var animation_player: AnimationPlayer = boss.get_node_or_null("AnimationPlayer")
if animation_player != null:
for animation_name: StringName in _boss_animation_names():
_expect(animation_player.has_animation(animation_name), "Boss AnimationPlayer should expose %s" % animation_name)
boss.queue_free()
await process_frame
func _check_boss_action_resources() -> void:
var resolver_script: Script = load("res://scenes/combat/action_resolver.gd")
_expect(resolver_script != null, "ActionResolver should load")
if resolver_script == null:
return
resolver_script.clear_cache()
var animation_names := _boss_animation_names()
for action_id: StringName in [
&"boss_combo_1",
&"boss_combo_2",
&"boss_combo_3",
&"boss_retreat_dash",
&"boss_lunging_stab",
&"boss_dash",
&"boss_shoot_1",
&"boss_shoot_2",
&"boss_2way_shoot",
]:
var action: Resource = resolver_script.get_action(action_id)
_expect(action != null, "%s should be a Boss ActionData resource" % action_id)
if action == null:
continue
_expect_bool(Array(action.get("input_pattern")).is_empty(), true, "%s should not expose a player input pattern" % action_id)
_expect(animation_names.has(action.get("animation")), "%s animation should exist on Boss AnimationPlayer" % action_id)
_expect(float(action.get("damage_mult")) >= 0.0, "%s damage should be data-driven on ActionData" % action_id)
_expect(float(action.get("knockback_mult_x")) >= 0.0, "%s should configure horizontal knockback for player hit reaction" % action_id)
resolver_script.clear_cache()
func _check_stage_and_main_contract() -> void:
var stage_scene: PackedScene = load("res://scenes/stage/stage.tscn")
var main_scene: PackedScene = load("res://scenes/main/main.tscn")
_expect(stage_scene != null, "Stage scene should load")
_expect(main_scene != null, "Main scene should load")
_expect_equal(ProjectSettings.get_setting("application/run/main_scene"), "res://scenes/main/main.tscn", "Project main scene should move to phase-7 main.tscn")
if stage_scene != null:
var stage := stage_scene.instantiate()
root.add_child(stage)
await process_frame
_expect(stage.has_node("ActorsContainer/Player"), "Stage should instance Player under ActorsContainer")
_expect(stage.has_node("ActorsContainer/Boss"), "Stage should instance Boss under ActorsContainer")
if stage.has_node("ActorsContainer/Player") and stage.has_node("ActorsContainer/Boss"):
var player := stage.get_node("ActorsContainer/Player") as Node2D
var boss := stage.get_node("ActorsContainer/Boss")
var boss_tree := boss.get_node("BossBehaviorTree")
_expect(boss_tree.get("target") == player, "BossBehaviorTree should resolve the Player target in Stage")
boss.call("look_at_target", player)
boss.call("flip_sprites")
_expect(boss.get("heading") == Vector2.LEFT, "Boss should face left when Player is left of Boss")
_expect(float(boss.get_node("Visual").scale.x) < 0.0, "Boss visual should flip for a left-facing target because the source art faces right")
stage.queue_free()
await process_frame
if main_scene != null:
var main: Node = main_scene.instantiate()
root.add_child(main)
await process_frame
_expect(main.has_node("Stage"), "Main should instance Stage")
_expect(main.has_node("ChartRunner"), "Main should own ChartRunner")
_expect(main.get_node("ChartRunner").get("chart") != null, "Main ChartRunner should reference the stage-7 boss chart")
main.queue_free()
await process_frame
func _check_stage_camera_follows_player_with_wider_view() -> void:
root.size = Vector2i(1152, 648)
var stage_scene: PackedScene = load("res://scenes/stage/stage.tscn")
if stage_scene == null:
return
var stage: Node = stage_scene.instantiate()
root.add_child(stage)
await process_frame
var player := stage.get_node_or_null("ActorsContainer/Player") as Node2D
var camera := stage.get_node_or_null("Camera2D") as Camera2D
var gate := stage.get_node_or_null("BossRoomGate")
_expect(player != null, "Stage authored camera check should find Player")
_expect(camera != null, "Stage authored camera check should find Camera2D")
_expect(gate != null, "Stage authored camera check should find BossRoomGate")
if player != null and camera != null and gate != null:
_expect_bool(bool(stage.get("use_authored_camera_view")), false, "Stage should keep the x1 follow-camera baseline")
var camera_start := camera.global_position
_expect_vector(camera_start, Vector2(1180, 395), "Stage Camera2D should start over the left-spawn player (new2)")
_expect_float(camera.zoom.x, 1.25, "Stage Camera2D should keep the x1 zoom")
_expect_float(camera.zoom.y, 1.25, "Stage Camera2D should keep the x1 zoom")
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 := get_root().size.x / (2.0 * camera.zoom.x)
_expect_float(camera.global_position.x + half_view_width, boss_entry_x, "Stage Camera2D should not show Boss room while the gate is sealed")
gate.call("unseal")
await process_frame
stage.call("_update_camera_follow")
_expect_vector(camera.global_position, player.global_position + Vector2(0.0, -165.0), "Stage Camera2D should follow Player after Boss room unlock")
stage.queue_free()
await process_frame
func _check_visible_ground_alignment() -> void:
var stage_scene: PackedScene = load("res://scenes/stage/stage.tscn")
if stage_scene == null:
return
var stage: Node = stage_scene.instantiate()
root.add_child(stage)
await process_frame
var player := stage.get_node_or_null("ActorsContainer/Player") as Node2D
var melee := stage.get_node_or_null("ActorsContainer/JinZhanMinion") as Node2D
var boss := stage.get_node_or_null("ActorsContainer/Boss") as Node2D
_expect(player != null, "Player should exist for anchor alignment check")
_expect(melee != null, "Initial minion should exist for anchor alignment check")
_expect(boss != null, "Boss should exist for anchor alignment check")
if player != null and melee != null and boss != null:
_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")
stage.queue_free()
await process_frame
func _check_boss_behavior_tree_uses_beat_ticks() -> void:
var rhythm := root.get_node_or_null("RhythmManager")
if rhythm != null and rhythm.has_method("stop_manager"):
rhythm.call("stop_manager")
var bus := _ensure_event_bus()
var stage_scene: PackedScene = load("res://scenes/stage/stage.tscn")
if stage_scene == null:
return
var stage: Node = stage_scene.instantiate()
root.add_child(stage)
await process_frame
var boss: Node = stage.get_node("ActorsContainer/Boss")
var controller: Node = boss.get_node("ActionController")
var boss_tree: Node = boss.get_node("BossBehaviorTree")
boss.set("combat_enabled", true)
boss.set("stationary", false)
boss_tree.set("enabled", true)
boss_tree.set("decision_interval_beats", 2.0)
# 决策间隔在 future 相位会 x0.5(§18.1)。真实 TimeAnchorSystem 在测试
# 进程里会因锚点 Miss 在随机时刻把相位翻到 future,这里钉回 past
# 保证 interval 断言按 1.0 缩放判定。
var phase_manager := root.get_node_or_null("TimePhaseManager")
if phase_manager != null and phase_manager.has_method("reset_to_initial"):
phase_manager.call("reset_to_initial", &"past")
# 相位钉回会触发 new3 §6.4 的切相位停顿;本段只验证节拍门控本身,
# 停顿契约由 test_minion_phase_system 覆盖,这里清掉。
boss_tree.set("_next_decision_beat", 0)
controller.connect("action_started", _on_action_started)
started_actions.clear()
await process_frame
await process_frame
_expect(started_actions.is_empty(), "BossBehaviorTree should not start attacks from delta time between beats")
bus.emit_signal("beat_ticked", 1)
await process_frame
_expect(not started_actions.is_empty(), "BossBehaviorTree should choose attacks on beat_ticked")
controller.call("_reset_to_idle")
boss.set("state", StringName("idle"))
started_actions.clear()
bus.emit_signal("beat_ticked", 2)
await process_frame
_expect(started_actions.is_empty(), "BossBehaviorTree should respect decision_interval_beats instead of attacking every frame")
bus.emit_signal("beat_ticked", 3)
await process_frame
_expect(not started_actions.is_empty(), "BossBehaviorTree should attack again on the next eligible beat grid")
stage.queue_free()
await process_frame
func _check_boss_entry_clears_precombat_chart_delay() -> void:
var rhythm := root.get_node_or_null("RhythmManager")
if rhythm != null and rhythm.has_method("stop_manager"):
rhythm.call("stop_manager")
var bus := _ensure_event_bus()
var stage_scene: PackedScene = load("res://scenes/stage/stage.tscn")
if stage_scene == null:
return
var stage: Node = stage_scene.instantiate()
root.add_child(stage)
await process_frame
var boss: Node = stage.get_node("ActorsContainer/Boss")
var controller: Node = boss.get_node("ActionController")
var boss_tree: Node = boss.get_node("BossBehaviorTree")
controller.connect("action_started", _on_action_started)
started_actions.clear()
boss.set("combat_enabled", false)
boss.set("stationary", true)
boss_tree.set("enabled", true)
boss_tree.call("_on_chart_event_upcoming", _make_chart_event(100, &"Boss", &"boss_lunging_stab"), 2.0)
_expect(int(boss_tree.get("_next_decision_beat")) > 1, "test setup should defer Boss decisions before room entry")
boss.set("combat_enabled", true)
boss.set("stationary", false)
bus.emit_signal("beat_ticked", 1)
await process_frame
_expect(not started_actions.is_empty(), "Boss should act on the first eligible beat after boss-room entry, even after precombat chart previews")
stage.queue_free()
await process_frame
func _check_boss_behavior_tree_handles_point_blank_pressure() -> void:
var rhythm := root.get_node_or_null("RhythmManager")
if rhythm != null and rhythm.has_method("stop_manager"):
rhythm.call("stop_manager")
var bus := _ensure_event_bus()
var stage_scene: PackedScene = load("res://scenes/stage/stage.tscn")
if stage_scene == null:
return
var stage: Node = stage_scene.instantiate()
root.add_child(stage)
await process_frame
var player: Node2D = stage.get_node("ActorsContainer/Player")
var boss: Node2D = stage.get_node("ActorsContainer/Boss")
var controller: Node = boss.get_node("ActionController")
var boss_tree: Node = boss.get_node("BossBehaviorTree")
boss.set("combat_enabled", true)
player.global_position = Vector2(100.0, 360.0)
boss.global_position = Vector2(170.0, 360.0)
boss.set("stationary", false)
boss_tree.set("enabled", true)
boss_tree.set("restrict_actions_by_time_phase", false)
boss_tree.set("decision_interval_beats", 1.0)
controller.connect("action_started", _on_action_started)
started_actions.clear()
bus.emit_signal("beat_ticked", 20)
await process_frame
_expect(not started_actions.is_empty(), "BossBehaviorTree should act when trapped at point blank")
if not started_actions.is_empty():
_expect(str(started_actions[started_actions.size() - 1]).begins_with("boss_combo"), "BossBehaviorTree should answer point-blank pressure with melee first, not by fleeing")
controller.call("_reset_to_idle")
boss.set("state", StringName("idle"))
boss_tree.set("_last_melee_beat", 21.0)
started_actions.clear()
bus.emit_signal("beat_ticked", 22)
await process_frame
_expect(not started_actions.is_empty(), "BossBehaviorTree should still act at point blank while melee is on cooldown")
if not started_actions.is_empty():
_expect_equal(started_actions[started_actions.size() - 1], &"boss_2way_shoot", "Point-blank pressure with melee on cooldown should be punished with the 2-way shot")
controller.call("_reset_to_idle")
boss.set("state", StringName("idle"))
boss_tree.set("_last_melee_beat", 23.0)
boss_tree.set("_last_two_way_beat", 23.0)
started_actions.clear()
bus.emit_signal("beat_ticked", 24)
await process_frame
_expect(not started_actions.is_empty(), "BossBehaviorTree should act when both melee and 2-way are on cooldown")
if not started_actions.is_empty():
_expect_equal(started_actions[started_actions.size() - 1], &"boss_retreat_dash", "BossBehaviorTree should only retreat once its point-blank answers are on cooldown")
controller.call("_reset_to_idle")
boss.set("state", StringName("idle"))
player.global_position = Vector2(100.0, 360.0)
boss.global_position = Vector2(360.0, 360.0)
started_actions.clear()
bus.emit_signal("beat_ticked", 25)
await process_frame
_expect(not started_actions.is_empty(), "BossBehaviorTree should follow a retreat with ranged pressure")
if not started_actions.is_empty():
_expect(str(started_actions[started_actions.size() - 1]).begins_with("boss_shoot"), "BossBehaviorTree should use a shoot action after creating range")
stage.queue_free()
await process_frame
func _check_boss_escapes_after_sustained_hit_chain() -> void:
var combat: Node = _ensure_combat_manager()
combat.call("_physics_process", 1.0 / 60.0)
var player: Node = load("res://scenes/characters/player.tscn").instantiate()
var boss: Node = load("res://scenes/enemies/boss.tscn").instantiate()
root.add_child(player)
root.add_child(boss)
await process_frame
var controller: Node = boss.get_node("ActionController")
var state_machine: Node = boss.get_node("StateMachine")
var escape_threshold := int(boss.get("escape_after_consecutive_hits"))
_expect(escape_threshold >= 3, "Boss should tolerate a short combo before burning its escape")
controller.connect("action_started", _on_action_started)
started_actions.clear()
boss.get_node("HealthComponent").call("set_values", 8000, 8000)
var player_action: Resource = load("res://resources/actions/ground_attack_left_1.tres")
player.get_node("DamageEmitter").call("configure_hit", player_action, {"label": "perfect"})
for hit_index: int in range(escape_threshold - 1):
combat.call("_physics_process", 1.0 / 60.0)
combat.call("resolve_hit", player.get_node("DamageEmitter"), boss.get_node("DamageReceiver"))
await process_frame
# 2026-07-05 定案:普攻恒霸体——打不出 Boss 硬直,但受击链照常累计。
_expect_equal(StringName(str(state_machine.call("build_context").get("life_state", &"Alive"))), &"Alive", "Boss should shrug off normal attacks without hitstun before the escape threshold")
_expect(not started_actions.has(&"boss_retreat_dash"), "Boss should not burn retreat before the hit chain threshold")
combat.call("_physics_process", 1.0 / 60.0)
combat.call("resolve_hit", player.get_node("DamageEmitter"), boss.get_node("DamageReceiver"))
await process_frame
_expect(started_actions.has(&"boss_retreat_dash"), "Boss should escape after a sustained hit chain")
_expect_equal(StringName(str(state_machine.call("build_context").get("life_state", &"Alive"))), &"Alive", "Boss should leave Hitstun when forced retreat starts")
_expect_int(int(controller.get("phase")), 1, "Boss forced retreat should start through ActionController Startup")
started_actions.clear()
for hit_index: int in range(escape_threshold + 1):
combat.call("_physics_process", 1.0 / 60.0)
combat.call("resolve_hit", player.get_node("DamageEmitter"), boss.get_node("DamageReceiver"))
await process_frame
_expect(not started_actions.has(&"boss_retreat_dash"), "Boss escape should respect its cooldown instead of chaining escapes")
player.queue_free()
boss.queue_free()
await process_frame
func _check_boss_driver_starts_beat_anchored_actions() -> void:
started_actions.clear()
var boss: Node = load("res://scenes/enemies/boss.tscn").instantiate()
root.add_child(boss)
await process_frame
var controller: Node = boss.get_node("ActionController")
var driver: Node = boss.get_node("EnemyActionDriver")
controller.connect("action_started", _on_action_started)
driver.call("start_action", &"boss_combo_1")
_expect(started_actions.has(&"boss_combo_1"), "Boss driver should start boss actions through ActionController")
_expect_equal(controller.get("beat_anchor_policy"), &"ANCHOR_ACTIVE", "Boss ActionController should keep Active beat anchoring")
_expect_int(int(controller.get("phase")), 1, "Boss action should enter Startup before Active")
boss.queue_free()
await process_frame
func _check_boss_projectile_request_and_visual() -> void:
projectile_requests.clear()
var bus := _ensure_event_bus()
if not bus.is_connected("projectile_requested", _on_projectile_requested):
bus.connect("projectile_requested", _on_projectile_requested)
var boss: Node = load("res://scenes/enemies/boss.tscn").instantiate()
root.add_child(boss)
await process_frame
var shoot: Resource = load("res://resources/actions/enemies/boss_shoot_1.tres")
_expect(shoot != null, "boss_shoot_1 should load for projectile request")
if shoot != null:
_expect_bool(bool(boss.get_node("ActionExecutor").call("execute", shoot, &"perfect", null)), true, "Boss projectile action should execute")
_expect_int(projectile_requests.size(), 1, "Boss shoot action should request one projectile at Active")
if not projectile_requests.is_empty():
var request := projectile_requests[0]
var spawn_position := request.get("spawn_position", Vector2.ZERO) as Vector2
var direction := request.get("direction", Vector2.ZERO) as Vector2
var context := request.get("context", {}) as Dictionary
_expect(spawn_position.x < boss.global_position.x, "Boss projectile should spawn from the muzzle side when facing left")
_expect(spawn_position.y < boss.global_position.y, "Boss projectile should spawn above the Boss root, not from the feet")
_expect(absf((boss.global_position.y - spawn_position.y) - 74.0) <= 2.0, "Boss projectile should spawn at the torso lane of the drawn bodies, got %.2f px above root" % (boss.global_position.y - spawn_position.y))
_expect(direction.x < 0.0, "Boss projectile should travel toward the current heading")
_expect_equal(StringName(str(context.get("team", &""))), &"enemy", "Boss projectile request should carry the enemy team context")
_expect(context.get("action", null) == shoot, "Boss projectile request should carry the shooting ActionData for damage resolution")
projectile_requests.clear()
var two_way: Resource = load("res://resources/actions/enemies/boss_2way_shoot.tres")
if two_way != null:
_expect_bool(bool(boss.get_node("ActionExecutor").call("execute", two_way, &"perfect", null)), true, "Boss two-way projectile action should execute")
_expect_int(projectile_requests.size(), 2, "Boss two-way shoot should request left and right projectiles")
if projectile_requests.size() == 2:
var first_direction := projectile_requests[0].get("direction", Vector2.ZERO) as Vector2
var second_direction := projectile_requests[1].get("direction", Vector2.ZERO) as Vector2
_expect(first_direction.x < 0.0 or second_direction.x < 0.0, "Boss two-way shoot should include a left projectile")
_expect(first_direction.x > 0.0 or second_direction.x > 0.0, "Boss two-way shoot should include a right projectile")
var projectile: Node = load("res://scenes/combat/player_projectile.tscn").instantiate()
root.add_child(projectile)
# 下面的帧号断言手动喂 _process/_physics_process;关闭引擎自动 tick
# 避免 await 帧的真实 delta 混入动画计时造成帧号随墙钟抖动。
projectile.set_process(false)
projectile.set_physics_process(false)
await process_frame
_expect(projectile.has_method("max_travel_distance"), "Projectile should expose max_travel_distance for Boss spacing checks")
if projectile.has_method("max_travel_distance"):
var retreat_action: Resource = load("res://resources/actions/enemies/boss_retreat_dash.tres")
var beat_time := 0.5
var retreat_distance := float(boss.get("boss_lunge_speed")) * absf(float(retreat_action.get("move_mult_x"))) * float(retreat_action.get("action_beats")) * beat_time
_expect(float(projectile.call("max_travel_distance")) > retreat_distance + float(boss.get("projectile_spawn_forward")), "Projectile max range should exceed Boss retreat distance plus muzzle offset")
var sprite := projectile.get_node_or_null("Sprite") as Sprite2D
if sprite == null and projectile.get_child_count() > 0:
sprite = projectile.get_child(0) as Sprite2D
_expect(sprite != null, "Projectile should create a Sprite2D child")
if sprite != null:
_expect_equal(sprite.texture.resource_path, "res://assets/art/effects/effect_sheet.png", "Projectile should use effect_sheet.png")
_expect_int(sprite.hframes, 6, "effect_sheet is a 6x2 grid of 32px cells, not five columns")
_expect_int(sprite.vframes, 2, "effect_sheet should be treated as two rows")
projectile.call("_process", 0.13)
_expect_int(sprite.frame, 2, "Projectile flight animation should advance through the four flight frames")
projectile.call("_process", 0.13)
_expect_int(sprite.frame, 0, "Projectile flight animation should loop while flying instead of vanishing mid-air")
projectile.set("direction", Vector2.RIGHT)
for step: int in range(10):
projectile.call("_physics_process", 0.1)
_expect_bool(projectile.is_queued_for_deletion(), true, "Projectile should expire by travel range, not by animation time")
projectile.queue_free()
var hit_projectile: Node = load("res://scenes/combat/player_projectile.tscn").instantiate()
var player: Node = load("res://scenes/characters/player.tscn").instantiate()
root.add_child(hit_projectile)
root.add_child(player)
await process_frame
_expect(hit_projectile is Area2D, "Projectile root should be an Area2D so it can collide with DamageReceiver")
player.get_node("HealthComponent").call("set_values", 1000, 1000)
if hit_projectile is Area2D:
_ensure_combat_manager()
hit_projectile.call("_on_area_entered", player.get_node("DamageReceiver"))
_expect(int(player.get_node("HealthComponent").get("current")) < 1000, "Projectile collision should reduce Player health")
_expect_bool(bool(hit_projectile.get("visible")), false, "Projectile should hide immediately after a hit instead of appearing to pass through")
_expect_bool(hit_projectile.is_queued_for_deletion(), true, "Projectile should be queued for deletion after a hit")
hit_projectile.queue_free()
var sweep_projectile: Node = load("res://scenes/combat/player_projectile.tscn").instantiate()
root.add_child(sweep_projectile)
await process_frame
player.global_position = Vector2(100.0, 0.0)
player.get_node("HealthComponent").call("set_values", 1000, 1000)
player.get_node("StateMachine").call("set_life_state", &"Alive")
sweep_projectile.set("direction", Vector2.RIGHT)
(sweep_projectile as Node2D).global_position = player.global_position + Vector2(-30.0, -72.0)
await physics_frame
(sweep_projectile as Node2D).global_position = player.global_position + Vector2(-30.0, -72.0)
_expect(sweep_projectile.has_method("_physics_process"), "Projectile should move and sweep for hits during physics ticks")
if sweep_projectile.has_method("_physics_process"):
sweep_projectile.call("_physics_process", 1.0 / 60.0)
_expect(int(player.get_node("HealthComponent").get("current")) < 1000, "Projectile sweep should hit before the visual center overlaps Player")
_expect_bool(bool(sweep_projectile.get("visible")), false, "Projectile sweep hit should hide immediately")
_expect((sweep_projectile as Node2D).global_position.x < player.global_position.x - 11.0, "Projectile center should still be outside Player hurtbox when sweep hit resolves")
sweep_projectile.queue_free()
var physical_projectile: Node = load("res://scenes/combat/player_projectile.tscn").instantiate()
root.add_child(physical_projectile)
await process_frame
player.global_position = Vector2(100.0, 0.0)
player.get_node("HealthComponent").call("set_values", 1000, 1000)
player.get_node("StateMachine").call("set_life_state", &"Alive")
physical_projectile.set("direction", Vector2.RIGHT)
(physical_projectile as Node2D).global_position = player.global_position + Vector2(-30.0, -70.0)
_expect((int((physical_projectile as Area2D).collision_mask) & int(player.get_node("DamageReceiver").collision_layer)) != 0, "Projectile mask should include Player DamageReceiver layer")
_expect((int(player.get_node("DamageReceiver").collision_mask) & int((physical_projectile as Area2D).collision_layer)) != 0, "Player DamageReceiver mask should include projectile layer")
for frame: int in range(4):
await physics_frame
_expect(int(player.get_node("HealthComponent").get("current")) < 1000, "Boss projectile at muzzle lane should hit Player through real Area2D physics overlap")
if is_instance_valid(physical_projectile):
_expect_bool(bool(physical_projectile.get("visible")), false, "Physical projectile hit should hide immediately")
_expect_bool(physical_projectile.is_queued_for_deletion(), true, "Physical projectile hit should queue deletion")
physical_projectile.queue_free()
player.queue_free()
boss.queue_free()
await process_frame
func _check_bidirectional_damage_flow() -> void:
var combat: Node = _ensure_combat_manager()
var player: Node = load("res://scenes/characters/player.tscn").instantiate()
var boss: Node = load("res://scenes/enemies/boss.tscn").instantiate()
root.add_child(player)
root.add_child(boss)
await process_frame
player.get_node("HealthComponent").call("set_values", 1000, 1000)
boss.get_node("HealthComponent").call("set_values", 8000, 8000)
var player_action: Resource = load("res://resources/actions/ground_attack_left_1.tres")
var boss_action: Resource = load("res://resources/actions/enemies/boss_combo_1.tres")
player.get_node("DamageEmitter").call("configure_hit", player_action, {"label": "perfect"})
boss.get_node("DamageEmitter").call("configure_hit", boss_action, {"label": "perfect"})
combat.call("resolve_hit", player.get_node("DamageEmitter"), boss.get_node("DamageReceiver"))
combat.call("resolve_hit", boss.get_node("DamageEmitter"), player.get_node("DamageReceiver"))
_expect(int(boss.get_node("HealthComponent").get("current")) < 8000, "Player hitbox should damage Boss HealthComponent through CombatManager")
_expect(int(player.get_node("HealthComponent").get("current")) < 1000, "Boss hitbox should damage Player HealthComponent through CombatManager")
player.queue_free()
boss.queue_free()
await process_frame
func _check_boss_death_state_is_terminal() -> void:
var boss: Node = load("res://scenes/enemies/boss.tscn").instantiate()
root.add_child(boss)
await process_frame
var state_machine: Node = boss.get_node("StateMachine")
var animation_player: AnimationPlayer = boss.get_node("AnimationPlayer")
var motion: Node = boss.get_node("MotionExecutor")
var emitter: Area2D = boss.get_node("DamageEmitter")
var receiver: Area2D = boss.get_node("DamageReceiver")
var behavior_tree: Node = boss.get_node("BossBehaviorTree")
var action: Resource = load("res://resources/actions/enemies/boss_lunging_stab.tres")
emitter.call("configure_hit", action, {"label": "perfect"})
motion.call("execute", action, Vector2.LEFT, 0.5, 120.0)
boss.set("velocity", Vector2(80.0, 0.0))
state_machine.call("set_life_state", &"Dead")
boss.call("handle_animations")
_expect_equal(animation_player.current_animation, "boss_die", "Boss should start the death animation when life_state becomes Dead")
_expect_bool(bool(animation_player.is_playing()), true, "Boss death animation should play when Dead is first observed")
_expect_bool(bool(emitter.get("monitoring")), false, "Boss death should disable active hitboxes")
_expect_bool(bool(motion.get("active")), false, "Boss death should cancel active movement")
_expect_equal(boss.get("velocity"), Vector2.ZERO, "Boss death should stop current velocity")
_expect_bool(bool(behavior_tree.get("enabled")), false, "Boss death should stop BehaviorTree decisions")
_expect_int(int(boss.get("collision_layer")), 64, "Boss body collision layer should remain inspectable after death")
_expect_int(int(boss.get("collision_mask")), 33, "Boss body collision mask should remain restored after death")
_expect_int(int(receiver.get("collision_layer")), 4, "Boss DamageReceiver layer should remain configured after death")
animation_player.advance(animation_player.get_animation("boss_die").length + 0.2)
animation_player.stop()
boss.call("handle_animations")
_expect_bool(bool(animation_player.is_playing()), false, "Boss death animation should not restart after it has already played once")
boss.queue_free()
await process_frame
func _check_boss_script_debt_cleanup() -> void:
var boss_source := _read_text("res://scenes/enemies/boss.gd")
var tree_source := _read_text("res://scenes/enemies/boss_behavior_tree.gd")
_expect(not boss_source.contains("iron_sentinel"), "boss.gd should not keep iron_sentinel art debt")
_expect(not boss_source.contains("_knockback_time_left"), "boss.gd should not keep the local knockback timer hack")
_expect(not tree_source.to_lower().contains("limboai"), "boss_behavior_tree.gd should not reference limboai")
_expect(not tree_source.contains("skill_"), "boss_behavior_tree.gd should use new boss ActionData ids")
func _boss_animation_names() -> Array[StringName]:
return [
&"boss_idle",
&"boss_run",
&"boss_dash",
&"boss_jump_fall",
&"boss_take_hit",
&"boss_die",
&"boss_ground_combo_1",
&"boss_ground_combo_2",
&"boss_ground_combo_3",
&"boss_lunging_stab",
&"boss_shoot_1",
&"boss_shoot_2",
&"boss_2way_shoot",
]
func _visible_sprite_bottom_global_y(sprite: Sprite2D) -> float:
if sprite.texture == null:
failures.append("%s should have a texture" % sprite.get_path())
return INF
var image := sprite.texture.get_image()
if image == null or image.is_empty():
failures.append("%s texture image should be readable" % sprite.get_path())
return INF
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 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:
bottom = y
if bottom < 0:
failures.append("%s should contain visible pixels" % sprite.get_path())
return INF
return sprite.to_global(sprite.offset + Vector2(0.0, bottom)).y
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 _on_action_started(action: Resource, _intent) -> void:
started_actions.append(StringName(str(action.get("id"))))
func _on_projectile_requested(projectile_scene: PackedScene, spawn_position: Vector2, direction: Vector2, context: Dictionary = {}) -> void:
projectile_requests.append({
"scene": projectile_scene,
"spawn_position": spawn_position,
"direction": direction,
"context": context,
})
func _ensure_event_bus() -> Node:
var existing := root.get_node_or_null("EventBus")
if existing != null:
return existing
var bus: Node = load("res://autoload/event_bus.gd").new()
bus.name = "EventBus"
root.add_child(bus)
return bus
func _ensure_combat_manager() -> Node:
var existing := root.get_node_or_null("CombatManager")
if existing != null:
return existing
var combat: Node = load("res://autoload/combat_manager.gd").new()
combat.name = "CombatManager"
root.add_child(combat)
return combat
func _make_chart_event(beat: int, target_id: StringName, action_id: StringName) -> Resource:
var event_script: Script = load("res://resources/chart_event.gd")
var event: Resource = event_script.new()
event.set("event_type", &"enemy_action")
event.set("beat_index", beat)
event.set("target_id", target_id)
event.set("action_id", action_id)
event.set("lead_beats", 2.0)
return event
func _read_text(path: String) -> String:
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
failures.append("Could not read %s" % path)
return ""
return file.get_as_text()
func _expect(condition: bool, label: String) -> void:
if not condition:
failures.append(label)
func _expect_equal(actual: Variant, expected: Variant, label: String) -> void:
if actual != expected:
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 _expect_float(actual: float, expected: float, label: String) -> void:
if not is_equal_approx(actual, expected):
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_int(actual: int, expected: int, label: String) -> void:
if actual != expected:
failures.append("%s: expected %d, got %d" % [label, expected, actual])
func _finish() -> void:
if failures.is_empty():
print("PASS boss integration")
quit(0)
else:
for failure: String in failures:
push_error(failure)
quit(1)