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
+307
View File
@@ -0,0 +1,307 @@
extends SceneTree
var failures: Array[String] = []
var started_actions: Array[StringName] = []
func _init() -> void:
_run.call_deferred()
func _run() -> void:
await process_frame
# 难度会缩放小怪血量(普通 ×3 / 困难 ×5);本测试关注相位系统,
# 钉住 easy 以保持 650 基线断言(直接写字段、不落盘)。
var settings := root.get_node_or_null("GameSettings")
if settings != null:
settings.set("difficulty", &"easy")
await _check_minion_scene_phase_swap_preserves_state()
await _check_minion_strength_profiles_and_action_pools()
await _check_stage_instances_phase_minions()
await _check_boss_phase_action_restrictions()
_finish()
func _check_minion_scene_phase_swap_preserves_state() -> void:
var bus := _ensure_event_bus()
var manager := _ensure_time_phase_manager()
var scene: PackedScene = load("res://scenes/enemies/minion.tscn")
_expect(scene != null, "Minion scene should load")
if scene == null:
return
var minion: Node2D = scene.instantiate()
minion.name = "TestPhaseMinion"
minion.set("past_form_id", &"jin_zhan_3")
minion.set("future_form_id", &"jin_zhan_1")
root.add_child(minion)
await process_frame
# 本检查只关心相位切换本身不移动小怪;关掉巡逻 AI,避免它在
# 等待帧里合法行走造成位置漂移误报。父节点先于子节点跑物理帧,
# 因此还要清掉上一帧遗留的 approach_direction,否则仍会漂移一步。
var behavior := minion.get_node_or_null("MinionBehavior")
if behavior != null:
behavior.set("enabled", false)
minion.set("approach_direction", 0.0)
var health: Node = minion.get_node_or_null("HealthComponent")
_expect(health != null, "Minion should have a HealthComponent")
if health != null:
_expect_int(int(health.get("maximum")), 650, "Minion max HP should follow the latest design baseline")
health.call("set_values", 321, 650)
minion.global_position = Vector2(222.0, 333.0)
manager.call("reset_to_initial", &"past")
await process_frame
_expect_equal(minion.get("form_id"), &"jin_zhan_3", "Past phase should use jin_zhan_3 for this slot")
manager.call("set_time_phase", &"future", &"debug")
await process_frame
_expect_equal(minion.get("form_id"), &"jin_zhan_1", "Future phase should swap this slot to jin_zhan_1")
_expect_equal(minion.global_position, Vector2(222.0, 333.0), "Phase swap should preserve minion position")
if health != null:
_expect_int(int(health.get("current")), 321, "Phase swap should preserve minion current HP")
manager.call("set_time_phase", &"past", &"debug")
await process_frame
_expect_equal(minion.get("form_id"), &"jin_zhan_3", "Switching back should restore the Past form")
minion.queue_free()
_cleanup_node(bus)
_cleanup_node(manager)
await process_frame
func _check_minion_strength_profiles_and_action_pools() -> void:
var bus := _ensure_event_bus()
var manager := _ensure_time_phase_manager()
var scene: PackedScene = load("res://scenes/enemies/minion.tscn")
if scene == null:
return
var past_strong: Node = scene.instantiate()
past_strong.name = "PastStrongMinion"
past_strong.set("past_form_id", &"jin_zhan_3")
past_strong.set("future_form_id", &"jin_zhan_1")
past_strong.set("strength_profile", &"past_strong")
root.add_child(past_strong)
await process_frame
manager.call("reset_to_initial", &"past")
await process_frame
_expect_int(int(past_strong.call("current_attack_interval_beats")), 2, "PastStrongEnemy should attack every two beats in Past")
_expect(_all_action_ids_begin_with(past_strong.call("current_action_ids"), "minion_jin_zhan_3"), "Past form should use jin_zhan_3 actions")
manager.call("set_time_phase", &"future", &"debug")
await process_frame
_expect_int(int(past_strong.call("current_attack_interval_beats")), 4, "PastStrongEnemy should attack every four beats in Future")
_expect(_all_action_ids_begin_with(past_strong.call("current_action_ids"), "minion_jin_zhan_1"), "Future form should use jin_zhan_1 actions")
past_strong.queue_free()
var future_strong: Node = scene.instantiate()
future_strong.name = "FutureStrongMinion"
future_strong.set("past_form_id", &"yuan_cheng_1")
future_strong.set("future_form_id", &"yuan_cheng_2")
future_strong.set("strength_profile", &"future_strong")
root.add_child(future_strong)
await process_frame
manager.call("set_time_phase", &"past", &"debug")
await process_frame
_expect_int(int(future_strong.call("current_attack_interval_beats")), 4, "FutureStrongEnemy should attack every four beats in Past")
_expect(_all_action_ids_begin_with(future_strong.call("current_action_ids"), "minion_yuan_cheng_1"), "Past form should use yuan_cheng_1 actions")
manager.call("set_time_phase", &"future", &"debug")
await process_frame
_expect_int(int(future_strong.call("current_attack_interval_beats")), 2, "FutureStrongEnemy should attack every two beats in Future")
_expect(_all_action_ids_begin_with(future_strong.call("current_action_ids"), "minion_yuan_cheng_2"), "Future form should use yuan_cheng_2 actions")
future_strong.queue_free()
_cleanup_node(bus)
_cleanup_node(manager)
await process_frame
func _check_stage_instances_phase_minions() -> void:
var bus := _ensure_event_bus()
var manager := _ensure_time_phase_manager()
var stage_scene: PackedScene = load("res://scenes/stage/stage.tscn")
_expect(stage_scene != null, "Stage scene should load")
if stage_scene == null:
return
var stage: Node = stage_scene.instantiate()
root.add_child(stage)
await process_frame
var jin := stage.get_node_or_null("ActorsContainer/JinZhanMinion")
# 当前关卡设计只预置近战怪;远程怪由 LevelDirector 在阶段二动态生成,
# 这里直接让导演生成一只来验证双形态相位切换。
var yuan: Node = null
var director := stage.get_node_or_null("LevelDirector")
if director != null:
# 出生点左移(new2)后玩家在 x=1180:远程怪要放在新射程 450 之外,
# 避免它在测试等待帧里开打、把形态切换推迟到攻击结束。
yuan = director.call("spawn_minion", &"ranged", Vector2(2100.0, 560.0))
_expect(jin != null, "Stage should instance JinZhanMinion")
_expect(yuan != null, "LevelDirector should spawn a ranged YuanCheng minion")
if yuan != null:
await process_frame
var yuan_behavior := yuan.get_node_or_null("MinionBehavior")
if yuan_behavior != null:
yuan_behavior.set("enabled", false)
yuan.set("approach_direction", 0.0)
if jin != null:
var jin_behavior := jin.get_node_or_null("MinionBehavior")
if jin_behavior != null:
jin_behavior.set("enabled", false)
jin.set("approach_direction", 0.0)
if jin != null and yuan != null:
manager.call("reset_to_initial", &"past")
await process_frame
_expect_equal(jin.get("form_id"), &"jin_zhan_3", "Map A/Past should contain jin_zhan_3")
_expect_equal(yuan.get("form_id"), &"yuan_cheng_1", "Map A/Past should contain yuan_cheng_1")
var jin_health: Node = jin.get_node("HealthComponent")
var yuan_health: Node = yuan.get_node("HealthComponent")
jin_health.call("set_values", 444, 650)
yuan_health.call("set_values", 333, 650)
var jin_pos: Vector2 = (jin as Node2D).global_position
var yuan_pos: Vector2 = (yuan as Node2D).global_position
manager.call("set_time_phase", &"future", &"debug")
await process_frame
_expect_equal(jin.get("form_id"), &"jin_zhan_1", "Map B/Future should swap jin_zhan_3 to jin_zhan_1")
_expect_equal(yuan.get("form_id"), &"yuan_cheng_2", "Map B/Future should swap yuan_cheng_1 to yuan_cheng_2")
_expect_equal((jin as Node2D).global_position, jin_pos, "JinZhan phase swap should preserve position")
_expect_equal((yuan as Node2D).global_position, yuan_pos, "YuanCheng phase swap should preserve position")
_expect_int(int(jin_health.get("current")), 444, "JinZhan phase swap should preserve HP")
_expect_int(int(yuan_health.get("current")), 333, "YuanCheng phase swap should preserve HP")
stage.queue_free()
_cleanup_node(bus)
_cleanup_node(manager)
await process_frame
func _check_boss_phase_action_restrictions() -> void:
var bus := _ensure_event_bus()
var manager := _ensure_time_phase_manager()
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 tree: Node = boss.get_node("BossBehaviorTree")
player.global_position = Vector2(100.0, 360.0)
boss.global_position = Vector2(170.0, 360.0)
boss.set("stationary", false)
# Boss 战入场后行为树才会出招(BossRoomGate 会置 combat_enabled)。
boss.set("combat_enabled", true)
tree.set("enabled", true)
tree.set("decision_interval_beats", 1.0)
controller.connect("action_started", _on_action_started)
started_actions.clear()
manager.call("reset_to_initial", &"past")
await process_frame
bus.emit_signal("beat_ticked", 20)
await process_frame
_expect(not started_actions.is_empty(), "Boss should act in Past")
if not started_actions.is_empty():
_expect(_action_has_tag(started_actions[started_actions.size() - 1], &"melee"), "Past Boss action should be melee-only")
_expect(not _action_has_tag(started_actions[started_actions.size() - 1], &"ranged"), "Past Boss action should not be ranged")
controller.call("_reset_to_idle")
boss.set("state", StringName("idle"))
started_actions.clear()
manager.call("set_time_phase", &"future", &"debug")
await process_frame
# new3 §6.4:相位切换后 Boss 停顿一拍,不立刻出招。
bus.emit_signal("beat_ticked", 21)
await process_frame
_expect(started_actions.is_empty(), "Boss should pause one beat right after the phase switch")
bus.emit_signal("beat_ticked", 22)
await process_frame
_expect(not started_actions.is_empty(), "Boss should act after the phase-switch pause")
if not started_actions.is_empty():
# new3 §6.7:远程相位被近身(≤240px)时优先后撤拉开距离,后撤不造成伤害。
_expect(_action_has_tag(started_actions[started_actions.size() - 1], &"retreat"), "Point-blank Future Boss should retreat first")
_expect(not _action_has_tag(started_actions[started_actions.size() - 1], &"melee"), "Future Boss action should not be melee")
controller.call("_reset_to_idle")
boss.set("state", StringName("idle"))
started_actions.clear()
bus.emit_signal("beat_ticked", 23)
await process_frame
_expect(not started_actions.is_empty(), "Boss should attack after the retreat")
if not started_actions.is_empty():
_expect(_action_has_tag(started_actions[started_actions.size() - 1], &"ranged"), "Future Boss attack should be ranged-only")
_expect(not _action_has_tag(started_actions[started_actions.size() - 1], &"melee"), "Future Boss attack should not be melee")
stage.queue_free()
_cleanup_node(bus)
_cleanup_node(manager)
await process_frame
func _on_action_started(action: Resource, _intent) -> void:
if action != null:
started_actions.append(StringName(str(action.get("id"))))
func _all_action_ids_begin_with(ids: Variant, prefix: String) -> bool:
if not ids is Array or (ids as Array).is_empty():
return false
for id_value: Variant in ids:
if not str(id_value).begins_with(prefix):
return false
return true
func _action_has_tag(action_id: StringName, tag: StringName) -> bool:
var resolver: Script = load("res://scenes/combat/action_resolver.gd")
if resolver == null:
return false
var action: Resource = resolver.call("get_action", action_id)
if action == null or not action.get("action_tags") is Array:
return false
return (action.get("action_tags") as Array).has(tag)
func _ensure_event_bus() -> Node:
var bus := root.get_node_or_null("EventBus")
if bus == null:
bus = load("res://autoload/event_bus.gd").new()
bus.name = "EventBus"
root.add_child(bus)
return bus
func _ensure_time_phase_manager() -> Node:
var manager := root.get_node_or_null("TimePhaseManager")
if manager == null:
manager = load("res://autoload/time_phase_manager.gd").new()
manager.name = "TimePhaseManager"
root.add_child(manager)
return manager
func _cleanup_node(node: Node) -> void:
if node == null or not is_instance_valid(node):
return
if node.name == "EventBus" or node.name == "TimePhaseManager":
return
if node != null and is_instance_valid(node) and node.get_parent() == root:
root.remove_child(node)
node.free()
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_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 minion phase system")
quit(0)
else:
for failure: String in failures:
push_error(failure)
quit(1)