739 lines
36 KiB
GDScript
739 lines
36 KiB
GDScript
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)
|