Initial project sync

This commit is contained in:
wxm
2026-07-06 22:55:14 -07:00
commit 8cca00d0da
1066 changed files with 58585 additions and 0 deletions
+738
View File
@@ -0,0 +1,738 @@
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)