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)